Java – Collection – Map – ConcurrentHashMap

> Thread-Safe ?

  동기화(Synchronize)라고 표현하기도 하며 어떠한 Class의 인스턴스가 여러개의 Thread에서 동시 참조되고 해당 객체에 Operation 이 발생해도 정합성을 유지해줄때 보통 우리는 Thread-Safe 하다 라고 표현한다.  @ThreadSafe 어노테이션을 이용해 해당 Class가 Thread-Safe 함을 표시하기도 한다.   어떠한 경우에도 개발자가 의도한대로 정확하게 동작한다라고도 이야기 할 수 있다.  참조하는(사용하는) 쪽에서 특별한 동기화 없이도 정확히 동작한다는 것을 이야기한다.  멀티 Thread 환경에서는 필수 적인 요소이다.  Java에서 Thread-Safe 를 이루는 방향 및 조건은 여러 가지가 있으나 이번 Post에는 따로 소개하지는 않는다.


> ConcurrentHashMap

  검색과 갱신 전체에 걸쳐 Thread-Safe 함을 보장하면서도 높은 성능을 보장하는 HashMap 이다.  HashMap처럼 기본적으로는 Hashtable 과 동일한 Spec을 제공한다.   Hashtable 또한 Thread-Safe를 보장한다. 그러나 차이점은 모든 작업이 Thread-Safe 임에도 불구하고 검색작업(get과 같은)에는 Lock이 수반되지 않으며, 전체 테이블을 잠궈야 하는 액션도 없다.  
  ConcurrentHashMap의 검색 작업(get 포함)은 Lock이 이루어지지 않으며 갱신 작업(put 및 remove 포함)과 동시에 수행 될 수 있다.  반대로 Hashtable 의 내부 코드를 살펴보면 Thread-Safe를 보장하는 방법으로 put, get 등의 검색 및 갱신 작업의 method 레벨에서 synchronized 키워드를 사용한다.  이는 간편하게 동시접근을 막아 Thread-safe 를 보장하는 방법 중 하나이다.  그러나 method 레벨에서 synchronized 키워드를 이용하면 Lock의 매개로 활용객체가 바로 객체바로 자신이 된다(this).  이는 전체적인 성능 저하를 가져온다.  어느 한 순간에 Lock이 설정된 어떤 method도 하나의 Thread만이 진입할 수 가 있게 된다. (누군가 get으로 Lock을 획득 중이면, put도 진입할 수가 없다.)

 ConcurrentHashMap의 검색은 검색 method가 실행되는 시점에 가장 최근에 완료된 갱신 작업의 결과를 반영한다.  putAll 및 clear와 같은 집계 작업의 경우 동시 검색에는 해당 시점에 put / remove 중인 일부 항목만 반영될 수 있다.  마찬가지로, iterator, Spliterator, Enumeration 수집 생성 시점 또는 이후 어느 한 시점의 상태를 반영하는 요소를 반환한다.  Hashtable에서 활용되던 ConcurrentModificationException은 이제 더이상 사용되지 않는다.  


> ConcurrentHashMap의 활용 예

 멀티 Thread 환경에서의 Thread-safe 한 Counter  (computeIfAbsent 사용)

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public class ConcurrentHashMapTest {
	public static void main(String[] args) {
		int loopSize = 30;
		CountDownLatch countDownLatch = new CountDownLatch(loopSize);
		ExecutorService tp = Executors.newFixedThreadPool(10);

		Map<String, LongAdder> testMap = new HashMap<>();

		for(int i = 0; i < loopSize; i++) {
			int selector = (i % 3);
			String type = (selector == 0) ? "HR" : (selector == 1) ? "SALES" : "IT";
			tp.submit(new Runner(type , countDownLatch, testMap));
		}

		try {
			countDownLatch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		System.out.println(testMap.toString());
		tp.shutdown();
	}

	static class Runner implements Runnable {
		private final String runnerNo;
		private final CountDownLatch countDownLatch;
		private Map<String, LongAdder> testMap;
		Runner(String runnerNo, CountDownLatch countDownLatch, Map<String, LongAdder> testMap){
			this.runnerNo = runnerNo;
			this.countDownLatch = countDownLatch;
			this.testMap = testMap;
		}
		@Override
		public void run() {
			try {
				testMap.computeIfAbsent(runnerNo, (value) -> new LongAdder()).increment();
			} catch (Exception e){
				e.printStackTrace();
			} finally {
				countDownLatch.countDown();
			}

		}
	}
}

ExecutorService를 이용해 30개의 Thread를 동시에 수행한다.  Thread 동시 시작을 위해 CountdownLatch가 활용되었다. 

12Line을 살펴보면 각 Thread에서 공유할 Map의 구현체를 HashMap을 사용하였다.   Thread에서는 생성자로 입력받은 Type을 Key로 같는 Map 의 Value를 LongAdder(Counter)로 활용한다.

Thread가 모두 종료되면 해당 Map의 내용을 출력한다.  Java 8 부터 추가된 computeIfAbsent를 이용해 값이 없으면 생성(new LongAdder()) 하고 있을 경우 Count (increment()) 한다.

30개의 Thread에 각 각 10개씩 Type String 값을 “HR”, “SALES”, “IT” 라고 할당해주었기 때문에 Count 값 역시 10개씩 출력되어야 한다.

먼저 HashMap을 이용했을때의 결과를 살펴보자.  


HashMap 결과

  • 1회차
{HR=7, IT=8, SALES=8}

Process finished with exit code 0
  • 2회차
{SALES=8, HR=7, IT=8}

Process finished with exit code 0
  • 3회차
{HR=9, IT=10, SALES=10}

Process finished with exit code 0

해당 결과는 역시 Non-ThreadSafe 의 전형을 보여준다. 

이제, 구현체를 ConcurrentHashMap으로 변경 후 다시 한번 결과를 살펴보자.  ( 12Line : new HashMap<>(); -> new ConcurrentHashMap<>(); )


ConcurrentHashMap 결과

{HR=10, IT=10, SALES=10}

Process finished with exit code 0

몇번을 수행해도 결과는 같다. 

 ConcurrentHashMap이 Thread-safe 하다 하더라도, 여러개의 Action (예 : get 수행 후 없을 경우 put 과 같은) 을 수행 할 경우까지 보장하지는 않는다.

이러한 경우를 위해 Java 8 이후에는 여러 가지 복합 작업을 수행할 수 있는 method를 제공해준다.  computeIfAbsent 는 그 중 하나이다.