28 Nov 2016

JSR 133 (Java Memory Model) FAQ 번역

JSR 133 (Java Memory Model) FAQ 를 번역한 글입니다.

JSR 133 (Java Memory Model) FAQ Jeremy Manson and Brian Goetz, February 2004

메모리 모델이란 무엇입니까?

멀티프로세서 환경에서 프로세서들은 1개 이상의 메모리 캐시를 갖게됩니다. 메모리 캐시는 데이터에 빠르게 접근함으로써 성능을 향상시키며 (프로세서에 가까이 위치하기 때문에) 메모리 버스안에서 발생하는 트래픽을 감소시켜줍니다. (많은 메모리 연산은 로컬 캐시에 의해 감소될 수 있기 때문에) 메모리 캐시는 성능을 크게 향상시킬 수 있으나 새로운 문제점들을 갖고 있습니다. 두개의 프로세서가 동시에 같은 메모리 주소에 접근한다면 어떤 문제가 발생할까? 어떤 조건에서 같은 값을 볼 수 있을까? 와 같은 질문이 있습니다.

프로세서 수준에서의 메모리 모델에서는 다른 프로세서의 메모리 기록을 현재 프로세서가 볼 수 있도록 필요 충분한 상태를 정의하고 있습니다. 그 역도 마찬가지입니다. 몇몇 프로세서는 강한 메모리 모델(strong memory model)을 갖고 있고 다른 몇몇 프로세서는 약한 메모리 모델(weaker memory model)을 갖고 있습니다. 먼저 강한 메모리 모델은 언제나 모든 프로세서가 같은 메모리 주소에서 정확히 같은 값을 볼 수 있습니다. 약한 메모리 모델에서는 다른 프로세서의 쓰기(writes) 연산을 현재 프로세서에 보여주거나 현재 프로세서의 쓰기 연산을 다른 프로세서에 보여주기 위해 로컬 프로세서의 캐시를 무효화하는(invalidate or flush) 메모리 장벽(Memory barriers) 이라는 특별한 명령어 집합을 갖고 있습니다. 이러한 메모리 장벽은 일반적으로 잠금(lock), 잠금 해제(unlock) 명령어를 실행할때 동작하며 고수준의 언어를 다루는 프로그래머에게 노출되지 않습니다.

강한 메모리 모델에서는 메모리 장벽을 많이 요구되지 않기 때문에 프로그램을 작성하는 것이 쉬운 편입니다. 하지만 몇몇 강한 메모리 모델은 메모리 장벽을 필요로 하며 이는 직관에 반하게 됩니다. 최근에는 프로세서 설계에 약한 메모리 모델을 사용하는 것이 권장되고 있는데, 이 모델이 캐시 일관성을 위해 느슨함을 갖고, 많은 수의 프로세서와 메모리에서 규모 확장성에 도움을 주기 때문입니다.

쓰레드간의 쓰기 연산의 가시성 문제는 컴파일러의 코드 재배열(reordering)에 의해 더 악화됩니다. 예를들어 쓰기 연산을 나중에 실행하는 것이 더 효율적일 수 있기 때문에 컴파일러는 프로그램의 의미(semantics)를 바꾸지 않는 선에서 코드를 재정렬 할 수 있습니다. 컴파일러가 연산을 뒤로 미루기로 결정한다면 다른 쓰레드는 그것이 수행될 때 까지 값을 볼 수 없습니다. 이것은 캐시의 효과를 보여줍니다.

게다가 메모리 쓰기 연산은 앞쪽으로 배치될 수 도 있으며, 이 경우에는 다른 스레드는 프로그램에서 실제로 쓰기가 발생하기 전에 쓰기 연산의 결과값을 확인할 수 있습니다. 이러한 모든 유연성은 설계에 의해 이루어 집니다. 컴파일러, 런타임, 하드웨어에 최적의 순서로 연산을 재정렬(reordering)하는 유연성을 부여함으로써 메모리 모델의 경계 안에서 더 높은 성능을 달성할 수 있습니다.

class Reordering {
    int x = 0, y = 0;
    public void writer() {
        x = 1;
        y = 2;
    }

    public void reader() {
        int r1 = y;
        int r2 = x;
    }
}

