6 minute read

최적화는 모든 프로그램에 필요한 작업이다. 하지만 최적화는 어려운 작업이며, 최적화를 잘못하면 올바르지 않은 결과를 낳을 수 있다.

이번 장에서는 자바 프로그램에서 동시성을 완벽하게 보장한 뒤, 이를 토대로 최적화하는 방법을 살펴본다.

스레드 안정성

스레드 안정성이란?

여러 스레드가 한 객체에 동시에 접근할 때, 어떤 런타임 환경에서든 다음 두 조건을 모두 충족하면서 객체를 호출하는 행위가 올바른 결과를 얻을 수 있다면, 그 객체는 스레드 안전하다라고 말한다.

특별한 스레드 스케줄링이나 대체 실행 수단을 고려할 필요 없다. 추가적인 동기화 수단이나 호출자 측에서 조율이 필요 없다.

자바에서의 스레드 안정성

스레드 안정성은 안전함의 정도에 따라 다음과 같이 분류된다.

  • 불변
  • 절대적 스레드 안전
  • 조건부 스레드 안전
  • 스레드 호환
  • 스레드 적대적

불변

불변 객체는 스레드 안전하다. 불변 객체는 생성된 이후 상태를 변경할 수 없는 객체를 말한다.

자바에서는 기본 데이터 타입은 final 키워드로 정의되기만 하면 불변성이 보장된다. 공유 데이터가 객체라면 객체의 메서드가 상태(=필드)를 변경하지 않도록 해야한다. 가장 간단한 방법은 상태에 해당하는 모든 변수를 final로 선언하는 것이다.

절대적 스레드 안전

절대적 스레드 안전은 어떤 런타임 환경에서든 스레드 안전하다는 것을 의미한다. 사실 이 조건을 만족하기는 매우 어렵다.

그래서 자바 API에서 스레드 안전하다고 표시된 클래스 대부분이 절대적 스레드 안전하다는 의미는 아니다.

조건부 스레드 안전

일반적으로 자바 API에서 스레드 안전하다고 표시된 클래스는 조건부 스레드 안전하다는 의미이다.

조건부 스레드 안전한 객체는 단일 작업을 별도 보호 없이 스레드 안전하게 수행한다.

스레드 호환

스레드 호환은 객체는 스레드 안전하지 않지만 호출자가 적절한 동기화 수단을 사용할 수 있는 경우를 의미한다.

ArrayList, HashMapVector, Hashtable의 스레드 호환 버전이다.

스레드 적대적

스레드 적대적은 객체가 스레드 안전하지 않다는 것을 의미한다. 호출자가 적절한 동기화 수단을 사용해도 멀티스레드 환경에서 스레드 안전하게 수행할 수 없는 경우를 의미한다.

Thread클래스의 suspend(), resume() 메서드는 스레드 적대적이다. 두 메서드는 교착 상태를 일으킬 수 있기 때문에 deprecated되었다.

스레드 안정성 구현

스레드 안정성을 구현하는 방법은 다음과 같다.

상호 배제 동기화

상호 배제 동기화는 메서드 또는 블록을 동기화하여 한 번에 하나의 스레드만 접근할 수 있도록 하는 것을 의미한다.

뮤텍스가 대표적 동기화 수단이며, 임계 영역과 세마포어도 있다.

자바에서 가장 기본적인 동기화 수단은 synchronized 키워드이다. javac가 생성한 바이트 코드에서는 monitorenter, monitorexit 명령어로 구현되며, 각각 동기화 블록 전후에 실행된다. 둘 모두 락으로 사용할 객체를 인자로 받는다.

synchronized 키워드에 객체를 명시하면 이 객체의 참조가 매개 변수로 전달된다. 객체를 명시하지 않으면 키워드가 위치한 메서드에 따라 적절한 객체를 선택한다.

monitorenter 명령어를 실행할 때는 먼저 객체의 락을 얻으려 시도한다. 객체가 잠겨있지 않거나 스레드가 락을 이미 소유하고 있으면 락 카운터를 증가시킨다.

monitorexit 명령어를 실행할 때는 락 카운터를 감소시킨다. 락 카운터가 0이 되면 락을 해제한다.

락을 얻으려 시도한 스레드가 락을 소유하고 있지 않으면 락을 얻을 때까지 블록된다.

  • 같은 스레드라면 동기화 블록에 재진입이 가능하다.
  • 동기화 블록은 작업을 마치고 락을 해제할 때 까지 다른 스레드가 접근할 수 없다.

따라서 synchronized는 주의해서 사용해야 한다.

