자바와 스레드 안정성
자바와 스레드 안정성
스레드 안정성이라는 용어는 무엇일까요? Java Concurrency In Practice 에 따르면 다음과 같습니다.
여러 스레드가 한 객체에 동시에 접근할 때, 다음 두 조건을 충족하면서 객체를 호출하는 행위가 올바른 결과를 얻을 수 있는 것입니다.
1. 특별한 스레드 스케줄링이나 대체 실행 수단을 고려할 필요 없다.
2. 추가적인 동기화 수단이나 호출자 측에서 조율이 필요 없다.
엄격한 구현이지만 현실 세계에서 사용하기에는 구현하기가 어렵습니다. 현실에서는 ‘안전함의 정도’에 따라서 공유 데이터의 안전 정도를 5단계로 나눌 수 있습니다.
불변
불변이란 객체 자체의 메서드 구현과 호출자 모두에서 아무런 안전장치 없이도 안전하다는 것을 뜻합니다. final 키워드를 사용하면 쉽게 불변성을 보장할 수 있습니다. 또한, String 객체도 불변 객체입니다. Long, Double, BigInteger, BigDecimal 등 대부분의 Number 하위 클래스는 불변 객체입니다. 다만 AtomicInteger 와 AtomicLong 은 불변 객체입니다.
AtomicInteger 를 Immutable 객체로 만든 이유
절대적 스레드 안전
이는 위에서 제시한 정의를 충족하는 단계입니다. Vector 클래스의 add(), get() 등의 메서드를 본다면 synchronized 메서드인 것을 확인할 수 있습니다. 이 메서드는 스레드 safe 합니다. 그러나, size() 메서드와 remove() 메서드가 별개이기에, 한 스레드에서 얻는 size 와 다른 스레드에서 제거하는 값이 달라질 수 있습니다.
만약 Vector 가 스레드에 절대적으로 안전해지고자 한다면, 항상 일관된 스냅샷을 유지해야만 할 것입니다.
조건부 스레드 안전
조건부 스레드 안전은 우리가 일반적으로 Thread Safe 하다고 말하는 안전 수준입니다. Vector, HashTable 등의 클래스는 Thread Safe 하다고 할 수 있습니다.
스레드 호환
스레드 호환이란 객체 자체는 Thread Safe 하지 않지만, 호출자가 적절히 조치하면 멀티스레드 환경에서도 안전하다는 뜻입니다. 자바의 클래스 대다수가 이 분류에 속합니다.
스레드 적대적
스레드 적대적이란 호출자가 동기화 조치를 취하더라도 멀티스레드 환경에서 안전하게 사용할 수 없다는 것을 뜻하지만, 자바에서는 처음부터 스레드를 지원한 덕분에 스레드 적대적 코드는 거의 없다고 할 수 있습니다.
그럼에도 불구하고, Thread 클래스의 suspend() 와 resume() 메서드를 예시로 들 수 있습니다. A 스레드를 B,C 스레드에서 공유한다고 가정해보겠습니다. B 스레드에서 A 스레드의 suspend() 를 호출하고 C 스레드에서 A 스레드의 resume() 을 호출하면 어떻게 될까요? 이 경우에 A 스레드는 교착 상태에 빠집니다. suspend() 에 의해서 블록된 스레드가 resume() 을 실행하려 한다면 교착이 일어납니다.
스레드 안정성을 구현하려면?
Mutual Exclusion
synchronized 키워드를 사용해서 코드 블록을 동기화할 수 있습니다. 객체의 락을 얻으려 시도하며, 현재 스레드가 락을 소유하고 있다면 카운터를 1 씩 증가시키고 객체를 반환하면 카운터를 1 감소시킵니다. 카운터가 0 이 되면 락이 해제되고, 락을 얻지 못한 스레드는 락이 해제될 때까지 블록됩니다.
따라서 synchronized 키워드는 사용에 매우 주의해야 합니다. 락을 소유한다는 것이 무거울 뿐더러, 다른 스레드가 해당 락을 획득할 때까지 블록되기 때문입니다. 이 블록 과정에서 운영 체제의 사용자 모드와 커널 모드의 전환이 필연적으로 발생합니다.
이외에도 Lock 인터페이스가 존재합니다. Lock 인터페이스는 synchronized 를 개선하고자 했는데, 대기 중 인터럽트, 페어락 등 몇 가지 추가된 기능을 제공합니다.
JDK 6 이후로 synchronized 에 대한 개선 작업이 이루어진 결과, 성능 면에서 Lock 키워드와 거의 동일하며 그 특유의 간결함 덕분에 synchronized 를 개발에 활용하는 경우도 여전히 많이 존햅나디ㅏ.
논블로킹 동기화
상호 배제 동기화는 스레드 정지와 깨우기로 인해서 성능 저하가 발생하며, 이를 해결하기 위해서 낙관락이라 불리는 논브로킹 동기화 기법을 사용할 수 있습니다. 잠재적으로 위험할 수 있더라도 작업을 진행하고, 충돌이 발생하면 보완 조취를 취하는 전략입니다.
락 최적화
스핀락과 적응형 스핀
대부분의 경우, 공유데이터는 잠깐만 잠기고 곧바로 해제됩니다. 그렇다면 공유 데이터에 대한 락을 획득하고자 하는 스레드를 블로킹 상태로 전환하는 것이 아니라 락을 획득할 때까지 루프를 돌게 하면 어떨까요?
이 방법에 착안한 것이 스핀락 방식입니다. 현대 HW 는 멀티코어이기에 락을 획득한 스레드가 락을 반납할 때까지 옆의 스레드가 루프를 돌면서 대기할 수 있습니다. 이는 스레드 전환 부하를 없앨 수 있지만, 락의 획득시간까지 프로세서 시간을 소비합니다.
따라서 일반적인 경우에는 스핀의 횟수에 제한을 두며 이는 JVM 에서 -XX:PreBlockSPin 매개 변수로 변경할 수 있습니다. 이를 개선한 적응형 스핀이 있는데 이는 이전 스핀 시간을 바탕으로 정해진 한계까지 점차적으로 스핀 횟수를 늘려나가며 최적의 스핀 시간을 찾는 방법입니다.
경량 락
경량 락의 목적은 스레드 경합을 없애 뮤텍스를 사용하는 기존 락의 성능 저하를 줄이는 것입니다.
편향 락
편향락은 경합이 없을 때 데이터의 동기화 장치들을 제거하여 프로그램 실행 성능을 높이는 최적화 기법입니다. 편향 락은 동기화는 하고 있지만 실질적인 경합이 없는 프로그램의 성능을 높일 수 있지만, 복잡한 로직을 필요로 하기에 오히려 동기화 서브시스템 설계에 방해가 됐습니다. 따라서 최신 JDK 에서는 제거됐습니다.