동시성 프로그래밍의 핵심 개념
다중 스레드 환경에서 두 가지 주요 문제는 스레드 간 통신 방식과 동기화 메커니즘입니다.
통신 메커니즘 분류
공유 메모리 모델: 스레드들이 공통 상태를 메모리에 저장하고 읽기/쓰기를 통해 암묵적으로 정보를 교환합니다.
메시지 전달 모델: 각 스레드는 독립된 상태를 유지하며 명시적인 메시지 송수신을 통해 통신합니다.
동기화 원리
공유 메모리에서는 동기화가 명시적으로 수행되며, 개발자가 특정 코드 영역의 상호 배제적 실행을 지정해야 합니다. 반면 메시지 기반 모델에서는 메시지 송신이 수신보다 먼저 발생하므로 동기화가 암묵적으로 이루어집니다.
Java는 공유 메모리 모델을 채택하여 스레드 간 통신을 투명하게 처리합니다.
Java 메모리 모델 구조
JVM에서 인스턴스 필드, 정적 필드, 배열 요소는 힙 메모리에 저장되어 모든 스레드가 접근 가능합니다. 지역 변수와 메서드 파라미터는 스레드 로컬 메모리에 존재하므로 가시성 문제가 발생하지 않습니다.
Java Memory Model(JMM)은 스레드 간 공유 변수의 가시성을 제어합니다. 추상적으로 표현하면:
- 주 메모리(Main Memory): 모든 스레드가 공유하는 변수 저장소
- 로컬 메모리(Thread Local Memory): 각 스레드의 작업 메모리로 변수 복사본을 관리
스레드 A와 B의 통신 과정:
- 스레드 A는 로컬 메모리의 변경사항을 주 메모리로 플러시
- 스레드 B는 주 메모리에서 업데이트된 값을 읽어옴
명령어 재배열(Reordering)
성능 최적화를 위해 컴파일러와 프로세서는 명령어 순서를 조정할 수 있습니다:
- 컴파일러 최적화 재배열: 단일 스레드 의미론을 유지하면서 문장 순서 변경
- 명령어 급간 병렬 재배열: 데이터 의존성이 없는 경우 실행 순서 조정
- 메모리 시스템 재배열: 캐시와 버퍼 사용으로 인한 로드/스토어 순서 변경
프로세서 버퍼링 효과
현대 프로세서는 쓰기 버퍼를 사용하여 메모리 쓰기 연산을 임시 저장합니다. 이는 파이프라인 효율성을 높이지만 다음과 같은 문제를 야기할 수 있습니다:
// 프로세서 A와 B가 동시에 실행
// 실제 실행 순서: A2 → A1 (버퍼 플러시 전후)
int x = 0, y = 0;
// 프로세서 A: x = 1; r1 = y;
// 프로세서 B: y = 1; r2 = x;
// 결과: r1 = 0, r2 = 0 가능
메모리 장벽(Memory Barrier)
JMM은 특정 재배열을 방지하기 위해 다음 유형의 메모리 장벽을 삽입합니다:
| 장벽 유형 | 예시 | 설명 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | Load1 이후에 Load2 및 후속 로드 명령어 실행 보장 |
| StoreStore | Store1; StoreStore; Store2 | Store1의 다른 프로세서 가시성 보장 후 Store2 실행 |
| LoadStore | Load1; LoadStore; Store2 | Load1 완료 후 Store2 및 후속 저장 명령어 실행 |
| StoreLoad | Store1; StoreLoad; Load2 | Store1 완전 가시성 확보 후 Load2 실행 |
Happens-Before 관계
JSR-133은 다음과 같은 happens-before 규칙을 정의합니다:
- 프로그램 순서 규칙: 동일 스레드 내 연산들의 순차적 일관성
- 모니터 잠금 규칙: 언락이 락보다 선행
- volatile 변수 규칙: volatile 쓰기가 읽기보다 선행
- 전이성: A → B, B → C 이면 A → C
데이터 의존성
같은 변수에 대한 읽기/쓰기 연산 간 의존성은 재배열을 금지합니다:
| 유형 | 예시 | 설명 |
|---|---|---|
| Write-Read | a = 1; b = a; | 쓰기 후 읽기 |
| Write-Write | a = 1; a = 2; | 연속 쓰기 |
| Read-Write | a = b; b = 1; | 읽기 후 쓰기 |
순차적 일관성 모델
Sequential Consistency 모델은 두 가지 특성을 제공합니다:
- 각 스레드 내 연산은 프로그램 순서대로 실행
- 모든 스레드가 동일한 전역 실행 순서 관찰
비동기화 프로그램 비교:
- SCM: 모든 스레드가 일관된 실행 순서 관찰
- JMM: 실행 순서 불일치 가능성 존재
동기화된 프로그램의 SC 효과
class SynchronizedExample {
private int value = 0;
private boolean ready = false;
public synchronized void write() {
value = 42;
ready = true;
}
public synchronized void read() {
if (ready) {
System.out.println(value);
}
}
}
Volatile 키워드
Volatile 특성
public class VolatileDemo {
private volatile long counter = 0L;
public void setValue(long val) {
counter = val; // volatile 쓰기
}
public long getValue() {
return counter; // volatile 읽기
}
public void increment() {
counter++; // 복합 연산 - 원자성 없음
}
}
Volatile 변수의 핵심 특성:
- 가시성: 마지막 쓰기 값 보장
- 원자성: 단일 읽기/쓰기는 원자적, 복합 연산은 비원자적
Volatile 메모리 의미
- 쓰기 시: 로컬 메모리 → 주 메모리 플러시
- 읽기 시: 로컬 메모리 무효화 후 주 메모리에서 로드
락(Lock) 메커니즘
락 해제/획득의 메모리 의미
- 락 해제: 로컬 메모리 변경사항 주 메모리 반영
- 락 획득: 로컬 메모리 무효화 및 주 메모리에서 재로드
Concurrent 패키지 구현 패턴
- 공유 변수 volatile 선언
- CAS 기반 원자적 조건 업데이트
- volatile 읽기/쓰기 + CAS 조합을 통한 스레드 통신
Final 필드 처리
Final 필드에 대한 두 가지 재배열 규칙:
- 생성자 내 final 필드 쓰기와 참조 할당 간 재배열 금지
- 객체 참조 읽기와 final 필드 읽기 간 재배열 금지
참조 타입 Final 필드
public class FinalReferenceExample {
private final StringBuilder builder;
public FinalReferenceExample() {
builder = new StringBuilder(); // final 참조 필드
builder.append("data"); // 참조 객체 필드 수정
}
}
JMM 설계 철학
JMM은 세 가지 프로그램 유형에 대해 서로 다른 가시성 보장을 제공합니다:
- 단일 스레드: 순차적 일관성 보장
- 정확 동기화 다중 스레드: 순차적 일관성 결과 보장
- 비동기화 다중 스레드: 최소 안전성 보장(이전 쓰기값 또는 기본값)