지식 정리/JAVA

[JAVA] HashMap의 동시성 문제

27200 2025. 6. 29. 11:37

공부한 내용을 정리하고자 작성한 내용입니다. 부족한 부분이 있다면 지적해 주시면 감사하겠습니다.

🤔 문제의식

https://to-travel-coding.tistory.com/135

 

쓰레드, 동기, 쓰레드풀, Runnable, Callable, Future

쓰레드란? 프로세스 내에서 실제로 작업을 수행하는 주체이다.프로세스의 코드에 정의된 절차에 따라 실행되는 특정한 수행 경로이다. Code, Data, Heap는 다른 쓰레드와 공유하며 pc, stack는 각각

to-travel-coding.tistory.com

 

쓰레드를 공부해 보면 항상 듣는 얘기가 있다. HashMap은 쓰레드 세이프하지 않다. 그렇기에, ConcurrentHashMap을 사용해주어야 한다는 것이다. 각각의 코드를 뜯어보며 어떤 이유로 쓰레드 세이프의 차이가 발생하는지 알아보자!


📖 HashMap

키(key)를 값(value)에 매핑하는 객체입니다.
Map은 중복된 키를 포함할 수 없으며, 각 키는 최대 하나의 값에만 매핑될 수 있습니다.

이 인터페이스는 과거의 Dictionary 클래스를 대체합니다.
(Dictionary는 완전히 추상적인 클래스였던 반면, Map은 인터페이스입니다.)

Map 인터페이스는 세 가지 컬렉션 뷰(collection view) 를 제공합니다.
이를 통해 Map의 내용을 다음과 같이 조회할 수 있습니다:

키의 집합 (Set<K>)

값들의 컬렉션 (Collection<V>)

키-값 쌍의 집합 (Set<Map.Entry<K,V>>)

Map의 정렬 순서(order) 는 이러한 컬렉션 뷰의 반복자(iterator)가 요소들을 반환하는 순서로 정의됩니다.
일부 Map 구현체 (예: TreeMap)는 이 순서에 대해 명확한 보장(ordering guarantee)을 제공하지만,
다른 구현체 (예: HashMap)는 순서를 보장하지 않습니다.

 

HashMap의 상위 인터페이스인 Map의 설명이다. 내용을 보면 알겠지만 쓰레드 세이프에 관한 내용은 서술되어있지 않다.


💡 메서드 분석

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

 

putval 메서드를 하나씩 살펴보자.

 

1) 테이블 초기화 또는 크기 확인

if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

 

내부 배열이 아직 생성되지 않았거나 크기가 0이면 resize()를 호출해 초기화 또는 확장한다.


2) 해시값 기반 버킷 인덱스 계산 및 해당 버킷 조회

if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);

 

 

i = (n-1) & hash로 해시값을 버킷 인덱스로 변환 (n은 보통 2의 거듭제곱) <- 내용은 코드의 윗부분에서 서술되어 있다.

만약 해당 버킷이 비어있으면 (p == null), 새 노드를 생성해 바로 추가한다.


3) 버킷에 이미 노드가 있을 경우

else {
    Node<K,V> e; K k;
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        e = p;
    else if (p instanceof TreeNode)
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
        // 연결 리스트 순회
        for (int binCount = 0; ; ++binCount) {
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                if (binCount >= TREEIFY_THRESHOLD - 1)
                    treeifyBin(tab, hash);
                break;
            }
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                break;
            p = e;
        }
    }
    if (e != null) {
        // 이미 키가 존재하는 경우, 값 업데이트
        V oldValue = e.value;
        if (!onlyIfAbsent || oldValue == null)
            e.value = value;
        afterNodeAccess(e);
        return oldValue;
    }
}

 

첫 번째 노드가 키와 동일한지 검사한다.

→ 같으면 그 노드를 e에 할당한다.

버킷이 TreeNode로 구성된 경우 

→ putTreeVal()로 트리 내 삽입 처리

버킷이 TreeNode로 구성되지 않은 경우 연결 리스트를 순회하며

1. 새 노드를 리스트 끝에 추가하거나
2. 동일 키 노드를 발견하면 해당 노드에 대해 갱신 준비

리스트가 너무 길어지면 (TREEIFY_THRESHOLD 이상) 리스트를 트리(bin을 트리화)로 변환해 성능 개선


