프로세스 안에서 실제로 CPU에 올라가 실행되는 단위. 프로세스가 "집"이라면 스레드는 그 안에 사는 "사람"이다.
무엇을 공유하고 무엇을 따로 갖나 — 이 표가 전부다
프로세스 (하나의 주소 공간)
┌──────────────────────────────────────────┐
│ 코드 · 데이터 · 힙 · 열린 파일 │ ← 공유
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │스레드 1 │ │스레드 2 │ │스레드 3 │ │
│ │ 스택 │ │ 스택 │ │ 스택 │ │ ← 각자
│ │ 레지스터│ │ 레지스터│ │ 레지스터│ │
│ │ PC │ │ PC │ │ PC │ │
│ └────────┘ └────────┘ └────────┘ │
└──────────────────────────────────────────┘
| 각자 갖는 것 | 왜 |
|---|---|
| 스택 | 함수 호출 흐름이 스레드마다 다르다 |
| 레지스터·PC | 지금 어느 명령을 실행 중인지가 다르다 |
| 스레드 로컬 저장소 | 스레드별 데이터를 두기 위해 |
힙과 전역 변수는 공유한다. 이것이 스레드의 장점이자 모든 문제의 근원이다.
왜 힙 공유가 문제인가
class Counter {
int count = 0; // 힙에 있음 → 모든 스레드가 본다
void increment() { count++; } // 이 한 줄이 사실 3단계
}
count++는 기계 수준에서
- ① 메모리에서 count 읽기 (레지스터로)
- ② 레지스터 값 +1
- ③ 레지스터 값을 메모리에 쓰기
①과 ③ 사이에 다른 스레드가 끼어들면 증가가 하나 사라진다. 1000번씩 두 스레드가 돌려도 결과가 2000이 아니다. 이것이 경쟁 조건이다.
반면 지역 변수는 스택에 있어 안전하다 — 스레드마다 스택이 따로이기 때문이다. "지역 변수만 쓰는 메서드는 스레드 안전하다"는 말의 근거가 이것이다.
프로세스 대신 스레드를 쓰는 이유
| 프로세스 | 스레드 | |
|---|---|---|
| 생성 비용 | 큼(주소 공간 복사) | 작음 |
| 컨텍스트 스위칭 | 비쌈(TLB·캐시 무효화) | 상대적으로 쌈 |
| 통신 | IPC 필요(파이프·소켓) | 메모리 공유로 즉시 |
| 안정성 | 하나가 죽어도 격리 | 하나가 죽으면 프로세스 전체 위험 |
같은 프로세스 안의 스레드끼리 전환하면 주소 공간이 그대로라 페이지 테이블을 안 바꿔도 되고 TLB도 유지된다. 이것이 "가볍다"의 실체다.
자바에서 — 가상 스레드
전통적으로 자바 스레드는 OS 스레드와 1:1이라 하나당 약 1MB 스택을 잡는다. 수만 개를 만들 수 없다.
자바 21의 가상 스레드(Project Loom)는 JVM이 관리하는 경량 스레드다. 블로킹 I/O를 만나면 OS 스레드를 붙잡지 않고 양보해, 수십만 개를 띄울 수 있다.
Thread.startVirtualThread(() -> { … }); // 스택이 힙에 저장되어 매우 가볍다
"스레드 풀로 동시성을 제한하던 이유가 사라진다" 는 것이 핵심 변화다.
다만 synchronized 블록 안에서 블로킹하면 여전히 OS 스레드를 붙든다(pinning) —
ReentrantLock으로 바꿔야 한다.