본문 바로가기
Java

Double-Checked Locking In Java

by Taler 2022. 10. 22.

해당 글을 번역하면서 이해한 내용을 정리합니다: https://en.wikipedia.org/wiki/Double-checked_locking

 

Double-checked locking - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search In software engineering, double-checked locking (also known as "double-checked locking optimization"[1]) is a software design pattern used to reduce the overhead of acquiring a lock by

en.wikipedia.org

 

Double-checked locking은 lock을 획득하기 전에 locking criterian(잠금 기준)을 테스트함으로써 lock의 획득으로 인한 오버헤드를 줄이기 위한 디자인 패턴이다. Locking criterian에 대한 검사를 먼저 진행해 잠금이 꼭 필요한 상황인지 확인하고, 필요한 상황인 경우에만 lock을 획득하도록 구현된 패턴이다. 

 

일부 언어나 하드웨어의 조합에서 구현된 패턴은 안전하지 않을 수 있다. 때때로 이는 anti-pattern으로 취급된다. Anti-pattern이란 실제 많이 사용되는 패턴이지만 비효율적이거나 비생산적인 패턴을 의미한다.

 

이는 일반적으로 multi-threaded 환경에서, 특히 Singleton pattern의 일부로 “lazy initialization’을 구현할 때 locking overhead를 줄이기 위해서 사용된다. Lazy initialization은 특정 객체에 처음 접근하기 전까지 그것을 초기화하는 것을 피하기 위해 사용된다.

 

 

Example in Java


예를 들어, 다음과 같이 Java 프로그래밍 언어로 작성된 아래의 코드를 확인하자.

 

// Single-threaded version
class Foo {
    private static Helper helper;

    public Helper getHelper() {
        if (helper == null) {
            helper = new Helper();
        }
        return helper;
    }

    // other functions and members...
}

 

위 코드는 싱글 스레드라는 가정 하에 구현된 Helper라는 객체를 사용하는 코드이다. 싱글 스레드를 사용할 때는 문제가 발생하지 않지만, 멀티 스레드를 사용할 때는 일반적인 동시성 문제가 발생해 제대로 작동하지 않는다. 이를 방지하기 위해서는 두 개의 스레드가 동시에 getHelper() 함수를 호출하는 경우를 막아야 하며, 해당 함수를 호출할 때 Lock이 획득되어야 한다. 이외의 경우 두 스레드가 동시에 객체를 만들려고 하거나, 불완전하게 초기화된 객체에 대한 참조를 얻게될 수도 있다.

 

Lock은 다음 예와 같이 값비싼 synchronized를 통해 얻을 수 있다.

 

// Correct but possibly expensive multithreaded version
class Foo {
    private Helper helper;

    public synchronized Helper getHelper() {
        if (helper == null) {
            helper = new Helper();
        }
        return helper;
    }

    // other functions and members...
}

 

그러나, 위 구현에는 비효율이 존재한다. 사실 생각해보면, getHelper()에 대한 첫 번째 호출에서 객체가 생성된다면, 이후는 해당 메소드로의 동시 접근을 허용해도 무방하다. 즉, 객체를 초기화하는 시간동안만 동기화시키면 된다. 그 후, 모든 호출은 단지 멤버 변수 helper에 대한 reference를 가져올 것이다. 때문에 위 Method 전체를 synchronizing하는 것은 극단적인 경우 성능이 100배 이상 저하될 수 있다.

 

다시 말하자면, 위 예시와 같은 경우 singleton의 초기화가 완료되면 lock의 획득 및 해제 잠금이 불필요하다. 따라서, 메서드가 호출될 때마다 lock을 획득하고 해제하도록 만드는 것은 비효율적이다.

 

많은 프로그래머들이 이러한 상황을 최적화하기 위해서 만든 프로세스는 아래와 같다.

  1. 잠금을 획득하지 않고 객체가 초기화되었는지 확인한다. 초기화 된 경우 즉시 반환한다.
  2. lock을 얻는다.
  3. 객체가 초기화되었는지 다시 확인한다. 다른 스레드가 먼저 잠금을 획득했다면, 이미 초기화가 진행됐을 수도 있다. 그렇다면 초기화된 객체를 반환한다.
  4. 아닌 경우 객체를 초기화하고, 반환한다.