위 코드에서 두개의 쓰레드로 writer(), reader() 동시에 실행시킨다고 가정해봅시다. 먼저 변수 y에 2를 쓰는 연산은 x에 1을 쓰는 연산 다음에 실행되기 때문에 프로그래머는 y가 2로 읽힌 이후에 x가 1로 읽힌다고 가정할 것입니다. 하지만 x와 y에 값을 쓰는 연산은 재정렬(reordering)될 수 있습니다. 그리고 재정렬(reordering)이 발생한다면 y의 쓰기 연산이 발생하고 x,y를 읽는 연산이 발생한 뒤 x의 쓰기 연산이 발생할 수 있습니다. 그 결과 reader()에서 r1는 2, r2는 0이 될 것입니다.

자바 메모리 모델은 멀티스레드 프로그램에서 코드가 어떤식으로 동작할지 그리고 멀티 스레드가 어떤식으로 메모리와 상호작용하는지 정의합니다. 또한 프로그램에서 저수준의 레지스터와 메모리에 프로그램 변수를 읽고 쓰는 것에 대한 상세한 내용을 정의합니다. 다양한 하드웨어와 컴파일러 최적화를 사용하여 정확하게 이것을 구현할 수 있습니다.

자바 언어는 volatile, final, synchronized를 포함하고 있으며 이를 이용하여 프로그래머가 컴파일러에게 동시성 요구사항을 설명하도록 도와줍니다. 자바 메모리 모델은 volatile, synchronized 등의 행동을 정의하고 있고 정확하게 동기화된 자바 프로그램이 모든 프로세서 아키텍처에서 정확하게 동작하도록 보장합니다.

C++ 과 같은 다른 언어도 메모리 모델을 가지고 있습니까?

예를들어 C와 C++ 은 멀티스레딩을 직접적으로 지원하지 않습니다. 이러한 언어들이 컴파일러와 아키텍처에서 발생하는 코드 재정렬(reordering)을 지원하기 위해 스레딩 라이브러리들에 의존합니다. 예를들어 쓰레딩 라이브러리(pthread)나 컴파일러, 플랫폼에 의해 멀티스레딩이 보장됩니다. (C++ 11은 언어 수준에서 멀티 쓰레딩을 지원합니다.)

JSR 133은 어떤 것을 다룹니까?

1997년 이후, 자바 언어 스펙(챕터 17)에 정의되어 있는 자바 메모리 모델에서 많은 결함이 발견되었습니다. 이러한 결함은 프로그램을 불확실하게 만들었으며 (예를들어 final 필드에서 값이 변하는 것이 관찰되는 등) 최적화를 수행하는 컴파일러에 악영향을 끼쳤습니다.

자바 메모리 모델은 최초로 프로그래밍 언어 수준에서 메모리 모델을 통합하는 것을 시도하였으며 다양한 아키텍처에서 동시성을 제공하기 위해 일관성있는 문맥을 제공하였습니다. 하지만 일관적이고 직관적인 메모리 모델을 정의하는 것은 예상했던 것보다 어려웠습니다. JSR 133은 이전의 메모리 모델의 문제를 해결하는 새로운 메모리 모델을 정의하였습니다. 이를 위해 final과 volatile의 의미가 변경될 필요가 있었습니다.

상세한 기능은 http://www.cs.umd.edu/users/pugh/java/memoryModel 에서 확인할 수 있습니다. 복잡하면서도 겉으로 보기엔 단순해 보이는 동기화의 개념을 확인하면서 놀라움을 느낄 수 있습니다. 운이 좋게도 당신은 구체적인 부분까지 이해할 필요가 없습니다. JSR 133의 목표 자체가 volatile과 synchronized 그리고 final이 어떻게 동작하는지에 대한 직관적인 틀을 제공하는 공식적인 의미 집합을 보여주는 것이기 때문입니다.

JSR 133의 목표

  • 기존의 타입 안정성과 기존의 안정성을 보장하면서 다른 것들을 강화합니다. 예를들어 이유없이 변수의 값들이 생성되지 않을것 입니다. 몇몇 쓰레드에 의해 관찰되는 값은 일부 쓰레드 합리적으로 배치될 수 있는 값이여야 합니다.
  • 정확하게 동기화된 프로그램은 가능한 직관적이고 간단한 의미를 가져야 한다.
  • 불완전하게 동기화된 프로그램의 의미는 잠재적인 보안 위험을 최소화 하도록 정의되어야 한다.
  • 프로그래머들가 멀티스레드 프로그램이 어떻게 메모리와 상호작용 하는지 추론할 수 있어야한다.
  • 다양한 아키텍처에서 정확하고 고성능 JVM을 구현하는 것이 가능해야한다.
  • 초기화 안정성이 보장되어야 한다. 만약에 객체가 잘 생성되었다면 (생성되는 도중 참조값이 노출될 수 없음을 의미) 그 참조값을 갖고 있는 모든 스레드들이 동기화 없이 값들을 볼 수 있을 것이다. (생성자에서 세팅된 final 필드를 위한 값들)
  • 기존의 코드에는 최소한의 영향만 끼쳐야한다.

