gimmesilver's blog

Agbird.egloos.com

포토로그



자바 병행 프로그래밍 - 잘못된 락 객체 사용법 프로그래밍

ConcurrentHashMap vs. HashTable 이란 글에서 아래와 같은 코드는 잘못된 방법일 뿐더러 위험한 방법이라고 했습니다.

class SharedData {
    private Integer intData = 0;
    private Boolean boolData = false;

    public int getInt() { synchronized (intData) { return intData; } }
    public void setInt(int n) { synchronized (intData) { intData = n; } }
    public boolean getBool() { synchronized (boolData) { return boolData; } }
    public void setBool(boolean b) { synchronized (boolData) { boolData = b; } }
}

 우선 잘못된 이유에 대해서 먼저 설명하자면 프로그래머의 의도와 달리 intData 나 boolData 객체는 동기화되지 않습니다. 그 이유는 setInt() 나 setBool() 함수가 호출될 때마다 락으로 사용되는 intData 나 boolData 객체가 변할 수 있는데 이런 경우 쓰레드들이 서로 다른 락에 접근하기 때문입니다. 예를 들어 다음과 같은 코드가 있다고 합시다.

public class SyncTest {
  static private Object lock = new Object();

  static class TestRunnable implements Runnable {
    @Override
    public void run() {
      try {
        synchronized (lock) {
          System.out.println("before sleep in thread");
          Thread.sleep(1000);
          System.out.println("after sleep in thread");
        }
      } catch (Interrupted Exception e) {
      }
    }
  }

  public static void main(String[] args) {
    ExecutorService threads = Executors.newFixedThreadPool(2);
    threads.submit(new TestRunnable());
    threads.submit(new TestRunnable());
    threads.shutdown();
    try {
      threads.awaitTermination(1, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
    }
    System.exit(0);
  }
}

 위 코드를 실행시켜 보면 항상 아래처럼 출력됩니다.

before sleep in thread
after sleep in thread
before sleep in thread
after sleep in thread

그러나 위에 동기화 부분을 다음과 같이 고치면 문제가 발생합니다.

synchronized (lock) {
  lock = new Object(); // assigned another object to lock
  System.out.println("before sleep in thread");
  Thread.sleep(1000);
  System.out.println("after sleep in thread");
}

 lock 객체가 동기화 블럭내에서 다른 객체로 변경되었기 때문에 이후에 다른 쓰레드에서 lock 객체에 접근하면 더이상 쓰레드간 동기화 효과가 없습니다. 자바에서 모든 객체는 레퍼런스 객체입니다. 그런데 synchronized 블럭에서 동기화를 위해 참조하는 객체는 이 레퍼런스 객체가 아니라 레퍼런스 객체가 참조하고 있는 실제 객체입니다. 따라서 비록 같은 lock 객체라 하더라도 이 객체가 참조하는 실제 객체가 바뀌었기 때문에 쓰레드간 동기화는 깨지고 맙니다.

 이 글의 처음에 소개된 SharedData 클래스의 잘못된 점은 intData 나 boolData 가 setter 메소드에서 다른 값이 할당되는 순간 Auto boxing 에 의해 다른 객체를 참조하기 때문입니다. 그러므로 이 시점에 다른 쓰레드가 synchronized 블럭에 접근하게 되더라도 다른 객체에 접근하기 때문에 락이 해제될때까지 기다리지 않습니다. 따라서 어떤 경우라도 synchronized 블럭 내에서 락 객체의 참조값을 변경하지 말아야 합니다. 보통 이런 실수를 할 소지는 적습니다만 SharedData 클래스 예처럼 Auto boxing 이 발생하는 Integer, Boolean, Long 등과 같은 클래스를 사용할 때는 자칫 착각할 수 있습니다. 

 이제 SharedData 클래스의 동기화 방식이 위험한 이유에 대해서 설명하겠습니다. 우선 아래 코드를 보시기 바랍니다.

public class SyncTest {
  static private String lock = "This is a lock;

  static class TestRunnable implements Runnable {
    @Override
    public void run() {
      try {
        synchronized (lock) {
          System.out.println("before sleep in thread");
          Thread.sleep(1000);
          System.out.println("after sleep in thread");
        }
      } catch (Interrupted Exception e) {
      }
    }
  }