프로세스를 살펴보면, 객체가 생성됐는지 확인하는 과정이 두 번 있다. 때문에 이 과정을 Double-checked locking이라고 한다. 위 지시사항에 맞게 개선된 예시 코드를 보자.

 

// Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
    private Helper helper;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }

    // other functions and members...
}

직관적으로 이 알고리즘은 우리가 직면했던 문제를 효율적으로 푸는 것처럼 보인다. 그러나 이 테크닉에는 많은 미묘한 문제들이 있어 일반적으로 사용하는 것을 피해야 한다. 미묘한 문제의 예시를 들자면, 다음과 같은 이벤트 시퀀스가 발생할 수 있다.

 

  1. 스레드 A는 객체가 초기화되지 않았음을 알아차리고 잠금을 획득한 뒤 객체 초기화를 시작한다.
  2. 일부 프로그래밍 언어의 semantics 때문에, 컴파일러가 생성한 코드는 A가 초기화 수행을 완료하기 전에 부분적으로 생성된 객체를 가리키도록 공유 변수를 업데이트할 수 있다.
    • 예를 들어 Java에서 생성자에 대한 호출이 inline화 된 경우, 공유 변수는 storage가 할당되면 inline된 생성자가 객체를 초기화하기 전에 즉시 업데이트 될 수 있다.
  3. 스레드 B는 공유 변수가 초기화되었음을 알아차리고 객체를 반환한다. 스레드 B는 객체가 이미 초기화되었다고 믿기 때문에 Lock을 획득하지 않는다. A가 수행하는 모든 초기화가 B에 표시되기 전에 B가 객체를 사용하는 경우 프로그램이 충돌할 수 있다.
    • B의 접근 전에 A가 초기화를 완료하지 못했거나, Cache coherence에 의해 A가 초기화한 객체의 일부가 아직 B가 사용하는 메모리에 동기화되지 못한 경우가 그 예시.

 

J2SE 1.4 및 이전 버전에서 Double-checked locking을 사용하는 것이 위험한 이유 중 하나이다. 여느 멀티스레딩 문제가 그렇듯, 해당 테크닉이 올바로 구현됐는지, 혹은 미묘한 문제가 있는지를 구분하는 것은 쉽지 않다. 컴파일러, 스케쥴러에 의한 스레드 간섭 및 기타 동시 시스템 활동의 특성에 따라 Double-checked locking의 잘못된 구현으로 인한 오류가 간헐적으로만 발생할 것이며, 이로 인해 실패 상황을 재현하는 것이 힘들어진다. 오류를 수정하고자 디버깅을 통해 그것을 재현하는 것은 더욱더 어려워지는 것이다..!

 

 

How to use in Java


그렇다면 Double-checked Locking은 아예 사용하지 못하는 것일까? 그건 아니다. 지금부터는 이런 미묘한 동시성 문제들을 피해서 안전하고 효율적으로 객체를 생성 및 사용하는 방법을 알아보자.

 

1. Volatile 키워드 사용

이런 미묘한 문제를 피하기 위해서 J2SE 5.0 이상의 버전에서 제공되는 volatile 키워드를 사용하면, 여러 스레드가 싱글톤 인스턴스를 올바르게 처리하도록 할 수 있다. 이때 volatile은 CPU cache가 아니라 언제나 memory에 값을 두고, memory에서만 읽도록 강제하는 키워드이다.

 

volatile을 사용해 개선한 코드는 아래와 같다.

 

// Works with acquire/release semantics for volatile in Java 1.5 and later
// Broken under Java 1.4 and earlier semantics for volatile
class Foo {
    private volatile Helper helper;
    public Helper getHelper() {
        Helper localRef = helper;
        if (localRef == null) {
            synchronized (this) {
                localRef = helper;
                if (localRef == null) {
                    helper = localRef = new Helper();
                }
            }
        }
        return localRef;
    }

    // other functions and members...
}

 

불필요해보이는 지역변수 ‘localRef’를 보자.

 

이것의 효과는 Helper가 이미 초기화된 경우 (대부분의 경우) return helper 대신, return localRef를 사용함으로써 volatile 필드가 한 번만 접근된다. 앞서 설명했듯 volatile은 CPU cache가 아닌 memory에서 값을 읽어오기 때문에 조금 더 느리며, 위와 같은 방식으로 구현한 경우 전체 성능을 최대 40%까지 향상시킨다.

 