이후에는 size가 threshold에 따라 resize 할지를 결정한다.

이는 사이즈가 너무 크면 리스트가 아닌 TreeMap을 사용한다.

사이즈가 다시 작아진 경우에도 동일하게 TreeMap -> 리스트로 자동적으로 변경된다.

 

위의 코드를 보면 알 수 있듯이 put을 하는 과정에서 Thread에 대한 고려가 전혀 없다.

그저 사이즈를 조절하며 넣는 것이 전부이다!


 

💡 테스트 코드

public class HashMapConcurrencyTest {

    private static final int THREAD_COUNT = 10;
    private static final int ENTRIES_PER_THREAD = 1000;

    @Test
    void testHashMapConcurrencyIssue() throws InterruptedException {
        Map<Integer, Integer> map = new HashMap<>();
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        // 병렬로 map에 데이터 넣기
        for (int i = 0; i < THREAD_COUNT; i++) {
            final int threadNum = i;
            new Thread(() -> {
                for (int j = 0; j < ENTRIES_PER_THREAD; j++) {
                    int key = threadNum * ENTRIES_PER_THREAD + j;
                    map.put(key, key);
                }
                latch.countDown();
            }).start();
        }

        // 모든 스레드 완료 대기
        latch.await();

        int expectedSize = THREAD_COUNT * ENTRIES_PER_THREAD;
        int actualSize = map.size();

        // AssertJ를 사용한 검증
        assertThat(actualSize)
                .as("기대하는 개수 %d개, 실제 개수 %d개", expectedSize, actualSize)
                .isEqualTo(expectedSize);
    }
}

 

테스트 코드를 이용하여 직접 테스트해 보자. 

 

코드의 원하는 동작은 0~9999까지 적합한 키의 값을 생성해 내는 것이다. 즉, 10000개를 기대한다.

<실행 결과>

코드 실행 결과 매번 실제 개수가 달라지긴 하지만 10000개가 나오는 경우는 없었다.

물론!! 나올 수도 있긴 하지만 예외가 발생한다는 게 중요하다.

 

HashMap 결론

단일 쓰레드 환경을 가정하고 설계되었기 때문에, 멀티스레드 환경에서 동기화 없이 사용할 경우 동시성 문제가 발생한다.

 


📖 ConcurrentHashMap

이 해시 테이블의 주요 설계 목표는 
(주로 get() 메서드, 그리고 반복자 및 관련 메서드를 포함한) 동시 읽기 가능성(concurrent readability)을 유지하면서,
업데이트 충돌(update contention)을 최소화하는 것이다.

부차적인 목표로는
java.util.HashMap과 같거나 더 나은 수준의 공간 효율성(space consumption)을 유지하고,
여러 스레드가 빈 테이블에 대해 초기 삽입을 빠르게 수행할 수 있도록 지원하는 것이다.

 

ConcurrentHashMap의 주석이다. 설계 목표 자체가 멀티 쓰레드에서의 충돌을 방지하는 것이라는 걸 알 수 있다.


💡 메서드 분석

public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh; K fk; V fv;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent // check first node without acquiring lock
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key, value);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

 

HashMap과 동일한 put, putval 부분을 가져왔다.

코드의 세부적인 차이점을 제외하고도, 가장 중요한 부분이 보일 것이다.

바로 synchronized의 존재이다.

즉, 코드 내부적으로도 동기를 걸어두고 진행을 하기에 문제가 발생하지 않는다는 것이다.

 

바로 코드를 통해 파악해 보자.


💡 테스트 코드

public class ConcurrentHashMapTest {

    private static final int THREAD_COUNT = 10;
    private static final int ENTRIES_PER_THREAD = 1000;

    @Test
    void testConcurrentHashMapThreadSafety() throws InterruptedException {
        Map<Integer, Integer> map = new ConcurrentHashMap<>();
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        // 병렬로 map에 데이터 넣기
        for (int i = 0; i < THREAD_COUNT; i++) {
            final int threadNum = i;
            new Thread(() -> {
                for (int j = 0; j < ENTRIES_PER_THREAD; j++) {
                    int key = threadNum * ENTRIES_PER_THREAD + j;
                    map.put(key, key);
                }
                latch.countDown();
            }).start();
        }

        // 모든 스레드 완료 대기
        latch.await();

        int expectedSize = THREAD_COUNT * ENTRIES_PER_THREAD;
        int actualSize = map.size();

        System.out.printf("기대하는 개수: %d개, 실제 개수: %d개", expectedSize, actualSize);

        // AssertJ를 사용한 검증
        assertThat(actualSize)
                .as("기대하는 개수 %d개, 실제 개수 %d개", expectedSize, actualSize)
                .isEqualTo(expectedSize);
    }
}

 

