ConcurrentHashMap을 통한 멀티스레드 환경 및 동시성 제어 알아보기

최근 프로젝트에서 인증 코드 저장을 위해 Redis와 자바의 자료구조인 ConcurrentHashMap 중 ConcurrentHashMap을 사용하기로 결정하였고, 이에 대한 깊은 이해를 위해 게시글을 작성하게 되었다.


1. 소개

1.1. 멀티스레드 환경에서의 동시성 문제

1. 멀티스레드 동시성 문제

Thread 1
Read: 100
Add 50
Write: 150
Shared Resource
100
Thread 2
Read: 100
Add 30
Write: 130
동시 접근 충돌!

  멀티스레드 프로그래밍은 현대 소프트웨어 개발에서 필수적인 요소이다. 하지만 여러 스레드가 동시에 같은 데이터에 접근할 때 발생하는 동시성 문제의 경우에는 개발자들에게 큰 도전 과제 중 하나이다. 데이터 불일치, 레이스 컨디션, 데드락 등의 문제가 발생할 수 있어 신중한 접근이 필요하다.

1.2. HashMap과 HashTable의 한계

  Java에서 전통적으로 사용되던 HashMap은 동기화되지 않아 멀티스레드 환경에서 안전하지 않다. 반면 Hashtable의 경우에는 모든 메서드에 synchronized 키워드를 사용하여 동기화하지만, 이로 인해 성능이 크게 저하된다. 이러한 하계를 극복하기 위해 ConcurrentHashMap이 등장했다.

  이전에 필자가 작성한 게시글을 참고하여도 좋을 것 같다.

2. ConcurrentHashMap 개요

2.1. ConcurrentHashMap의 탄생 배경

  ConcurrentHashMap은 Java 5에서 처음 도입되었다. 기존의 동기화된 컬렉션들의 성능 문제를 해결하고, 동시성과 확장성을 개선하기 위해 설계되었다.

2.2. 주요 특징 및 장점

  • 세밀한 락킹: 전체 맵이 아닌 일부분만 락을 걸어 동시성을 높인다.
  • 락-프리 읽기 연산: 대부분의 읽기 작업에서 락을 사용하지 않아 성능이 향상된다.
  • 원자적 업데이트 연산: 복합 연산을 원자적으로 수행할 수 있는 메서드를 제공한다.
  • 확장성: 동시에 많은 스레드가 접근해도 좋은 성능을 유지한다.

3. ConcurrentHashMap의 내부 구조

3.1. 세그먼트 개념 (Java 7 이전)

ConcurrentHashMap (Java 7)
Segment 1
Segment 2
Segment 3
버킷 1
버킷 2
버킷 1
버킷 2
버킷 1
버킷 2
각 세그먼트마다 별도의 락을 사용

Java 7 이전의 ConcurrentHashMap은 세그먼트라는 개념을 사용했다. 맵을 여러 개의 세그먼트로 나누고, 각 세그먼트마다 별도의 락을 사용했다. 이를 통해 스레드가 서로 다른 세그먼트에 동시에 접근할 수 있었다.

  Java 7 이전의 ConcurrentHashMap은 세그먼트라는 개념을 사용했다. 맵을 여러 개의 세그먼트로 나누고, 각 세그먼트마다 별도의 락을 사용했다. 이를 통해 스레드가 서로 다른 세그먼트에 동시에 접근할 수 있었다.

3.2. 노드 기반 구조 (Java 8 이후)

ConcurrentHashMap (Java 8+)
버킷 1
버킷 2
버킷 3
버킷 4
노드
노드
노드
트리 루트
노드
노드 기반 구조, 연결 리스트 또는 레드-블랙 트리 사용

Java 8 부터는 세그먼트 대신 노드 기반의 구조를 사용한다. 각 버킷이 하나의 노드를 가리키며, 해시 충돌 시 연결 리스트나 레드-블랙 트리를 사용한다. 이 구조는 더욱 세밀한 락킹을 가능하게 하며, 메모리 사용량도 줄였다.

필자는 이러한 구조적 변화를 통해 ConcurrentHashMap의 성능과 확장성을 크게 향상시켰다고 본다.

  Java 8 부터는 세그먼트 대신 노드 기반의 구조를 사용한다. 각 버킷이 하나의 노드를 가리키며, 해시 충돌 시 연결 리스트나 레드-블랙 트리르 사용한다. 이 구조는 더욱 세밀한 락킹을 가능하게 하며, 메모리 사용량도 줄였다.

  필자는 이러한 구조적 변화를 통해 ConcurrentHashMap의 성능과 확장성을 크게 향상시켰다고 본다.

4. 동시성 제어 메커니즘

4.1. 세그먼트 락킹 (Java 7 이전)

  Java 7 이전에는 각 세그먼트마다 ReentrantLock을 사용했다. 특정 세그먼트에 접근할 때만 해당 세그먼트의 락을 획득하여 작업을 수행했다.

4.2. CAS(Compare-and-Swap) 연산 (Java 8 이후)

시작
현재 값 읽기
새 값 계산
현재 값 == 예상 값?
Yes
값 갱신
No
재시도
종료

  Java 8부터는 CAS 연산을 주로 사용한다. CAS는 락을 사용하지 않고도 원자적 업데이트를 가능하게 하는 저수준 연산이다.

4.3. 세밀한 락킹 전략

  노드 기반 구조에서는 각 버킷 단위로 락을 사용할 수 있어, 더욱 세밀한 동시성 제어가 가능하다.

4.4. 읽기 작업의 락-프리(lock-free) 구현

  대부분의 읽기 작업은 락을 사용하지 않고 수행된다. 이는 높은 동시성과 성능을 제공한다.

