멀티스레드 환경에서 스레드를 직접 다루다 보면 예상치 못한 문제를 마주칩니다. 스레드가 서로 다른 값을 읽거나, 동시에 같은 자원을 수정해 데이터가 망가지는 상황이 대표적입니다. 이 글은 자바에서 스레드를 생성하고 제어하는 기초부터 시작해, 메모리 가시성 문제와 동기화 해결책까지 하나의 흐름으로 정리합니다.
자바에서 스레드를 생성하는 방법은 두 가지입니다. Thread 클래스를 상속하거나 Runnable 인터페이스를 구현하는 방식입니다.
Thread 상속
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
HelloThread helloThread = new HelloThread();
helloThread.start();
Runnable 구현
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
Thread thread = new Thread(new HelloRunnable());
thread.start();
두 방식의 실행 결과는 동일하지만 구조에서 차이가 있습니다. Thread 상속 방식은 구현이 단순한 반면, 자바는 단일 상속만 허용하므로 다른 클래스를 함께 상속받을 수 없습니다. Runnable 구현 방식은 스레드 객체와 실행할 작업을 분리해 코드 가독성을 높이고, 여러 스레드가 동일한 Runnable 객체를 공유할 수 있어 자원 관리에 유리합니다. 또한 정적 중첩 클래스, 익명 클래스, 람다로도 구현할 수 있어 Runnable 방식을 권장합니다.
start() 메서드를 호출하면 자바는 스레드를 위한 별도의 스택 공간을 할당합니다. run()을 직접 호출하면 새로운 스레드가 아닌 현재 스레드에서 실행되므로 반드시 start()를 호출해야 합니다. 스레드 간 실행 순서는 보장하지 않습니다. 스레드는 동시에 실행되기 때문에 실행 순서와 실행 기간을 모두 예측할 수 없습니다.
스레드는 생성부터 종료까지 6가지 상태를 가집니다.
| 상태 | 설명 |
|---|---|
NEW | 스레드 객체가 생성됐지만 start()가 호출되지 않은 상태 |
RUNNABLE | 실행 중이거나 CPU 스케줄링 대기열에서 실행을 기다리는 상태 |
BLOCKED | synchronized 락을 획득하기 위해 대기하는 상태 |
WAITING | wait(), join() 등으로 다른 스레드의 작업 완료를 무기한 기다리는 상태 |
TIMED_WAITING | sleep(ms), wait(timeout) 등으로 일정 시간 동안 대기하는 상태 |
TERMINATED | 실행이 완료된 상태. 한 번 종료된 스레드는 다시 시작할 수 없음 |
상태 전이는 다음 순서로 이루어집니다.
NEW → RUNNABLE: start() 호출RUNNABLE → BLOCKED / WAITING / TIMED_WAITING: 락 대기 또는 wait(), sleep() 호출BLOCKED / WAITING / TIMED_WAITING → RUNNABLE: 락 획득 또는 대기 완료RUNNABLE → TERMINATED: run() 메서드 실행 완료
join()
join()을 호출한 스레드는 대상 스레드가 TERMINATED 상태가 될 때까지 WAITING 상태로 대기합니다. join(ms)를 사용하면 지정한 시간만큼만 대기할 수 있습니다.
interrupt()
실행 중인 스레드에 인터럽트 신호를 보냅니다. 대상 스레드가 WAITING이나 TIMED_WAITING 상태에 있으면 InterruptedException이 발생하며 RUNNABLE 상태로 전환됩니다. 자바는 인터럽트 예외가 한 번 발생하면 스레드의 인터럽트 상태를 다시 false로 초기화합니다. 인터럽트의 목적을 달성했다면 인터럽트 상태를 정상으로 돌려두어야 합니다.
yield()
Thread.yield()를 호출하면 현재 실행 중인 스레드가 CPU를 양보하도록 힌트를 제공합니다. 다른 스레드에게 실행 기회를 주지만 강제적인 순서는 보장하지 않습니다.
멀티스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성이라고 합니다.
CPU는 처리 성능을 개선하기 위해 메인 메모리와 CPU 코어 사이에 캐시 메모리를 사용합니다. 메인 메모리는 상대적으로 속도가 느리고, 캐시 메모리는 CPU와 가까워 속도가 빠릅니다. CPU는 메인 메모리의 값을 캐시 메모리에 올려두고 연산합니다.
문제는 캐시 메모리에서 변경된 값이 메인 메모리에 즉시 반영되지 않는다는 점입니다. 예를 들어 CPU 코어1이 runFlag 값을 false로 바꿔도, 이 변경이 메인 메모리에 반영되는 시점을 알 수 없습니다. CPU 코어2는 캐시 메모리 또는 메인 메모리에서 이전 값인 true를 읽을 수 있습니다. 주로 컨텍스트 스위칭이 발생할 때 반영되지만 갱신을 보장하지는 않습니다.

