> 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 는 그 중 하나이다.