Java Map의 동시성 문제 (HashMap vs. ConcurrentHashMap vs. Hashtable)

2025. 1. 18. 20:39·language/java

Map은 key-value 형태의 자료를 저장할 수 있는 컬렉션 자료구조 중 하나입니다.

key-value 형태는 사람이 알아보기 쉽고, 제공되는 api 또한 간단하기 때문에 많이 사용되는 자료구조인데요.

Map의 구현체 중 가장 많이 사용되는 구현체 중 하나로는 HashMap 자료구조가 있습니다.

하지만, 멀티 쓰레드 환경에서는 동시성 문제가 발생할 수 있기 때문에 사용에 주의가 필요합니다.

 

HashMap의 동시성 문제

HashMap

public class MapExample {

    private final Map<String, Integer> map;

    public MapExample() {
        this.map = new HashMap<>();
    }

    public void add(String key) {
        map.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
    }

    public Integer getValue(String key) {
        return map.get(key);
    }
}

 

HashMap ConcurrentTest

class MapExampleTest {

    @Test
    void givenHashmap_whenAddHundredConcurrently_thenValueMustBeHundred() throws InterruptedException {
        // given
        MapExample mapExample = new MapExample();
        int THREAD_COUNT = 1000;
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        // when
        for (int i = 0; i < THREAD_COUNT; i++) {
            executor.submit(() -> {
                mapExample.add("key");
                latch.countDown();
            });
        }

        latch.await();
        executor.close();

        // then
        assertEquals(THREAD_COUNT, mapExample.getValue("key"));
    }
}

 

1,000개의 쓰레드가 동시 접근한다고 할 때, 결과를 확인하면 기대했던 값보다 더 작은 값이 나오는 것을 확인할 수 있습니다.

 

그렇다면 Hashtable과 ConcurrentHashMap으로 바꾸고 결과를 확인해보면 어떨까요?

 

Hashtable

public class MapExample {

    private final Map<String, Integer> map;

    public MapExample() {
        this.map = new Hashtable<>();
    }

    public void add(String key) {
        map.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
    }

    public Integer getValue(String key) {
        return map.get(key);
    }
}

 

ConcurrentHashMap

public class MapExample {

    private final Map<String, Integer> map;

    public MapExample() {
        this.map = new ConcurrentHashMap<>();
    }

    public void add(String key) {
        map.compute(key, (k, v) -> (v == null) ? 1 : v + 1);
    }

    public Integer getValue(String key) {
        return map.get(key);
    }
}

Map의 구현체만 바꾸고 테스트는 동일하게 진행

Hashtable과 ConcurrentHashMap 모두 1,000개의 쓰레드가 동시에 접근하더라도 원하는 결과를 반환하는 것을 확인할 수 있습니다. 이는 내부적으로 두 구현체가 HashMap과 달리 Synchronized를 사용해 쓰레드가 동시에 접근하는 것을 막아 하나의 쓰레드만 critical section에 접근할 수 있도록 하기 때문입니다.

 

그렇다면 Hashtable과 ConcurrentHashMap 중 어떤 것을 사용해야 할까요?

 

ConcurrentHashMap vs. Hashtable

class MapExampleTest {

    private static final int THREAD_COUNT = 10;
    private static final int OPERATION_COUNT = 1_000_000;

    @Test
    void mapTimeTakenTest() throws InterruptedException {
        // given
        Map<Integer, Integer> map = new Hashtable<>();
        // Map<Integer, Integer> map = new ConcurrentHashMap<>();

        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        // when
        long start = System.currentTimeMillis();
        for (int i = 0; i < THREAD_COUNT; i++) {
            executor.submit(() -> {
                for (int j = 0; j < OPERATION_COUNT; j++) {
                    int key = j % 1000;
                    map.put(key, key);
                    map.get(key);
                }
                latch.countDown();
            });
        }

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

        // then
        System.out.println("Time taken: " + (end - start) + "ms");
    }
}

테스트는 10개의 쓰레드가 100만번씩 반복문을 돌며, key에 데이터를 넣고 꺼내는 연산을 수행하는 테스트입니다.

Hashtable과 ConcurrentHashMap을 사용했을 때 테스트 수행 시간을 각각 비교해봅시다.

 

Hashtable

 

ConcurrentHashMap

 

수행 시간이 85% 이상 줄어드는 것을 확인할 수 있었습니다. 구현체만 변경했을 뿐인데 왜 이렇게 큰 차이가 발생할까요?

 

Hashtable은 모든 메서드가 synchronized로 선언되어있습니다. 하지만, 모든 메서드가 동기화되기 때문에 성능 부하가 커진다는 문제가 있습니다. 하나의 메서드를 호출하면 모든 메서드에 대해 락이 발생하기 때문에 읽기 메서드와 쓰기 메서드가 모두 critical section이 되버립니다.

public synchronized int size();

public synchronized boolean isEmpty();

