스레드 안전성
스레드 안전한 상태
- 특별한 스레드 스케줄링이나 대체 실행 수단을 고려할 필요가 없다.
- 추가적인 동기화 수단이나 호출자 측에서 조율이 필요 없다.
→ 두 조건이 충족하면 스레드 안전하다고 본다. 자바언어의 공유 데이터의 안전한 정도!
- 불변
자바 언어에서 불변 객체는 객체 자체의 메서드 구현과 호출자 모두에서 아무런 안전장치 없이도 스레드 안전하다. → final 키워드의 가시성 때문에 어느 시점에 접근해도 동일한 값을 반환한다.
자바 불변 객체의 예시
- final 키워드 final 키워드로 변수를 선언하면 생성자 종료 후에는 값을 변경할 수 없다.
- Wrapper 클래스
- BigInteger, BigDecimal
- Java.time의 주요 API
- 열거타입 열거 타입은 프로그램 시작 시 한번만 생성되며 이후 재할당이나 수정이 불가능하다. 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다.
- 특징 장점 : 불변 객체는 상태 변경이 없으므로 스레드 안전성을 자동으로 보장함 단점 : 추가 객체 생성으로 메모리 사용 증가 결론 : 동기화 없이 안전하게 객체를 공유해야하는 멀티스레드 환경, 캐싱을 통해 성능 최적화, 데이터를 표현하기 위한 단순 객체일 경우 사용
- 절대적 스레드 안전
절대적 스레드 안전은 브라이언 계츠가 제시한 스레드 안전성 정의를 완벽하게 충족한다. 이정의는 사실 매우 엄격하다. 어떤 런타임 환경에서든 호출자가 추가적인 동기화 조치를 할 필요 없다 라는 조건을 만족시키려면 비용이 많이 들거나 때로는 비현실적 일 수도 있다.
-> Java에서 제공하는 대부분의 API가 절대적 스레드 안전처럼 보이지만 사실은 아니다. 아래 예시를 통해 이해할 수 있다. - 조건부 스레드 안전
- 일반적 수준의 스레드 안전을 의미한다. → 메서드를 별도의 조치 없이 사용 가능, 멀티스레드 환경에서 추가적인 동기화 조치가 필요할 수 있는 수준이다.
- 예 1 ) Vector 클래스
Vector는 Collection 등장 이전에 설계된 가변 배열 클래스이다. 전박전인 역할은 ArrayList와 비슷하다. (다른점은 capacity 메서드가 있다는 정도?) 대부분의 메서드에 synchronized 키워드가 걸려있다는 것이 큰 차이점
- 예 2) HashTable 마찬가지로 메서드 수준의 동기화 지원 , 즉 단일 메서드 호출에 대해서만 동기화 제공 멀티 스레드 환경에서 별도의 처리 필요
- 예 3 ) Collections.syncronizedCollection() 메서드로 매핑한 스레드 Collections.syncronizedCollection() 메서드는 동기화 된 Collection 객체를 반환하여 동기화된 블록을 통해 보호 메서드 수준의 동기화를 지원, 멀티스레드 환경에서 별도의 처리 필요
- 사용하지 않는 이유 : ArrayList가 Vector와 비슷한 역할을 수행하면서도 크기 확장 방식에서 더 유연하고, 동기화가 적용되어 있지 않아 단일 스레드 환경에서 성능이 더 뛰어나다.
- 스레드 호환 스레드 호환이란 객체 자체는 스레드로 부터 안전하지 않지만 호출자가 적절히 조치하면 멀티스레드 환경에서도 안전하게 사용할 수 있다는 뜻이다.
- 예 1 ) ArrayList, HashMap 동기화를 지원하지 않는 클래스 → 3의 예시 3 , syncronized 키워드로 메서드 수준의 동기화 가능 / 멀티 스레드 환경에서 별도의 처리 필요
- 스레드 적대적 안전하지 않은 경우
- Thread 클래스의 suspend(), resume()
- System.setIn(), System,setout(), System.runfinalizerOnExit()
스레드 안전 구현
- 상호배제 동기화 한 데이터에 여러 스레드가 접근할 때, 단 하나의 스레드만 사용 가능
syncronized 키워드 (모니터락 : 뮤텍스나 세마포아보다 더 고수준의 동기화)
- 동작 과정
1. synchronized은 동일한 모니터를 객체에 대해 오직 하나의 스레드만 임계영역에 접근할 수 있도록 보장하며 모니터의 조건 변수를 통해 스레드간 협력으로 동기화를 보장해준다.
2. synchronized가 적용된 한 개의 메서드만 호출해도 같은 모니터의 모든 synchronized 메서드까지 락에 잠기게 되어 락이 해제될 때까지는 접근이 안되는 특징을 가지고 있다.
3. 락은 스레드가 synchronized에 들어가기전에 자동 확보되며 정상적이든 비정상적이든 예외가 발생해서든 해당 블록을 벗어날 때까지 자동으로 해제된다.
-
- concurrent 패키지의 lock (ReentrantLock)
- 직접 un/lock()을 통해 해제 및 획득 → try-finally 필수
- lock을 여러번 호출하여 재진입
- syncronized와 달리 대기 중인 스레드들이 락을 공정하게 획득하도록 보장 , 일정시간만 대기하도록 제어 가능, 인터럽트 가능, 상태 확인 가능
- 현재 두 방식의 유의미한 성능 차이가 없으므로, JVM에 락 관리를 맞기는 것이 효율적일 수 있다?
- 한계
- 스레드를 멈추거나 깨우는 과정에서 많은 실행 비용 발생
- 간단한 코드에서도 과도한 락 사용 우려
- concurrent 패키지의 lock (ReentrantLock)
- 논블로킹 동기화 경합하는 다른 스레드가 없다고 가정하고 실행, 충돌 시 보완 조치 (성공까지 재시도)
- Lock-Free : 락을 사용하지 않는다 → Deadlock 발생 X
- CAS (Compare and Swap) : 현재 메모리 위치의 값을 예상값과 비교한 후 같은 값인 경우 수행 다른 경우 재시도
- AtomicInterger 메서드의 동작 예시
- incrementAndGet() 메서드는 현재 값을 1 증가시킨 후 그 값을 반환합니다. 이 과정에서 CAS 연산을 사용하여 값의 일관성을 유지
- compareAndSet(int expect, int update)는 현재 값이 기대하는 값과 같을 때만 새로운 값으로 변경하며, 성공 여부를 반환
import java.util.concurrent.atomic.AtomicInteger; public class AtomicIntegerExample { public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(0); // 값을 원자적으로 증가시키기 int newValue = atomicInteger.incrementAndGet(); System.out.println("New value after increment: " + newValue); // 출력: 1 // 비교하고 교환 (CAS) 연산 boolean isUpdated = atomicInteger.compareAndSet(1, 5); // 현재 값이 1이면 5로 설정 System.out.println("Compare and set result: " + isUpdated); // 출력: true System.out.println("Current value: " + atomicInteger.get()); // 출력: 5 // 값이 기대와 다를 경우 boolean failedUpdate = atomicInteger.compareAndSet(1, 10); // 현재 값이 1이 아님 System.out.println("Compare and set result: " + failedUpdate); // 출력: false System.out.println("Current value: " + atomicInteger.get()); // 출력: 5 } }
- ABA 문제 : 현재 값이 예상 값과 동일한 지 비교하기 때문에 값이 변경된 후 원래 값으로 돌아온 경우 문제가 발생할 수 있다. →스탬프를 통해 값이 몇번 변경되었는 지 추적 (상호배제가 낫다)
import java.util.concurrent.atomic.AtomicInteger; public class ABAProblemExample { private static AtomicInteger atomicInteger = new AtomicInteger(1); public static void main(String[] args) { // 스레드 1: CAS를 수행하려고 함 Thread thread1 = new Thread(() -> { try { // 스레드 1이 어떤 작업을 하기 전에 잠시 대기 Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } // 현재 값이 1이면 10으로 변경 시도 boolean isUpdated = atomicInteger.compareAndSet(1, 10); System.out.println("Thread 1 - Compare and set result: " + isUpdated); System.out.println("Thread 1 - Current value: " + atomicInteger.get()); }); // 스레드 2: 값을 변경하여 ABA 문제를 발생시킴 Thread thread2 = new Thread(() -> { // 현재 값이 1이면 2로 변경 atomicInteger.compareAndSet(1, 2); System.out.println("Thread 2 - Changed value to 2"); // 다시 2에서 1로 변경 (ABA 문제 발생) atomicInteger.compareAndSet(2, 1); System.out.println("Thread 2 - Changed value back to 1"); }); thread1.start(); thread2.start(); try { thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
- AtomicInterger 메서드의 동작 예시
- 동기화가 필요 없는 매커니즘 공유 데이터를 전혀 사용하지 않는 경우 동기화가 필요 없다.
- 재진입 코드 : 실행 중 아무때나 끼어들어도 상관 없는 코드
'JVM' 카테고리의 다른 글
가비지 컬렉터와 가비지 컬렉션 알고리즘과 종류 (GC) (0) | 2025.01.13 |
---|---|
JVM 락 최적화 (0) | 2024.12.05 |
자바의 동기화 매커니즘 (0) | 2024.12.05 |