락을 소유한다는 것 자체가 실행 비용 측면에서 상당히 무거운 작업이기 때문에 제한적으로 이용해야 한다.

java.util.concurrent.locks.Lock 인터페이스가 새로운 상호 배제 동기화를 제공한다.

Lock 인터페이스는 논블록킹 동기화를 지원한다. 클래스 라이브러리 수준해서 동기화를 구현해 다양한 락으로 확장이 가능하다.

ReentrantLock 클래스는 재진입이 가능한 락을 구현한 클래스이다.

ReentrantLock 클래스는 synchronized 키워드와 동일한 기능을 제공하나, 대기 중 인터럽트, 페어 락, 둘 이상의 조건 지정 등 더 많은 기능을 제공한다.

  • 대기 중 인터럽트: 락 소유 스레드가 오랜 시간 락을 해제하지 않을 때 대기 중인 타 스레드들이 락을 포기하고 다른 일을 할 수 있도록 한다.
  • 페어 락: 같은 락을 얻기 위해 대기 스레드가 많을 때 락 획득을 시도한 시간 순서대로 락을 획득하는 것을 보장한다.
  • 둘 이상의 조건 지정: 락을 획득하기 위해 여러 가지 조건이 필요한 경우 조건을 지정할 수 있다.

synchronized 키워드는 락을 얻을 때 블록되지만, ReentrantLock 클래스는 락을 얻을 때 블록되지 않는다. 때문에 synchronized 는 멀티스레드 환경에서 성능이 좋지 않으나, ReentrantLock 클래스는 멀티스레드 환경에서 성능이 안정적이다.

그러나 둘 다 쓸수 있으면 synchronized 를 사용하는 것이 좋다. 그 이유는,

  • synchronized는 자바 구문 수준 동기화 수단이며 매우 명확하고 간결하다.
  • Lockfinally 블록에서 락을 해제해야 한다.
  • Lock은 어느 스레드가 락을 얻었는지 알 수 없다.

논블록킹 동기화

논블록킹 동기화는 락을 얻지 못해 블록되지 않고 다른 작업을 할 수 있도록 하는 것을 의미한다. 이는 낙관적 동기화 기법을 사용한다.

잠재적으로 위험하더라도 일단 작업을 진행하고, 충돌이 발생하면 보완 조치를 취하는 것이다.

하드웨어 명령어 집합이 작업 진행과 충돌 감지라는 두 단계를 마치 한 명령어처럼 원자적으로 수행할 수 있어야 한다.

이러한 기능을 제공하는 명령어는 다음과 같다. : TAS(Test and Set), CAS(Compare and Swap), LL/SC(Load Linked/Store Conditional), FAA(Fetch and Add), swap

CAS 명령어는 피연산자를 세 개 받는다. 첫 번째는 메모리 주소(V), 두 번째는 비교할 값(A), 세 번째는 저장할 값(B)이다.

만약 V가 현재 A와 같다면 B를 V에 저장한다. 만약 V가 A와 다르다면 갱신을 수행하지 않는다. 갱신 여부와 무관하게 A를 반환한다.

JDK 5에서 sun.misc.Unsafe 클래스의 compareAndSwapInt()compareAndSwapLong() 등의 메서드로 구현되어 있다. 그러나 이 메서드는 자바 API에서 제공하지 않는다. 사용자가 직접 사용할 수는 없으나, AtomicInteger 등의 클래스에서 사용된다.

JDK 9 이후 VarHandle 클래스가 추가되어 이 기능을 제공한다.

하지만 모든 상호 배제 동기화 시나리오를 대신할 수는 없다. ABA 문제가 있기 때문이다.

ABA 문제는 메모리 주소 V가 처음에 A 값을 가지고 있다가 B 값으로 변경되었다가 다시 A 값으로 변경되는 경우를 의미한다. 이 경우 다른 스레드가 값을 변경하였음에도 변경되지 않은 것으로 판단할 수 있다.

동기화가 필요 없는 경우

메서드의 반환값을 예측할 수 있고 똑같은 입력에는 항상 똑같은 결과를 반환한다면, 그 메서드는 재집입 가능하고 당연히 스레드 안전하다.

락 최적화

JDK 6부터 자바 락 구현이 많이 개선되었다. 적응형 스핀, 락 제거, 락 범위 확장, 경량 락, 편향 락 등 다양한 락 최적화 기술을 구현하였다.

적응형 스핀

스핀 락은 스레드를 멈추지 않고 루프를 돌면서 락을 기다리는 것을 의미한다.

이 락이 잠시 걸린 경우에는 효과가 좋지만, 락이 오래 걸리면 프로세서 자원을 낭비하게 된다.

