Hashtable vs ConcurrentHashMap

Gongmeda·2023년 11월 22일
0
post-thumbnail

간단한 Todo-list API를 만드는 스터디를 진행하면서 DB를 연결하기 전에 Repository 인터페이스를 in-memory 기반의 구현체로 처리했었습니다.

이때, Map 타입의 변수를 통해 저장하고 조회하도록 구현했습니다. 다만, Spring MVC의 멀티스레드 환경을 고려하여 thread-safe한 구현체를 사용하려 했고, 대표적으로 HashtableConcurrentHashMap이 있었습니다. 이 둘을 비교하여 ConcurrentHashMap을 사용하게 된 과정을 알아보겠습니다.

역사

Hashtable은 Java 1.0부터 존재했으며 1.2버전부터는 Map 인터페이스를 구현하도록 수정되었다고 합니다.

ConurrentHashMapHashTable의 동시성 성능을 개선하기 위해 Java 1.5에 등장한 구현체입니다.

성능 테스트

각 구현체의 성능을 측정해보기 위해 JUnit으로 테스트 코드를 작성해봤습니다.

public class MapConcurrencyTest {

    static final int ITERATION_COUNT = 10000000;
    static CountDownLatch latch;
    static Map<Integer, Integer> map;
    static List<Long> times;

    private void init(Map<Integer, Integer> m) {
        latch = new CountDownLatch(20);
        map = m;
        for (int i = 1; i <= 1000; i++) {
            map.put(i, i);
        }
    }

    @Test
    public void test() throws InterruptedException {
        for (Map m : new Map[]{
                new HashMap<Integer, Integer>(),
                new ConcurrentHashMap<Integer, Integer>(),
                new Hashtable<Integer, Integer>()
        }) {
            times = new ArrayList<>();

            for (int t = 0; t < 10; t++) {
                init(m);

                long start = System.currentTimeMillis();

                // Create 10 Writer Threads
                for (int counter = 0; counter < 10; counter++) {
                    new Thread(() -> {
                        for (int i = 1; i <= ITERATION_COUNT; i++) {
                            int key = i % 1000 + 1;
                            map.put(key, key);
                        }
                        latch.countDown();
                    }).start();
                }

                // Create 10 Reader Threads
                for (int counter = 0; counter < 10; counter++) {
                    new Thread(() -> {
                        for (int i = 1; i <= ITERATION_COUNT; i++) {
                            int key = i % 1000 + 1;
                            map.get(key);
                        }
                        latch.countDown();
                    }).start();
                }

                // Wait for all threads to complete
                latch.await();

                long time = System.currentTimeMillis() - start;
                times.add(time);
            }

            System.out.println("Map: " + m.getClass().getSimpleName());
            System.out.println("Execution Times: " + Arrays.toString(times.toArray()));
            System.out.println("Average execution Time: " + times.stream().mapToLong(Long::longValue).average().getAsDouble() + "ms");
            System.out.println();
        }
    }
}

이를 실행하여 아래와 같은 결과가 나왔습니다.

Map: HashMap
Execution Times: [848, 723, 794, 751, 770, 748, 748, 743, 743, 741]
Average execution Time: 760.9ms

Map: ConcurrentHashMap
Execution Times: [3338, 3820, 3637, 3732, 3806, 3692, 3627, 3580, 3765, 3987]
Average execution Time: 3698.4ms

Map: Hashtable
Execution Times: [23102, 22990, 23054, 23021, 22986, 22965, 22892, 22974, 22970, 22960]
Average execution Time: 22991.4ms

결과를 통해 알 수 있듯이 성능은 HashMap > ConcurrentHashMap > Hashtable인 것을 알 수 있습니다.

HashMap의 경우는 thread-safe하지 않아 동기화 오버헤드가 없어 가장 빠른 것이 자명합니다. 하지만, ConcurrentHashMapHashtable은 왜 이렇게 차이가 많이 날까요?

왜 Hashtable은 ConcurrentHashMap보다 느릴까?

Hashtableget(), put() 모두 synchronized를 통해 테이블 전체를 lock하는 반면, ConcurrentHashMapget()에는 lock을 걸지 않고, put()에서만 사용되는 키 값에 따라 세분화된 lock을 제공하여 여러 스레드가 동시에 다른 부분을 수정할 수 있습니다.

실제 구현체의 코드를 통해 이를 확인할 수 있습니다.

Hashtable 내부 코드

public synchronized V get(Object key) {
    // 세부 구현
}
public synchronized V put(K key, V value) {
    // 세부 구현
}

둘 다 synchronized 키워드로 선언되어 동작 시 한번에 하나의 스레드만 접근할 수 있는 것을 확인할 수 있습니다.

ConcurrentHashMap 내부 코드

public V get(Object key) {
    // 세부 구현
}
public V put(K key, V value) {
	return putVal(key, value, false);
}

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 기타 로직 생략

    for (ConcurrentHashMap.Node<K,V>[] tab = table;;) { // 무한 루프를 통해 삽입될 인덱스를 확인
        ConcurrentHashMap.Node<K,V> f; int n, i, fh; K fk; V fv;
        if () // 세부 구현: 해당 인덱스에 노드가 없는 경우 lock을 걸지 않고 삽입
        else {
            synchronized (f) {
                // 실제 삽입 로직 (동기화 블록)
            }
        }
    }
    
    // 기타 로직 생략

    return null;
}

get()synchonized가 없고, put()의 경우는 키값(인덱스)에 따라 필요한 경우에만 사용하여 블로킹을 최소화 한 것을 확인할 수 있습니다.

ConcurrentHashMap의 내부 구현에 대한 자세한 분석이 궁금하다면 해당 블로그 아티클을 참고하기 바랍니다.

결론

위와 같은 특징 때문에, 스레드간 동기화가 필요하다면 대부분 ConcurrentHashMap을 사용한다고 합니다.

참고

profile
백엔드 깎는 장인

0개의 댓글