Effective Java : 아이템 11. 완벽 공략

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다.


    완벽 공략 27. 해시맵 내부의 연결 리스트 (내부 구현은 언제든지 바뀔 수도 있다)

    • 자바 8에서 해시 충돌 시 성능 개선을 위해 구현이 변경됨.
      • 내부적으로 동일 버켓에 일정 개수(8개 이상) 이상의 엔트리가 추가되면, 연결 리스트 대신 이진 트리를 사용하도록 변경됨.
    • 자료구조가 변경되면서 탐색 시간은 어떻게 변경될까? 
      • 연결 리스트에서 어떤 값을 찾는데 걸리는 시간은? : O(N)
      • 이진 트리에서 어떤 값을 찾는데 걸리는 시간은? : O(logN)

    해시 충돌이 발생하지 않는다면 항상 한번만에 버켓에서 값을 찾아온다. 그렇지만 해시 충돌이 많아진다면 버켓에서 값을 찾아오는데 많은 시간이 필요하게 될 것이다. 예를 들어서 버켓이 Linked List로만 구현되어 있다면, 처음부터 시작해서 그 값을 찾는데는 O(N)이 걸릴 것이다. 

    자바 8이후 부터는 이 부분을 개선하기 위해서 버켓에 8개 이상의 엔트리가 쌓이게 되면, 버켓을 Linked List에서 이진 트리로 변경한다. 이렇게 자료 구조를 변경하는 이유는 앞으로도 많은 해시 충돌이 발생할 것으로 기대하며, O(logN)만에 버켓에서 값을 가져올 수 있도록 하기 위함이다. 


    완벽 공략 28. 스레드 안전 

    • '스레드 안전하다' 란?
      • 멀티 스레드 환경에서 안전한 코드를 '스레드 안전하다'라고 한다.
      • 가장 안전한 방법은 여러 스레드 간에 공유하는 데이터가 없도록 만드는 것임.
    • 공유하는 데이터가 있을 때, 스레드 안전하고 싶다면? 
      • Syncrhonization (가장 원초적인 방법임) 
      • ThreadLocal
      • 불변 객체 사용
      • Synchronized 데이터 사용
        • 예를 들면 HashTable 같은 것. (얘는 Syncronzied로 떡칠되어 있음). 태생적으로 스레드 안전한 HashTable 같은 것을 공유 데이터로 쓴다면 안전함. 반면 HashMap은 쓰레드 안전하지 않기 때문에 이 자료구조를 사용하는 것도 쓰레드 안전하지 않게 된다. 
      • Concurrent 데이터 사용 
        • ConcurrentModification을 지원하는 List, Set 같은 것을 사용. Concurrent를 지원한다는 것은 여러 스레드가 동시에 접근 가능하도록 하는 것임. 
        • 동시에 여러 쓰레드가 값을 읽어갈 수 있도록 바꿔도 됨. 이런 경우라면 Concurrent 데이터를 사용. 
        • ArrayList 같은 경우에는 한번에 여러 쓰레드가 접근하면 ConcurrentModificationException이 발생했는데, 이 경우에는 발생하지 않음. 이것은 동시성을 허용한다는 관점에서 '쓰레드 안전'하다고 이야기 하는 것임. 

     

    아래의 hashCode()는 멀티 쓰레드에서 '실질적으로 쓰레드 안전한 코드'가 될 수 있다.  우선 쓰레드 안전하지 않다고 이야기 했던 것은 hashCode 필드를 공유하는 상태이며, 여러 스레드가 접근하면 데이터가 뒤죽박죽 섞일 수 있기 때문이다. 하지만 아래 코드는 어떤 쓰레드가 어떻게 접근하더라도 areaCode / preFix / lineNume이 같은 값을 가진다면 항상 같은 HashCode를 가진다.

    따라서 다양한 스레드가 계속 필드에 접근하고 값을 수정하더라도 hashCode 필드에는 항상 같은 값이 저장된다. 따라서 '실질적으로 스레드 안전'하다고 볼 수 있는 것이다. 

    private int hashCode; // 자동으로 0으로 초기화된다.
    
    @Override 
    public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Short.hashCode(areaCode);
            result = 31 * result + Short.hashCode(prefix);
            result = 31 * result + Short.hashCode(lineNum);
            this.hashCode = result;
        }
        return result;
    }

    실질적으로 스레드 안전하지 않은 코드는 스레드 안전하게 바꿔줘야한다. 어떻게 바꿔줄 수 있을까?

     

    Syncronized 키워드 사용하기

    메서드 자체를 syncronized 키워드로 관리하면 해당 메서드에는 한번에 하나의 스레드만 접근할 수 있다. 이 방법의 단점은 hashCode() 메서드가 자주 호출될 때 발생한다. 자주 호출되는 경우에는 여러 스레드가 Lock을 획득할 때 까지 기다릴 것이기 때문에 병목 구간이 발생한다. 이 부분을 약간 개선하기 위해 제공되는 것은 DoubleCheckedLock이 있다. 

     

    private int hashCode; // 자동으로 0으로 초기화된다.
    
    @Override 
    public syncronized int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Short.hashCode(areaCode);
            result = 31 * result + Short.hashCode(prefix);
            result = 31 * result + Short.hashCode(lineNum);
            this.hashCode = result;
        }
        return result;
    }

     

    DoubleCheckedLock 사용해서 만들기 

    DoubleCheckLock은 아래 같은 개념으로 작성하는 것이다. 메서드 전체 블록을 사용하기 보다는 Lock이 적용되는 부분을 적게 가져가는 것이다. 아래 코드는 다음과 같이 동작한다

    • hashCode 필드가 0이 아니면, hashCode는 초기화 되었다. hashCode는 항상 같은 값일 것이기 때문에 바로 가져가면 된다. 따라서 리턴해주면 된다. (Lock 적용 X)
    • syncronzied (this) 블록은 hashCode가 초기화 되지 않았을 때 실행되는 코드다. 이 부분부터는 한번에 하나의 스레드만 접근할 수 있기 때문에 한번에 하나의 스레드만 값을 업데이트 한다.
    • 스레드가 동시에 들어왔으나 Lock을 뒤늦게 획득한 스레드의 관점에서 result는 이미 0이 아닐 것이기 때문에 캐싱된 값을 읽어서 반환하게 된다. 

    이런 형식으로 작성된 Lock을 Double Checked Lock이라고 한다. Syncronzied가 적용되는 Block을 적게 가져가면서 병목 현상을 조금은 더 줄여주는 형식이다. 

    private volatile int hashCode;
    
    @Override 
    public int hashCode() {
    	if (this.hashCode != 0) 
        	return hashCode;
            
        synchronized (this) {
        	int result = hashCode;
            if (result == 0) {
            	result = Short.hashCode(areaCode);
                result = 31 * result + Short.hashCode(prefix);
                result = 31 * result + Short.hashCode(lineNum);
                this.hashCode = result;
            }
            return result;
    	}
    }

     

    volatile → Syncronized와 함께 사용하기 

    자바에서 스레드가 작업한 변수는 각 스레드의 작업 메모리(CPU의 캐시 메모리)에 복사된다. 그렇기 때문에 한 스레드에서 변수를 변경하더라도 다른 스레드에서는 변경된 값을 바로 알 수 없을 수도 있다. 스레드끼리의 일관성과 동기화에 문제를 일으킬 수 있음을 의미한다.

    CPU 캐시는 각 쓰레드 별로 별도로 관리되기 때문에 여러 스레드가 동일한 변수에 접근하더라도 각각의 CPU 캐시에 변수의 사본이 저장될 수 있다. 이것은 한 스레드에 의해서 변경된 변수의 값이 다른 스레드에는 바로 반영되지 않을 수 있음을 의미한다. 다른 쓰레드는 변경된 값을 메인 메모리에서 바로 읽지 않고, CPU 캐시에 저장된 값을 계속 사용할 수도 있기 때문이다. 

    만약 volatile 키워드를 사용하면, 해당 키워드가 있는 변수의 값을 바로 메인 메모리에 쓰고 읽기 때문에 다른 스레드에서 항상 최신 값을 읽을 수 있다. 따라서 Syncronized Block을 사용할 때 volatile 키워드를 함께 사용하는 것이 좋다. 

    댓글

    Designed by JB FACTORY