본문 바로가기

WEB개발/JAVA

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

synchronized

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

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

 

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

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

 

 

  • 동기화 메서드
    메서드 전체를 동기화하여 한 스레드가 메서드를 실행하는 동안 다른 스레드의 접근을 막습니다.
public class Counter {
    private int count = 0;

    // synchronized 메서드
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}
  • 동기화 블록
    특정 코드 블록만 동기화하여 동기화 범위를 줄임으로써 성능을 최적화합니다.
public class Counter {
    private int count = 0;

    // synchronized 블록
    public void increment() {
        synchronized (this) { // 현재 객체를 lock
            count++;
        }
    }

    public int getCount() {
        synchronized (this) {
            return count;
        }
    }
}

 

 

 


 

Double Checked Locking 패턴에서 스레드 안전성 문제

객체생성 순서

 

  1. 메모리를 할당.
  2. 생성자를 호출하여 객체를 초기화.
  3. instance 변수에 객체의 참조를 할당.

문제점

 

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

해결방안

 

1. Thread-Safe Singleton

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

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

 

2. volatile

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {
        // private constructor
    }

    public static Singleton getInstance() {
        if (instance == null) { // 첫 번째 검사
            synchronized (Singleton.class) {
                if (instance == null) { // 두 번째 검사
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

 


 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 객체에 접근해도, 객체의 일관성이 유지되도록 합니다.

SynchronizedSet 

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

SynchronizedSet 클래스는 내부적으로 synchronized 키워드를 이용하여 메서드에 접근하는 스레드들의 동시 접근을 막습니다.

 

하지만 아래와 같은 작업에서는 원자성을 보장하지는 않습니다.

if (!set.contains(element)) { set.add(element); }

 

읽기-수정-쓰기(Read-Modify-Write, RMW)의 문제

RMW 작업이란, "현재 상태를 읽고(read), 그 상태를 기반으로 변경(modify)한 뒤, 새로운 상태를 다시 쓰는(write)" 연산입니다.
이 과정에서 중간 상태에서 다른 스레드가 개입할 가능성이 있기 때문에 동시성 문제가 발생할 수 있습니다.

 

Set<Integer> syncSet = Collections.synchronizedSet(new HashSet<>());

Thread t1 = new Thread(() -> {
    if (!syncSet.contains(1)) { // 읽기
        syncSet.add(1);         // 쓰기
    }
});

Thread t2 = new Thread(() -> {
    syncSet.add(1); // 다른 스레드가 동시에 접근
});

t1.start();
t2.start();

 

  • Thread t1이 syncSet.contains(1)를 호출하여 "값이 없다"고 판단합니다.
  • 동시에, Thread t2가 syncSet.add(1)을 실행하여 값을 추가합니다.
  • Thread t1이 다시 실행되어 syncSet.add(1)을 호출합니다.
  • 결과적으로 t1과 t2가 모두 add(1)을 실행하는 동작이 일어날 수 있습니다.

 

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) 문제가 발생할 수 있습니다

 

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