본문 바로가기

java

hashtable vs concurrenthashmap

지난 번에 자바 프로그램 튜닝이라는 글에서 동기화된 Map이 필요하다면 HashTable 대신ConcurrentHashMap 을 사용하는 것이

더 좋다고 썼습니다만 그 이유에 대해서는 언급하지 않아서 찜찜하던 차에 이에 대해서 Java Concurrency in Practice 의 

저자인 브라이언 게츠가 쓴 글이 있어서 소개합니다.

Java theory and practice: Concurrent collections classes

 요약/첨언/정리 하자면 다음과 같습니다.

 HashTable 은 동기화를 위해 synchronized 키워드를 이용해서 메소드 전체에 락을 겁니다. 이 방법은 간편하고 안전한 반면 

Scalability 가 떨어집니다. 

다시 말하면 해당 HashTable 객체를 참조하는 쓰레드의 갯수가 많아질수록 락을 획득하기 위해 대기하는 시간이 길어져서 성능이 

급격히 나빠집니다(이것은 Collections.synchronizedMap 객체도 마찬가지입니다).
 반면에 ConcurrentHashMap 에서는 내부적으로 여러 개의 세그먼트를 두고 각 세그먼트마다 별도의 락을 가지고 있습니다. 

때문에 여러 쓰레드에서 ConcurrentHashMap 객체에 동시에 데이터를 삽입, 참조하더라도 그 데이터가 다른 세그먼트에 위치하면 

서로 락을 얻기 위해 경쟁하지 않습니다. 마치 오라클 같은 DB에서 특정 레코드를 업데이트할 때 테이블 전체에 락을 거는 것이 

아니라 해당 레코드에만 락을 걸어서 다른 레코드를 읽고 쓰는데에는 영향을 주지 않는 것과 비슷합니다. 
 이런 방법을 lock striping 이라고 합니다. 이름에서 의미하듯 컬렉션 데이터에 줄을 그어 각 영역마다 다른 락으로 동기화하는 

방법입니다. 이렇게 하면 데이터의 각 영역이 서로 영향을 주지 않는 작업에 대해서는 경쟁이 발생하지 않기 때문에 여러 쓰레드에서 

빈번하게 접근하더라도 락 획득을 위한 대기 시간을 많이 줄일 수 있습니다. 

물론 효과를 극대화하기 위해서는 상황에 따라 적절히 세그먼트를 나누는 것이 필요합니다. 데이터를 너무 적은 수의 조각으로 나누면 

경쟁을 줄이는 효과가 적을 것이고 너무 많은 수의 조각으로 나누면 이 세그먼트를 관리하는 비용이 커지기 때문입니다.
 ConcurrentHashMap 에서는 기본적으로 16개의 세그먼트로 나눠서 내부 데이터를 관리합니다. 물론 사용자가 직접 이 갯수를 튜닝할 

수 있습니다. ConcurrentHashMap 의 생성자에는 initialCapacity, loadFactor, concurrencyLevel 이라는 세 개의 튜닝 인자가 있는데 

이 중 세 번째 인자인 concurrencyLevel 이 바로 이 세그먼트를 나누는 갯수를 의미합니다. 

 한편, ConcurrentHashMap 에서 사용한 lock striping 기법과 유사하게 내부 데이터를 별도의 락으로 관리하는 다른 기법이 있습니다. 

예를 들어 다음과 같은 클래스가 있을 때,

class SharedData {
    private int intData;
    private boolean boolData;

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

위 클래스를 synchronized 메소드로 동기화하는 것은 앞서 설명한 HashTable 처럼 scalability 측면에서 좋지 않습니다. 

왜냐하면 intData 에 접근하는 쓰레드와 boolData 에 접근하는 쓰레드는 동기화할 필요가 없는데도 불구하고 불필요하게 락을 공유하기 

때문입니다. 따라서 만약 위 SharedData 객체가 여러 쓰레드에서 아주 빈번하게 참조된다면 intData 와 boolData 에 대해서 별도의 

락을 사용하는 것이 좋습니다. 

class SharedData {
    private int intData;
    private boolean boolData;
    private Object intSync = new Object();
    private Object boolSync = new Object();

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

