난이도 : 초급

Peter HaggarIBM

2002 년 5 월 01 일
2003 년 1 월 07 일 수정

모든 프로그래밍 언어에는 고유의 이디엄이 있다. 이중 대부분이 유용하다. 문제는 몇몇 이디엄의 경우 원래 표명했던 것이 아니라는 것이 나중에 입증되거나 설명한대로 작동하지 않다는 점이다. 자바에는 많은 유용한 이디엄이 있다. 하지만 결코 사용되어서는 안되는 이디엄도 있다. Double-checked locking이 바로 그것이다. 이글에서는 double-checked locking 이디엄의 근원부터 살펴본다.

Singleton creation pattern은 일반적인 프로그래밍 이디엄이다. 다중 쓰레드와 함께 사용할 때 동기화 유형을 사용해야 한다. 좀더 효율적인 코드를 만들기위한 노력으로 자바 프로그래머들은 코드가 동기화 되는 것을 제한하기위해 Singleton creation pattern과 함께 쓰일 double-checked locking 이디엄을 만들었다. 하지만 자바 메모리 모델에 대한 이해의 부족으로 double-checked locking 이디엄은 작동을 보장할 수 없다. 게다가 작동 실패의 이유는 명확하지 않고 자바 메모리 모델과 밀접하게 연관되어 있다. 때문에, double-checked locking으로 인한 코드 작동 실패의 원인을 검사하기 힘들다. 이 글을 통해 단지 그것이 어디서 고장이 났는지를 이해할 수 있도록 double-checked locking 이디엄을 연구해본다.

Singleton creation 이디엄

double-checked locking 이디엄이 어디서 기원했는지를 이해하려면 일반적인 singleton creation 이디엄을 알아야 한다. (Listing 1):


Listing 1. Singleton creation 이디엄

import java.util.*;
class Singleton
{
  private static Singleton instance;
  private Vector v;
  private boolean inUse;

  private Singleton()
  {
    v = new Vector();
    v.addElement(new Object());
    inUse = true;
  }

  public static Singleton getInstance()
  {
    if (instance == null)          //1
      instance = new Singleton();  //2
    return instance;               //3
  }
}

 

이 클래스의 디자인은 단지 하나의 Singleton 객체가 만들어졌다는 것을 확인시켜주고 있다. 생성자는 private으로 선언되고 getInstance() 메소드는 단지 하나의 객체를 만든다. 이것은 단일 쓰레드 프로그램에 적합하다. 하지만 다중 쓰레드가 개입되면 동기화를 통해서 getInstance() 메소드를 방어해야 한다. getInstance() 메소드가 방어되지 않으면 Singleton 객체의 두 개의 다른 인스턴트를 리턴할 수 있다. getInstance() 메소드를 동시에 호출하고 다음 이벤트를 따라가는 두 개의 쓰레드를 생각해보자:

  1. Thread 1은 getInstance() 메소드를 호출하고 //1에서 그 instance null이라는 것을 결정한다.
  2. Thread 1은 if 블록으로 들어가지만, //2의 라인을 실행하기 전에 thread 2에 선점된다.
  3. Thread 2는 getInstance() 메소드를 호출하고 그 instance가 //1에서 null 이라는 것을 결정한다.
  4. Thread 2는 if 블록으로 들어가서 새로운 Singleton 객체를 만들고 instance 변수를 이 새로운 //2에 있는 새로운 객체에 할당한다.
  5. Thread 2는 //3에 있는 Singleton 객체 레퍼런스를 리턴한다.
  6. Thread 2는 thread 1에 선점된다.
  7. Thread 1는 남겨진 곳에서 부터 시작하고 다른 Singleton 객체가 만들어지는 결과가 된 //2 라인을 실행한다.
  8. Thread 1은 //3에서 이 객체를 리턴한다.

결과는 getInstance() 메소드가 단지 하나의 객체를 만들어야 하는데 두개의 Singleton 객체를 만들었다. 이 문제는 단지 하나의 쓰레드가 한 번에 코드를 실행하도록 getInstance() 메소드를 동기화시켜 수정할 수 있다 (Listing 2):


Listing 2. 쓰레드 방지 getInstance() 메소드

public static synchronized Singleton getInstance()
{
  if (instance == null)          //1
    instance = new Singleton();  //2
  return instance;               //3
}

 