  public static void main(String[] args) {
    // 이전 코드와 동일...
  }
}

위 코드가 제대로 동작할까요? 
 - 예 그렇습니다.
그렇다면 좋은 방법일까요?
 - 아뇨 그렇지 않습니다(Joshua Bloch 말투 좀 흉내내 봤습니다).

그러면 왜 좋은 방법이 아닐까요? 왜냐하면 String 객체를 락으로 사용하는 것은 예기치 않은 dead lock 을 발생시키기 때문입니다. 예를 들어 아래 코드를 보시죠.

public class BadLockSample {
  static void main(String[] args) throws Exception {
    String lock = "This is a lock";
    synchronized (lock) {
      Future<String> future = Executors.newSingleThreadExecutor().submit(new Callable<String>() {
        @Override
        public String call() throws Exception {
          String anotherLock = "This is a lock";
          synchronized (anotherLock) {
            return "Result";
          }
        }
      });
      System.out.println(future.get());
    }
  }
}

 위 코드에서 메인 쓰레드와 Callable 클래스가 호출되는 쓰레드는 서로 다른 락 객체를 사용하고 있습니다만 실행시켜보면 데드락이 발생하여 프로그램이 종료하지 않습니다. 왜 그럴까요? 그 이유는 자바에서 문자열을 다른 객체와 달리 특별한 방식으로 관리 하기 때문입니다. 자바는 메모리를 절약하기 위해 컴파일 시점에 평가 가능한 문자열에 대해서 영구 메모리에 문자열을 저장하며 이 문자열을 참조하는 String 객체들은 명시적으로 new String() 을 사용해서 객체를 생성하지 않는한 같은 문자열일 경우 동일한 객체를 참조하게 됩니다(혹은 String.intern() 메소드를 호출하면 명시적으로 이런 처리가 가능합니다).
 결국 위에서 lock 과 anotherLock 은 다른 레퍼런스 객체이지만 동일한 상수 문자열을 참조하기 때문에 사실 동일한 객체나 마찬가지입니다. 따라서 위 프로그램의 메인 쓰레드와 Callable 쓰레드는 같은 락을 획득하려고 경쟁하기 때문에 데드락이 발생합니다. 
 그런데 이게 SharedData 클래스와 무슨 관계일까요? SharedData 에서는 String 이 아니라 Integer 와 Boolean 객체를 사용했고 이들 클래스는 Auto Boxing 에 의해 primitive type 값을 자동으로 해당 타입에 맞는 객체로 생성해줍니다. 따라서 String 에서처럼 같은 객체를 참조할 일은 없을 듯 싶습니다.
 하지만 실제로는 그렇지 않습니다. 왜냐하면 Integer 나 Boolean 같은 Wrapping type class 들은 성능 향상을 위해 몇몇 값들에 대해서는 매번 객체를 새로 생성하는 것이 아니라 미리 만들어 놓은 객체를 재 사용하기 때문입니다(이런 방식을 Flyweight pattern 이라고 합니다).
 따라서 SharedData 객체를 만약 아래와 같이 사용하게 되면 데드락이 발생합니다.

public static void main(String[] args) {
static void main(String[] args) throws Exception {
    Integer lock= 0;
    synchronized (lock) {
      Future<String> future = Executors.newSingleThreadExecutor().submit(new Callable<String>() {
        @Override
        public String call() throws Exception {
          return new SharedData().getInt();
        }
      });
      System.out.println(future.get());
    }
  }
}

 결론적으로 값 객체를 직접 락으로 사용하지 말아야 합니다. 꼭 값 객체를 별도의 락으로 동기화시키려면 java.util.concurrent.lock.ReentrantLock 같은 락 전용 클래스 객체를 사용하는 것이 좋습니다.

p.s. 노파심에서 언급하는 건데...혹여라도 ReentrantLock 을 아래처럼 사용하지는 마세요...

Lock lock = new ReentrantLock();
synchronized (lock) {
  ....
}

java.util.concurrent.lock.Lock 관련 클래스들의 올바른 사용법은 다음과 같습니다.

Lock lock = new ReentrantLock();
lock.lock();
try {
  ....
} finally {
  lock.unlock();
}

핑백

  • gimmesilver's blog : 자바 병행 프로그래밍시 유의해야 하는 버그 패턴 6가지 2010-12-22 20:57:12 #