 이렇게 하면 intData 와 boolData 는 서로 다른 락을 사용하기 때문에 불필요한 락 획득을 위한 대기 시간을 없앨 수 있습니다. 

이런 방법을 lock Splitting 이라고 합니다. 말 그대로 락을 여러 개로 쪼개는 방법입니다. 

그런데 사실 위 클래스처럼 동기화해야 할 데이터가 int 나 boolean 같은 primitive type 이라면 저렇게 synchronized 를 사용하는 

것보다 Atomic 객체를 사용하는 것이 더 좋습니다. java.util.concurrent.atomic 패키지에는 다양한 primitive type 을 위한 동기화 

객체를 제공하고 있습니다. 이들은 좋은 동기화 성능과 몇 가지 편리한 atomic 메소드를 가지고 있습니다. 

따라서 위 클래스는 아래처럼 수정하는 것이 가장 좋은 방법입니다.

class SharedData {
    private AtomicInteger intData = new AtomicInteger(0);
    private AtomicBoolean boolData = new AtomicBoolean(false);

    public int getInt() { return intData.get(); }
    public void setInt(int n) { intData.set(n); }
    public boolean getBool() { return boolData.get(); }
    public void setBool(boolean b) { boolData.set(b); }
}


 이제 다시 ConcurrentHashMap 으로 넘어와서 ConcurrentHashMap 에는 이와 같은 효율적인 동기화 기법 외에도 몇 가지 장점을 

더 가지고 있습니다. 가령 아래와 같은 작업을 하고 싶다고 했을 때,

Map<?,?> map = ...
if (false == map.containsKey(key)) {
    map.put(key, value);
} else {
    ...
}

 map 이 HashTable 나 Collections.synchronizedMap 객체라고 한다면 map.containsKey() 실행 이후에 다른 쓰레드가 선점할 수 

있기 때문에 문제가 발생할 수 있습니다. 따라서 아래와 같이 락으로 감싸줘야 합니다.

synchronized (map) {
    if (false == map.containsKey(key)) {
        map.put(key, value);
    } else {
        ...
    }
}

하지만 ConcurrentHashMap 을 사용하면 이런 번거로움을 피할 수 있습니다.

ConcurrentHashMap map = ...
if (map.putIfAbsent(key, value) == false) {
    ...
}

마지막으로 HashTable 이나 Collections.synchronizedMap 의 데이터를 순환할 때 역시 순환 도중에 다른 쓰레드가 이 객체의 

데이터를 삽입/삭제하는 것을 막기 위해서는 아래와 같이 해줘야 합니다.

synchronized (map) {
    for (Map.Entry<?,?> entry : map.entrySet()) {
        ...
    }
}

하 지만 ConcurrentHashMap 의 경우에는 entrySet(), keySet(), values() 가 모두 일종의 view 컬렉션을 반환하기 때문에 순환 도중에 

다른 쓰레드가 ConcurrentHashMap 객체의 데이터를 삽입/삭제하더라도 ConcurrentModificationException 이 발생하지 않습니다. 

p.s. 위에 lock splitting 을 설명하는 코드에서 intData와 boolData를 동기화시키기 위해 별도의 Object 객체를 생성해서 락으로 

사용했습니다. 하지만 얼핏생각해보면 아래처럼 멤버 객체 자체를 락으로 활용하는 것이 더 좋아보입니다. 

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() { synchornized (boolData) { return boolData; } }
    public void setBool(boolean b) { synchronized (boolData) { boolData = b; } }
}

하지만 위 코드는 잘못된 방법일 뿐더러 위험한 방법입니다. 왜일까요? 


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();
}


'java' 카테고리의 다른 글

몇가지 유용한 getProperty  (0) 2016.09.19
generate java JNI Header  (0) 2016.08.18
multipart/form-data 자바에서 받는 여러가지 선택지! file upload  (0) 2016.08.11
hssf vs xssf  (0) 2016.08.10
setSoTimeout, connect timeout  (0) 2016.08.05