코드 재정렬은 무엇을 의미하나요?

프로그램 변수에 접근하는 명령어들은 프로그램에 명시된 것과 달리 다른 순서로 실행되는 경우는 많다. 컴파일러는 최적화를 위해 명령어의 순서를 자유롭게 할 수 있다. 프로세서는 특정상황에서 순서에 맞지 않게 명령어를 실행할 수 있다. 데이터는 레지스터, 캐시, 메인메모리 사이를 프로그램에 명시된 것과 다른 순서로 이동할 수 있다.

예를들어 만약에 쓰레드가 필드 a에 값을 쓴뒤 필드 b에 값을 쓰고 b는 a에 의존하지 않는 값이라면 컴파일러는 이 연산의 순서를 재정렬 할 수 있다. 또한 필드 b를 a보다 먼저 캐시에서 메모리로 플러시(flush) 할 수 있다. 많은 재정렬(reordering) 케이스가 존재한다. (예를들면 CPU, 컴파일러, JIT)

컴파일러, 런타임, 하드웨어는 싱글 스레드 프로그램에서 프로그램 스스로 명령어 재정렬의 영향을 확인할 수 없도록 해야한다. 그러나 올바르지 않게 동기화된 멀티스레드 프로그램에서 프로그램에서 명시된 것과 다른 순서로 변수에 접근하는 것을 확인할 수 있다.

대부분의 경우 하나의 스레드는 다른 스레드가 무엇을 하는지 알 수 없다. 이것을 위해 동기화(synchronization)이 존재한다.

이전의 메모리 모델은 무엇이 잘못되었습니까?

오래된 메모리 모델은 몇가지 심각한 문제를 갖고 있었다. 이해하기 어려운 문제였고 덕분에 광범위하게 문제가 퍼지게 되었다. 예를들면 오래된 모델은 모든 JVM에서 발생하는 메모리 재정렬(reordering) 지원하지 않는다. 이러한 혼란때문에 JSR-133이 만들어지게 되었다.

예를들면 final 필드가 사용되었을때 쓰레드 간의 동기화가 필요없다는 것은 널리 알려진 사실이다. 이것은 합리적인 가정이며 우리가 당연하게 생각하는 것이지만 오래된 메모리 모델에서는 그렇지 않다. 오래된 메모리 모델은 final 필드를 다른 필드와 다르게 다루지 않는다. 즉 동기화는 모든 쓰레드가 final 필드의 값을 볼 수 있게하는 유일한 방법이었다. 그 결과 모든 쓰레드는 final 필드의 기본값을 보는 것이 가능해졌고 시간이 지난뒤 생성자에서 초기화된 값을 볼 수 있었다. 즉 String과 값은 불변객체의 값이 변화하는 것을 관측할 수 있었다는 것이다.

오래된 메모리 모델에서는 volatile 변수의 쓰기가 nonvolatile 변수의 읽기 쓰기와 함께 재정렬(reordering) 되는것이 가능했다. 많은 개발자들은 volatile의 이러한 부분 때문에 많은 혼동을 갖게되었다.

결론적으로 멀티쓰레드 프로그램이 올바르게 동기화 되지않았을때 프로그래머들은 더 많은 혼란에 빠지게되고 더 착각하게 되었다.

올바르게 동기화 되지 않은것은 무엇을 의미하나요?

사람마다 올바르지 않게 동기화된 코드를 다르게 생각할 수 있습니다. 하지만 자바 메모리 모델의 관점에서 올바르지 않게 동기화된 코드는 다음을 의미합니다.

  1. 하나의 쓰레드에서 하나의 변수에 값을 쓴다.
  2. 다른 하나의 쓰레드에서 같은 변수에 값을 읽어 들인다.
  3. 읽고 쓰는 코드에 순서가 없다.

