자바 메모리 모델

자바 메모리 모델(Java Language Memory Model, JMM)은 프로그램 내의 상태와 컴퓨터에 설치된 실제 메모리 간의 관계를 정의하고 있다. 만약 프로그램이 순차적으로만 처리된다는 보장이 있다면 사실 이런 별도의 모델이 필요하지 않을지도 모른다. 하지만 멀티 코어를 활용한 하드웨어 시스템이 보편화 된 현재와 같은 환경에선 멀티스레드 프로그래밍은 옵션이 아닌 필수로 자리잡았고, 그에 따라 병렬 처리를 활용한 효율적인 프로그램의 실행을 위해 추가적인 고려사항들이 발생하게 됐다. 자바 메모리 모델은 바로 이런 맥락에서, 멀티스레드 환경에서 여러 스레드가 어떻게 공유되는 상태를 올바르게 동기화하며 병렬 처리의 이점을 충분히 활용할 수 있을지에 관한 장치를 제공한다.

 

동기화와 가시성

자바의 synchronized 키워드나 이에 상응하는 java.concurrent.lock 패키지의 클래스는 멀티스레드 환경에서 임계 영역(critical section)을 지정해 상호 배타적(mutually exclusive)구간을 설정함으로써 스레드 간 동기화를 가능케한다. 자바의 인스턴스나 클래스는 각자가 고유한 모니터(monitor)를 갖고 있는데, 이 모니터는 단 하나의 스레드만이 점유할 수 있다. 따라서 여러 스레드가 특정 임계 영역에 동시에 접근하고자 하는 상황(해당하는 특정 모니터의 소유권을 취득하고자 다투는 상황)에서 모니터의 소유권을 기준으로 배타적 접근이 가능하게 된다. 하지만 배타성은 임계 영역에 포함된 오퍼래이션이 둘 이상의 스레드에서 동시에 진행되지 않음을 보장할 뿐이다. 완전한 동기화를 위해선 특정 스레드가 임계 영역 내에서 작업한 변경 사항을 그 이후에 해당 임계 영역에 접근하는 다른 스레드에서도 일관성 있게 확인할 수 있어야 하는 가시성(visibility)의 측면도 함께 고려해야 한다. 즉, 임계 영역에서 변경된 내용은 그 다음에 오는 임계 영역의 접근자에게 가시적이어야 하며, 자바의 동기화 기능은 JMM을 통해 이런 측면도 함께 보장하고 있다.

자바의 표준 문서에선 JMM 명세를 통해 ‘먼저 발생한 일(happens-before)’이 무엇인지 보장함으로써 가시성의 단서를 제공하고 있다. 이에 해당하는 내용은 다음과 같다.

  • 동일 스레드 내에서 먼저 오는 액션은 먼저 수행된다.
  • 소멸자는 생성자가 완전히 종료된 후에 수행된다.
  • 서로가 동기화되는 두 액션은 순서대로 수행된다.
  • 모니터의 언락은 뒤에 위치한 모니터의 락보다 앞서 수행된다.
  • volatile 필드에 쓰는 행위는 뒤에 위치한 해당 필드의 읽기 행위보다 앞서 수행된다.
  • 스레드의 start() 행위는 시작된 스레드의 다른 모든 동작보다 앞서 수행된다.
  • 특정 스레드의 모든 행위는 해당 스레드에 관한 join()의 반환보다 앞서 수행된다.

즉, 자바는 JMM을 통해 서로 다른 스레드에서 각각 쓰기와 읽기 동작을 수행하더라도 이런 happens-before 관계만 분명히 지켜진다면 반드시 대상 필드의 값이 가시적임을 보장한다.

 

가시성을 위협하는 요소들

프로세서 캐시