여러 스레드에서 값을 읽고 써야 한다면 volatile 키워드를 사용합니다. volatile로 선언된 변수는 캐시 메모리를 거치지 않고 메인 메모리에서 직접 읽고 씁니다.
static class MyTask implements Runnable {
volatile boolean flag = true;
volatile long count;
@Override
public void run() {
while (flag) {
count++;
}
}
}
volatile을 사용하지 않으면 main 스레드가 flag를 false로 변경해도 work 스레드는 캐시 메모리의 이전 값을 계속 읽어 루프를 빠져나오지 못할 수 있습니다. volatile을 적용하면 flag 변경이 즉시 메인 메모리에 반영되어 work 스레드가 변경된 값을 읽습니다. 단, 캐시 메모리를 사용할 때보다 성능이 느려지므로 꼭 필요한 곳에서만 사용합니다.
happens-before는 자바 메모리 모델(JMM)에서 스레드 간 작업 순서를 정의하는 개념입니다. A 작업이 B 작업보다 happens-before 관계에 있다면, A 작업에서 변경된 모든 메모리 내용은 B 작업에서 볼 수 있습니다. 즉, A 작업에서 변경된 내용은 B 작업이 시작되기 전에 메모리에 반영됩니다.
대표적인 happens-before 규칙은 다음 두 가지입니다.
- volatile 규칙:
volatile변수에 대한 쓰기는 이후 같은 변수에 대한 읽기보다 happens-before 관계를 형성합니다. 한 스레드가volatile변수에 값을 쓰면 이후 어떤 스레드가 같은 변수를 읽더라도 반드시 그 값을 볼 수 있습니다.- 모니터 락 규칙:
synchronized블록의 unlock은 이후 같은 모니터 락의 lock보다 happens-before 관계를 형성합니다. 락을 반납하기 전까지의 모든 변경 내용은 이후 락을 획득하는 스레드에서 볼 수 있습니다.
멀티스레드 환경에서 여러 스레드가 동시에 접근하는 자원을 공유 자원이라 합니다. 인스턴스의 필드, 클래스 변수 등이 대표적입니다. 여러 스레드가 동시에 접근해서는 안 되는 공유 자원을 수정하는 코드 영역을 임계 영역(critical section)이라 합니다.
아래는 잔액 1000원인 계좌에서 두 스레드 t1, t2가 동시에 800원 출금을 시도하는 상황입니다.
public boolean withdraw(int amount) {
// 1. 검증 단계: 잔액 확인
if (balance < amount) {
return false;
}
// 2. 출금 단계: 잔액 감소
sleep(1000);
balance -= amount;
return true;
}
t1이 잔액을 확인하고 sleep() 중일 때 t2도 잔액을 확인합니다. 이 시점에 t1이 아직 잔액을 줄이지 않았으므로 t2는 잔액을 1000원으로 읽습니다. 결과적으로 두 스레드 모두 출금에 성공해 800원이 두 번 빠져나가는 문제가 발생합니다. 이처럼 멀티스레드 환경에서 실행 순서나 타이밍에 따라 결과가 달라지는 문제를 경쟁 상태(Race Condition)라 합니다.
synchronized 키워드로 임계 영역을 보호할 수 있습니다. 자바의 모든 객체는 내부에 자신만의 모니터 락(monitor lock)을 가지고 있습니다. synchronized 블록이나 메서드에 진입하려면 반드시 해당 인스턴스의 락을 획득해야 합니다.
public boolean withdraw(int amount) {
synchronized (this) {
if (balance < amount) {
return false;
}
sleep(1000);
balance -= amount;
return true;
}
}
두 스레드 t1, t2가 동시에 withdraw()를 호출하는 상황을 가정합니다. t1이 먼저 락을 획득하고 임계 영역에 진입합니다. t2도 진입을 시도하지만 락이 없으므로 BLOCKED 상태로 대기합니다. t1이 임계 영역을 마치고 락을 반납하면 t2가 락을 획득하고 RUNNABLE 상태로 전환됩니다. 이때 잔액이 이미 200원이므로 t2의 출금은 실패합니다.
synchronized는 임계 영역 보호와 함께 메모리 가시성도 보장합니다. 락을 반납하는 시점에 임계 영역 안에서 변경한 모든 값이 메인 메모리에 반영되기 때문입니다.