이러한 것을 데이터 레이스(data race)라고 합니다. 데이터 레이스를 갖고 있는 프로그램은 올바르지 동기화된 프로그램이라고 할 수 없습니다.

동기화는 어떤것을 해줍니까?

동기화의 다양한 의미가 존재합니다. 가장 잘 알려진 상호배제(mutual exclusion) 입니다. 상호배제란 한번에 하나의 쓰레드가 모니터(monitor)를 보유하는 것입니다. 이는 하나의 쓰레드가 모니터에 의해 보호되는 동기화 블록에 진입한다는 의미입니다. 이 쓰레드가 블록에서 나오기 전까지 다른 쓰레드는 이 동기화 블록에 진입할 수 없습니다.

그러나 상호배제보다는 동기화보다 더 많은 요소를 갖고있습니다. 동기화(synchronization)는 한 쓰레드가 동기화 블록안에 있거나 진입하기 직전의 메모리 쓰기(writes)를 같은 모니터에 의해 동기화된 다른 쓰레드에게 예측가능한 방식으로 보여지게 됩니다. 동기화 블록에서 빠져나온뒤 우리는 모니터를 해제(release)합니다. 이때 캐시 메모리의 데이터를 메인 메모리로 이동시키며 쓰레드의 메모리 쓰기(writes) 연산들은 다른 쓰레드에게 보여지게 됩니다. 쓰레드는 동기화 블록에 들어가기전에 모니터를 획득(acquire)합니다. 이 모니터는 메인 로컬 캐시를 무효화 시키며 메인 메모리로부터 다시 데이터를 불러옵니다. 결국 이전에 수행된 모든 쓰기 연산을 볼 수 있게 됩니다.

다시 캐시의 관점에서 본다면 이 이슈는 다중 처리기(multi processor)에만 관련있는 것 처럼 보입니다. 그러나 단일 처리기(single processor)에서도 명령 재배열(reordering)은 쉽게 관찰됩니다. 예를들면 컴파일러는 코드를 동기화 블록에 진입하기 전이나 후로 이동 시키는 것은 불가능합니다. 캐시에서 모니터 획득, 해제하는 것은 많은 영향을 끼친다고 할 수 있습니다. (위의 Reordering 클래스를 확인해보면 알 수 있습니다. 단일 프로세서라 하더라도 동기화 블록을 사용하지 않는다면 예상하지 못한 결과가 발생할 수 있습니다.)

새로운 메모리 모델은 메모리 연산들과 쓰레드 연산에 부분 순서를 정의했습니다. (read field, write field, lock, unlock, start, join) 이를 연산들은 다른 연산들 이전에 실행(happen before) 된다고 한다. 하나의 연산이 다른 연산이 실행되기 이전에 실행될때 이는 각 연산이 순차적인 순서로 실행됨을 뜻하며 첫번째 연산의 결과는 두번째에 보이게 됩니다. 규칙은 아래와 같습니다.

  • 각 연산은 프로그램 순서상 나중에 오는 같은 쓰레드의 모든 연산들 보다 먼저 실행된다.
  • 모니터의 해제(unlock)는 뒤이어 실행되는 같은 모니터에 대한 획득(lock) 이전에 실행된다.
  • volatile 변수 쓰기 연산은 뒤이어 실행되는 같은 volatile 변수 읽기 연산 이전에 실행된다.
  • start() 메소드는 시작된 쓰레드안에 어떤 연산보다 먼저 호출된다.
  • 시작된 쓰레드안의 모든 연산은 그것을 기다리는 다른 쓰레드의 join() 메서드의 반환보다 먼저 실행된다.

동기화 블록에서 나오려는 쓰레드의 모든 메모리 연산들은 곧 같은 동기화 블록으로 들어가려는 쓰레드에 가시성을 띄는것을 의미합니다. 모든 메모리 연산은 반드시 모니터를 해제하기 전에 실행되고 모니터 해제는 모니터 획득 이전에 발생하기 때문입니다.

메모리 장벽을 만들기 위해 사용하는 다음 코드는 동작하지 않습니다.

synchronized (new Object()) {}