5. 주요 연산의 동시성 처리

put

시작
해시 계산
버킷 찾기
CAS 연산
종료

get

시작
해시 계산
버킷 찾기
값 반환
종료

remove

시작
해시 계산
버킷 찾기
CAS 연산
종료

5.1. put 연산

  put 연산은 해당 버킷에 대해 동기화를 수행한다. 비어있는 버킷에 새 노드를 삽입할 때는 CAS 연산을 사용하며, 기존 노드가 있을 경우 해당 버킷에 대한 락을 획득한다.

5.2. get 연산

  get 연산은 기본적으로 락을 사용하지 않는다. volatile 변수를 사용하여 최신 값을 읽어오며 최신 값을 읽어오며, 필요한 경우에만 동기화 배리어를 사용한다.

5.3. remove 연산

  remove 연산은 put과 유사하게 동작한다. 해당 버킷에 대한 락을 획득한 후 노드를 제거한다.

5.4. size 연산

  size 연산은 맵의 전체 크기를 계산한다. 정확한 크기를 얻기 위해 모든 버킷에 락을 걸어야 하므로, 비용이 큰 연산이다. 대신 추정치를 반환하는 빠른 버전의 메서드도 제공한다.

6. 성능 최적화 기법

6.1. 리사이징 최적화

임계값 도달: 리사이징 시작
새로운 크기의 배열 생성
기존 배열을 청크로 분할
각 청크를 새 배열로 이동 (병렬 처리)
기존 배열 참조를 새 배열로 변경
리사이징 완료

  ConcurrentHashMap은 리사이징 과정에서도 다른 스레드의 읽기/쓰기 작업을 허용한다. 리사이징은 점진적으로 수행되며, 전체 맵에 대한 락을 사용하지 않는다.

6.2. 읽기 연산 최적화

  volatile 변수와 메모리 배리어를 사용하여 락 없이도 최신 값을 읽을 수 있도록 최적화되어 있다.

6.3. 쓰기 연산 최적화

  CAS 연산과 세밀한 락킹을 통해 쓰기 연산의 동시성을 최대화한다.

7. ConcurrentHashMap의 API와 사용 패턴

ConcurrentHashMap
기본 연산
put()
get()
remove()
원자적 연산
putIfAbsent()
replace()
remove()
대량 연산
forEach()
search()
reduce()

7.1. 기본 API 소개

  ConcurrentHashMap은 기본적인 Map 인터페이스 메서드(put, get, remove 등)를 제공한다.

7.2. 원자성 연산 메서드

  putIfAbset, replace, computeIfAbsent 등의 원자적 연산 메서드를 제공한다. 이들은 복합 연사을 안전하게 수행할 수 있게 해준다.

7.3. 벌크 연산과 스트림 지원

  Java 8부터는 forEach, reduce, search 등의 벌크 연산과 스트림 API를 지원한다.

8. 실제 사용 사례 및 성능 비교

HashMap
Hashtable
ConcurrentHashMap
성능
높음
낮음
동시성 처리

8.1. 캐시 구현

  ConcurrentHashMap은 멀티스레드 환경에서의 캐시 구현에 적합하다. 높은 동시성과 좋은 성능을 제공한다.

8.2. 공유 데이터 저장소

  여러 스레드가 동시에 접근하는 공유 데이터 저장소로 사용될 수 있다.

8.3. HashMap, Hashtable과의 성능 비교

  일반적으로 ConcurrentHashMap은 Hashtable보다 훨씬 좋은 성능을 보이며, 멀티스레드 환경에서는 HashMap보다 안전하고 효율적이다.

9. 주의사항 및 모범 사례

9.1. Null 키/값 사용 금지

  ConcurrentHashMap은 null 키와 값을 허용하지 않는다. 이는 동시성 문제를 방지하기 위함이다.

9.2. 반복자(Iterator) 사용 시 주의점

  반복자는 fail-safe이며, 생성 시점의 맵의 상태를 반영하다. 반복 중 맵이 변경되어도 ConcurrentModificationException이 발생하지 않는다.

9.3. 동시성 레벨 설정

  Java 7 이전 버전에서는 생성자를 통해 동시성 레벨(세그먼트 수)을 설정할 수 있었다. Java 8 이후로는 이 설정이 무시된다.

10. Java 버전별 ConcurrentHashMap의 변화

10.1. Java 7에서의 구현

  세그먼트 기반 구조를 사용했으며, 기본적으로 16개의 세그먼트를 가졌다.

10.2. Java 8에서의 개선사항

  노드 기반 구조로 변경되었으며, 레드-블랙 트리를 도입하여 해시 충돌 시 성능을 개선했다.

10.3. 최신 Java 버전에서의 추가 기능

  Java 9 이후로 적은 원소 수에 대한 최적화, 직렬화 개선 등의 기능이 추가되었다.

11. 결론

ConcurrentHashMap
장점
  • 높은 동시성
  • 스레드 안전
  • 빠른 성능
활용 분야
  • 멀티스레드 환경
  • 캐시 구현
  • 고성능 애플리케이션
"동시성과 성능을 모두 잡는 최적의 선택"

11.1. ConcurrentHashMap의 중요성

  ConcurrentHashMap은 멀티스레드 환경에서 안전하고 효율적인 데이터 구조로, 현대 Java 프로그래밍에서 중요한 역할을 한다.

11.2. 멀티스레드 프로그래밍에서의 할용

  적절히 사용한다면 동시성 문제를 해결하면서도 높은 성능을 얻을 수 있어, 멀티스레드 프로그래밍에서 매우 유용하다.