13. 스레드 안전성과 락 최적화
13.2 스레드 안전성
- Java Concurrency In Practice 의 지은이 Brian Goetz 가 내린 스레드 안전하다는 개념의 정의
여러 스레드가 한 객체에 동시에 접근할때, 어떤 런타임 환경에서든 다음 두 조건을 모두 충족하면서 객체를 호출하는 행위가 올바른 결과를 얻을 수 있다면, “그 객체는 스레드 안전하다”
- 특별한 스레드 스케줄링이나 대체 실행 수단을 고려할 필요가 없다.
- 추가적인 동기화 수단이나 호출자 측에서 조율이 필요없다.
13.2.1 자바언어의 스레드 안전성
- 스레드 안전성은 안전하냐 아니냐 보다 정도로 나뉜다.
불변
- final, 값이 변하지 않기때문에 완전하게 스레드 안전하다.
- ex) String, Integer , (AtomicInteger는 아님)
절대적 스레드 안전
- 어떤 런타임 환경에서든 호출자가 추가적인 동기화 조치를 할 필요 없다.
- ex) java.util.Vector 는 모든 메서드가 synchronized 이지만, 여러 메서드가 혼합되어 호출되는 환경에서는 스레드안전하지않다.
// thread a~z for (i ... vector.size()) vector.remove(i) // thread 0~100 for (i ... vector.size()) vector.get(i)
위 코드에서 ArrayIndexOutOfBoundsException 이 발생할 수 있다. 대안은 vector 객체를 syncronized 로 감싸야한다.
synchronized(vector){ for (i ... vector.size()) vector.remove(i) } ...
조건부 스레드 안전
- 일반적으로 스레드안전하다 라고 말하는 수준.
- 단일 작업(메서드)를 별도 보호조치 없이 스레드로부터 안전하게 수행한다.
- ex) Vector
스레드 호환
- 스레드 안전하지않다
- 객체 자체는 스레드로부터 안전하지 않지만, 호출자가 적절히 조치하면 멀티스레드 환경에서도 안전하게 사용할 수 있다.
- ex) ArrayList, HashMap
스레드 적대적
- 호출자가 동기화 조치를 취하더라도 안전하지않은경우.
- 현재 java에서 예시가 거의 없다.
- ex) Thread.suspend(), Thread.resume() Vector처럼 메서드를 동기화하더라도 두 메서드가 동시 호출되는경우 교착상태.
13.2.2 스레드 안전성 구현
상호 배제 동기화
- 하나의 스레드만 데이터를 사용할 수 있다.
- ex) mutex, 임계영역, semaphore , synchronized
synchronized
- 락을 가지고있는 같은 스레드라면 락 잡힌 영역에 재진입가능.
- 락을 소유한 스레드가 락을 해제하도록 강제할 방법이 없다.
- 락 대기, 소유 절차는 프로세서 시간을 많이 소모한다. (플랫폼 스레드를 정지하거나 깨우려면 운영체제의 도움이 필요.)
- 언페어락 : 락 해제가되면 대기중인 모든 스레드가 다음 락 후보. 페어락보다 성능이 매우떨어질수있음.
ReentrantLock
- 대기중인 인터럽트 : 락을 소유한 스레드가 오랜시간 락을 해제하지 않을때 같은 락을 얻기위해 대기중인 다른 스레드들은 락을 포기하고 다른작업가능.
- 페어락 : 락획득을 시도한 시간순서대로 락을 얻는다.
-
둘 이상의 조건 지정 : Condition 객체와 연결지을 수 있다.
- ~JDK6 ReentrantLock 의 처리량이 높아 권장됨.
- JDK7~ 둘의 성능은 비슷. synchronized가 JVM자체적으로 lock의 해제, 예외등 처리를 할 수 있으므로 권장.
논블로킹 동기화
- 락 구간의 작업을 일단 진행하고, 공유데이터를 놓고 경합하는 다른 스레드가 없다면 성공, 충돌이 발생한다면 보완조치.
- 일반적으로 보완조치는 무한 재시도
- 하드웨어 명령어 집합의 발전이 됨에 따라 가능해짐. 작업 진행과 충돌감지가 원자적인 한개의 명령어로 지원되기때문.
- CAS(Compare and swap)
- ex) AtomicInteger.compareAndSet(), getAndIncrement() 가 CAS연산을 사용.
map.compute 는 threadSafe한가? -> ConcurrentHashMap을 사용할때만 스레드 세이프.
동기화가 필요없는 메커니즘
- 재진입 코드 ex) Util function, 모든 정보는 매개변수로부터.
- 스레드 로컬 저장소 : 데이터를 공유하는 다른 코드도 같은 스레드에서 수행된다면 활용가능.
13.3 락 최적화
13.3.1 스핀 락과 적응형 스핀
스핀락
- 락 획득,해제시 블로킹(스레드 일시정지)가 성능에 악영향을 주는 주된 원인이므로, 일시정지 대신 루프를 돌게 한다.
- 스레드 전환 부하는 없지만, 프로세서 시간을 소비하므로, 장시간 lock 되는경우 비효율적. 이를 방지하기위해 최대 스핀횟수 지정가능.
적응형 스핀
- 스핀락의 최대 스핀횟수 대신 같은 락의 이전 스핀 시간에 따라 결정.
13.3.2 락 제거
-
JIT 가 데이터 경합이 일어나지 않는다고 판단되면 락을 제거함
-
ex) String + String 시에 javac 가 String은 불변객체이므로, 갸변객체 코드로 변환한다.
- ~JDK4 : StringBuffer.append // 모든 메서드가 synchronized
- JDK5~ : StringBuilder.append // 동기화되지않음.
언제 String + String을 사용해도 괜찮은가?
- ✅ 괜찮은 경우:
- 단순한 문자열 연결 (2-3개 정도)
- 한 번에 연결하는 경우
- 반복문이 아닌 일반적인 코드
- ✅ 여전히 StringBuilder 권장하는 경우:
- 반복문 안에서 문자열 연결
- 복잡한 조건부 문자열 구성
- 성능이 매우 중요한 부분
13.3.3 락 범위 확장
- 원칙적으로는 락 범위를 최소화한다.
- 락 획득이 반복문에 있다면, 유효범위를 작업 전체로 늘린다.
13.3.4 lock 최적화 전략
락 종류 | 구현 방식 | 특징 | 장점 | 단점 | 추가 정보 |
---|---|---|---|---|---|
중량락 | 운영체제의 뮤텍스 이용 | 전통적인 락 구현 방식 | 안정적이고 확실한 동기화 | 성능 오버헤드가 큼 | 기본적인 락 구현 |
경량락 | JVM 메모리의 객체 헤더에 락 플래그를 CAS 연산으로 설정 | 경합이 없을 때 최적화된 성능 | - 경합이 없을 때 성능 향상 - CAS 연산 활용 |
- 경합 발생 시 중량락으로 확장 필요 - 경합 시 뮤텍스 + CAS로 오히려 느려짐 |
락 플래그를 10 으로 설정하여 중량락으로 확장 |
편향락 | 객체 헤더에 스레드 ID와 편향락 플래그 저장 | 단일 스레드만 사용하는 락에 대한 최적화 | - 동기화 장치 제거로 최고 성능 - 단일 스레드 사용 시 오버헤드 최소화 |
- 다른 스레드 접근 시 편향모드 즉시 종료 - Object.hashCode() 호출된 객체는 사용 불가 - 경쟁적 락 사용 시 불필요한 작업 증가 |
- 최신 JDK에서 제거됨 - hashCode 자리를 스레드 ID로 사용 |
- 중량락 : 운영체제의 뮤텍스를 이용해 구현한 락
- 경량락 : 대부분 락은 실제로 경합을 겪지 않는다는 경험법칙을 이용하여 동기화 성능 개선.
- JVM의 메모리에서의 객체 헤더에 락 플래그를 CAS 연산으로 설정.
- 다른 스레드가 락을 두고 경합이 발생하면 락 플래그를 중량락
10
으로 확장해야한다. - 경합이 없다면 최적화, 경합이 있다면 뮤텍스 + CAS 연산이라 오히려 느려짐.
- 편향락 : 경합이 없을때 동기화 장치들을 제거하여 최적화하는 기법.
- 락을 한개의 스레드만 사용하는경우 제거되며, 다른 스레드가 락을 얻으려하는 즉시 편향모드는 종료된다.
- 객체 헤더에 스레드id, 편향락 플래그를 둔다.
- 스레드 id의 자리가 원래는 객체의 hashCode 자리여서, Object.hashcode() 가 한번 호출된 객체는 편향락을 사용할 수 없다.
- 객체 헤더의 hashCode자리는 Object.hashCode() 가 최초 호출될때 세팅된다.
- 스레드 id의 자리가 원래는 객체의 hashCode 자리여서, Object.hashcode() 가 한번 호출된 객체는 편향락을 사용할 수 없다.
- 프로그램의 락 대부분을 여러 스레드가 경쟁적으로 얻으려한다면 불필요한 작업이 필요. 최신 JDK에서 제거됨.