위 코드는 어떤 연산도 아니며 컴파일러는 이 코드를 삭제할 것 입니다. 왜냐면 컴파일러는 이 블록에 진입할때 어떤 쓰레드도 같은 모니터를 사용하지 않는다는 사실을 알기 때문입니다. happen-before 관계를 명확하게 사용하여 다른 쓰레드의 연산 결과를 볼 수 있게하는 것은 반드시 필요합니다.

중요한 사항: happen-before 관계를 명확하게 하기 위해 쓰레드들을 같은 모니터로 동기화하는 것은 중요합니다. 객체 X에 의해 동기화되어 쓰레드 A에 보이는 모든 것은 Y로 동기화된 쓰레드 B에 보이지 않습니다. 해제(release)와 획득(acquire)은 같은 모니터에서 실행되어야 합니다. 그렇지 않으면 데이터 레이스(data race)가 발생합니다.

final 필드의 값이 바뀌는 것을 어떻게 볼 수 있습니까?

final 필드의 값이 바뀌는 것처럼 보이는 예들 중 하나는 String 클래스의 특정 구현과 관련 있습니다.

String은 3개의 필드로 구현되어 있습니다. char[], offset, length 입니다. 오직 char[]만 가지는 대신 이러한 필드를 사용하여 구현하는 근본적인 이유는 다른 String 객체들과 String Buffer 객체들이 같은 char[]을 공부하게하고 추가적인 객체 복사를 막기 위해서 입니다. 예를들면 substring 메서드는 새로운 String 객체를 만들고 원본 객체와 같은 char[]를 갖고 offset과 length만 다른 값을 갖습니다. String 메서드를 위해 이 3가지 필드는 final로 선언되어 있습니다.

String s1 = "/usr/tmp";
String s2 = s1.substring(4);

문자열 s2는 offset은 4, length 4를 갖습니다. 그러나 오래된 메모리 모델에서는 다른 쓰레드에서 offset이 0을 갖는 것을 관찰할 수 있으며 나중에 4로 변하는 것을 볼 수 있습니다. 이것은 마치 문자열이 “/usr”에서 “/tmp”로 바뀌는 것 처럼 보입니다.

오래된 메모리 모델에서는 이런 상황이 발생할 수 있습니다. 여러개의 JVM은 이러한 문제를 보여주었습니다. 새로운 메모리 모델에서는 이것을 불가능하게 하였습니다.

새로운 JMM에서 final 필드는 어떻게 동작합니까?

final 필드의 값은 생성자 안에서 세팅됩니다. 객체가 한번에 “정확하게(correctly)” 생성된다고 가정해봅시다. 일단 객체가 생성되면 final 필드에 할당된 값들은 동기화 없이 다른 모든 쓰레드에게 가시성을 띄게됩니다. 게다가, final 필드에 의해 참조되는 객체나 배열의 값 또한 최신일 것 입니다.

정확하게(correctly) 생성된 객체는 어떤 객체를 의미를 지닐까요? 간단하게 말하면 객체가 생성되는 동안 그 참조값을 외부로 내보내지 말아야 하는 것을 뜻합니다. [Safe Construction Techniques]http://www.ibm.com/developerworks/library/j-jtp0618/ 의 예제를 확인해보세요. 즉, 생성중인 객체의 참조값을 다른 스레드가 볼 수 있는 다른 어느 곳에도 두지 말라는 뜻 입니다. static 필드에 할당하지 마십시오. 다른 객체의 리스너로 등록하지 마십시오. 이러한 작업은 생성자가 호출중이 아닌 호출된 이후에 수행되어야 합니다.

class FinalFieldExample {
  final int x;
  int y;
  static FinalFieldExample f;
  public FinalFieldExample() {
    x = 3;
    y = 4;
  }

  static void writer() {
    f = new FinalFieldExample();
  }

  static void reader() {
    if (f != null) {
      int i = f.x;
      int j = f.y;
    }
  }
}

위 예제는 final 필드가 어떤식으로 사용되는지 보여주고 있습니다. reader를 실행하는 쓰레드 입장에서는 f.x는 final이기 때문에 3으로 보이게 됩니다. 그러나 y는 final이 아니기 때문에 4로 보이지 않을 수 있습니다. 만약 FinalFieldExample의 생성자가 다음과 같다면

public FinalFieldExample() { // bad!

  x = 3;
  y = 4;
  // bad construction - allowing this to escape

  global.obj = this;
}

쓰레드 입장에서 global.obj로 부터 this를 참조값을 읽을 수 있게되고 이는 x의 값은 3으로 보장되지 않습니다.