Listing 2의 코드는 getInstance() 메소드로 멀티쓰레드 액세스에 잘 작동한다. 하지만 이것을 분석해보면 동기화는 메소드의 첫 번째 호출에만 필요하다는 것을 깨닫게 된다. 지속적인 호출은 동기화를 필요로하지 않는다. 첫 번째 호출이 //2에서 코드를 실행하는 유일한 호출이기 때문이다. 이 라인은 동기화가 필요한 유일한 라인이다. 모든 다른 호출들은 instance null이 아니라는 것을 결정하고 이것을 리턴한다. 다중 쓰레드는 첫 번째 것을 제외하고는 모든 호출에 대해 일관성 있게 실행할 수 있다. 하지만, 메소드가 synchronized 될 때, 메소드의 모든 호출에 대해 동기화의 대가를 지불해야한다.

이 메소드를 좀더 효율적으로 만들기 위해서 double-checked locking이라는 이디엄이 만들어졌다. 첫 번째 것을 제외한 모든 메소드 호출에 대해 동기화를 피하려는 생각이다. 동기화의 비용은 JVM 과는 다르다. 초기에 이 비용은 매우 높았다. 향상된 JVM이 등장함에 따라, 동기화의 비용은 감소했지만 여전히 synchronized 메소드 또는 블록에 들어가고 나오는 것에 대한 퍼포먼스 패널티는 존재한다. JVM 기술의 발전과 관계없이 프로그래머들은 불필요하게 프로세스 시간을 낭비하기를 결코 원하지 않는다.

Listing 2의 //2 행만이 동기화가 필요하기 때문에, 이것을 동기화 블록으로 래핑한다 (Listing 3):


Listing 3. getInstance() 메소드

public static Singleton getInstance()
{
  if (instance == null)
  {
    synchronized(Singleton.class) {
      instance = new Singleton();
    }
  }
  return instance;
}

 

Listing 3의 코드는 다중 쓰레드에 나타났던 문제와 같다. 두 개의 쓰레드는 instance null이면 동시적으로 if 문 내부에서 얻어질 수 있다. 그리고나서 하나의 쓰레드가 instance를 초기화하기 위해서 synchronized 블록으로 들어간다. 그러는 동안 다른 것들은 블록화된다. 첫 번째 쓰레드가 synchronized 블록을 종료할 때 기다리고 있는 쓰레드가 들어가서 다른 Singleton 객체를 만든다. 두 번째 쓰레드가 synchronized 블록에 들어갈 때, instance이 non-null인지를 검사하지 않는다.

 


위로



Double-checked locking

Listing 3의 문제를 해결하려면 instance를 검사해야한다. double-checked locking 이디엄을 Listing 3에 적용하면 Listing 4의 결과가 나온다.


Listing 4. Double-checked locking 예제

public static Singleton getInstance()
{
  if (instance == null)
  {
    synchronized(Singleton.class) {  //1
      if (instance == null)          //2
        instance = new Singleton();  //3
    }
  }
  return instance;
}

 

double-checked locking 이론은 //2에서의 두번째 체크가 두 개의 다른 Singleton 객체들이 Listing 3에 나타난 것처럼 만들어질 수 없도록 한다는 것이다:

  1. Thread 1은 getInstance() 메소드로 들어간다.
  2. Thread 1은 instance null이기 때문에 //1에 있는 synchronized 블록으로 들어간다.
  3. Thread 1은 thread 2에 선점된다.
  4. Thread 2는 getInstance() 메소드로 들어간다.
  5. Thread 2는 instance가 여전히 null이기 때문에 //1에서 lock 얻기를 시도한다. 하지만 thread 1이 lock을 보유하고 있기 때문에 thread 2는 //1에서 블록한다.
  6. Thread 2는 thread 1에 선점된다.
  7. Thread 1은 실행하고 인스턴스가 //2에서 여전히 null 이기 때문에, Singleton 객체를 만들고 이것의 레퍼런스를 instance에 할당한다.
  8. Thread 1은 synchronized 블록을 종료하고 getInstance() 메소드에서 인스턴스를 리턴한다.
  9. Thread 1은 thread 2에 선점된다.
  10. Thread 2는 //1에서 lock을 얻어서 instance null인지를 점검한다.
  11. instance가 non-null이기 때문에, 두 번째 Singleton 객체는 만들어지지 않고 thread 1에 만들어진것이 리턴된다.

