언어/Java

ConcurrentHashMap

린예라 2024. 11. 23. 00:24

Concurrent의 영어 뜻은 돌시발생의, 일치하는, 경쟁상대 등이 있다.

동시성을 지닌 해쉬맵이라고 보면 된다.

여러 스레드에서 공유해도 안전성을 보장한다는 것이다. 멀티스레드 환경에서 동시성과 성능을 모두 고려하여 설계되었다.

 

특징

1) 멀티스레드 안전 (Thread-Safe)

  • ConcurrentHashMap은 멀티스레드 환경에서 동기화 문제 없이 안전하게 사용할 수 있습니다.
  • 내부적으로 동기화를 통해 데이터를 보호하며, 읽기/쓰기 작업을 효율적으로 처리합니다.

2) 높은 성능

  • synchronized를 사용하여 전체 맵을 잠그는 대신, 세분화된 잠금(Lock Striping)을 사용합니다.
  • 자바 8 이전에는 세그먼트(Segment)라는 구조로 나누어 락을 관리했으며, 자바 8부터는 CAS(Compare-And-Swap)와 bucket-level 동기화로 구현되어 더 간단하고 효율적입니다.

3) Null 키와 Null 값 금지

  • ConcurrentHashMap은 null 키나 null 값을 허용하지 않습니다.
    • 이는 HashMap과의 주요 차이점 중 하나입니다.
    • 이유: null 값은 멀티스레드 환경에서 의미를 모호하게 만들 수 있기 때문입니다.

비교

HashMap vs ConcurrentHashMap vs Hashtable

특징 HashMap ConcurrentHashMap Hashtable
스레드 안전성 스레드 안전하지 않음 스레드 안전 스레드 안전
동기화 방식 없음 부분 동기화 (세분화된 락) 전체 동기화 (synchronized)
Null 키/값 1개의 null 키와 여러 개의 null 값 허용 null 키/값 허용하지 않음 null 키/값 허용하지 않음
성능 단일 스레드 환경에서 가장 빠름 멀티스레드 환경에서 높은 성능 멀티스레드 환경에서 낮은 성능

 

싱글스레드에서는 HashMap이 ConcurrentHashMap에 비해 빠르고 간단하니 더 적합하다.

멀티스레드 환경에서는 안정성과 최대한의 성능을 챙긴 ConcurrentHashMap를 사용하면 된다.

Hashtable은 레거시 코드로 , ConcurrentHashMap의 등장 이후 사용하지 않는 것을 권장한다.

 

즉 기존의 Hashtable은 전체에 동기화를 걸었다면, 이것의 성능을 최대한 올리기 위해 부분적으로 동기화를 적용한 상위호환이 ConcurrentHashMap 이라고 볼 수 있다.

 

ConcurrentHashMap의 간단 동작

자바 8 이전: 세그먼트(Segment) 기반

  • ConcurrentHashMap은 내부적으로 세그먼트라는 구조로 나뉘어, 각 세그먼트에 대해 별도의 락을 걸어 동기화했습니다.
  • 장점: 동시성이 높은 경우, 다른 스레드가 다른 세그먼트를 수정할 수 있음.

 자바 8 이후: CAS와 버킷 락

  • 자바 8부터는 세그먼트 구조를 제거하고, 더 간단하고 효율적인 방식으로 변경되었습니다.
  • CAS(Compare-And-Swap): 값을 원자적으로 갱신하기 위해 사용.
  • 버킷 락: 특정 버킷에만 동기화 적용.

단점

  1. 단일 스레드 환경에서는 HashMap보다 느림.
  2. null 키와 값을 사용할 수 없어 일부 코드를 수정해야 할 수 있음.
  3. 아주 높은 동시성에서 락 경합이 발생할 가능성.

사용예제

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        // 여러 스레드에서 동시 접근
        Thread t1 = new Thread(() -> map.put("A", 1));
        Thread t2 = new Thread(() -> map.put("B", 2));
        Thread t3 = new Thread(() -> {
            // computeIfAbsent: 키 "C"가 존재하지 않으면 값을 계산하여 추가.
            // 여기서 키 "C"가 없으므로 람다식 key -> 3이 실행되고, "C"에 값 3이 추가됩니다.
            map.computeIfAbsent("C", key -> 3);
        });

        // 스레드 시작
        t1.start();
        t2.start();
        t3.start();

        // 스레드 완료 대기
        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        map.forEach((key, value) -> System.out.println(key + ": " + value));
        /* 최종 결과 출력
        A: 1
        B: 2
        C: 3
        */
    }
}

만약 위 코드를 일반 HashMap에서 실행하면 정상적으로 결과가 출력될 수도 있지만, 동시에 접근하여 값이 덮어써져서 누락되거나 문제가 생길 수 있다.

위그나마 3개의 스레드라 그나마 오류나 문제가 생길 확률이 적을 수 있지만, 스레드의 숫자가 늘어날 수록 문제가 생길확률도 크게 올라갈 것이다.