Java ConcurrentHashMap 1.7의 분할 잠금 구조와 내부 메커니즘

ConcurrentHashMap 1.7의 설계 핵심: Segment

JDK 1.7 버전의 ConcurrentHashMap은 고가용성과 동시성을 확보하기 위해 '세그먼트(Segment)'라는 개념을 도입했습니다. 이는 데이터 영역을 여러 개의 작은 단위로 쪼개고, 각 단위마다 독립적인 잠금을 적용하는 분할 잠금(Lock Striping) 기법을 기반으로 합니다.

  • Segment: ReentrantLock을 상속받아 구현된 객체로, 내부적으로 해시 테이블의 일부를 관리하며 해당 영역에 대한 잠금 역할을 수행합니다.
  • HashEntry: 실제 키-값 쌍을 저장하는 노드이며, 체이닝(Chaining) 방식을 통해 충돌을 해결합니다.
  • 이중 해싱: 데이터를 찾기 위해 두 번의 해시 연산을 수행합니다. 첫 번째 해시는 어떤 Segment에 속할지 결정하고, 두 번째 해시는 해당 Segment 내부의 어떤 버킷에 위치할지 결정합니다.

주요 내부 구성 요소

ConcurrentHashMap의 핵심은 전체 맵을 작은 테이블인 Segment들로 나누는 것입니다. 이를 통해 쓰기 작업 시 전체 맵을 잠그지 않고 특정 Segment만 잠금으로써 병렬 처리 효율을 극대화합니다.

static final class MapEntry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile MapEntry<K,V> next;

    MapEntry(int hash, K key, V val, MapEntry<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.val = val;
        this.next = next;
    }
}

static final class Partition<K,V> extends ReentrantLock implements Serializable {
    transient volatile MapEntry<K,V>[] bucketTable;
    transient int entryCount;
    transient int modificationCount;
    transient int resizeThreshold;
    final float fillFactor;

    Partition(float lf, int threshold, MapEntry<K,V>[] tab) {
        this.fillFactor = lf;
        this.resizeThreshold = threshold;
        this.bucketTable = tab;
    }
}

데이터 삽입 과정 (Put Operation)

데이터 삽입 시에는 대상이 되는 Segment를 찾아 잠금을 획득한 후 작업을 수행합니다. 다른 Segment에 대한 접근은 잠금에 영향을 받지 않으므로 동시 쓰기가 가능합니다.

final V putData(K key, int hash, V value, boolean onlyIfAbsent) {
    // 잠금 획득 시도 또는 스캔 후 잠금
    MapEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    V previousValue = null;
    try {
        MapEntry<K,V>[] table = bucketTable;
        int targetIdx = (table.length - 1) & hash;
        MapEntry<K,V> firstNode = table[targetIdx];
        
        for (MapEntry<K,V> e = firstNode; ; ) {
            if (e != null) {
                K k;
                if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {
                    previousValue = e.val;
                    if (!onlyIfAbsent) {
                        e.val = value;
                        ++modificationCount;
                    }
                    break;
                }
                e = e.next;
            } else {
                if (node != null) {
                    node.next = firstNode;
                } else {
                    node = new MapEntry<K,V>(hash, key, value, firstNode);
                }
                int newSize = entryCount + 1;
                if (newSize > resizeThreshold && table.length < MAXIMUM_CAPACITY) {
                    rehash(node);
                } else {
                    table[targetIdx] = node;
                }
                ++modificationCount;
                entryCount = newSize;
                break;
            }
        }
    } finally {
        unlock();
    }
    return previousValue;
}

리해싱(Rehash) 및 확장

Segment 내부의 테이블이 일정 부하 수준(Load Factor)을 초과하면 리해싱이 발생합니다. 이는 해당 Segment 내부에서만 독립적으로 이루어집니다.

  • 노드 이동: 버킷 내에 단일 노드만 존재하는 경우 새로운 인덱스로 즉시 이동시킵니다.
  • 최적화된 전송(LastRun): 체인 내에서 같은 새로운 인덱스로 이동하게 될 연속된 후속 노드들을 찾아 한꺼번에 이동시킴으로써 불필요한 노드 생성을 최소화합니다.
  • 불변성 활용: HashEntry의 일부 필드(key, hash, next 등)가 final로 설계된 버전의 경우, 삭제 시 해당 노드 이전의 노드들을 복제하여 다시 연결하는 방식을 취하기도 합니다.
private void transfer(MapEntry<K,V> newNode) {
    MapEntry<K,V>[] oldTab = bucketTable;
    int oldLen = oldTab.length;
    int newLen = oldLen << 1;
    resizeThreshold = (int)(newLen * fillFactor);
    MapEntry<K,V>[] newTab = (MapEntry<K,V>[]) new MapEntry[newLen];
    int mask = newLen - 1;

    for (int i = 0; i < oldLen; i++) {
        MapEntry<K,V> head = oldTab[i];
        if (head != null) {
            MapEntry<K,V> nextNode = head.next;
            int idx = head.hash & mask;
            if (nextNode == null) {
                newTab[idx] = head;
            } else {
                MapEntry<K,V> lastRun = head;
                int lastIdx = idx;
                for (MapEntry<K,V> last = nextNode; last != null; last = last.next) {
                    int k = last.hash & mask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                newTab[lastIdx] = lastRun;
                for (MapEntry<K,V> p = head; p != lastRun; p = p.next) {
                    int h = p.hash;
                    int k = h & mask;
                    newTab[k] = new MapEntry<K,V>(h, p.key, p.val, newTab[k]);
                }
            }
        }
    }
    int newNodeIdx = newNode.hash & mask;
    newNode.next = newTab[newNodeIdx];
    newTab[newNodeIdx] = newNode;
    bucketTable = newTab;
}

조회 작업 (Get Operation)

get 연산은 잠금을 사용하지 않습니다. HashEntryvaluenext 필드가 volatile로 선언되어 있어 최신 데이터를 안전하게 읽을 수 있기 때문입니다. 이는 읽기 성능이 매우 중요한 고병렬 환경에서 큰 장점으로 작용합니다.

태그: java ConcurrentHashMap Multi-threading data-structure Segmented-Lock

8월 28일 23:35에 게시됨