double-checked locking 이론은 완벽하다. 안타깝게도 현실은 그와 반대라는 것이다. double-checked locking과 관련한 문제는 이것이 단일 또는 다중 프로세서 머신에서 작동하는 것을 보장할 수 없다는 점이다.

double-checked locking 실패는 JVM의 버그 때문이 아니고 현재의 자바 플랫폼 메모리 모델 때문이다. 메모리 모델은 "난잡한 작성"을 허용하고 이것이 이디엄이 실패하는 주요 이유이다.

 


위로



out-of-order write

이 문제를 설명하기 위해서, Listing 4의 //3행을 다시한번 살펴보아라. 이 코드는 Singleton 객체를 만들고 객체를 참조하기 위해서 instance 변수를 초기화한다. 이 코드의 문제는 instance 변수가Singleton 생성자의 바디가 실행하기 전에 non-null 이 될 수 있다는 점이다.

이것은 여러분이 가능하다고 생각했던 모든것과 배치가 될 수 있다. 하지만 가능한 현실이다.이것이 어떻게 발생했는지를 설명하기전에 어떻게 이것이 double-checked locking 이디엄을 고장냈는지를 살펴보면서 이러한 현실을 받아들이자:

  1. Thread 1은 getInstance() 메소드로 들어간다.
  2. Thread 1은 //1의 synchronized 블록으로 들어간다. instance null이기 때문이다.
  3. Thread 1은 //3 으로 가서 non-null 인스턴스를 만든다. 생성자가 실행하기 전이다.
  4. Thread 1은 thread 2에 선점된다.
  5. Thread 2는 인스턴스가 null인지를 점검한다. null이 아니기 때문에, thread 2는 instance 레퍼런스를 완전히 만들어졌지만 부분적으로 초기화된 Singleton 객체로 리턴한다.
  6. Thread 2는 thread 1에 선점된다.
  7. Thread 1은 생성자를 실행함으로서 Singleton 객체의 초기화를 완료하고 레퍼런스를 리턴한다.

thread 2는 한 객체를 리턴하는데, 그 객체의 생성자는 실행되지 않았다.

이를 설명해줄 다음의 가상 코드를 살펴보자: instance =new Singleton();

mem = allocate();             //Allocate memory for Singleton object.
instance = mem;               //Note that instance is now non-null, but
                              //has not been initialized.
ctorSingleton(instance);      //Invoke constructor for Singleton passing
                              //instance.

 

Listing 5의 코드를 보자. getInstance() 메소드를 간략하게 나타냈다. "double-checkedness"를 제거했다. instance=new Singleton(); 라인을 JIT 컴파일러가 어떻게 컴파일하는지에만 관심이 있다. 어셈블리 코드에서 생성자가 실행된다는 것을 확인하기 위해 간단한 생성자도 제공했다.


Listing 5. out-of-order write를 표현한 Singleton 클래스

class Singleton
{
  private static Singleton instance;
  private boolean inUse;
  private int val;  

  private Singleton()
  {
    inUse = true;
    val = 5;
  }
  public static Singleton getInstance()
  {
    if (instance == null)
      instance = new Singleton();
    return instance;
  }
}

 

Listing 6에는 getInstance() 메소드의 바디를 위한 Sun JDK 1.2.1 JIT 컴파일러에서 만들어진 어셈블리 코드가 포함되어 있다.


Listing 6. Listing 5 코드에서 만들어진 어셈블리 코드

;asm code generated for getInstance
054D20B0   mov         eax,[049388C8]      ;load instance ref
054D20B5   test        eax,eax             ;test for null
054D20B7   jne         054D20D7
054D20B9   mov         eax,14C0988h
054D20BE   call        503EF8F0            ;allocate memory
054D20C3   mov         [049388C8],eax      ;store pointer in 
                                           ;instance ref. instance  
                                           ;non-null and ctor
                                           ;has not run
