Java의 동시성에 대해서 좀 더 알아보자.
| 여러 스레드가 모두 한 CPU의 캐시 메모리를 읽으면?
여러 스레드가 모두 한 CPU의 캐시 메모리를 읽으면 *가시성 문제가 해결될까?
- 모든 스레드가 동일한 CPU 캐시를 참조한다면 데이터가 항상 동기화 상태를 유지할 것이므로 가시성 문제는 발생하지 않는다.
- 하지만, 실제 시스템에서는 멀티코어 CPU 환경에서 각 코어가 자신의 L1/L2 캐시를 독립적으로 관리한다.
- ex. 스레드 A는 CPU1의 캐시에서 작업하고, 스레드 B는 CPU2의 캐시에서 작업하는 경우, 두 캐시간의 데이터 동기화가 이루어지지 않으면 가시성 문제가 발생한다.
- 이에 대한 해결 방법은 Java에서 volatile 키워드와 같은 동기화 메커니즘을 통해 변수의 변경사항을 즉시 다른 스레드에서 관찰 가능하도록 보장한다.
- synchronized 블록이나 CAS 알고리즘도 가시성 문제를 해결한다. 이는 CPU 캐시를 강제로 무효화하고 값을 메인 메모리로부터 다시 읽게 만든다.
*가시성 문제 : 여러 스레드가 동일한 데이터를 읽고 쓰는 상황에서 발생한다. 스레드가 특정 변수의 값을 수정했지만, 다른 스레드에서는 변경된 값을 즉시 관찰하지 못할 수도 있다. 이는 CPU 캐시와 메인 메모리 간의 데이터 불일치 때문이다.
조금 더 알아보자.
- 각각의 CPU에는 독립적인 코어가 있고, 코어는 자체 캐시 메모리를 사용한다.
- CPU 코어마다 캐시 메모리가 따로 존재한다. 이 캐시 메모리에는 프로그램 실행 중 자주 접근하는 변수가 저장된다.
- RAM은 모든 CPU 코어가 공유하는 메인 메모리이다. CPU 캐시 메모리는 RAM과 독립적으로 동작할 수 있고, 캐시에 저장된 값은 메인 메모리와 항상 동기화되지 않을 수 있다.
- stopRequested : RAM에서 관리되는 공유 변수로, 각각의 CPU 캐시에 복사되어 사용된다. CPU 1의 캐시 메모리에는 stopRequested = false, CPU 2의 캐시 메모리에서는 stopRequested = true로 값이 다르게 나타날 수 있다.
- 위와 같이 stopRequested가 다르면 다음과 같은 문제가 발생한다.
- CPU 1의 Thread 1이 stopRequested = false를 읽고 계속 루프를 실행한다. 이 값은 CPU 1의 캐시 메모리에만 유지되며, RAM에서 변경된 값(true)을 읽지 않는다.
- CPU 2의 Thread 1이 stopRequested = true로 값을 변경한다. 이 변경 사항은 CPU 2의 캐시에만 반영되고, 메인 메모리에도 기록된다. 그러나, CPU 1의 캐시는 메인 메모리와 동기화되지 않으므로, 변경된 값을 관찰하지 못한다.
- 요약하자면, 두 Thread가 동일한 변수를 사용하지만, 캐시 동기화 문제로 인해 변수의 변경 사항이 서로 다른 스레드에서 즉시 반영되지 않는다.
- 이러한 문제가 가시성 문제이며, 이는 멀티스레드 프로그래밍에서 주요한 동기화 문제 중 하나이다.
| CAS(Compare and Swap) 알고리즘
CAS 알고리즘은 현재 스레드가 가지고 있는 기존 값과 메모리가 가지고 있는 값을 비교해 같은 경우 변경할 값을 메모리에 반영하고 true를 반환한다. 값이 다른 경우에는 변경 값이 반영되지 않고 false를 반환한 다음 재시도를 하는 방식으로 동작한다. *CAS 알고리즘을 통해 가시성 문제와 원자성 문제를 해결할 수 있다.
- 메모리의 현재 값을 읽기
- 읽은 값과 기대 값(스레드가 가지고 있는 값)을 비교
- 값이 동일 : 새로운 값을 메모리에 기록, true를 반환
- 값이 불일치 : 기록을 거부, false 반환 및 재시도
*CAS가 가시성 문제와 원자성 문제를 해결하는 방식 : 가시성 문제는 값 변경 시 CPU 캐시를 무효화하고 메모리 동기화를 강제한다. 원자성 문제는 값을 읽고, 비교하고, 갱신하는 모든 과정이 단일 연산처럼 처리된다.
| Vector, Hashtable, Collections.SynchronizedXXX의 문제점
Vector, Hashtable, Collections.SynchronizedXXX 클래스는 synchronized 메소드 또는 블록을 사용하며, 하나의 잠금 객체를 공유한다.
- 메소드마다 synchronized 키워드를 사용하여 전체 객체에 잠금을 적용한다.
- 하나의 스레드가 특정 메소드의 잠금을 획득하는 경우 다른 스레드들은 모든 메소드에 접근하지 못하고 Blocking 상태가 된다.
- 이는 동시성 수준이 낮아지고 애플리케이션 성능 저하의 원인이 될 수 있다.
- 대안으로는 ConcurrentHashMap이나 CopyOnWriteArrayList와 같은 Non-Blocking 또는 세분화된 잠금을 사용하는 컬렉션을 사용하는 것이 더 효율적이다.
| SynchronizedList와 CopyOnArrayList의 차이
- SynchronizedList : 읽기와 쓰기 동작 시 인스턴스 자체에 잠금이 걸린다.
- Collections.synchronizedList()로 생성된 리스트
- 모든 읽기 및 쓰기 작업에 전체 리스트 객체에 잠금이 걸린다.
- 동시 읽기 성능이 떨어질 수 있다.
- CopyOnArrayList : 쓰기 동작 시 해당 블록에 잠금을 걸고 원본 배열에 있는 요소를 복사하여 새로운 임시 배열을 만들고, 이 임시 배열에 쓰기 동작을 수행한 후 원본 배열을 갱신한다. 따라서, 읽기 동작은 잠금 없이 바로 읽을 수 있다.
- 다수의 읽기 작업에 적합하다.
- 쓰기 작업이 빈번한 경우 메모리 복사 오버헤드가 발생한다.
| ConcurrentHashMap vs. SynchronizedMap
- ConcurrentHashMap : 각 테이블 버킷을 독립적으로 잠그는 방식을 사용한다.
- 만약, 빈 버킷에 노드를 삽입할 경우 잠금(Lock) 대신 CAS 알고리즘을 사용한다.
- 기존 노드 수정 시에는 접근한 버킷에만 잠금이 걸려 스레드 경합을 최소화하며 동시성을 보장해준다.
- 읽기는 잠금 없이 volatile 키워드로 메모리 일관성을 보장한다.
- SynchronizedMap : 읽기와 쓰기 동작 시 인스턴스 자체에 잠금이 걸린다.
- Collections.synchronizedMap()로 생성된 Map 이다.
- 모든 읽기 및 쓰기 작업에 전체 Map 객체에 잠금이 걸린다.
- 동시성이 낮아지고, 성능 병목이 발생할 가능성이 크다.
'💻 개발 > Java' 카테고리의 다른 글
동시성 프로그래밍 - 기초 (0) | 2024.12.10 |
---|---|
JCF와 스레드 - JCF 심화 및 스레드 (0) | 2024.12.05 |
JCF와 스레드 - JCF 기초 (0) | 2024.12.04 |
Java 모의 면접 후기 (0) | 2024.12.01 |
11/25 - TIL : JPA와 N+1 (0) | 2024.11.25 |