    ... 개의 경우 Atomic 클래스를 사용하는 것이 성능을 떨어뜨리지 않으면서도 가독성이나 유연성 면에서 훨씬 낫다. 2번과 관련해서 예전에 비슷한 글(http://agbird.egloos.com/4863601)을 쓴적이 있는데 참조 변수와 실제 객체의 차이를 명확히 인식하고 있지않으면 나중에 검출하기 힘든 미묘한 버그를 만들 수 있다. 동기화 대상은 어디까 ... more

덧글

  • park.suhyuk 2009/03/20 10:09 # 삭제 답글

    좋은글 잘 읽었습니다. 한 가지 궁금한 점은, primitive type 객체의 변경시에 auto boxing에 의해서 해당 레퍼런스가 변경된다는 의미인가요? 아래의 함수는 긴작업을 진행중인데, 해당 상태를 체크하는 프로그램입니다
    [코드-1]
    private static Boolean flag = new Boolean(false);
    public void run() {
    synchronized (flag) {
    flag = true;
    // time consuming job.
    flag = false;
    }
    }
    public boolean isProcessing() {
    synchronized (flag) { return flag; }
    }
    와 같은 코드에서 현재 작업중이다 아니다를 판단하는 코드인데, 이런 경우에 동기화 블록내에서 해당 flag 값의 레퍼런스가 변할 수 있다는 의미인가요?
    만일 그렇다면 해당 flag값의 변경을 블록 외부로 바꾸면 안전한 코드일까요?

    [코드-2]
    private static Boolean flag = new Boolean(false);
    public void run() {
    flag = true;
    synchronized (flag) {
    // time consuming job.
    }
    flag = false;
    }
    이렇게 접근해도 문제가 된다면, 아래와 같은 코드로 바꾸게되면 ... ?

    [코드-3]
    private static Object lock = new Object();
    public void run() {
    synchronized (lock) {
    flag = true;
    // time consuming job.
    flag = false;
    }
    }
    public boolean isProcessing() {
    synchronized (lock) { return flag; }
    }

    감사합니다..~~
  • silverbird 2009/03/20 13:02 #

    위 글에서도 강조했지만 value object 를 lock 으로 사용하는 것은 바람직하지 않습니다.
    게다가 제시하신 코드에서 실제로 동기화가 필요한 것은 run() 함수 내의 작업이 아니라 flag 변수 자체이므로 synchronized {} 는 빼고 flag 변수를 AtomicBoolean 으로 만드는게 좋겠습니다.
    (위 코드대로라면 코드1 과 코드3 의 isProcessing() 함수는 항상 false 를 리턴하겠죠.)
    만약 AtomicBoolean 을 지원하지 않는 JDK 5 이전 버전이라면 flag 를 volatile 변수로 지정하면 됩니다.
  • park.suhyuk 2009/03/23 08:38 # 삭제 답글

    아~ concurrent에 AtomicBoolean이란게 있군요... 감사합니다.
    저도 답변글을 보면서 코드로 직접 테스트를 해보았습니다. 실험하면서 재미있는 사실을 경함하게 되었습니다. 결론부터 말씀드리면 재미있게도 2번, 3번코드의 경우만 false를 반환하더군요...

    왜 그런지 살펴보았더니, 본문에서 말씀 하신대로 Boolean 인스턴스의 경우 true, false에 의해서 auto boxing 되고 있으므로 true일 경우와 false일 경우 각기 다른 ref-id를 반환하게 됩니다. 그래서 synchronized 블록을 입성할 때에 ref-id가 1000번이라면 해당 1000번으로 blocking하지만 블록 내에서 false로 변경되는 순간 ref-id가 1001번으로 바뀌게 됩니다. 즉, isProcessing()함수를 호출할 때에는 1000번 객체에 대해서는 blocking하지만 1001번 객체에 대해서는 accessing가능한 샘이죠...

    결국 기능을 수행하는 샘이되더군요... 하지만 tricky한 방법이고 해당 true/false 값으로 대입하는 연산 사이에 예상치못한 또 다른 버그가 양산될 수 있으니 사용하지 않는 편이 좋겠다는 생각이 듭니다. 어쨌거나 한 수 배우고 갑니다. 감사합니다.
댓글 입력 영역