HashMap의 부적절한 사용으로 인한 CPU 100% 문제 분석

이전 프로젝트에서 비슷한 문제가 발생했었는데, 그때는 CurrentHashMap을 사용했습니다. 이 글은 그러한 문제를 재조명하고, 개발자들에게 경고하기 위한 것입니다.

HashMap의 잘못된 사용으로 인해 최근에도 여러 사례가 발생했습니다. 이에 대한 자세한 내용은 다음과 같습니다.

다음 코드는 HashMap의 쓰레드 안전하지 않은 사용으로 인한 데드락(실제로는 데드리프) 시뮬레이션 예제입니다:

이 문제는 이미 여러 차례 논의되었습니다. 특히, 교육 과정과 특정 서적에서는 velocity가 원인이 되어 CPU 사용률이 100%까지 올라가는 버그에 대해 언급한 바 있습니다. 이는 HashMap의 부적절한 사용 때문이었습니다.

HashMap의 오용인지, 아니면 HashMap 자체의 잠재적인 문제인지에 대한 논란이 있었습니다. 그러나 대부분의 경우, 동시성 환경에서 HashMap을 잘못 사용함으로써 발생하는 것으로 판명되었습니다. 이 문제를 해결하기 위해 다음 코드를 살펴보겠습니다:

HashMap의 모든 메소드에 synchronized 키워드를 추가하면 정상적으로 동작합니다. 아래의 코드에서는 HashMap의 entries가 순환 구조를 형성할 수 있는 가능성을 확인할 수 있습니다.

HashMap의 get() 메소드뿐만 아니라 put() 및 다른 외부 메소드에서도 이러한 위험이 존재합니다. 이는 JVM의 버그가 아닌, 오래 전부터 알려진 문제입니다 (참조: http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457). Sun의 엔지니어들은 이를 버그로 간주하지 않으며, 대신 ConcurrentHashMap을 사용하도록 권장합니다.

아래는 HashMap의 transfer 메소드에서 발생할 수 있는 데드리프 상황을 설명하는 코드입니다. 이 메소드는 데이터 확장 시 기존 컨테이너에서 새 컨테이너로 데이터를 이동시키는 역할을 합니다.

void 확장_작업(Entry[] 새로운_테이블) { 
    Entry[] 소스 = 테이블;
    int 새로운_용량 = 새로운_테이블.length; 
    for (int j = 0; j < 소스.length; j++) {
        Entry<K,V> 항목 = 소스[j]; 
        if (항목 != null) {
            소스[j] = null; 
            do {
                Entry<K,V> 다음_항목 = 항목.다음; 
                int i = 해시_인덱스(항목.해시, 새로운_용량);
                항목.다음 = 새로운_테이블[i];
                새로운_테이블[i] = 항목;
                항목 = 다음_항목;
            } while (항목 != null); 
        }
    }
}

두 개의 스레드가 동시에 실행되는 경우, 첫 번째 스레드가 특정 지점에 도달했을 때 두 번째 스레드가 이미 한 라운드의 do/while 작업을 완료했다면 다음과 같은 상황이 발생할 수 있습니다:

  1. E1 노드 삽입, E1 노드의 다음 포인터는 새로운 컨테이너의 해당 인덱스 위치를 가리킵니다(null 또는 entry).

  2. E2 노드 삽입, E2 노드의 다음 포인터는 현재 인덱스 위치의 참조값(E1)을 가리킵니다.

  3. 다음 포인터가 null이 아니므로 계속 진행하며, 두 노드 사이에 순환 구조가 형성됩니다. 이것이 데드리프입니다.

이러한 문제는 HashMap의 문제가 아니라 사용 시나리오의 문제입니다. 병렬 환경에서는 스레드 안전하지 않은 컨테이너를 사용하는 것은 안전하지 않습니다.

추가 참고 자료: ConcurrentHashMap 심층 분석(1), ConcurrentHashMap 심층 분석(2)

태그: java HashMap ConcurrentHashMap ThreadSafety PerformanceOptimization

6월 13일 21:39에 게시됨