여러 스레드가 같은 Heap 메모리(공유 자원)에 동시에 접근하면 문제가 생긴다.
public class RaceCondition {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) count++;
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) count++;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count: " + count); // 20000이 아님
}
}
기대값은 20000이지만 실행할 때마다 다른 값이 나온다.
count++는 사실 3단계다.
1. READ → count 값을 읽어옴
2. ADD → 1을 더함
3. WRITE → count에 저장
이 3단계 사이에 컨텍스트 스위칭이 일어나면:
t1: READ → 100
t2: READ → 100 ← t1이 아직 WRITE 안 했음
t1: WRITE → 101
t2: WRITE → 101 ← 101이 두 번 저장됨, +2가 아니라 +1
이게 Race Condition(경쟁 조건) 이다.
t1.join(); // t1이 종료될 때까지 main 스레드 대기
t2.join(); // t2가 종료될 때까지 main 스레드 대기
join() 없이 출력하면 t1, t2가 아직 실행 중인데 main이 먼저 count를 출력해버린다.
어떤 작업이 중간에 끊기지 않고 전부 실행되거나, 전혀 실행되지 않거나를 보장하는 성질
count++는 READ → ADD → WRITE 3단계라 원자적이지 않다.
synchronized는 락을 잡은 스레드가 블록을 다 실행할 때까지 다른 스레드가 진입하지 못하게 막아서 원자성을 보장한다.
for (int i = 0; i < 10000; i++) {
synchronized (MyThread.class) {
count++;
}
}
synchronized (lockA)는 "lockA 객체의 모니터 락을 획득하겠다" 는 의미다.
Java의 모든 객체는 모니터 락을 하나씩 가지고 있다.
락 획득 → 임계 구역 실행 → 락 반납
동기화가 되려면 경쟁하는 스레드들이 반드시 같은 객체의 락을 써야 한다.
// 같은 락 → 동기화 됨
synchronized (lockA) { count++; } // t1
synchronized (lockA) { count++; } // t2
// 다른 락 → 동기화 안 됨
synchronized (lockA) { count++; } // t1
synchronized (lockB) { count++; } // t2 → t1과 간섭 없음, Race Condition 발생
public static synchronized void increment() {
count++;
}
인스턴스 메서드 → 해당 인스턴스(this)의 락
static 메서드 → 해당 클래스(MyThread.class)의 락
// 메서드 방식 → 메서드 전체가 임계 구역
public static synchronized void process() {
String data = db.query(); // 공유 자원 아님, 락 필요 없음 (100ms 소요)
count++; // 공유 자원, 락 필요
log.write(data); // 공유 자원 아님, 락 필요 없음 (50ms 소요)
}
// → DB 조회 + count++ + 로그 출력 150ms 동안 락을 잡고 있음
// → 다른 스레드는 150ms 전부 대기
// 블록 방식 → 꼭 필요한 부분만 임계 구역
public static void process() {
String data = db.query(); // 락 없이 실행 (100ms)
synchronized (MyClass.class) {
count++; // 락 점유 시간 최소화
}
log.write(data); // 락 없이 실행 (50ms)
}
// → count++ 하는 순간만 락을 잡음
// → 다른 스레드의 대기 시간 대폭 감소
| synchronized 메서드 | synchronized 블록 | |
|---|---|---|
| 임계 구역 범위 | 메서드 전체 | 블록 내부만 |
| 락 대상 지정 | 불가 (자동 결정) | 직접 지정 가능 |
| 락 점유 시간 | 길다 | 짧다 |
멀티 스레드 환경에서 각 스레드는 성능을 위해 메인 메모리(RAM)에서 값을 직접 읽지 않고 CPU 캐시에 복사해서 사용한다.
메인 메모리: running = true
CPU 캐시 (t1): running = true
CPU 캐시 (t2): running = true
t1이 running = false로 변경
→ t1 CPU 캐시: running = false
→ t2 CPU 캐시: running = true ← t2는 변경을 인식 못함
t1이 값을 변경해도 t2는 자기 캐시의 값을 보고 있어서 변경을 인식하지 못한다.
이게 가시성 문제(Visibility Problem) 다.
private static volatile boolean running = true;
volatile을 붙이면 해당 변수를 CPU 캐시가 아닌 메인 메모리에서 직접 읽고 쓴다.
모든 스레드가 항상 같은 값을 보게 된다.
volatile은 가시성만 보장한다. 원자성은 보장하지 않는다.
static volatile int count = 0;
count++; // READ → ADD → WRITE 3단계 → 여전히 Race Condition 발생
| 가시성 보장 | 원자성 보장 | |
|---|---|---|
| volatile | ✅ | ❌ |
| synchronized | ✅ | ✅ |
volatile은 한 스레드만 쓰고 나머지는 읽기만 하는 플래그 변수 상황에 적합하다.
여러 스레드가 동시에 쓰는 상황이면 synchronized를 써야 한다.
두 스레드가 서로 상대방이 가진 락을 기다리며 영원히 대기하는 상태
public class DeadlockTest {
static Object lockA = new Object();
static Object lockB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("t1: lockA 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) { // lockB 대기
System.out.println("t1: lockB 획득");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("t2: lockB 획득");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) { // lockA 대기
System.out.println("t2: lockA 획득");
}
}
});
t1.start();
t2.start();
}
}
실행 흐름:
t1: lockA 획득 → sleep(100)
t2: lockB 획득 → sleep(100)
t1: lockB 요청 → t2가 가지고 있음 → 대기
t2: lockA 요청 → t1이 가지고 있음 → 대기
→ 둘 다 영원히 대기 → 프로그램 멈춤
sleep(100)은 t2가 lockB를 먼저 잡을 시간을 주기 위한 장치다.
없으면 t1이 lockB까지 다 잡아버려서 데드락이 발생하지 않을 수 있다.
데드락은 아래 4가지 조건이 동시에 만족될 때 발생한다. 하나라도 깨면 데드락이 발생하지 않는다.
// t1, t2 모두 lockA → lockB 순서로 획득
synchronized (lockA) {
synchronized (lockB) { ... }
}
t1: lockA 획득
t2: lockA 대기 ← t1이 이미 가지고 있음
t1: lockB 획득 → 작업 완료 → lockB 반납 → lockA 반납
t2: lockA 획득 → lockB 획득 → 작업 완료
순환 대기 조건이 깨지면서 데드락이 발생하지 않는다.