synchronized는 메서드 단위와 코드 블록 단위로 동기화 범위를 지정할 수 있습니다. 임계 영역은 가능한 최소한의 범위로 적용해야 합니다. 범위가 넓을수록 동시에 실행 가능한 코드가 줄어들어 성능이 저하됩니다.
synchronized에는 두 가지 한계가 있습니다.
첫째, 무한 대기 문제입니다. BLOCKED 상태의 스레드는 락이 풀릴 때까지 무기한 대기합니다. 특정 시간까지만 대기하는 타임아웃이 없고, 중간에 인터럽트로 깨울 수도 없습니다.
둘째, 공정성 문제입니다. 락이 반납됐을 때 대기 중인 여러 스레드 중 어떤 스레드가 락을 획득할지 알 수 없습니다. 특정 스레드가 오랫동안 락을 획득하지 못하는 기아 현상이 발생할 수 있습니다.
이 한계를 해결하기 위해 자바는 LockSupport를 제공합니다. LockSupport는 스레드를 WAITING 상태로 전환하고 필요할 때 깨우는 저수준 기능을 제공합니다.
park(): 스레드를 WAITING 상태로 전환parkNanos(nanos): 스레드를 지정한 나노초 동안 TIMED_WAITING 상태로 전환unpark(thread): WAITING 상태의 대상 스레드를 RUNNABLE 상태로 전환WAITING 상태의 스레드에 인터럽트가 발생하면 RUNNABLE 상태로 전환되므로, synchronized와 달리 중간에 깨울 수 있습니다. ReentrantLock은 이 LockSupport를 내부적으로 활용합니다.
ReentrantLock은 Lock 인터페이스의 대표적인 구현체로, synchronized의 한계를 해결합니다.
lock() 사용
private final Lock lock = new ReentrantLock();
public boolean withdraw(int amount) {
lock.lock();
try {
if (balance < amount) {
return false;
}
sleep(1000);
balance -= amount;
return true;
} finally {
lock.unlock();
}
}
lock.unlock()은 반드시 finally 블록에서 호출해야 합니다. 예외가 발생해도 락이 반드시 반납되도록 보장하기 위해서입니다.
tryLock() 사용
public boolean withdraw(int amount) {
if (!lock.tryLock()) {
return false;
}
try {
if (balance < amount) {
return false;
}
balance -= amount;
return true;
} finally {
lock.unlock();
}
}
tryLock()은 락 획득을 시도하고 즉시 성공 여부를 반환합니다. 이미 다른 스레드가 락을 보유 중이면 false를 반환해 무한 대기를 방지합니다. tryLock(long time, TimeUnit unit)을 사용하면 지정한 시간 동안만 대기할 수 있습니다.
ReentrantLock은 공정 모드와 비공정 모드를 선택할 수 있습니다.
// 비공정 모드 (기본값)
private final Lock nonFairLock = new ReentrantLock();
// 공정 모드
private final Lock fairLock = new ReentrantLock(true);
비공정 모드는 성능이 우선이지만 기아 현상이 발생할 수 있습니다. 공정 모드는 락 대기 순서를 보장해 기아 현상을 방지하지만 성능이 저하될 수 있습니다.

