J-C-04 JMM 작업 메모리 동기화와 volatile의 진짜 의미 그리고 가상 스레드까지
2026-02-03 |
글 정보
- 카테고리
- Programming/Java/Core
- 태그
- JavaLevel2
왜 작업 메모리 동기화가 문제의 시작인가
- JMM은 스레드마다 작업 메모리(Working Memory)가 있다고 모델링합니다.
- 작업 메모리에는 메인 메모리 값의 사본이 있을 수 있습니다.
- 그래서 한 스레드의 변경이 다른 스레드에 즉시 보이지 않을 수 있습니다.
- 이 지연은 “일정 시간 뒤에 무조건 반영”이라기보다 언제 반영될지 보장되지 않는 상태에 가깝습니다. (시간 기반 규칙이 아니라 가시성 규칙이기 때문입니다)
일반 변수가 동기화되는 대표 시점들
- 동기화는 결국 happens-before 관계를 만들어 “가시성”과 “순서”를 보장하는 장치입니다.
- 명시적 동기화
synchronized는 모니터 락 획득과 해제를 경계로 동기화가 일어납니다.
volatile은 해당 변수의 읽기와 쓰기에 동기화 규칙을 부여합니다.
- 스레드 시작과 종료
Thread.start()는 “시작 이전에 준비된 내용”이 새 스레드에 보이도록 하는 규칙이 있습니다.
Thread.join()은 “종료된 스레드가 남긴 결과”가 합류한 스레드에 보이도록 하는 규칙이 있습니다.
- Lock과 Atomic
Lock의 lock/unlock은 synchronized와 비슷하게 경계에서 가시성을 만들 수 있습니다.
Atomic*는 원자적 연산과 함께 필요한 메모리 가시성 규칙을 제공합니다.
- 클래스 로딩과 초기화
- 클래스 초기화 과정에서의 정적 초기화는 스레드 안전하게 한 번만 수행되며 가시성 규칙이 묶입니다.
- 기타 JVM 최적화
- “최적화가 알아서 동기화해준다”는 기대는 위험합니다.
- 최적화는 성능 목적이며 스레드 안전을 자동으로 보장하지 않습니다.
작업 메모리 변화가 즉시 반영되지 않는다는 말의 정확한 뜻
- 작업 메모리의 변경이 메인 메모리에 바로 반영되지 않을 수 있다는 설명은 방향이 맞습니다.
- 다만 “일괄 처리로 성능 향상”은 구현 설명으로는 너무 단정적입니다. (하드웨어 캐시, 스토어 버퍼, 컴파일러 최적화 등 여러 요소가 얽힙니다)
- 개발자 관점에서 필요한 결론은 아래입니다.
- 동기화 없이 공유 변수를 쓰면
- 다른 스레드가 오래된 값을 볼 수 있습니다.
- 명령의 실행 순서가 기대와 다르게 관측될 수 있습니다.
- 테스트에서 재현이 어렵고 운영에서만 터질 수 있습니다.
스레드 시작과 종료에서의 동기화 이해하기
Thread.start가 보장하는 것
- 새 스레드는 시작 시점에 부모 스레드가 start 이전에 만든 변화를 볼 수 있습니다.
- 이 보장은 “부모 작업 메모리를 메인 메모리에 강제 플러시한다”처럼 물리적으로 해석하기보다 JMM의 관측 규칙으로 이해하는 게 안전합니다.
Thread.join이 보장하는 것
join()이 반환되면 종료된 스레드의 작업 결과가 합류한 스레드에서 관측 가능해집니다.
- 그래서 스레드 종료를 “동기화 포인트”로 쓰는 패턴이 가능합니다.
디버거와 println이 동기화를 만들어 보이는 이유
브레이크포인트를 걸면 동기화된다 문제
- 브레이크포인트는 동기화를 보장하지 않습니다. (JMM 규칙이 추가되는 것이 아닙니다)
- 대신 아래 효과로 버그가 “사라져 보일” 수 있습니다.
- 스케줄링이 바뀝니다.
- 타이밍이 늘어납니다.
- JIT 최적화가 달라질 수 있습니다.
- 그래서 디버거에서만 정상이고 운영에서 깨지는 현상이 자주 나옵니다.
System.out.println이 동기화를 유발한다 문제
System.out은 PrintStream이며 많은 출력 메서드는 내부적으로 동기화(synchronized)를 사용합니다.
- 그래서 println이 우연히 공유 상태 관측 타이밍을 바꿀 수 있습니다.
- 하지만 이것은 “출력 때문에 스레드 안전해졌다”가 아니라 “버그가 가려졌다”에 더 가깝습니다.
volatile과 메모리 가시성
volatile이 해주는 것
volatile은 멀티스레드에서 해당 변수의 가시성을 강하게 만듭니다.
volatile 변수의 읽기와 쓰기는 다른 메모리 연산과의 재정렬을 일부 제한합니다.
- 그래서 “플래그 기반 상태 공유”에 특히 잘 맞습니다.
volatile이 해주지 못하는 것
volatile은 원자성을 자동으로 보장하지 않습니다.
- 예를 들어
count++는 여러 단계로 나뉘어 실행되며 경쟁 조건이 발생할 수 있습니다.
volatile의 성능 설명 보정
- volatile의 비용은 I O 성능이 아니라 메모리 배리어와 캐시 동기화 비용에 더 가깝습니다. (디스크나 네트워크 I O를 의미하는 I O와는 다른 이야기입니다)
- 그래도 남용하면 성능이 떨어질 수 있으니 “필요한 변수에만” 쓰는 게 좋습니다.
예시 종료 플래그
class StopFlagExample {
private static volatile boolean stop = false;
public static void main(String[] args) throws Exception {
Thread t = new Thread(() -> {
while (!stop) {
// busy work
}
});
t.start();
Thread.sleep(100);
stop = true;
t.join();
}
}
stop이 volatile이 아니면 다른 스레드가 변경을 못 보고 루프가 끝나지 않을 수 있습니다.
C C++의 volatile과 Java의 volatile은 같지 않다
- C C++에서 volatile은 주로 컴파일러 최적화 억제 의미로 쓰였고 스레드 동기화 의미와는 분리되어 논의됩니다. (언어 표준과 플랫폼에 따라 복잡합니다)
- Java에서 volatile은 JMM에 의해 가시성과 순서 의미가 명확히 부여됩니다.
- “Java는 재정렬 최적화를 적용하지 않는다”는 말은 부정확합니다. (JIT와 CPU는 재정렬을 할 수 있고 JMM이 관측 가능한 재정렬을 제한합니다)
경쟁 조건 Race Condition 정리
Race condition이란
- 여러 스레드가 같은 자원에 동시에 접근해 관측 결과가 실행마다 달라지는 문제입니다.
- 컴파일 시점에 잘 안 잡히고 런타임에서만 드러납니다.
왜 고급언어 한 줄이 위험한가
- 고급언어 한 줄은 여러 기계어로 쪼개질 수 있습니다.
- 대표적으로 증가 연산은 아래 3단계로 나뉩니다.
- 메모리에서 읽기
- 레지스터에서 더하기
- 메모리에 쓰기
- 스레드가 끼어들면 결과가 틀어집니다.
예시 count++ 경쟁
class Race {
static int count = 0;
static void inc() {
count++; // read, add, write
}
}
- 이 문제는
volatile만으로는 해결되지 않습니다.
- 해결책은 아래 중 하나입니다.
synchronized로 임계 구역을 묶습니다.
AtomicInteger.incrementAndGet()을 씁니다.
- 경쟁 자체를 줄이는 구조로 바꿉니다.
스레드 동기화와 OS 동기화 객체
- OS는 다양한 동기화 프리미티브를 제공합니다.
- Windows에는
Mutex, Semaphore, Event 같은 커널 오브젝트가 있습니다.
- 다만 Java의
synchronized가 항상 OS의 Mutex로 바로 매핑된다고 보면 오해가 생깁니다.
- 경쟁이 약하면 유저 영역에서 빠르게 처리하다가 필요하면 커널 도움을 받는 식으로 바뀔 수 있습니다.
- 이 흐름이 흔히 말하는 경량 락에서 중량 락으로의 팽창입니다.
중량 락이 무거운 이유를 정확히 말하면
- 커널 개입은 보통 아래 비용을 동반합니다.
- 시스템 콜 비용이 늘어납니다.
- 스케줄링과 컨텍스트 스위칭 가능성이 커집니다.
- 그래서 “가능하면 짧게 끝내고 경쟁을 줄이는 구조”가 유리합니다.
Spin Lock 설명 정리
- 스핀 락은 “객체”라기보다 바쁜 대기 루프로 표현되는 전략입니다.
- 스핀은 CPU를 계속 사용합니다.
- 스핀은 아래 상황에서만 이득이 될 수 있습니다.
- 락 보유 시간이 매우 짧습니다.
- 코어가 충분히 남아 있습니다.
- 경쟁이 길어지면 스핀은 오히려 손해가 됩니다.
가상 스레드 Virtual Thread를 동기화 관점에서 보기
가상 스레드가 나온 배경
- 스레드는 실행의 단위입니다.
- OS 커널 스레드는 생성 비용과 컨텍스트 스위칭 비용이 큽니다.
- 그래서 “많은 동시성”을 커널 스레드만으로 처리하는 건 비효율적일 수 있습니다.
가상 스레드의 핵심 아이디어
- 가상 스레드는 애플리케이션 관점의 스레드이며 커널 스레드와 1:1로 고정되지 않습니다.
- 많은 가상 스레드가 소수의 커널 스레드 위에서 스케줄링될 수 있습니다.
- 그래서 대량의 동시 작업을 더 가볍게 다루는 데 목적이 있습니다.
가상 스레드가 있다고 동기화가 필요 없어지나
- 가상 스레드가 있어도 공유 데이터를 쓰면 동기화는 여전히 필요합니다.
- 가상 스레드는 “동시성 모델의 비용”을 낮추는 것이고 “데이터 경쟁”을 자동으로 없애는 것이 아닙니다.
- 즉 아래는 그대로입니다.
- 경쟁 조건은 그대로 발생합니다.
volatile, synchronized, Lock, Atomic의 필요성은 그대로입니다.
최종 정리
- 동기화 문제는 “메인 메모리와 작업 메모리의 분리”라는 JMM 모델로 이해하면 빠르게 정리됩니다.
Thread.start()와 join()은 중요한 happens-before 경계입니다.
- 브레이크포인트와 println은 버그를 고칠 수 있는 수단이 아니라 버그를 숨길 수 있는 요인입니다.
volatile은 가시성과 일부 순서를 보장하지만 원자성 해결책이 아닙니다.
- 가상 스레드는 동기화를 없애는 기술이 아니라 동시성의 비용을 낮추는 기술입니다.