- 2009/02/17 19:57
- Agbird.egloos.com/4849046
- 덧글수 : 5
지난
번에 자바 프로그램 튜닝이라는 글에서
동기화된 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; } }
}
하지만 위 코드는 잘못된 방법일 뿐더러 위험한 방법입니다. 왜일까요?
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; } }
}
하지만 위 코드는 잘못된 방법일 뿐더러 위험한 방법입니다. 왜일까요?