WEB개발/JAVA

[JAVA] Thread-Safe, Concurrent Collection class, Double Checked Locking

wooyeon06 2021. 9. 23. 16:48

synchronized

서버에 동시에 여러 request다 요청될 경우 data 안정성과 신뢰성을 보장할  없습니다.

따라서 data의 thread-safe 를 하기 위해 자바에서는 synchronized 키워드를 제공해 스레드간 동기화를 시켜 data의 thread-safe를 가능케합니다.

 

Synchronized 키워드는 변수와 함수에 사용해서 동기화   있습니다.

하지만 Synchronized 키워드를 너무 남발하면 오히려 프로그램 성능저하를 일으킬  있습니다.

 

// 1. 메서드에서 사용하는 경우

public synchronized void method(){// 코드}

 

// 2. 객체 변수에 사용하는 경우(block)

private Object obj = new Object();

public void exampleMethod(){ synchronized(obj){//코드 }}

 

 

Double Checked Locking 패턴에서 스레드 안전성 문제는 "out-of-order writes"와 "visibility" 문제로 나뉩니다.

  1. out-of-order writes 문제: 첫 번째 스레드가 synchronized 블록 안에서 객체를 생성하고 필드에 할당한 후, 두 번째 스레드가 synchronized 블록에 진입할 때, 첫 번째 스레드에서 할당된 객체 참조 값이 캐시에 저장될 수 있습니다. 이후, 첫 번째 스레드가 객체를 생성하고 필드에 할당하는 명령어가 캐시에 저장되기 전에, 두 번째 스레드가 객체 참조 값을 읽어와 null이 아닌 값으로 판단할 수 있습니다. 이 경우, 두 번째 스레드는 이미 객체가 생성된 상태에서 다시 객체를 생성하게 되어, 예기치 않은 동작을 유발할 수 있습니다.
  2. visibility 문제: 첫 번째 스레드가 synchronized 블록 안에서 객체를 생성하고 필드에 할당한 후, 두 번째 스레드가 synchronized 블록에 진입할 때, 첫 번째 스레드에서 할당된 객체 참조 값이 캐시에 저장될 수 있습니다. 그 후, 첫 번째 스레드가 객체를 생성하고 필드에 할당한 후, 해당 값이 캐시에 저장되지 않고 직접 메모리에 쓰이지 않으면, 두 번째 스레드는 객체 참조 값을 읽어와 null인 것으로 판단할 수 있습니다. 이 경우, 두 번째 스레드는 객체가 아직 생성되지 않은 것으로 오해하게 되어, 다시 객체를 생성하게 됩니다.

이러한 문제를 해결하기 위해서는 volatile 키워드를 사용하거나,

public class Singleton {
    private volatile static Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if(instance == null) {
            synchronized(Singleton.class) {
                if(instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

 

Thread-Safe Singleton 패턴을 사용하는 것이 좋습니다.

public class Singleton {
    private static Singleton instance = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        return instance;
    }
}

위 코드에서는 클래스 초기화 시점에 private 생성자를 호출하여 Singleton 객체를 생성합니다. 이렇게 구현된 Singleton 클래스는 클래스 초기화 시점에서 이미 객체가 생성되기 때문에, 스레드 안전성을 보장합니다. 또한, synchronized 블록을 사용하지 않기 때문에 성능 저하가 발생하지 않습니다. 하지만, 이 방식은 초기화 시점에서 객체가 생성되기 때문에, 필요한 경우에만 객체를 생성하고자 할 때는 부적절한 방법입니다

 

 


 Concurrent Collection class

HashMap에 동기화 기능을 적용한 ConcurrentHashMap 이 여기에 속해있습니다. 동기화를 위해서 SynchronizedMap을 사용할 수 있지만 지금 살펴볼 ConcurrentHashMap의 성능이 더 좋습니다

이유는 바로 동기화 블록 범위(Scope)에 있는데요. ConcurrentHashMap은 동기화를 진행하는 경우 Map 전체에 락(Lock)을 걸지 않고 Map을 여러 조각으로 나누어서 부분적으로 락을 거는 형태로 구현되어 있기 때문입니다.

이러한 특징은 다중 스레드(Multi-Thread) 환경에서 더 효율적인 성능을 보입니다. 정말 그러한지 실제로 테스트를 해봅시다.

 

  • List Interface
    • ArrayList : 상대적으로 빠르고 요소에 대해 순차적으로 접근할 수 있다.
    • Vector : ArrayList의 이전 버전이며 모든 메서드가 동기화 되어 있다. (Thread Safe)
    • LinkedList : 순서가 변경되는 경우 노드 링크만 변경하면 되므로 삽입, 삭제가 빈번할 때 빠르다.
  • Set Interface
    • HashSet : 빠른 접근 속도를 가지고 있으나 순서를 예측할 수 없다.
    • LinkedHashSet : 요소가 추가된 순서대로 접근할 수 있다.
    • TreeSet : 요소들의 정렬 방법을 직접 지정할 수 있다.
    • SynchronizedSet  : 등기화 블럭을 가진 Set (Thread Safe)
  • Map Interface
    • HashMap : 중복을 허용하지 않고 순서를 보장하지 않으며 null 값을 허용한다.
    • Hashtable :
      1. 스레드 안전(Thread-safe)하지 않습니다. 
      2. null을 키 또는 값으로 사용할 수 없습니다.
      3. Hashtable의 크기는 동적으로 조정되지 않습니다. 따라서 Hashtable의 크기를 미리 결정해야 합니다.
      4. Hashtable은 순서를 보장하지 않습니다. 즉, 저장된 데이터를 검색할 때 저장된 순서대로 반환되지 않을 수 있습니다.
      5. Hashtable은 HashMap과 마찬가지로 Map 인터페이스를 구현하며, 키와 값을 매핑합니다.
    • TreeMap : 정렬된 순서대로 Key와 Value를 저장하므로 빠른 검색이 가능하지만 요소를 추가할 때 정렬로 인해 오래걸린다.
    • LinkedHashMap : HashMap과 기본적으로 동일하지만 입력한 순서대로 접근이 가능하다.
    • ConcurrentHashMap :  key, value에 null을 허용하지 않습니다. 또한 putIfAbsent라는 메소드가 존재합니다. 키값이 조재하면 기존의 값을 반화 없다면 put후 반환 (Thread Safe)

 

 

 

Vector :

 

 자바의 Vector 클래스는 동기화된(synchronized) 메서드를 이용하여 thread-safe하게 구현되어 있습니다. Vector 클래스는 add, remove, get 등의 메서드를 호출할 때마다 내부적으로 synchronized 키워드를 사용하여 스레드 안전성을 보장합니다. 이를 통해 여러 스레드가 동시에 Vector 객체에 접근해도, 객체의 일관성이 유지되도록 합니다.

Vector 클래스는 Java Collections Framework의 일부이며, ArrayList와 유사하지만, Vector는 thread-safe하며 ArrayList는 그렇지 않습니다. 따라서, 멀티스레드 환경에서 안전하게 사용할 수 있는 자료구조가 필요한 경우에는 Vector 클래스를 사용하는 것이 좋습니다. 그러나, Vector의 내부적인 동기화 처리는 성능에 부담을 주기 때문에, 단일 스레드 환경에서는 ArrayList를 사용하는 것이 더 효율적입니다.

 

 

SynchronizedSet 

 

 Java에서는 Collections 클래스를 통해 다양한 유형의 컬렉션을 제공합니다. 그 중 SynchronizedSet 클래스는 동기화된(synchronized) 래퍼(wrapper) 클래스입니다. SynchronizedSet 클래스는 Set 인터페이스를 구현하며, 내부적으로 다른 Set 객체를 감싸는 형태로 동작합니다. 따라서, SynchronizedSet 객체는 다른 Set 객체와 동일한 기능을 제공하지만, thread-safe한 특성을 가지게 됩니다.

SynchronizedSet 클래스는 내부적으로 synchronized 키워드를 이용하여 메서드에 접근하는 스레드들의 동시 접근을 막습니다. 예를 들어, add 메서드를 호출하면 내부적으로 다음과 같은 코드가 실행됩니다.

synchronized(mutex) {
    set.add(element);
}

여기서 mutex는 내부적으로 사용되는 객체이며, 스레드들의 동시 접근을 막기 위해 사용됩니다. add 메서드를 호출하는 스레드가 mutex 객체를 소유하지 못하면 대기 상태로 진입하게 되며, 다른 스레드가 mutex 객체를 반환하면 대기하던 스레드가 실행을 재개합니다. 이를 통해, 다수의 스레드가 동시에 SynchronizedSet 객체에 접근할 때, 객체의 일관성이 유지될 수 있습니다.

따라서, SynchronizedSet 클래스는 내부적으로 동기화된 메서드를 이용하여 thread-safe한 특성을 가지게 됩니다. 그러나, SynchronizedSet 클래스는 내부적으로 모든 메서드에 synchronized 키워드를 사용하기 때문에, 멀티스레드 환경에서 사용될 경우에도 성능이 저하될 가능성이 있습니다. 따라서, 대량의 데이터를 처리하는 경우에는 다른 thread-safe한 컬렉션 클래스를 사용하는 것이 좋습니다.

 

 

ConcurrentHashMap 

 

 ConcurrentHashMap은 스레드 안전(Thread-safe)한 자료구조입니다. 여러 스레드가 동시에 ConcurrentHashMap 인스턴스를 수정하더라도, 내부적으로 동기화 메커니즘이 구현되어 있어서 예상치 못한 결과를 방지합니다.

 

ConcurrentHashMap은 Java 5부터 제공되며, Hashtable과 비슷한 기능을 제공하지만 보다 높은 동시성을 지원합니다. ConcurrentHashMap은 내부적으로 세분화된 잠금(locking) 방식을 사용하여 스레드 간 충돌을 최소화하면서도 높은 동시성을 제공합니다. 이러한 잠금 방식은 매우 세밀하게 적용되어 있기 때문에, ConcurrentHashMap은 대부분의 동시성 작업에서 높은 성능을 제공합니다.

 

하지만, ConcurrentHashMap도 완전한 스레드 안전성을 보장하지는 않습니다. ConcurrentHashMap은 내부적으로 동기화 메커니즘이 구현되어 있지만, 동시성 작업을 수행하면서 여전히 일부 상태 경합 상황(race condition)이 발생할 수 있습니다. 따라서 ConcurrentHashMap을 사용할 때에도 적절한 동기화와 상태 검사를 수행해야 합니다.

 

ConcurrentHashMap은 일반적으로 스레드 안전(Thread-safe)한 자료구조로 알려져 있지만, 완전한 스레드 안전성을 보장하지는 않습니다. 이는 다음과 같은 이유 때문입니다.

 

 

1. "원자성(atomicity)" 보장하지 않음: ConcurrentHashMap에서 제공하는 putIfAbsent()나 replace() 메소드는 원자적(atomic)으로 실행되지 않을 수 있습니다. 즉, 여러 스레드가 동시에 동일한 키를 수정하려고 할 때, 상태 경합(race condition)이 발생하여 두 스레드가 동시에 같은 키를 추가할 수 있습니다.

 

2. "읽기-수정-쓰기(RMW) 동시성" 문제: ConcurrentHashMap은 읽기와 쓰기를 동시에 수행할 수 있지만, 동일한 키에 대한 "읽기-수정-쓰기(RMW)" 작업을 수행하는 경우, 동시성 문제가 발생할 수 있습니다. 즉, 여러 스레드가 동시에 동일한 키를 수정하거나 삭제하려고 할 때, 내부적인 상태 경합이 발생하여 데이터 불일치(inconsistent data) 문제가 발생할 수 있습니다

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

synchronized (map) {
    Integer value = map.get(key);
    if (value == null) {
        map.put(key, 1);
    } else {
        map.put(key, value + 1);
    }
}

 

3. "크기 조정" 문제: ConcurrentHashMap은 내부적으로 해시테이블을 사용하여 데이터를 저장합니다. 따라서, 데이터가 저장될 위치를 계산하는 해시 함수가 충돌할 경우, 충돌을 해결하기 위해 해시테이블의 크기를 재조정해야 할 수 있습니다. 이 때, 해시테이블의 크기를 재조정하는 작업은 전체 해시테이블을 재구성해야 하므로, 다수의 스레드가 동시에 접근하면 충돌이 발생할 가능성이 있습니다.

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

while (true) {
    Integer value = map.get(key);
    if (value == null) {
        Integer oldValue = map.putIfAbsent(key, 1);
        if (oldValue == null) {
            break;
        }
    } else {
        Integer newValue = value + 1;
        if (map.replace(key, value, newValue)) {
            break;
        }
    }
}

 

 

참조

https://coding-start.tistory.com/68

https://madplay.github.io/post/java-collection-synchronize