synchronized원자성 vs Thread safe원자성Thread safesynchronized가 단일 JVM에서만 동작하는 이유모니터 락CAS (Compare-And-Swap)경량 락 (Lightweight Lock)경량 락에서 CAS 동작무거운 락 (Heavyweight Lock)무거운 락에서 CAS 동작추가: CAS를 쓴다고 lock-free는 아니다정리
synchronized
synchronized는 같은 프로세스(JVM) 안에서 동기화를 보장하는 키워드이다.동기화란, 여러 스레드/프로세스가 공유 자원(같은 객체나 변수)에 접근할 때 데이터를 일관되게 유지하도록 맞추는 것을 의미한다. 더 정확히 말하면, 실행 순서와 메모리 상태를 서로 맞추는 것이다.
단일 스레드 환경에서도 동작 자체는 보장되지만, 경쟁 자체가 없기 때문에 사실상 멀티 스레드 환경에서만 의미가 있다.
여기서 원자성(Atomicity)과 Thread safe의 개념을 구분해야 한다.
원자성 vs Thread safe
원자성
특정 연산이 중간에 끊기지 않고 완전히 실행되거나, 아예 실행되지 않는 성질을 말한다.
// i++는 사실 3단계 연산 int temp = i; // 1. read temp = temp + 1; // 2. increment i = temp; // 3. write
synchronized 없이는 스레드 A가 1번을 실행한 직후 스레드 B가 끼어들 수 있다. synchronized는 이 3단계를 하나의 블록으로 묶어 원자적으로 만든다.
Thread safe
멀티스레드 환경에서 공유 상태가 항상 일관성 있게 유지되는 성질이다.
이 둘을 굳이 구분하는 이유는, 원자성이 보장돼도 Thread safe가 아닐 수 있기 때문이다.
class Counter { private int count = 0; public synchronized void increment() { count++; } // 원자성 O public synchronized void decrement() { count--; } // 원자성 O // 문제: check-then-act 패턴 public void incrementIfZero() { if (count == 0) { // A 스레드가 여기서 0 확인 // → B 스레드가 끼어들어 increment() 호출 increment(); // A는 0이 아닌데 그냥 증가시킴 } } }
increment() 자체는 원자적이지만, incrementIfZero()는 read → check → write가 하나의 원자적 단위로 묶이지 않아서 Thread safe하지 않다.
즉, 원자성은 Thread safe를 위한 도구 중 하나일 뿐이다. synchronized로 원자성을 확보해도 설계 수준에서 경쟁 조건(race condition)을 제거해야 진정한 Thread safe가 된다.
Thread safe의 3요소
- 원자성: 작업이 중간에 끊기지 않음 (상호 배제로 달성)
- 가시성: 변경된 값을 다른 스레드가 즉시 볼 수 있음
- 순서 보장: 명령어 재정렬 방지 — happens-before 규칙
synchronized가 단일 JVM에서만 동작하는 이유
synchronized는 JVM 힙에 존재하는 객체(Object)의 모니터 락을 기반으로 동작한다. 그런데 JVM 프로세스끼리는 메모리를 공유하지 않는다.
같은 JVM 안의 스레드들은 동일한 객체의 Mark Word를 보기 때문에 서로의 락 상태를 알 수 있지만, 다른 프로세스 간에는 락을 공유할 수 없다.
그래서 다중 서버 환경에서는 synchronized만으로는 부족하고, DB Lock, Redis Lock, Zookeeper 같은 외부 분산 락을 사용해야 한다.
모니터 락
모든 Java 객체는 힙 메모리에 존재하고, 객체 헤더(Object Header)에 락 상태를 기록한다. 그 안의 Mark Word에는 "지금 어떤 스레드가 이 객체를 점유 중인가" 가 기록된다.
평상시 (synchronized 안 쓸 때) obj(공유 객체) ┌─────────────────────────────┐ │ Mark Word │ ← 해시코드, GC 정보 등 저장 │ (모니터 정보 없음) │ 모니터는 아직 생성 안 됨 └─────────────────────────────┘ synchronized 진입 시도 → 경량 락 먼저 시도
스레드들은 같은 힙의 같은 객체를 참조하기 때문에 Mark Word를 공유할 수 있고, 그래서 동기화가 가능하다. 용어를 정리하면 다음과 같다.
- Mark Word: 객체 헤더의 일부로, 락 상태나 ObjectMonitor 포인터를 저장하는 공간
- 모니터(ObjectMonitor):
owner + Entry Set + Wait Set을 포함하는 동기화 구조체 - owner: 지금 락을 점유한 스레드. 이 필드가 곧 상호 배제 역할을 한다.
- Entry Set: 락을 얻으려고 대기하는 스레드들의 집합. owner가 비면 이 중 하나가 락을 가져간다.
- Wait Set:
wait()를 호출하고 멈춘 스레드들의 집합.notify()/notifyAll()로 깨어나면 Entry Set으로 이동해 다시 락을 다툰다.
- Mark Word는 무거운 락 진입 시 ObjectMonitor의 주소를 가리킨다.
CAS (Compare-And-Swap)
CAS는 메모리 주소의 값이 예상값과 일치할 때만 새 값으로 교체하는 CPU 단일 명령어이다.
CAS(메모리주소, 예상값, 새값) → 메모리주소의 현재값 == 예상값 → 현재값을 새값으로 교체, true 반환 → 메모리주소의 현재값 != 예상값 → 아무것도 안 함, false 반환
예시로 보면 더 명확하다.
상황: count = 0, 스레드A와 스레드B가 동시에 count를 1로 바꾸려 함 스레드A 실행 CAS(count주소, 0, 1) → count 현재값(0) == 예상값(0) → count를 1로 교체, true 반환 → 락 획득 성공 스레드B 실행 (동시에 시도) CAS(count주소, 0, 1) → count 현재값(1) != 예상값(0) → 아무것도 안 함, false 반환 → 락 획득 실패 → Entry Set 대기
"읽기 → 비교 → 쓰기" 3단계를 CPU의 단일 명령어(x86: CMPXCHG)로 처리하므로 원자성이 보장된다.
경량 락 (Lightweight Lock)
경량 락은 락 획득 시 스레드 스택 프레임에 Lock Record를 생성하고, CAS로 Mark Word에 그 주소를 기록하는 방식이다. ObjectMonitor를 생성하지 않기 때문에 무거운 락보다 비용이 적다.
Lock Record: 경량 락 획득 시 스레드 스택 프레임에 생성되는 구조체. 원래 Mark Word 값을 백업해두는 공간이자, 락 획득의 증거 역할을 한다.
경량 락에서 CAS 동작
공유 객체의 Mark Word 값을 내 스레드 스택의 Lock Record에 복사한다. 이후 그 복사한 값이 Mark Word 주소의 현재 값과 같다면, Lock Record 주소로 교체하여 락을 획득한다.
[락 획득] CAS(MarkWord주소, 현재MarkWord값, Lock Record주소) 1단계: MarkWord 읽기 temp = *(MarkWord주소) → 현재 들어있는 값을 읽어옴 2단계: Lock Record에 백업 Lock Record.displaced = temp → 읽어온 값을 백업 3단계: CAS 시도 CAS(MarkWord주소, temp, Lock Record주소) → MarkWord 안의 값이 아직 temp랑 같으면 → 교체 성공 → 다른 스레드가 이미 바꿔놨으면 → 실패 [락 해제] CAS(MarkWord주소, Lock Record주소, displaced MarkWord) → 백업해둔 원래 MarkWord 값 복원 → Lock Record 소멸
무거운 락 (Heavyweight Lock)
경량 락 CAS가 실패하면, JVM이 ObjectMonitor를 생성하고 Mark Word를 그 주소로 교체한다. 이 과정을 팽창(inflate)이라고 부른다. ObjectMonitor는 HotSpot JVM 내부(C++)에 정의된 구조체이다.
무거운 락에서 CAS 동작
[1단계: 팽창]
Mark Word를 ObjectMonitor 포인터로 교체
[2단계: 락 획득]
CAS(ObjectMonitor.owner, null, 현재스레드) → 성공: owner가 현재 스레드로 세팅 → 락 획득 → 실패: Entry Set에 들어가서 BLOCK 상태로 대기
synchronized 진입 순간 (무거운 락) obj(공유 객체) ┌─────────────────────────────┐ │ Mark Word │ ← ObjectMonitor 포인터로 교체됨 │ → ObjectMonitor 주소 가리킴 │ └─────────────────────────────┘ ↓ ObjectMonitor (JVM이 그때 생성) ┌──────────────────────────────┐ │ owner │ ← 현재 락을 점유한 스레드 포인터 │ (스레드A 포인터 저장) │ ├──────────────────────────────┤ │ Entry Set │ ← 락 얻으려고 대기 중인 스레드들 ├──────────────────────────────┤ │ Wait Set │ ← wait() 호출한 스레드들 └──────────────────────────────┘
여기서 owner 필드가 곧 뮤텍스(상호 배제) 역할을 한다. 한 번에 한 스레드만 owner가 될 수 있고, 나머지는 Entry Set에서 대기한다.
추가: CAS를 쓴다고 lock-free는 아니다
경량 락과
ObjectMonitor의 owner 획득도 내부적으로 CAS를 사용한다. 그렇다고 synchronized가 lock-free인 것은 아니다.lock-free 여부는 CAS를 사용했는지가 아니라 진행 보장(progress guarantee) 으로 결정된다. “락을 획득한 스레드가 갑자기 멈추면, 다른 스레드는 계속 진행할 수 있는가?”
synchronized에서는 owner가 멈추면 그 락을 기다리던 스레드들은 락이 해제될 때까지 모두 대기한다. 따라서 진행이 락에 의존하는 blocking 방식이다.반면
ConcurrentLinkedQueue는 락 없이 CAS 재시도로 동작한다. 내 CAS가 실패했다는 것은 다른 스레드가 이미 성공해 상태를 변경했다는 의미다. 실패한 스레드는 다시 읽어 재시도하고, 적어도 하나의 스레드는 계속 작업을 완료한다. 이러한 진행 보장(progress guarantee) 을 제공하는 알고리즘을 non-blocking 알고리즘이라 하며, ConcurrentLinkedQueue는 그중 lock-free에 해당한다.결국 CAS는 원자적 연산을 제공하는 구현 재료일 뿐이다. 같은 CAS로도 blocking 락을 만들 수 있고, lock-free 자료구조도 만들 수 있다.
정리
- synchronized는 단일 JVM 내에서의 동기화를 보장하는 키워드다.
- 원자성과 Thread safe는 다른 개념이며, 원자성은 Thread safe의 한 요소일 뿐이다.
- JVM은 비용이 적은 경량 락을 먼저 시도하고, 경합이 발생하면 무거운 락으로 팽창시킨다.
- 두 락 모두 CAS를 통해 원자적으로 락 상태를 변경한다.
- 다중 서버 환경에서는 synchronized로는 부족하므로 분산 락을 도입해야 한다.