public synchronized Enumeration<K> keys();

public synchronized Enumeration<V> elements();

public synchronized boolean contains(Object value);

public boolean containsValue(Object value);

public synchronized boolean containsKey(Object key);

public synchronized V get(Object key);

public synchronized V put(K key, V value);

public synchronized V remove(Object key);

public synchronized void putAll(Map<? extends K, ? extends V> t);

public synchronized void clear();

public synchronized Object clone();

public synchronized String toString();

실제로 Hashtable 내부 메서드를 확인하면, 대부분의 메서드에 synchronized가 걸려 있는 것을 확인할 수 있습니다.

 

반면, ConcurrentHashMap은 모든 메서드에 대해 synchronized를 걸지 않고 내부적으로 Node와 CAS 연산을 이용합니다. ConcurrentHashMap의 메서드를 확인해봅시다.

public int size();

public boolean isEmpty();

public V get(Object key);

public boolean containsKey(Object key);

public boolean containsValue(Object value);

public V put(K key, V value);

public void putAll(Map<? extends K, ? extends V> m);

public V remove(Object key);

public void clear();

확실히 hashmap과는 차이가 있는 것을 확인할 수 있습니다.

 

실제 ConcurrentHashMap 코드에 들어가면 최상단에 다음과 같은 내용이 주석으로 포함된 것을 확인할 수 있습니다.

더보기

전체 조회의 동시성을 지원하고 업데이트에 대해 높은 기대 동시성을 제공하는 해시 테이블입니다. 이 클래스는 Hashtable과 동일한 기능 사양을 준수하며, Hashtable의 각 메서드에 해당하는 버전을 포함합니다. 그러나 모든 작업이 스레드 안전하더라도 조회 작업에는 잠금이 필요하지 않으며, 모든 액세스를 방지할 수 있는 방식으로 테이블 전체를 잠글 수 있는 지원은 없습니다. 이 클래스는 프로그램에서 Hashtable의 스레드 안전성에 의존하지만 동기화 세부 사항에는 의존하지 않는 경우 완전히 호환 가능합니다.

 

조회 작업(예: get)은 일반적으로 블로킹되지 않으므로 업데이트 작업(예: put 및 remove)과 겹칠 수 있습니다.

조회 작업은 시작 시점에 완료된 가장 최근의 업데이트 작업의 결과를 반영합니다. putAll 및 clear와 같은 집계 작업의 경우, 동시에 수행되는 조회는 일부 항목만 삽입 또는 제거된 상태를 반영할 수 있습니다. 이와 유사하게, Iterator, Spliterator, Enumeration은 이터레이터 또는 열거자가 생성된 시점 또는 그 이후의 해시 테이블 상태를 반영하는 요소를 반환합니다. 이러한 이터레이터는 ConcurrentModificationException을 던지지 않습니다.

 

그러나 이터레이터는 단일 스레드에서만 사용하도록 설계되었습니다. size, isEmpty, containsValue와 같은 집계 상태 메서드의 결과는 다른 스레드에서 동시 업데이트가 진행 중인 경우 프로그램 제어에는 유용하지 않을 수 있습니다. 대신 이러한 메서드의 결과는 모니터링 또는 추정 목적으로 충분히 유용할 수 있는 일시적 상태를 반영합니다.

 

충돌(즉, 서로 다른 해시 코드를 가진 키가 테이블 크기로 나눈 동일한 슬롯에 할당되는 경우)이 너무 많을 때 테이블은 동적으로 확장됩니다. 이로 인해 매핑당 약 2개의 버킷(로드 팩터 임계값 0.75에 해당)이 유지되도록 평균적으로 예상됩니다. 매핑이 추가 및 제거됨에 따라 이 평균값 주위에는 큰 변동이 있을 수 있지만, 전반적으로 해시 테이블의 시간/공간 절충안을 수용합니다. 그러나 모든 유형의 해시 테이블에서 리사이징은 상대적으로 느린 작업일 수 있습니다. 가능하면 initialCapacity 생성자 인수를 사용하여 크기를 추정하는 것이 좋습니다. 추가적인 loadFactor 생성자 인수를 사용하여 초기 테이블 밀도를 지정함으로써 주어진 요소 수에 대한 공간 할당을 사용자 정의할 수 있습니다.

 

또한 이전 버전과의 호환성을 위해 생성자는 내부 크기 조정을 위한 힌트로 expectedConcurrencyLevel을 선택적으로 지정할 수 있습니다. 동일한 해시 코드를 가진 많은 키를 사용하는 것은 모든 해시 테이블의 성능을 느리게 만드는 확실한 방법입니다. 이를 완화하기 위해 키가 Comparable일 경우, 이 클래스는 키 간 비교 순서를 사용하여 충돌을 완화할 수 있습니다.

 