054D20C8   mov         ecx,dword ptr [eax] 
054D20CA   mov         dword ptr [ecx],1   ;inline ctor - inUse=true;
054D20D0   mov         dword ptr [ecx+4],5 ;inline ctor - val=5;
054D20D7   mov         ebx,dword ptr ds:[49388C8h]
054D20DD   jmp         054D20B0

 

이 어셈블리 코드는 getInstance() 메소드를 호출하는 테스트 프로그램을 실행하여 만들어졌다. 프로그램이 실행되는 동안 Microsoft Visual C++ 디버거를 실행하고 이것을 자바 프로세스에 붙였다.

어셈블리 코드의 처음 두 개의 행은 049388C8 메모리 로케이션에서 instance 레퍼런스를 eax 로 로딩하고 null을 테스트 한다. 이것은 Listing 5의 첫 행의 getInstance()메소드와 상응한다. 이 메소드가 처음 호출되고 instance null이면 코드는 B9으로 진행된다. BE에 있는 코드는 Singleton 객체용 힙에서 메모리를 할당하여 eax의 메모리에 포인터를 저장한다. C3 eax 에서 포인터를 가져다가 049388C8 메모리 로케이션에 인스턴스 레퍼런스에 저장한다. 결과적으로 인스턴스는 현재 non-null이고 유효한 Singleton 객체를 참조한다. 하지만 이 객체용 생성자는 아직 실행되지 않았고 그것은 double-checked locking이 문제가 있다는 것이다. C8 라인에서,instance 포인터는 역참조되고 ecx에 저장된다. CA D0 행은 true 5 값을 Singleton 객체에 저장하는 인라인 생성자를 나타낸다. C3 행을 실행하고 나서 다른 쓰레드에 의해 이 코드가 인터럽트된다면 생성자를 완료하기 전에 double-checked locking은 실패한다.

모든 JIT 컴파일러가 위와 같은 코드를 생성하는 것은 아니다. 어떤것은 생성자가 실행된 후 바로 인스턴스가 non-null이 되는 코드를 만든다. IBM SDK for Java technology, version 1.3과 Sun JDK 1.3 모두 이와 같은 코드를 만든다. 하지만 이러한 인스턴스에 double-checked locking을 사용해야 된다는 것을 말하는 것은 아니다. 이것이 실패할 수 있는 다른 이유들이 있다. 게다가 어떤 JVM에서 여러분의 코드가 실행될지를 항상 알고있는것은 아니지 않는가? 그리고 JIT 컴파일러는 이 이디엄을 중단시키는 코드를 만들기위해 언제나 변화할 수 있다.

 


위로



Double-checked locking: Take two

현재 double-checked locking 코드가 실행되지 않는다면 다른 코드 버전을 제안하겠다 (Listing 7). out-of-order write 문제를 방지하기 위함이다.


Listing 7. out-of-order write 문제 해결

public static Singleton getInstance()
{
  if (instance == null)
  {
    synchronized(Singleton.class) {      //1
      Singleton inst = instance;         //2
      if (inst == null)
      {
        synchronized(Singleton.class) {  //3
          inst = new Singleton();        //4
        }
        instance = inst;                 //5
      }
    }
  }
  return instance;
}

 

Listing 7의 코드를 보게되면 상황이 우습게 되고있다는 것을 알게된다. 기억해야할 것은 double-checked locking은 간단한 세 라인의 getInstance() 메소드의 동기화를 막을 수단으로 만들어졌다는 것이다. Listing 7의 코드는 손에서 떠났다. 이 코드는 문제를 해결하지 않는다. 천천히 그 이유를 살펴보자.

out-of-order write 문제를 방지하기 위해 시도되었다. 이를 inst 로컬 변수와 두 번째 synchronized 블록을 이용하여 수행한다:

  1. Thread 1 getInstance() 메소드로 들어간다.
  2. instance null이기때문에, thread 1은 //1에서 첫 번째 synchronized 블록으로 들어간다.
  3. 로컬 변수inst instance 값을 가지고 이것은 //2 에서 null이다.
  4. inst null 이기 때문에, thread 1은 //3의 두 번째 synchronized 블록으로 들어간다.
  5. Thread 1은 //4에서 코드 실행을 시작하면서 Singleton 생성자가 실행되기 전에 inst를 non-null로 만든다. (이것이 out-of-order write 문제이다.)
  6. Thread 1은 Thread 2에 선점된다.
  7. Thread 2는 getInstance() 메소드로 들어간다.
  8. instance null이기 때문에, thread 2는 //1에 있는 첫 번째 synchronized 블록으로 들어가는 것을 시도한다. thread 1이 현재 이 lock을 보유하고 있기 때문에, thread 2는 블록된다.
  9. Thread 1은 //4의 실행을 완료한다.
  10. Thread 1은 완전히 생성된 Singleton 객체를 //5의 instance 변수에 할당하고 synchronized 블록 모두를 종료한다.
  11. Thread 1은 instance를 리턴한다.
  12. Thread 2는 실행해서 instance를 //2의 inst에 할당한다.
  13. Thread 2는 instance가 non-null이라는 것을 확인하고 이를 리턴한다.

