개발놀이터

ConcurrentMap, ConcurrentHashMap 본문

Java

ConcurrentMap, ConcurrentHashMap

마늘냄새폴폴 2023. 2. 18. 18:37

이번 포스팅은 기존 Map 인터페이스의 구현체 중 thread-safe 하다고 알려져있는 바로 그 구현체 ConcurrentMap입니다.

 

앞선 포스팅인 Map에 대한 기본적인 내용과 지금 쓰려는 이 내용은 지식의 깊이부터 다르기 때문에 성격이 맞지않다고 판단하여 따로 분리하여 포스팅하였습니다. 

 

 

들어가기에 앞서

Map은 자바 컬렉션 중에서 가장 대중적으로 사용하는 것 중 하나입니다. 

 

그리고 그 중 가장 많이 사용하는 HashMap은 thread-safe 하지 않은 구현체입니다. java 1.5 이전 (ConcurrentMap이 나오기 전) 에는 Hashtable이라는 클래스를 많이 사용했습니다. 

 

Hashtable은 HashMap과 다르게 멀티스레드 환경에서 thread-safe를 보장합니다. 

 

하지만 Hashtable은 문제가 있습니다. 

 

    public synchronized int size() {
        return count;
    }
    
    public synchronized boolean isEmpty() {
        return count == 0;
    }
    
    public synchronized Enumeration<K> keys() {
        return this.<K>getEnumeration(KEYS);
    }
    
    public synchronized Enumeration<V> elements() {
        return this.<V>getEnumeration(VALUES);
    }
    
    public synchronized boolean contains(Object value) {
        if (value == null) {
            throw new NullPointerException();
        }

        Entry<?,?> tab[] = table;
        for (int i = tab.length ; i-- > 0 ;) {
            for (Entry<?,?> e = tab[i] ; e != null ; e = e.next) {
                if (e.value.equals(value)) {
                    return true;
                }
            }
        }
        return false;
    }
    
    ....이하생략

 

보시면 모든 메서드에 synchronized를 붙여서 thread-safe를 보장하고있습니다. 

 

때문에 Hashtable을 사용하면 thread-safe는 보장하지만 효율적인 측면에서 많이 떨어지는 모습을 보여줍니다. 

 

마치 데이터베이스 격리수준의 Serializable이 생각나는 구조네요

 

thread-safe를 보장하면서 높은 수준의 성능을 보여주는 것이 바로 자바 1.5 부터 등장한 ConcurrentMap입니다. 

 

 

ConcurrentMap

ConcurrentMap은 Map 인터페이스의 확장 버전입니다. ConcurrentMap은 thread-safety의 문제를 해결하기 위한 구조 혹은 가이드를 제공하기위해 설계되었습니다. 

 

몇몇 인터페이스의 디폴트 메서드를 오버라이딩함으로써, ConcurrentMap은 thread-safety와 메모리 일관성인 원자적 실행을 제공하기위한 적절한 구현체로서 가이드라인을 제공합니다. 

 

  • getOrDefault
  • forEach
  • replaceAll
  • computeIfAbsent
  • computeIfPresent
  • compute
  • merge

위의 메서드들은 오버라이딩 했을 때 key혹은 value에 null값을 허용하지 않습니다. 

 

  • putIfAbsent
  • remove
  • replace(key, oldValue, newValue)
  • replace(key, value)

위의 메서드들은 기존 디폴트 인터페이스의 구현체들 없이 원자성을 보장합니다. 

 

 

ConcurrentHashMap

ConcurrentHashMap은 ConcurrentMap를 그대로 계승하고 있지만 bucket이라는 특이한 개념을 도입하여 thread-safe를 보장합니다. 

 

우선 bucket의 개념을 설명하는 것 보다 계속해서 등장하는 bucket의 느낌을 살려보시면 더 이해하기 편할 것 같습니다. 

 

이 bucket이라는 것은 늦게 초기화되는데, 각각의 bucket은 bucket안에 있는 첫번째 노드를 잠금으로써 독립적으로 lock을 획득할 수 있습니다. 하지만 읽기 작업은 잠그지않고 update contention들이 최소화됩니다. 

 

Contention 이란?

출처 : 위키피디아

resource contention이란 메모리에 랜덤으로 접근하거나 디스크 저장소에 접근하거나 캐시 메모리에 접근하거나 내부적인 bus에 접근하거나 외부적인 네트워크 디바이스에 접근하는 것과 같은 resource를 공유하기위해 접근하는 모든 충돌상태를 말합니다. 

 

쉽게말해서 리소스에 접근하기위해 사용되는 disadvantage 라고 할 수 있습니다. 

 

즉 리소스에 접근하기 위한 디스어드벤티지를 최소화하여 성능이 더 빠르고 좋다. 라고 해석해도 될 것 같습니다. 

 

 