HashMap과 동일한 코드에 구현체만 ConcurrentHashMap으로 수정을 해주었다.

<실행 결과>

예외가 발생하지 않고, 원하는 대로 동작하는 것을 알 수 있다.

 

ConcurrentHashMap 결론

멀티스레드 환경에서 안전한 해시 기반 Map 구현체로, 동시 읽기와 쓰기를 효율적으로 지원하도록 설계되었다.


📖 성능 차이

당연하게도 단일 쓰레드에서 처리를 한다면 HashMap을 사용하는 게 성능이 좋을 수 있다.

하지만, 내가 직접 HashMap에 synchronized를 추가해 준다면? 사실상 ConcurrentHashMap과 동일한 성능을 보여주지 않을까?

 

테스트 코드를 통해 직접 파악해 보자.


💡 테스트 코드

public class ManualSynchronizedMapTest {

    private static final int THREAD_COUNT = 10;
    private static final int OPERATIONS_PER_THREAD = 100_000;

    // 직접 동기화를 적용한 Map 래퍼 클래스
    static class SynchronizedMapWrapper<K, V> implements Map<K, V> {
        private final Map<K, V> delegate = new HashMap<>();
        private final Object lock = new Object();

        @Override
        public V put(K key, V value) {
            synchronized (lock) {
                return delegate.put(key, value);
            }
        }

        @Override
        public V get(Object key) {
            synchronized (lock) {
                return delegate.get(key);
            }
        }

        // 나머지 Map 메서드는 생략 or 필요에 따라 추가
        // 여기선 성능 테스트에 필요한 최소 구현만 제공

        @Override public int size() { synchronized (lock) { return delegate.size(); } }
        @Override public boolean isEmpty() { synchronized (lock) { return delegate.isEmpty(); } }
        @Override public boolean containsKey(Object key) { synchronized (lock) { return delegate.containsKey(key); } }
        @Override public boolean containsValue(Object value) { synchronized (lock) { return delegate.containsValue(value); } }
        @Override public V remove(Object key) { synchronized (lock) { return delegate.remove(key); } }
        @Override public void putAll(Map<? extends K, ? extends V> m) { synchronized (lock) { delegate.putAll(m); } }
        @Override public void clear() { synchronized (lock) { delegate.clear(); } }
        @Override public java.util.Set<K> keySet() { synchronized (lock) { return delegate.keySet(); } }
        @Override public java.util.Collection<V> values() { synchronized (lock) { return delegate.values(); } }
        @Override public java.util.Set<Entry<K, V>> entrySet() { synchronized (lock) { return delegate.entrySet(); } }
    }

    @Test
    void compareManuallySynchronizedMapAndConcurrentHashMap() throws InterruptedException {
        System.out.println("🔁 테스트 시작 (직접 synchronized vs ConcurrentHashMap)");

        Map<Integer, Integer> syncMap = new SynchronizedMapWrapper<>();
        Map<Integer, Integer> concurrentMap = new ConcurrentHashMap<>();

        System.out.println("\n🧪 put() 성능 비교:");
        long syncPutTime = measureWritePerformance(syncMap, "SynchronizedMap(직접 락)");
        long concurrentPutTime = measureWritePerformance(concurrentMap, "ConcurrentHashMap");

        System.out.println("\n🧪 get() 성능 비교:");
        long syncGetTime = measureReadPerformance(syncMap, "SynchronizedMap(직접 락)");
        long concurrentGetTime = measureReadPerformance(concurrentMap, "ConcurrentHashMap");

        System.out.println("\n📊 최종 결과 (ms)");
        System.out.printf("SynchronizedMap(직접 락) - put: %d ms, get: %d ms%n", syncPutTime, syncGetTime);
        System.out.printf("ConcurrentHashMap - put: %d ms, get: %d ms%n", concurrentPutTime, concurrentGetTime);
    }

    private long measureWritePerformance(Map<Integer, Integer> map, String label) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        long start = System.currentTimeMillis();