핵심 라인은 //5 이다. 이 라인은 instance null 이거나 완전히 생성된 Singleton 객체를 참조한다는 것을 확인하도록 되어있다. 문제는 이론과 진실이 직교하며 실행되는 곳에서 발생한다.

Listing 7의 코드는 메모리 모델의 현재 정의 때문에 작동하지 않는다. Java Language Specification (JLS)은 synchronized 블록 안에서 작성된 코드가 synchronized 블록 밖으로 이동하지 못하도록 되어있다. 하지만, synchronized 블록에 없는 코드가 코드가 synchronized 블록 안으로 이동할 수 없다는 것을 뜻하는 것은 아니다.

JIT 컴파일러는 여기에서 최적화 기회를 본다. 최적화는 //4에 있는 코드와 //5 에 있는 코드를 제거하여 이를 조합하여 Listing 8과 같은 코드를 만든다:


Listing 8. Listing 7의 최적화 코드

public static Singleton getInstance()
{
  if (instance == null)
  {
    synchronized(Singleton.class) {      //1
      Singleton inst = instance;         //2
      if (inst == null)
      {
        synchronized(Singleton.class) {  //3
          //inst = new Singleton();      //4
          instance = new Singleton();               
        }
        //instance = inst;               //5
      }
    }
  }
  return instance;
}

 

최적화가 되면 이전과 같은 out-of-order write 문제가 생긴다.

 


위로



volatile

inst 변수와 instance 변수에 volatile 키워드를 사용하는 경우도 있다. JLS (참고자료)에서 volatile이 선언된 변수는 영속성이 있어 재정리 되지 않아야 한다. double-checked locking 과 관련한 문제를 해결하기 위해 volatile을 사용할 때 두 가지 문제가 발생한다:

  • 여기서의 문제는 순차적 영속성에 있지 않다. 코드는 이동되고 재정리 되지 않는다.
  • 많은 JVM이 순차적 영속성을 정확히 고려한 volatile 을 구현하지 않는다.


Listing 9. volatile의 순차적 영속성 

class test
{
  private volatile boolean stop = false;
  private volatile int num = 0;

  public void foo()
  {
    num = 100;    //This can happen second
    stop = true;  //This can happen first
    //...
  }

  public void bar()
  {
    if (stop)
      num += num;  //num can == 0!
  }
  //...
}

 

JLS에 의하면, stop num volatile로 선언되기 때문에, 그들은 순차적으로 영속적이여야 한다. 이는, stop이 언제나 true이면, num 100으로 설정되어야 한다는 것을 의미한다. 하지만, 많은 JVM이 volatile의 순차적 영속성 기능을 구현하지 않기 때문에 이러한 것에 의존할 수 없다. 따라서 thread 1이 foo를 호출하고 thread 2가 bar를 동시에 호출하면, thread 1은 num  100으로 설정되기 전에 stop true로 설정해야한다. 이렇게 되면 thread 2에서 stop true로 설정되고 num은 여전히 0으로 설정된다.

 


위로



솔루션

모든 JVM 구현에서 작동한다는 것을 보장할 수 없기 때문에 어떤 형태로든 double-checked locking은 사용되어서는 안된다. JSR-133은 메모리 모델 관련한 문제를 언급하고 있지만 double-checked locking은 새로운 메모리 모델에서 지원되지 않는다. 따라서 다음 두 가지 옵션이 있다:

  • getInstance() 메소드의 동기화를 수락한다. (Listing 2).
  • 동기화를 그만두고 static 필드를 사용한다.