따라서 스핀 락의 대기 시간에 제한을 두어 프로세서 자원을 낭비하지 않도록 한다.

이를 위해 JDK 6에서는 적응형 스핀 락을 도입하였다.

적응형 스핀 락은 스핀 시간이 고정되지 않고, 같은 락의 이전 스핀 시간과 락 소유자의 상태에 따라 스핀 시간을 조절한다. 기존 스핀 횟수까지는 참고해 시도한다.

락 제거

락 제거는 데이터 경합이 없다고 판단하는 경우 JIT 컴파일러가 락을 제거하는 것을 의미한다.

이에 대한 근거는 탈출 분석이다. 탈출 분석은 객체 내의 모든 힙 데이터가 타 스레드에 의해 접근되지 않는 경우 스택에 있는 데이터처럼 스레드 안전하다고 판단한다.

락 범위 확장

연이은 다수의 작업이나 순환문에서 같은 락 객체를 반복해 사용하는 경우, 락 범위를 확장하여 락을 한 번만 획득하도록 한다.

경량 락

경량 락은 락을 획득하는 데 드는 비용을 줄이기 위해 사용되는 락으로, 스레드 경합을 없애 뮤텍스를 사용하는 기존 락의 성능을 향상시키기 위해 사용된다.

핫스팟 가상 머신에서 객체 헤더는 두 부분으로 나뉜다.

첫번째 부분은 마크 워드로, 해시 코드와 GC 등 객체의 런타임 정보를 저장한다. 두 번째 부분에는 메서드 영역의 테이터 타입 데이터를 가리키는 포인터를 저장하고, 배열인 경우 배열 길이를 저장한다.

이 마크 워드는 객체 상태에 따라 자신의 저장 공간을 재사용한다.

코드가 동기화 블록에 진입하려 할 때 락 객체가 잠겨 있지 않다면(락 플래그가 01) 가상 머신은 가장 먼저 현재 스레드의 스택 프레임에 락 레코드를 생성한다. 락 레코드는 사실상 마크 워드를 복사한 것이며, 소유 락 객체를 저장한다.

그 다음 가상 머신은 CAS 연산으로 락 객체의 마크 워드를 락 레코드를 가리키는 포인터로 변경한다. 변경에 성공하면 락을 얻는 데 성공한 것이다.

둘 이상의 스레드가 같은 락을 두고 경합하는 상황이라면 경량 락은 더 이상 유효하지 않다. 따라서 락 플래그 값을 10으로 수정해 중량 락으로 확장한다.

해제 과정도 CAS 연산으로 현재 마크 워드와 옮겨진 마크 워드를 교체하는 식으로 이루어진다. 교체에 실패하면 다른 스레드가 락을 얻으려 시도했고, 일시 정지된 스레드는 락 해제와 동시에 재개된다.

편향 락

JDK 6에서는 편향 락을 도입하여 락 획득 비용을 줄였다.

편향 락은 경합이 없을 때 데이터의 동기화 장치들을 제거하여 프로그램 실행 성능을 높이는 최적화 기법이다. 경량 락에서 한걸음 더 나아가 CAS 연산도 사용하지 않게 해 락 획득 비용을 줄였다.

편향 락이란, 마지막으로 썼던 스레드가 락을 ‘찜’해두는 것을 의미한다.

편향 락이 활성화된 가상 머신에서 어떤 스레드가 락 객체를 처음 획득하면, 객체의 헤더에서 락 플래그는 01로, 편향 모드는 1로 설정된다. 그리고 락을 얻은 스레드 ID를 마크 워드에 저장한다. 이때 CAS 연산을 사용하고, 연산이 성공하면 편향 락을 소유한 스레드는 동기화 작업 없이 해당 동기화 블럭에 진입할 수 있다. 다른 스레드가 락을 얻으려는 즉시 편향 모드를 0으로 변경한다.

이때, 마크 워드 공간의 대부분(23비트)이 락을 소유한 스레드의 아이디를 저장하는 데 사용된다. 원래 이 공간은 객체의 해시 코드가 저장되던 곳이다. 신원 해시 코드를 이미 계산한 객체의 경우 편향 모드가 진행되지 않는다. 편향 모드 객체가 해시코드 계산을 필요로 하는 경우 편향 모드를 0으로 변경하고 해시코드를 계산한다.

편향 락은 -XX:+UseBiasedLocking 옵션을 통해 활성화할 수 있다.

Vector, Hashtable 등 초기 컬렉션을 사용하는 경우 편향 락이 활성화되어 있으면 성능이 좋아진다.

Leave a comment