| 프로세스(Process) | 스레드(Thread) | |
|---|---|---|
| 정의 | 실행 중인 프로그램. OS 가 자원(메모리, 파일 핸들)을 배정하는 단위 | 프로세스 안에서 실행되는 흐름. CPU 스케줄링 단위 |
| 메모리 | 독립된 주소 공간. 다른 프로세스 메모리 접근 불가 | 같은 프로세스의 힙을 공유. 스택만 각자 소유 |
| 생성 비용 | 큼 (주소 공간 복제) | 작음 (스택 하나 할당) |
| 통신 | IPC (파이프, 소켓, 공유 메모리) 필요 | 그냥 같은 변수를 읽고 쓰면 됨 — 그래서 위험 |
| 하나가 죽으면 | 다른 프로세스 무관 | 프로세스 전체(JVM)가 영향받을 수 있음 |
JVM 은 하나의 프로세스이고, 그 안에서 여러 스레드가 돕니다. main 도 스레드이고, GC 스레드, JIT 컴파일러 스레드가 백그라운드에서 함께 돕니다.
┌──────────────────────── JVM 프로세스 ────────────────────────┐
│ │
│ Thread-1 스택 Thread-2 스택 main 스택 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 지역변수 i │ │ 지역변수 i │ │ 지역변수 │ │
│ │ 참조 acc ─┼──┐ │ 참조 acc ─┼──┐ │ ... │ │
│ └──────────┘ │ └──────────┘ │ └──────────┘ │
│ │ │ │
│ ┌─────────────▼───────────────────▼──────── 힙(Heap) ──────┐ │
│ │ BankAccount 객체 { balance = 1000 } ← 모두가 공유 │ │
│ │ String 상수, 배열, 컬렉션 ... │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘핵심 판단 기준: "이 데이터를 두 스레드 이상이 동시에 접근하는가? 그중 하나라도 쓰기(write)를 하는가?" 둘 다 예 → 동기화 필요.
// 방법 1: Thread 상속
class Worker extends Thread { public void run() { ... } }
new Worker().start();
// 방법 2: Runnable 구현 (권장)
Runnable task = () -> { ... };
new Thread(task).start();Runnable 을 권장하는 이유:
| 이유 | 설명 |
|---|---|
| 단일 상속 제약 | 자바는 클래스 하나만 상속 가능. Thread 를 상속하면 다른 클래스를 상속할 수 없음 |
| 관심사 분리 | 무엇을(작업) 과 어떻게(스레드) 를 분리. 같은 작업을 어떤 실행기에도 넘김 |
| 람다 사용 | Runnable 은 함수형 인터페이스라 람다로 간결하게 작성 |
| 재사용 | 하나의 Runnable 을 여러 스레드가 공유 실행 가능 |
start() 와 run() 의 차이: run() 을 직접 호출하면 현재 스레드에서 그냥 메서드가 실행됩니다(새 스레드 없음). start() 가 OS 에 새 스레드를 요청하고, 그 스레드가 run() 을 호출합니다.
start()
NEW ───────────────────────► RUNNABLE ◄──────────────────┐
│ ▲ │
synchronized 락 대기 │ │ 락 획득 │ notify() / 시간 만료 /
▼ │ │ join 대상 종료
BLOCKED │
│ │
wait() / join() / park() │ │
────────────────────────────► WAITING ───────────────────┤
│
sleep(ms) / wait(ms) / join(ms) │
────────────────────────────► TIMED_WAITING ─────────────┘
run() 종료 / 예외 ──────────► TERMINATED| 상태 | 의미 |
|---|---|
| NEW | new Thread() 만 함. 아직 start() 전 |
| RUNNABLE | 실행 중이거나 CPU 를 기다리는 중(OS 스케줄러 대기 포함) |
| BLOCKED | synchronized 진입을 위해 모니터 락을 기다리는 중 |
| WAITING | wait(), join(), LockSupport.park() 로 무기한 대기 |
| TIMED_WAITING | sleep(ms), wait(ms) 등 시간 제한 대기 |
| TERMINATED | run() 이 끝남. 다시 start() 불가 |
운영에서 스레드 덤프(jstack)를 뜨면 이 상태들이 보입니다. BLOCKED 가 많으면 락 경합, WAITING 이 많으면 외부 I/O 대기나 데드락을 의심합니다.
count++ 는 왜 위험한가count++ 는 한 줄이지만 CPU 입장에서는 세 단계입니다.
1. LOAD : 메모리(힙)의 count 값을 CPU 레지스터로 읽는다 (읽기)
2. ADD : 레지스터 값에 1 을 더한다 (계산)
3. STORE : 레지스터 값을 메모리 count 에 쓴다 (쓰기)바이트코드로도 getfield → iconst_1 → iadd → putfield 네 명령입니다. 두 스레드가 이 사이에 끼어들면:
| 시각 | Thread-A | Thread-B | count |
|---|---|---|---|
| t1 | LOAD (0) | 0 | |
| t2 | LOAD (0) | 0 | |
| t3 | ADD → 1 | 0 | |
| t4 | ADD → 1 | 0 | |
| t5 | STORE 1 | 1 | |
| t6 | STORE 1 | 1 (2 여야 함) |
두 번 증가했는데 결과는 1 — 갱신 손실(lost update) 입니다. 이렇게 "여러 스레드의 실행 순서(interleaving, 끼어들기)에 따라 결과가 달라지는 상황"을 경쟁 조건이라 합니다. 발생 조건은 (1) 공유 상태 (2) 최소 하나의 쓰기 (3) 원자적이지 않은 연산 — 이 세 가지의 결합입니다.
해결 방향은 세 가지뿐입니다.
AtomicInteger, CASsynchronized, Lock 으로 한 번에 한 스레드만 진입synchronized 는 모니터 락(monitor lock, 또는 intrinsic lock) 을 사용합니다. 자바의 모든 객체는 자기만의 모니터를 하나씩 갖고 있습니다. 어떤 스레드가 그 모니터를 잡으면(acquire), 다른 스레드는 같은 모니터를 필요로 하는 코드에 들어올 수 없고 BLOCKED 상태로 기다립니다.
// 1) 메서드 동기화: 락 = this (static 이면 락 = Counter.class)
public synchronized void increment() { count++; }
// 2) 블록 동기화: 락 객체를 명시. 임계 영역(critical section)을 최소화할 수 있음
private final Object lock = new Object();
public void increment() {
// 락 없이 할 수 있는 일은 밖에서
synchronized (lock) { count++; } // 꼭 필요한 부분만 잠금
}| 방식 | 락 대상 | 특징 |
|---|---|---|
synchronized 인스턴스 메서드 |
this |
간단하지만 외부에서 this 로 락을 잡을 수 있어 통제 불가 |
synchronized static 메서드 |
Class 객체 |
모든 인스턴스가 하나의 락을 공유 |
synchronized (obj) 블록 |
obj |
락 범위 최소화 가능. 전용 private final Object lock 권장 |
재진입(reentrancy): 같은 스레드가 이미 잡은 락을 다시 요청하면 바로 통과합니다. 모니터는 "소유 스레드 + 재진입 횟수"를 기억하고, 카운트가 0 이 될 때 해제됩니다. 그래서 synchronized 메서드 안에서 같은 객체의 다른 synchronized 메서드를 호출해도 데드락이 나지 않습니다.
synchronized 가 보장하는 두 가지:
원자성과 별개로 가시성 문제가 있습니다. 현대 CPU 는 코어마다 캐시(L1/L2)를 갖고, 메모리 대신 캐시에서 읽고 씁니다. JIT 컴파일러도 "이 변수는 루프 안에서 안 바뀌네"라고 판단하면 읽기를 레지스터로 끌어올립니다(hoisting).
private boolean running = true; // volatile 없음
// Thread-A
while (running) { /* work */ } // JIT: running 을 한 번만 읽고 레지스터에 캐시 → 무한 루프 가능
// Thread-B
running = false; // 메인 메모리엔 썼지만 A 의 캐시/레지스터는 그대로volatile 은 "이 변수는 항상 메인 메모리에서 읽고 메인 메모리에 써라, 재배치(reordering)하지 마라"는 지시입니다.
volatile |
synchronized |
|
|---|---|---|
| 가시성 | O | O |
| 원자성 | X (count++ 는 여전히 위험) |
O |
| 블로킹 | 없음 | 있음 (락 대기) |
| 적합한 경우 | 플래그(on/off), 한 스레드만 쓰고 여럿이 읽는 설정 값 | 읽고-계산-쓰기 복합 연산 |
volatile int count; count++ 는 여전히 경쟁 조건입니다. volatile 은 읽기와 쓰기 각각을 메인 메모리와 동기화할 뿐, 읽기-계산-쓰기 세 단계를 하나로 묶어주지 않습니다.
자바 메모리 모델(JMM)은 "어떤 쓰기가 어떤 읽기에 보이는가"를 happens-before 관계로 정의합니다. A happens-before B 이면, A 의 결과가 B 에 보장되어 보입니다. 주요 규칙:
| 규칙 | 내용 |
|---|---|
| 프로그램 순서 | 한 스레드 안에서 앞 문장은 뒤 문장보다 먼저 |
| 모니터 락 | unlock 은 이후의 같은 락 lock 보다 먼저 |
| volatile | volatile 쓰기는 이후의 같은 변수 volatile 읽기보다 먼저 |
| 스레드 시작 | t.start() 는 t 안의 모든 동작보다 먼저 |
| 스레드 종료 | t 의 모든 동작은 t.join() 리턴보다 먼저 |
| 추이성 | A→B, B→C 이면 A→C |
실무 함의: Thread.join() 후에 결과를 읽으면 동기화 없이도 안전하게 보입니다. ExecutorService.submit() 전의 동작은 작업 안에서 보이고, Future.get() 후에는 작업의 결과가 보입니다. 이 규칙 덕분에 스레드 풀에 결과 객체를 넘겨받을 때 별도의 volatile 이 필요 없습니다.
AtomicInteger, AtomicLong, AtomicReference 는 락 없이 원자적 갱신을 제공합니다. 내부는 CPU 의 CAS 명령(x86 의 cmpxchg)입니다.
CAS(주소, 기대값, 새값):
if (메모리[주소] == 기대값) { 메모리[주소] = 새값; return true; }
else return false;
← 이 비교-교체가 하드웨어 수준에서 원자적으로 실행됨incrementAndGet() 의 실제 동작:
int current;
do {
current = get(); // 현재 값 읽기
} while (!compareAndSet(current, current + 1)); // 그 사이 안 바뀌었으면 교체, 바뀌었으면 재시도락을 잡지 않으므로 BLOCKED 가 없고(non-blocking), 경합이 적을 때 synchronized 보다 훨씬 빠릅니다. 경합이 극심하면 재시도가 반복되어 오히려 느려질 수 있는데, 그럴 때는 LongAdder(내부적으로 셀을 분산)를 씁니다.
java.util.concurrent.locks.ReentrantLock 은 synchronized 와 같은 상호 배제를 제공하면서 추가 기능이 있습니다.
| 기능 | synchronized |
ReentrantLock |
|---|---|---|
| 타임아웃 시도 | X | tryLock(1, SECONDS) |
| 인터럽트 가능 대기 | X | lockInterruptibly() |
| 공정성(fairness, 대기 순서 보장) | X | new ReentrantLock(true) |
| 조건 변수 여러 개 | wait/notify 하나 |
newCondition() 여러 개 |
| 락 해제 | 블록 끝에서 자동 | finally 에서 반드시 unlock() |
private final ReentrantLock lock = new ReentrantLock();
public void transfer() {
lock.lock();
try { /* 임계 영역 */ }
finally { lock.unlock(); } // 예외가 나도 반드시 해제
}단순한 경우엔 synchronized 가 더 안전(unlock 깜빡 못 함)하고, 타임아웃/공정성이 필요할 때만 ReentrantLock 을 씁니다.
두 스레드가 서로가 가진 락을 기다리며 영원히 멈추는 상태입니다.
Thread-A: lock(계좌1) → lock(계좌2) 대기 ─┐
│ 서로 기다림 → 영원히 정지
Thread-B: lock(계좌2) → lock(계좌1) 대기 ─┘데드락은 다음 4가지 조건이 모두 성립할 때만 발생합니다(Coffman 조건).
| 조건 | 의미 | 깨는 방법 |
|---|---|---|
| 상호 배제 | 자원을 한 번에 하나만 사용 | 락 자체를 없앰 (불변 객체, Atomic) |
| 점유와 대기 | 자원을 쥔 채 다른 자원을 기다림 | 필요한 락을 한꺼번에 획득, 또는 tryLock 실패 시 전부 해제 |
| 비선점 | 남의 자원을 뺏을 수 없음 | tryLock(timeout) 으로 포기 |
| 순환 대기 | A→B→A 원형 대기 | 락 순서 고정 (가장 실용적) |
실무 예방책 우선순위:
jstack 이 "Found one Java-level deadlock" 을 알려줌. ThreadMXBean.findDeadlockedThreads() 로 프로그램에서도 감지 가능.스레드를 매번 만들면 비용이 크고(OS 스레드 생성, 1MB 스택) 개수 제한도 없어 위험합니다. 스레드 풀은 스레드를 미리 만들어 재사용합니다.
| 팩토리 | 동작 | 적합한 경우 |
|---|---|---|
newFixedThreadPool(n) |
정확히 n 개. 초과 작업은 무한 큐에 대기 | CPU 바운드, 동시성 상한 필요할 때 |
newCachedThreadPool() |
필요하면 계속 생성, 60초 놀면 제거. 큐 없음 | 짧은 작업 폭주. 무제한 생성 위험 |
newSingleThreadExecutor() |
1개. 순서 보장 | 순차 처리가 필요한 이벤트 |
newVirtualThreadPerTaskExecutor() (21) |
작업마다 가상 스레드 | I/O 바운드 대량 작업 |
newScheduledThreadPool(n) |
지연/주기 실행 | 스케줄러 |
newFixedThreadPool 의 큐가 무한(LinkedBlockingQueue)이라 작업이 쌓이면 OOM 이 날 수 있고, newCachedThreadPool 은 스레드가 무한히 늘 수 있습니다. 실무에서는 new ThreadPoolExecutor(core, max, keepAlive, unit, new ArrayBlockingQueue<>(cap), rejectionPolicy) 로 직접 상한을 정하는 것이 원칙입니다.
submit vs execute:
execute(Runnable) |
submit(Runnable/Callable) |
|
|---|---|---|
| 반환 | void |
Future<T> |
| 예외 | 스레드의 UncaughtExceptionHandler 로 → 콘솔에 찍히고 사라짐 |
Future 안에 저장. get() 할 때 ExecutionException 으로 나옴 |
| 함정 | get() 을 안 하면 예외가 조용히 사라짐 |
올바른 종료:
executor.shutdown(); // 새 작업 거부, 기존 작업은 완료
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 완료 대기
executor.shutdownNow(); // 시간 초과 시 강제 인터럽트
}shutdown() 을 안 하면 non-daemon 워커 스레드가 살아 있어 JVM 이 종료되지 않습니다. JDK 19+ 에서는 ExecutorService 가 AutoCloseable 이라 try-with-resources 가 close() 에서 shutdown + awaitTermination 을 해줍니다.
Future.get() 은 블로킹입니다. CompletableFuture 는 "완료되면 이걸 해라"를 체이닝합니다.
CompletableFuture<Order> orderF = CompletableFuture.supplyAsync(() -> fetchOrder(id), pool);
CompletableFuture<User> userF = CompletableFuture.supplyAsync(() -> fetchUser(id), pool);
CompletableFuture<String> result = orderF
.thenCombine(userF, (order, user) -> user.name() + ":" + order.amount()) // 둘 다 끝나면
.thenApply(String::toUpperCase) // 변환
.exceptionally(ex -> "FALLBACK"); // 실패 시
String s = result.join();| 메서드 | 역할 |
|---|---|
supplyAsync(Supplier, executor) |
비동기 시작. executor 생략 시 ForkJoinPool.commonPool() |
thenApply(f) |
결과 변환 (map) |
thenCompose(f) |
결과로 또 다른 CompletableFuture 실행 (flatMap) |
thenCombine(other, f) |
두 결과 합치기 |
allOf(...) / anyOf(...) |
여러 개 모두/하나 완료 대기 |
exceptionally(f) / handle(f) |
예외 처리 |
join() / get() |
블로킹 결과 획득 |
기존 스레드(플랫폼 스레드)는 OS 스레드와 1:1 이라 무겁습니다(생성 ~1ms, 스택 1MB, 수천 개가 한계). 가상 스레드는 JVM 이 관리하는 가벼운 스레드로, 소수의 OS 스레드(캐리어) 위에 수백만 개를 올릴 수 있습니다.
가상 스레드 100만 개 ──┐
├── 캐리어(플랫폼) 스레드 8개 (CPU 코어 수) ── OS
가상 스레드가 I/O 로 블로킹되면 → 캐리어에서 내려오고(unmount) 다른 가상 스레드가 올라감(mount)try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> { Thread.sleep(1000); return 1; }); // 10만 개가 1초 만에 끝남
}
} // close() 가 모든 작업 완료 대기| 플랫폼 스레드 | 가상 스레드 | |
|---|---|---|
| 매핑 | OS 스레드 1:1 | M:N (JVM 스케줄링) |
| 생성 비용 | 큼 | 매우 작음 (객체 하나) |
| 최대 개수 | 수천 | 수백만 |
| 블로킹 I/O | OS 스레드가 놀게 됨 | 캐리어를 양보 → 효율적 |
| 적합 | CPU 바운드 계산 | I/O 바운드 (HTTP 호출, DB, 파일) |
| 풀링 | 필수 | 하지 말 것 (작업마다 새로 만듦) |
언제 쓰나: 요청당 스레드 모델에서 I/O 대기가 대부분인 작업(외부 API 호출, DB 쿼리). CPU 계산은 어차피 코어 수만큼만 병렬이라 이득이 없습니다. 주의: synchronized 블록 안에서 블로킹하면 캐리어가 고정(pinned)되어 이점이 사라지므로(JDK 21 기준, 24에서 개선) ReentrantLock 을 쓰는 것이 좋습니다.
ThreadLocal<T> 은 "변수 하나인데 스레드마다 값이 다른" 저장소입니다. 스프링의 SecurityContextHolder, 트랜잭션 컨텍스트, SimpleDateFormat(스레드 안전하지 않음) 공유가 대표 용도입니다.
private static final ThreadLocal<String> REQUEST_ID = ThreadLocal.withInitial(() -> "none");
REQUEST_ID.set("req-42"); // 이 스레드에서만 보임
String id = REQUEST_ID.get();
REQUEST_ID.remove(); // 스레드 풀에서는 반드시 remove! 안 하면 다음 요청이 이전 값을 봄스레드 풀은 스레드를 재사용하므로 remove() 를 빼먹으면 다른 요청의 값이 새어나가는 보안 사고가 됩니다. 가상 스레드는 매번 새로 만들어지니 이 문제는 없지만 수백만 개에 ThreadLocal 을 쓰면 메모리가 부담이라 JDK 21 프리뷰 ScopedValue 가 대안으로 나왔습니다.