Option 2는 Listing 10에 나와있다:


Listing 10. static 필드를 이용한 Singleton 구현

class Singleton
{
  private Vector v;
  private boolean inUse;
  private static Singleton instance = new Singleton();

  private Singleton()
  {
    v = new Vector();
    inUse = true;
    //...
  }

  public static Singleton getInstance()
  {
    return instance;
  }
}

 

Listing 10의 코드는 동기화를 사용하지 않고 static getInstance() 메소드에 호출이 있을때까지 Singleton 객체가 만들어지지 않는다는 것을 확실히 하고있다.

 


위로



스트링은 변하지 않는다!

out-of-order writes 문제가 있는 String 클래스와 생성자 실행에 앞서 non-null 이 되는 레퍼런스가 이상할 것이다. 다음 코드를 보자:

private String str;
//...
str = new String("hello");

 

String 클래스는 변하지 않는다. 하지만 out-of-order write 문제가 발생할 수 있는가? 문제는 그럴 수 있다는 것이다. String str로 액세스 하는 두 개의 쓰레드를 생각해보자. 한 쓰레드는 str 레퍼런스가 생성자가 실행하지 않는 곳에서 String 객체를 참조한다는 것을 본다. 사실 Listing 11에는 이러한 것이 발생하는 것을 보여준다. 이 코드는 내가 테스트 한 JVM 로만 고장을 일으킨다는 것을 명심해라. IBM 1.3과 Sun 1.3 JVM은 변하지 않는 String을 만든다


Listing 11. Mutable String 예제

class StringCreator extends Thread
{
  MutableString ms;
  public StringCreator(MutableString muts)
  {
    ms = muts;
  }
  public void run()
  {
    while(true)
      ms.str = new String("hello");          //1
  }
}
class StringReader extends Thread
{
  MutableString ms;
  public StringReader(MutableString muts)
  {
    ms = muts;
  }
  public void run()
  {
    while(true)
    {
      if (!(ms.str.equals("hello")))         //2
      {
        System.out.println("String is not immutable!");
        break;
      }
    }
  }
}
class MutableString
{
  public String str;                         //3
  public static void main(String args[])
  {
    MutableString ms = new MutableString();  //4
    new StringCreator(ms).start();           //5
    new StringReader(ms).start();            //6
  }
}

 

이 코드는 //3의 두 개의 쓰레드에 의해 공유된 String 레퍼런스를 포함하는 //4의 MutableString 클래스를 만든다. //5와 //6라인에 두 개의 개별 쓰레드에 StringCreator StringReader라는 두 개의 객체가 만들어지면서 MutableString 객체에 레퍼런스를 전달한다. StringCreator 클래스는 무한 루프로 들어가고 //1에서 "hello" 값을 가진 String 객체를 만든다. StringReader는 무한 루프로 들어가고 현재 String 객체가 //2에서 "hello" 값을 갖고 있는지를 확인한다. 그렇지 않다면, StringReader 쓰레드는 메시지를 프린팅하고 정지한다. String 클래스는 변하지 않고 이 프로그램에서 어떤 아웃풋도 볼 수 없다.

Sun JDK 1.2.1같은 오래된 JVM 에서 이 코드를 실행하면 out-of-order write 프로그램이 되고 non-immutable String이 된다.

 


위로



요약

singleton의 비싼 동기화를 피하기위해 특별히 천재적인 프로그래머들은 double-checked locking 이디엄을 개발했다. 약한 메모리 모델의 분야를 재정의하는 작업이 진행중이다. 하지만 아무리 새로운 메모리 모델이어도 double-checked locking은 작동하지 않을 것이다. 이 문제에 대한 최상의 솔루션은 동기화를 수락하거나 static field를 사용하는 것이다.

 


위로



참고자료

 


위로



필자소개

Peter Haggar

Peter Haggar는 IBM의 소프트웨어 엔지니어이다. Practical Java Programming Language Guide (Addison-Wesley)의 저자이며 자바 프로그래밍 관련하여 많은 글을 집필했다. 개발 툴, 클래스 라이브러리, OS 등 광범위한 분야에 많은 경험을 갖고 있다.




위로





출처: http://www.ibm.com/developerworks/kr/library/j-dcl.html

블로그 이미지

유효하지않음

,