멀티 코어 CPU 아키텍처에선 각 CPU 코어마다 별도의 캐싱 공간이 마련돼 있다. 메인 메모리를 모든 CPU가 함께 공유하는 상황에서 개별 코어 마다 캐시가 독립적으로 동작하는 상황은 메인 메모리와 각 캐시 사이의 데이터 일관성이 보장되지 않는 캐시 일관성(cache coherence) 문제를 유발한다. 즉, 자바 프로그램이 동작하며 실행되는 둘 이상의 스레드가 각기 다른 코어에서 수행되는 상황에서 스레드 간에 공유되는 프로그램 상태의 가시성이 예상치 못한 행태를 보일 수 있다. 따라서 올바른 동기화 구문을 명시적으로 추가해 JMM의 happens-before 관계를 보장해줘야만 JVM이 메인 메모리와 캐시 간의 일관성을 보장해주게 된다. 즉 동기화 구문(sycnrhonized나 volatile과 같은)을 사용한다면 데이터의 기록 시에 코어에 캐시된 데이터가 최종적으로 메인 메모리에 기록되며, 읽을 때 역시 캐시가 아닌 메인 메모리로부터 데이터를 가져옴을 보장할 수 있다.

동작의 재배열

자바의 컴파일러는 자바 코드를 바이트 코드로 컴파일 하면서 정해진 범위 내에서 자유롭게 최적화를 수행할 수 있다. 이런 최적화에는 단순히 코드의 특정 구문을 보다 효율적인 다른 구문으로 치환하는 경우도 있지만, 때론 중복된 구문을 삭제하거나 구문 간의 순서를 재배치함으로써 소기의 목적을 달성하기도 한다. 뿐만 아니라 CPU의 연산 시에도 인스트럭션을 재배열하거나 보다 작은 마이크로 인스트럭션으로 나눠서 처리할 수도 있기 때문에 자바로 작성된 프로그램의 정확한 수행 순서를 예측하기란 쉽지가 않다. 자바는 앞서 설명한 happens-before 관계를 보장함으로써 일반적인 단일 스레드 환경에서의 수행 순서를 보장하고 있지만, 멀티스레딩 환경에선 최적화에 따른 재배열(reordering)이 예상치 못한 가시성의 문제를 일으키지 않도록 보장하기 위해 명시적으로 동기화를 처리해 happens-before 관계를 분명히 밝혀야 한다.

 

Volatile 키워드

synchronized 키워드나 그에 상응하는 동기화 구문은 임계 영역이란 구간을 지정해 동기화를 보장한다. 반면에 volatile 키워드는 변수에 선언된다는 측면에서 조금 다른 접근법을 취한다. volatile로 선언된 필드에 접근할 때는 마치 해당 접근 구문을 synchronized 키워드로 감싼 것과 같이 동작하며 동기화를 보장하게 된다. 즉, volatile로 선언된 필드에 쓰기 동작을 하면 해당 내용은 원자적이고 배타적으로 메인 메모리에 기록되며, 읽기 동작 역시 배타적으로 수행되어 다른 스레드가 간섭(interleave)할 수 없음이 보장된다. 그런데 현실적인 문제를 생각해보자면 이런 단순한 필드 동기화 만으로는 volatile 키워드의 활용도가 상당히 낮을 수 밖에 없다는 문제가 있다. 예컨데 이런 동기화로는 volatile 필드 자체의 가시성은 보장되지만, 해당 오퍼래이션을 수행할 때 volatile이 아닌 필드가 재배열되면서(volatile 필드와의 상대적 전후관계가 뒤바뀌는 등) 예상치 않은 동작을 할 가능성을 남기게 된다. 따라서 JSR133이 반영된 JDK5부턴 volatile 필드의 동기화 보장뿐만 아니라 해당 volatile 필드와 관련된 다른 필드가 volatile 필드의 위치를 넘나들며 재배열될 수 있는 가능성을 제거해 확장된 개념의 동기화를 제공하게 됐다. 이에 따라 특정 스레드에서 volatile 필드에 쓰기 동작을 수행하는 시점에서의 가시성과 동일한 수준의 가시성이 해당 volatile 필드를 읽어가는 다른 스레드에서도 고스란히 보장된다. 개발자는 이런 동기화 방식을 바탕으로 직관적으로 보다 쉽게 이해할 수 있는 코드를 작성할 수 있게 됐다.

 

Reference