        for (int i = 0; i < THREAD_COUNT; i++) {
            final int threadNum = i;
            executor.submit(() -> {
                for (int j = 0; j < OPERATIONS_PER_THREAD; j++) {
                    int key = threadNum * OPERATIONS_PER_THREAD + j;
                    map.put(key, key);
                }
                latch.countDown();
            });
        }

        latch.await();
        long end = System.currentTimeMillis();
        executor.shutdown();

        long time = end - start;
        System.out.printf("%s - put() 수행 시간: %d ms%n", label, time);
        return time;
    }

    private long measureReadPerformance(Map<Integer, Integer> map, String label) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        long start = System.currentTimeMillis();

        for (int i = 0; i < THREAD_COUNT; i++) {
            final int threadNum = i;
            executor.submit(() -> {
                for (int j = 0; j < OPERATIONS_PER_THREAD; j++) {
                    int key = (threadNum * OPERATIONS_PER_THREAD) + j;
                    map.get(key);
                }
                latch.countDown();
            });
        }

        latch.await();
        long end = System.currentTimeMillis();
        executor.shutdown();

        long time = end - start;
        System.out.printf("%s - get() 수행 시간: %d ms%n", label, time);
        return time;
    }
}

 

오버라이드를 통해 HashMap에 직접 synchronized를 걸어주었다.

<실행 결과>

 

put 메서드의 경우 절반 정도의 속도를, get메서드의 경우 1/3 정도의 속도를 보여준다. 

동일하게 synchronized를 걸어준 것인데 왜 차이가 발생할까?

💡핵심 포인트

읽기(get()) 락 없이 락프리(lock-free) 방식으로 동작 (volatile 변수와 메모리 가시성 보장)
쓰기(put(), remove()) 1) 특정 버킷(bucket, 즉 해시값에 대응되는 배열 슬롯)에서
2) 필요한 경우에만 제한적 락을 사용 (보통 그 버킷에 대한 락)
락 방식 버킷별로 synchronized 블록 또는 CAS(Compare-And-Swap) 연산을 사용해 충돌 최소화

synchronized를 통해 전체 Map에 대해 락을 걸어준 것과 다르게, 효율적으로 처리해 두었기 때문이다.


📖 최종 결론

1. HashMap

장점

  • 단일 스레드 환경에서 뛰어난 성능을 발휘한다.
  • 구조가 단순하고, 메모리 사용량이 적으며, 구현이 간단하다.
  • 삽입, 조회, 삭제 모두 평균적으로 빠른 시간복잡도를 가진다.

단점

  • 멀티스레드 환경에서 동기화가 없으면 안전하지 않다.
  • 동시 수정 시 내부 구조가 손상되어 데이터 유실, 무한 루프, 크래시 등 심각한 문제가 발생할 수 있다.
  • 동기화를 위해 Collections.synchronizedMap 등을 쓰거나 외부에서 락을 걸면 병목이 발생해 성능 저하가 크다.

2. ConcurrentHashMap

장점

  • 멀티스레드 환경에서 안전하게 동작하도록 설계됨.
  • 읽기 작업은 거의 락 없이 동작하며, 쓰기 작업은 내부적으로 매우 세분화된 락 또는 CAS 연산으로 병목 최소화.
  • 다중 스레드가 동시에 put(), get()을 수행해도 데이터 일관성 보장.
  • 초기 삽입 성능 및 공간 효율성이 HashMap과 유사하거나 뛰어남.

단점

  • 단일 스레드 환경에서는 HashMap보다 약간 오버헤드가 존재할 수 있다.
  • 내부 구조가 복잡해 구현 난이도가 높고, 디버깅이 어려울 수 있다.
  • 모든 작업에 락이 없지는 않기 때문에 극한의 쓰기 집중 부하는 성능 저하가 있을 수 있다.

💡최종 요약

기준 HashMap ConcurrentHashMap
스레드 안전성 없음 (외부 동기화 필요) 기본 제공
성능 (단일 스레드) 매우 우수 약간의 오버헤드 존재
성능 (멀티스레드) 동기화 없으면 문제 발생 뛰어난 동시성 및 확장성 지원
     
사용 용도 단일 스레드, 동기화 필요 없을 때 멀티스레드 환경, 고성능 동시성 필요할 때