정리하자면 ConcurrentHashMap은 읽기 작업에는 여러 쓰레드가 동시에 읽을 수 있지만, 쓰기 작업에는 특정 bucket에 대한 lock을 사용한다는 것입니다. 즉, 여러 스레드에서 ConcurrentHashMap 객체에 동시에 데이터를 삽입, 참조하더라도 그 데이터가 다른 세그먼트(bucket)에 위치하면 서로 락을 얻기위해 경쟁하지 않습니다. 

 

ConcurrentHashMap은 멀티스레드 환경에서 key-value를 쓰는 작업에서 메모리 일관성을 보장합니다. 

 

이를 확인해보기위해 공식문서에서 제공하는 예제코드를 잠깐 보도록 하겠습니다. 크게 어렵지않으니 잘 따라오실 수 있을겁니다.

 

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Memo2 {

    public static void main(String[] args) throws InterruptedException {
//        HashMap<String, Integer> map = new HashMap<>();
        Map<String, Integer> map = new ConcurrentHashMap<>();
        List<Integer> list = parallelSum100(map, 100);

        long count = list.stream().filter(x -> x != 100).count();

        System.out.println(count);
    }

    private static List<Integer> parallelSum100(Map<String, Integer> map,
                                                int executionTimes) throws InterruptedException {
        List<Integer> sumList = new ArrayList<>(1000);
        for (int i = 0; i < executionTimes; i++) {
            map.put("test", 0);
            ExecutorService executorService = Executors.newFixedThreadPool(4);
            for (int j = 0; j < 10; j++) {
                executorService.execute(() -> {
                    for (int k = 0; k < 10; k++)
                        map.computeIfPresent(
                                "test",
                                (key, value) -> value + 1
                        );
                });
            }
            executorService.shutdown();
            executorService.awaitTermination(5, TimeUnit.SECONDS);
            sumList.add(map.get("test"));
        }
        return sumList;
    }
}

 

좀 복잡해보이는데 하나하나 설명해보도록 하겠습니다. 

 

		List<Integer> sumList = new ArrayList<>(1000);
        for (int i = 0; i < executionTimes; i++) {
            map.put("test", 0);		// map에 key는 test value는 0으로 초기화합니다.
            ExecutorService executorService = Executors.newFixedThreadPool(4);
            //스레드를 네개 생성합니다. 
            for (int j = 0; j < 10; j++) {
                executorService.execute(() -> {		//각각의 스레드를 for문에 맞춰 실행합니다.
                    for (int k = 0; k < 10; k++)
                        map.computeIfPresent(	// map에 test라는 key가 존재하면
                                "test",			// value를 1 증가시킵니다.
                                (key, value) -> value + 1
                        );
                });
            }
            executorService.shutdown();		// 스레드를 종료시키고
            executorService.awaitTermination(5, TimeUnit.SECONDS);	// 5초의 시간을 줍니다. 
            sumList.add(map.get("test"));	// 그리고 리스트에 결괏값을 넣습니다. 
        }
        
5초의 시간을 주는 이유는 저 시간이 존재하지 않는 경우 바로 실행횟수만큼 for문이 돌기 때문에
그때 동시성 문제가 발생하여 값이 꼬여버리지 않게 하기 위해 하는 작업이라고 생각합니다.

 

위의 로직을 진행하고 parallelSum100이라는 메서드에 넘겨주는 파라미터인 Map에 따라 결괏값이 달라집니다. 

 

Main 메서드에서는 parallelSum100으로부터 리턴되는 List를 stream API를 이용하여 filter로 걸러 Map에 담긴 값이 100이 아닌 (동시성 문제가 발생하여 값이 꼬인) 개수를 찾는 것입니다. 

 

넘겨주는 Map이 HashMap인 경우 29가 나오는데 즉 동시성 문제가 발생한 개수가 29개라는 의미입니다. 반면에 ConcurrentHashMap을 넘겨주면 값이 0이 나오는데 이 말은 동시성 문제가 발생한 개수가 0개라는 의미입니다. 

 

 

마치며

이렇게 ConcurrentMap, ConcurrentHashMap에 대해서 알아봤습니다. 개념에 대해서 알아보고 검증까지 하면서 ConcurrentHashMap이 동시성문제에서 자유롭다는 것을 확인했습니다. 

 

여기까지 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

 

출처

https://www.baeldung.com/java-concurrent-map

 

'Java' 카테고리의 다른 글

가상 스레드 (부제 : 자바의 미래)  (0) 2024.06.25
Java8 StreamAPI  (1) 2024.04.28
상속과 super 그리고 super()  (0) 2022.09.15
final 키워드  (0) 2022.08.04
try-with-resouce  (0) 2022.08.04