synchronized는 모니터 락을 사용해 BLOCKED 상태로 무한 대기하는 반면, ReentrantLock은 LockSupport를 활용해 WAITING 상태로 대기하며 타임아웃과 인터럽트를 지원합니다. 단순한 임계 영역 보호에는 synchronized로 충분하고, 타임아웃·인터럽트·공정성 제어가 필요한 경우 ReentrantLock을 사용합니다.
스레드를 안전하게 다루려면 스레드가 어떻게 생성되고 상태가 전이되는지, 캐시 메모리로 인한 메모리 가시성 문제가 왜 발생하는지, 공유 자원을 보호하기 위한 동기화 방법이 어떻게 발전해 왔는지를 이해해야 합니다. 다음 글에서는 생산자 소비자 문제를 통해 스레드 간 협력이 필요한 상황과 해결 방법을 다룹니다.
Thread상속 방식은 구현이 단순하지만 자바의 단일 상속 제약으로 인해 다른 클래스를 함께 상속받을 수 없습니다.Runnable구현 방식은 스레드 객체와 실행 작업을 분리해 유연성이 높고, 여러 스레드가 동일한Runnable인스턴스를 공유할 수 있습니다. 또한 정적 중첩 클래스, 익명 클래스, 람다로도 구현할 수 있어 실무에서Runnable을 권장합니다.
volatile은 메모리 가시성만 보장합니다. 한 스레드가 변경한 값을 다른 스레드가 즉시 볼 수 있도록 보장하지만,count++처럼 읽기-수정-쓰기가 하나로 묶여야 하는 복합 연산(compound operation) 의 원자성은 보장하지 않습니다. 두 스레드가 동시에 같은 값을 읽고 각각 수정하면 한 쪽의 변경이 사라질 수 있습니다.
synchronized는 메모리 가시성과 원자성을 모두 보장하며 임계 영역에 하나의 스레드만 진입하도록 제어합니다. 단순히 읽고 쓰는 플래그성 변수에는volatile을, 검증과 수정이 함께 이루어지는 복합 연산에는synchronized나ReentrantLock을 사용합니다.
출처💡
GeeksforGeeks — Atomic vs Volatile vs Synchronized in Java, SEI CERT Oracle Coding Standard for Java
두 상태 모두 스레드가 대기 중인 상태지만 목적과 동작이 다릅니다.
BLOCKED는synchronized락을 획득하기 위해 대기하는 상태로, 인터럽트로 깨울 수 없습니다.WAITING은wait(),join(),LockSupport.park()등으로 진입하며notify(),unpark(), 또는 인터럽트로 깨울 수 있습니다.BLOCKED는synchronized에서만 사용되고,WAITING은 더 범용적으로 활용됩니다.
단순한 임계 영역 보호에는
synchronized로 충분합니다. 아래 상황에서는ReentrantLock을 사용합니다.
- 타임아웃이 필요한 경우:
tryLock(time, unit)으로 지정한 시간 동안만 락 획득을 시도합니다.- 인터럽트로 대기를 중단해야 하는 경우:
lockInterruptibly()로 락 대기 중 인터럽트를 받으면InterruptedException이 발생하며 대기를 중단합니다.- 공정성 제어가 필요한 경우:
new ReentrantLock(true)로 락 대기 순서를 보장해 기아 현상을 방지합니다.- 대기 집합을 분리해야 하는 경우:
Condition을 사용해 생산자와 소비자처럼 역할별로 대기 공간을 나눌 수 있습니다.