정확하게 생성된 값을 보는 능력은 멋지다고 할 수 있습니다. 그러나 만약 필드가 참조값을 갖는다면 당신의 코드가 가르키는 객체(배열)가 최신값일 원할 것 입니다. 만약 그 필드가 final 이라면 이것이 보장됩니다. 그럴경우 다른 쓰레드가 정확한 참조값을 보는 것에 대한 걱정을 하지 않아도 되지만 배열의 내용은 정확하지 않을 수 있습니다. 다시말해서 여기서 “정확한(correct)” 값은, “최종적으로 이용 가능한 값”을 뜻하는 것이 아닌 “객체의 생성자가 끝날 때의 최신”을 뜻합니다.

이 모든 것들을 설명한 뒤에도, 쓰레드가 불변 객체(final 필드만 포함하는 객체)를 생성한 뒤에 다른 모든 쓰레드에게 그 객체가 정확하게 관찰되길 원한다면 늘 그렇듯이 여전히 동기화를 사용할 필요가 있습니다. 예를들면 불변 객체의 참조값이 다른 쓰레드에게 보이도록 하는 다른 방법은 없습니다. 프로그램이 final 필드에서 얻게되는 이점은 어떻게 동시성이 다뤄져야 하는지에 대한 깊은 이해와 함께 고려되어야 합니다.

만약 당신이 final 필드를 변경하기 위해 JNI를 사용한다면 어떠한 정의된 동작도 사용할 수 없습니다.

volatile은 어떤것을 합니까?

Volatile 필드는 쓰레드간에 상태를 공유하기 위해 사용되는 필드입니다. 각각의 읽기 연산은 마지막으로 어떤 쓰레드에서 수행된 쓰기 연산의 결과를 볼 수 있습니다. 사실상 캐싱이나 명령 재배열(reordering)의 결과로 보이는 “오래된(stale)” 값이 보이는 것을 무효하기 위해 고안된 필드입니다. 컴파일러와 런타임(runtime)은 레지스터에 이러한 값을 할당하는 것을 금지합니다. 이 필드는 한번 쓰기연산이 일어난 이후에 캐시에서 메모리로 바로 플러시(flush)됩니다. 그래서 이 필드는 즉시 다른 쓰레드에게 가시성을 갖게됩니다. 비슷하게 volatile 필드에 대한 읽기 연산이 수행되기 전에 메인 메모리에 저장된 값을 보기 위해 로컬 프로세서의 캐시는 무효화됩니다. volatile 변수들에 대한 연산을 재배열(reordering)에는 추가적인 제약(restictions)이 존재합니다.

오래된 메모리 모델에서는 volatile 변수에 대한 연산은 서로 재배열될 수 없었고 nonvolatile 변수에 대한 연산은 재배열 될 수 있었습니다. 이것은 volatile 필드의 한 쓰레드에서 다른 쓰레드로 상태를 전달하는 유용성을 약화시켰습니다.

새로운 메모리 모델에서는 volatile 변수에 대한 연산은 여전히 재배열되지 않습니다. 단, 차이점은 그 코드 주변의 일반적인 필드들에 대한 연산을 재배열하는 것이 어려워진 점입니다. volatile에 쓰기 연산은 모니터 해제와 같은 메모리 수준의 효과를 갖고 읽기 연산은 모니터 획득과 같은 효과를 갖습니다. 사실상, 새로운 메모리 모델은 volatile 필드 연산과 그 주변의 필드 연산을 재배열(reordering)하는데 더 엄격한 제약조건을 갖고있기 때문에 쓰레드 A에서 volatile 필드에 쓰기연산을 하고 쓰레드 B는 volatile 필드를 읽었을 때 그 순간 쓰레드 A가 볼 수 있는 모든 것(volatile이든 nonvolatile이든)은 쓰레드 B에서 보이게 됩니다.

volatile 필드의 간단한 사용 예제입니다.

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.

    }
  }
}

하나의 쓰레드가 writer를 호출하고 다른 쓰레드가 reader를 호출한다고 가정해봅시다. 변수 v에 쓰기연산은 x의 값을 메모리로 내보내고 v를 읽을 경우 그 값을 메모리에서 불러옵니다. A reader에서 v의 값을 true로 본다면 v값이 바뀌기 전에 수행된 x=42는 가시성을 띄게됩니다. 오래된 메모리 모델에서는 적용되지 않을 수 있습니다. 만약 v가 volatile이 아니라면 컴파일러는 writer를 재배열(reorder)하고 reader에서 x를 읽었을때 값이 0일 수 있습니다.