2. VarHandle 클래스 사용

Java9에서는 이 문제를 조금 더 직관적으로, 그리고 일종의 정해를 제공하기 위해 VarHandle 클래스를 도입했다. 이 클래스를 사용하면 필드에 액세스하기 위해 완화된 원자성을 사용해 memory model이 약한 machine에서 읽기 속도가 다소 빨라지고, 메커니즘이 더 어려워지고, sequential consistency가 손실된다.

 

volatile 필드는 더이상 Synchronization order, volatile field들로의 접근이 전체적인 order에 참여하지 않는다.

 

// Works with acquire/release semantics for VarHandles introduced in Java 9
class Foo {
    private volatile Helper helper;

    public Helper getHelper() {
        Helper localRef = getHelperAcquire();
        if (localRef == null) {
            synchronized (this) {
                localRef = getHelperAcquire();
                if (localRef == null) {
                    localRef = new Helper();
                    setHelperRelease(localRef);
                }
            }
        }
        return localRef;
    }

    private static final VarHandle HELPER;
    private Helper getHelperAcquire() {
        return (Helper) HELPER.getAcquire(this);
    }
    private void setHelperRelease(Helper value) {
        HELPER.setRelease(this, value);
    }

    static {
        try {
            MethodHandles.Lookup lookup = MethodHandles.lookup();
            HELPER = lookup.findVarHandle(Foo.class, "helper", Helper.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    // other functions and members...
}

 

3. Initialization-on-demand holder 관용 사용 (static holder)

Helper 객체가 static(ClasssLoader 당 하나씩)인 경우, 대안으로 아래의 initialization-on-demand holder 관용을 사용할 수 있다.

 

// Correct lazy initialization in Java
class Foo {
    private static class HelperHolder {
       public static final Helper helper = new Helper();
    }

    public static Helper getHelper() {
        return HelperHolder.helper;
    }
}

 

이것은 nested class들이 참조될 때까지 load되지 않는다는 사실에 의존한다.

 

4. FinalWrapper Class 사용

Java5에서 final field semantic은 volatile을 사용하지 않고 helper 객체를 안전하게 생성하는 것으로 응용할 수 있다.

 

public class FinalWrapper<T> {
    public final T value;
    public FinalWrapper(T value) {
        this.value = value;
    }
}

public class Foo {
   private FinalWrapper<Helper> helperWrapper;

   public Helper getHelper() {
      FinalWrapper<Helper> tempWrapper = helperWrapper;

      if (tempWrapper == null) {
          synchronized (this) {
              if (helperWrapper == null) {
                  helperWrapper = new FinalWrapper<Helper>(new Helper());
              }
              tempWrapper = helperWrapper;
          }
      }
      return tempWrapper.value;
   }
}

 

위 코드에서 지역변수 tempWrapper는 정확성을 위해서 사용하는 것이다. 간단한 null check나 return문에까지 helperWrapper 객체를 매번 불러와 사용하면, Java memory model에서 허용되는 읽기 재정렬(Read reordering)으로 인해 실패할 수 있다.

 

 

결론


한 마디로 정리하면, Double-checked locking은 객체 생성 및 조회를 하나의 critical section에 포함하지 않으려는 노력이라고 할 수 있다. '객체가 있는지 확인하고, 없으면 생성하고, 있으면 반환한다.'라는 함수를 최대한 효율적으로 짜려는 움직임이었던 것 같다.

 

사실 지금까지의 코딩 수준에서는 멀티스레딩을 고려할만할 것이 없었다. 과제 수준에서의 멀티스레딩은 거의 고려하지 않아도 되는 수준이었으며, 이후에는 프레임워크가 알아서 관리해주니 소홀해질 수밖에 없었던 것 같다. 앞으로의 프로그램 설계에서는 이런 동시성 이슈를 포용할 수 있는 코드를 작성하려고 노력해야겠다.

 

또 이번에 느낀 거지만, 거대한 프레임워크를 이루는 내부 코드를 뜯어보면 이런 기묘한 테크닉이 사용된 경우가 많다. 프로그램의 크기가 커질수록 생각해야하는 것들이 많아지는 것 같은데, 이런 것들을 하나씩 보다보면 정말 가야할 길이 멀다고 느껴진다...

 

자바의 깊이...

댓글