ConcurrentHashMap의 newKeySet() 또는 newKeySet(int) 메서드를 사용하여 ConcurrentHashMap의 키에 대한 Set 투영을 생성하거나 keySet(Object) 메서드를 사용하여 조회할 수 있습니다. 이러한 경우, 키만 중요하며 매핑된 값은 사용되지 않거나 모두 동일한 매핑 값을 가질 수 있습니다.

 

ConcurrentHashMap은 java.util.concurrent.atomic.LongAdder 값을 사용하고 computeIfAbsent을 통해 초기화하여 확장 가능한 빈도 맵(히스토그램 또는 다중 집합의 한 형태)으로 사용할 수 있습니다. 예를 들어, ConcurrentHashMap<String, LongAdder>인 freqs에 값을 추가하려면 freqs.computeIfAbsent(key, k -> new LongAdder()).increment();를 사용할 수 있습니다.

 

이 클래스와 그 뷰 및 이터레이터는 Map 및 Iterator 인터페이스의 선택적 메서드를 모두 구현합니다.

Hashtable과 달리, ConcurrentHashMap은 null을 키나 값으로 사용할 수 없습니다.

 

ConcurrentHashMap은 다른 스레드에 의해 동시에 업데이트되는 맵에 대해서도 안전하게 적용될 수 있도록 설계된 순차 및 병렬 대량 작업 세트를 지원합니다. 병렬 작업은 다음 세 가지 종류와 네 가지 형태를 가지며, 키, 값, 항목, (key, value) 쌍을 인수 및/또는 반환 값으로 사용하는 함수를 수용합니다.

 

정리하면,

  1. 쓰레드 안전성
    • 조회 작업은 잠금이 필요하지 않고, 업데이트 작업과 동시에 실행될 수 있다
    • 조회는 가장 최근 업데이트된 작업의 결과를 반환한다
    • 전체 테이블을 잠그는 기능이 제공되지 않는다
  2. 동작 특성
    • Iterator, Spliterator, Enumeration은 생성 시점의 상태를 반영하며, ConcurrentModificationException을 던지지 않는다
    • size, isEmpty 같은 집계 메서드의 결과는 동시 업데이트 중에는 신뢰할 수 없지만, 모니터링에 사용될 수 있다
  3. 리사이징 및 충돌 관리
    • 충돌이 많을 경우 테이블 크기를 동적으로 확장한다
    • 초기 용량(initialCapacity), 로드 팩터(loadFactor), 예상 동시성 수준(expectedConcurrencyLevel)을 설정해 성능을 최적화할 수 있다
  4. null key & value 금지
    • Hashtable과 달리 null key와 value를 허용하지 않는다
  5. 병렬 작업 지원
    • 병렬 작업을 위한 순차 및 병렬 대량 작업을 지원
      • forEach : 각 요소에 대해 작업 수행
      • search : 조건에 맞는 첫 결과 반환
      • reduce : 요소를 누적 처리
    • 병렬 작업의 성능은 작업 크기와 함수 복잡도에 따라 달라진다
  6. 추가 기능
    • 키 전용 Set : newKeySet(), keySet() 메서드로 생성 가능
    • 빈도 맵 : computeIfAbsent, LongAdder를 사용해 효율적인 빈도 맵 구현 가능

java api문서를 확인해보면 hashmap은 JDK1.0부터, ConcurrentHashMap은 JDK1.5부터 지원하는 것을 확인할 수 있습니다.

일반적인 멀티쓰레드 환경에서는 ConcurrentHashMap을 사용하는 것이 권장되며, Hashtable은 특별한 이유가 없는 한 구버전 호환용으로만 사용하는 것이 바람직합니다.

'language > java' 카테고리의 다른 글

java의 불변 리스트 (Arrays.asList vs. List.of)  (0) 2025.01.23
Lombok의 @Builder와 @SuperBuilder  (0) 2025.01.23
'language/java' 카테고리의 다른 글
  • java의 불변 리스트 (Arrays.asList vs. List.of)
  • Lombok의 @Builder와 @SuperBuilder
koosco
koosco
웹 개발 공부하고 있어요 :)
  • koosco
    koosdata
    koosco
  • 전체
    오늘
    어제
    • ROOT (28)
      • WEB (2)
      • language (4)
        • java (3)
        • pug (1)
      • framework (3)
        • spring (3)
        • react (0)
      • database (1)
        • mysql (1)
      • DevOps (4)
        • Linux (4)
      • Error Log (2)
      • 독서 (3)
      • Computer Science (9)
        • Data Structure (2)
        • Network (2)
        • Design Pattern (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    mysql
    Design Pattern
    rabbitmq
    DATABASE
    가상면접 사례로 배우는 대규모 시스템 설계
    Container
    Spring
    linux
    독서
    DNS
    pug
    WSL
    vercel
    Network
    Java
    github pages
    amqp
    자료구조
    React
    HTML
    AWS
    docker
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
koosco
Java Map의 동시성 문제 (HashMap vs. ConcurrentHashMap vs. Hashtable)
상단으로

티스토리툴바