volatile이 동기화 수준에 갖는 의미는 상당히 강화되었습니다. volatile 필드에 대한 읽기, 쓰기연산은 가시성을 목적으로 절반 수수준으로 동기화 하는 것처럼 동작합니다.

중요 사항: 정확하게 happen-before 관계를 만들기 위해 두개의 쓰레드가 동일한 volatile 변수에 접근하는 것은 중요합니다. volatile 변수 f에 값을 쓴뒤 쓰레드 A에 보이는 모든 값들은 volatile 변수 g를 읽은 쓰레드 B에 보이지 않습니다. 해제(release), 획득(acquire)을 반드시 동일한 volatile 필드에서 수행되어야 올바르게 동작합니다.

새로운 메모리 모델은 DCL(Double-checked locking) 문제를 해결했습니까?

악명높은 DCL(double-checked locking, 멀티 쓰레드 싱글톤 패턴)은 동기화의 오버헤드를 피하면서 지연 초기화(lazy initialization)을 지원하기 위해 고안된 트릭입니다. 아주 초기의 JVM에서 동기화는 매우 느렸었고 개발자들은 동기화를 없애려고 많은 애를 썼습니다. DCL은 다음과 같습니다.:

// double-checked-locking - don't do this!


private static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();
    }
  }
  return instance;
}

이것은 끔찍하게 영리해보입니다. – 공통 코드에서 동기화를 피할 수 있습니다. 이 코드는 한가지 문제를 갖고 있습니다. – 동작하지 않는 코드입니다. 가장 확실한 이유는 instance를 초기화하는 것과 instance 필드에 쓰는 것은 컴파일러나 캐시에 의해 재배열(reordering)된다는 것 입니다. 그래서 부분적으로 생성된 Something을 반환하게 됩니다. 그 결과 초기화되지 않은 객체를 읽게됩니다. 왜 이것이 틀린지와 왜 알고리즘상의 보정이 잘못된지에 대한 많은 다른 이유가 있습니다. 오래된 자바 메모리 모델에서는 이런 문제를 해결할 수 없습니다. 더 자세한 사항은 Double-checked locking: Clever, but brokenThe “Double Checked Locking is broken” declaration에서 확인할 수 있습니다.

많은 사람들은 volatile 키워드가 DCL을 사용할때 발생하는 이 문제를 해결할 수 있다고 생각할 것입니다. JVM 1.5버전 이전에는 volatile은 이러한 동작을 보장하지 않았습니다. 새로운 메모리 모델에서는 instance 필드를 volatile로 만듬으로써 이러한 문제는 해결될 것입니다. volatile은 Somthing 초기화와 이것을 읽는 쓰레드의 값의 반환 사이에 happens-before 관계를 만들기 때문입니다.

대신에, 이해하기 쉽고 쓰레드 안전한 On Demand Holder 초기화를 사용해보세요.

private static class LazySomethingHolder {
  public static Something something = new Something();
}

public static Something getInstance() {
  return LazySomethingHolder.something;
}

이 코드는 정적(static) 필드에 대한 초기화 보장 때문에 정확성이 보장됩니다. 만약 필드가 정적 초기화(initializer)에 의해 초기화 된다면 이 클래스에 접근하는 모든 쓰레드에게 정확하게 보이는 것을 보장합니다.

VM을 만드는 중이라면 무엇을 볼까요?

http://gee.cs.oswego.edu/dl/jmm/cookbook.html 을 확인해 보세요.

제가 왜 관심을 가져야 할까요?

제가 왜 관심을 가져야 할까요? 동시성 버그는 디버그하기 매우 어렵습니다. 그들은 테스트중에 잘 나타나지 않으며 작업량이 많아질때까지 기다렸다가 나타납니다. 또한 그런 버그를 다시 만들기 어렵습니다. 사전에 프로그램이 정확하게 동기화 되도록 하는 것에 많은 노력을 기울이는 편이 낫습니다. 쉬운일은 아니지만 적절하게 동기화 되지 않은 어플리케이션을 디버깅하는것 보다 훨씬 쉽습니다.


Tags:
0 comments