1. 프로세스와 스레드 소개
멀티태스킹과 멀티프로세싱
단일 프로그램 실행
프로그램의 실행이란 프로그램을 구성하는 코드를 순서대로 CPU에서 연산(실행)하는 일이다. CPU 코어가 1개면 한 번에 하나의 프로그램 코드만 실행 가능하다. 초창기 컴퓨터는 한 번에 하나의 프로그램만 실행할 수 있어서 음악 프로그램이 끝나야만 워드 프로그램을 실행할 수 있었다. 지금 시각으로 보면 매우 불편한 방식이다.
[단일 실행 흐름]
프로그램A: 코드1 -> 코드2 -> 코드3 -> 코드4 (완료)
프로그램B: 코드1 -> 코드2 -> 코드3 -> 코드4 (완료)
-> A가 끝난 후에야 B 시작 가능
멀티태스킹 (Multitasking)
하나의 컴퓨터 시스템이 동시에 여러 작업을 수행하는 능력이다. 핵심 원리는 시분할(Time Sharing)로, CPU가 약 0.01초(10ms) 단위로 여러 프로그램의 코드를 번갈아 수행한다. 사람은 이를 동시에 실행되는 것처럼 인식한다. 애니메이션 원리와 동일하다. 1초에 30~60장의 사진을 보여주면 사람은 그것을 움직이는 영상으로 인식하는 것과 같다.
[멀티태스킹 실행 흐름]
CPU 코어1: [A1] -> [B1] -> [A2] -> [B2] -> [A3] -> [B3] ...
-> 빠르게 번갈아 수행하여 동시 실행처럼 보임
스케줄링(Scheduling): CPU에 어떤 프로그램이 얼마만큼 실행될지 운영체제가 결정하는 것이다. 단순 시간 분할이 아닌 우선순위와 최적화 기법도 함께 사용한다.
멀티프로세싱 (Multiprocessing)
컴퓨터 시스템에서 둘 이상의 프로세서(CPU 코어)를 사용하여 여러 작업을 동시에 처리하는 기술이다. CPU 코어가 2개면 물리적으로 동시에 2개의 프로그램을 처리할 수 있다. 코어 수보다 많은 프로그램도 멀티태스킹 기법으로 실행 가능하다.
[멀티프로세싱 예시: 코어 2개, 프로그램 3개]
CPU 코어1: [A1] -> [C1] -> [A2] -> ...
CPU 코어2: [B1] -> [B2] -> [C2] -> ...
-> 실질적으로 3개의 프로그램이 더 빠르게 처리됨
참고: CPU 안에는 실제 연산을 처리하는 코어가 있다. 과거엔 CPU 1개 = 코어 1개였지만, 현재는 보통 2개 이상의 코어가 내장되어 있다.
멀티프로세싱 vs 멀티태스킹 비교
구분 멀티프로세싱 멀티태스킹
| 관점 | 하드웨어 장비 | 운영체제 소프트웨어 |
| 방식 | 여러 CPU 코어로 동시 처리 | 단일 CPU에서 시간 분할 |
| 성능 향상 | 하드웨어 기반 | 소프트웨어 기반 |
| 예시 | 다중 코어 프로세서 사용 | 여러 앱이 동시에 실행되는 OS 환경 |
현대 컴퓨터는 멀티프로세싱 + 멀티태스킹을 동시에 사용한다.

프로세스와 스레드
프로세스 (Process)
운영체제 안에서 실행 중인 프로그램이다. 프로그램 실행 전에는 단순한 파일에 불과하지만, 실행 후에는 프로세스가 생성된다. 자바로 비유하면 클래스는 프로그램이고, 인스턴스는 프로세스다.
각 프로세스는 독립적인 메모리 공간을 보유하며 프로세스끼리 서로 메모리에 직접 접근할 수 없다. 하나의 프로세스가 충돌해도 다른 프로세스에 영향을 주지 않는다.
프로세스의 메모리 구성
영역 설명
| 코드 섹션 | 실행할 프로그램의 코드 저장 |
| 데이터 섹션 | 전역 변수 및 정적 변수 저장 |
| 힙 (Heap) | 동적으로 할당되는 메모리 영역 |
| 스택 (Stack) | 메서드 호출 시 생성되는 지역 변수와 반환 주소 저장 (스레드별 보유) |

스레드 (Thread)
프로세스 내에서 실행되는 작업의 단위다. 실, 실을 꿰다 라는 의미를 가지며, 코드를 한 줄씩 순서대로 실행하는 흐름이 곧 스레드다. main()부터 시작해서 하나씩 순서대로 실행한다. 프로세스는 하나 이상의 스레드를 반드시 포함한다.
스레드의 메모리 공유 방식
구분 공유 여부 설명
| 코드 섹션 | 공유 | 같은 프로세스 내 모든 스레드가 공유 |
| 데이터 섹션 | 공유 | 전역/정적 변수 공유 |
| 힙 (Heap) | 공유 | 동적 할당 메모리 공유 |
| 스택 (Stack) | 개별 | 각 스레드가 자신만의 스택 보유 |
- 단일 스레드: 한 프로세스 내에 하나의 스레드
- 멀티 스레드: 한 프로세스 내에 여러 스레드
멀티스레드가 필요한 이유
[워드 프로그램 - 프로세스A]
스레드1: 문서 편집
스레드2: 자동 저장
스레드3: 맞춤법 검사
[유튜브 - 프로세스B]
스레드1: 영상 재생
스레드2: 댓글 작성
프로세스는 실행 환경과 자원을 제공하는 컨테이너 역할이고, 스레드는 CPU를 사용해서 코드를 실제 실행하는 역할이다.
스레드와 스케줄링
실제로 CPU를 사용해서 코드를 실행하는 것은 스레드다. 운영체제는 내부에 스케줄링 큐를 보유하며 각 스레드는 스케줄링 큐에서 대기한다.
단일 코어 스케줄링
[단일 코어 스케줄링 흐름]
스케줄링 큐: [스레드B2] [스레드B1] [스레드A1]
① A1을 큐에서 꺼내 CPU 실행
-> A1: 코드 수행 + CPU 연산
② A1을 잠시 멈추고 다시 큐에 삽입
스케줄링 큐: [스레드A1] [스레드B2] [스레드B1]
③ B1을 큐에서 꺼내 CPU 실행
④ 이 과정 반복
멀티 코어 스케줄링
[멀티 코어 스케줄링 흐름: 코어 2개]
스케줄링 큐: [스레드B2] [스레드B1] [스레드A1]
① A1, B1을 병렬로 실행 (코어1: A1, 코어2: B1)
큐: [스레드B2]
② A1 수행 일시 정지 -> 다시 큐에 삽입
큐에서 B2를 꺼내 코어1에서 실행
(코어1: B2, 코어2: B1 계속)
③ 이 과정 반복
-> 물리적으로 진짜 동시 실행 가능
컨텍스트 스위칭 (Context Switching)
스레드를 멈추는 시점에 CPU에서 사용하던 값들을 메모리에 저장하고, 다시 실행할 때 그 값들을 CPU에 불러오는 과정이다. 컨텍스트(Context)란 현재 작업하는 문맥이다. 스레드 전환 시 이전 실행 위치(PC), 레지스터 값 등을 저장/복원하며 비용(오버헤드)이 발생한다.
사람에 비유하면
프로그램A 개발 중에 갑자기 프로그램B 수정 요청이 들어온다. B를 수정하고 다시 A로 복귀할 때, 어디까지 했는지 찾아야 하고 변수 값들을 다시 머릿속에 불러와야 한다. 이 과정 자체가 비용이다.
컨텍스트 스위칭 비용 분석 (예시: 1~10000 더하기)
상황 방식 결과
| CPU 코어 2개 | 스레드 2개로 병렬 처리 | 효율적 (연산 2배 빠름) |
| CPU 코어 1개 | 스레드 2개로 분할 | 비효율 (연산 시간 + 컨텍스트 스위칭 시간) |
| CPU 코어 1개 | 단일 스레드 | 효율적 (컨텍스트 스위칭 비용 없음) |
멀티스레드는 대부분 효율적이지만, 컨텍스트 스위칭 비용 때문에 항상 효율적이지는 않다.

적정 스레드 수 (실무 기준)
CPU 코어 수 스레드 수 결과
| 4개 | 2개 | CPU 활용률 낮음, 컨텍스트 스위칭 적음 |
| 4개 | 100개 | CPU 활용률 높음, 컨텍스트 스위칭 많음 |
| 4개 | 4~5개 | 최적 (CPU 100% 활용 + 스위칭 최소화) |
이상적인 스레드 수: CPU 코어 수 + 1개. 특정 스레드가 대기 중일 때 나머지 스레드가 CPU를 활용할 수 있다.
CPU 바운드 vs I/O 바운드
CPU 바운드 작업 (CPU-bound)
CPU의 연산 능력을 많이 요구하는 작업이다. CPU 처리 속도가 작업 완료 시간을 결정한다. 예시로는 복잡한 수학 연산, 데이터 분석, 비디오 인코딩, 과학 시뮬레이션 등이 있다. 적정 스레드 수는 CPU 코어 수 + 1개다.
I/O 바운드 작업 (I/O-bound)
입출력(I/O) 작업을 많이 요구하는 작업이다. I/O 완료까지 대기 시간이 많고 CPU는 상대적으로 유휴 상태다. 예시로는 DB 쿼리, 파일 읽기/쓰기, 네트워크 통신, 사용자 입력 처리 등이 있다. 적정 스레드 수는 CPU 코어 수보다 훨씬 많이, 성능 테스트로 최적값을 도출해야 한다.
웹 애플리케이션 서버 예시
[상황]
- CPU 코어: 4개
- 요청 1개 처리 시 CPU 사용량: 1% (나머지 99%는 DB 응답 대기)
[잘못된 설정]
스레드 수 = 4개 -> 동시 사용자 4명만 처리, CPU 사용률 4%
-> 비싼 장비로 교체해도 CPU 2% 사용, 사용자 4명... 낭비!
[올바른 설정]
스레드 수 = 100개 -> 동시 사용자 100명 처리, CPU 사용률 100%
-> 성능 테스트로 최적값 도출 필요
작업 유형 적정 스레드 수 이유
| CPU 바운드 | CPU 코어 수 + 1 | CPU를 거의 100% 사용하므로 코어 수에 최적화 |
| I/O 바운드 | 코어 수보다 많이 (성능 테스트 필요) | CPU를 많이 사용하지 않으므로 더 많은 스레드 가능 |
단, 스레드를 너무 많이 생성하면 컨텍스트 스위칭 비용도 증가하므로 성능 테스트가 필수다.
2. 스레드 생성과 실행
자바 메모리 구조 복습
스레드를 제대로 이해하려면 자바 메모리 구조를 반드시 알고 있어야 한다.
영역 설명
| 메서드 영역 (Method Area) | 클래스 실행 코드(바이트 코드), static 변수, 런타임 상수 풀 저장. 모든 영역에서 공유 |
| 스택 영역 (Stack Area) | 스레드별로 하나씩 생성. 메서드 호출 시 스택 프레임 쌓임. 지역 변수, 중간 연산 결과 포함 |
| 힙 영역 (Heap Area) | 객체(인스턴스)와 배열 생성. GC가 관리. 더 이상 참조 없으면 GC가 제거 |
핵심: 스택 영역은 스레드 수만큼 생성된다. 스레드 1개 → 스택 1개, 스레드 3개 → 스택 3개.

스레드 생성 - Thread 상속
스레드를 만드는 두 가지 방법이 있다. Thread 클래스를 상속하거나, Runnable 인터페이스를 구현하는 것이다.
방법 1: Thread 클래스 상속
package thread.start;
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
- Thread.currentThread(): 현재 코드를 실행하는 스레드 객체를 반환
- Thread.currentThread().getName(): 실행 중인 스레드 이름을 반환
- run() 메서드를 재정의해서 스레드가 실행할 로직을 작성
package thread.start;
public class HelloThreadMain {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName() + ": main() start");
HelloThread helloThread = new HelloThread();
System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
helloThread.start(); // run()이 아닌 start()!
System.out.println(Thread.currentThread().getName() + ": start() 호출 후");
System.out.println(Thread.currentThread().getName() + ": main() end");
}
}
실행 결과
main: main() start
main: start() 호출 전
main: start() 호출 후
Thread-0: run()
main: main() end
스레드 생성 전/후 메모리 구조


시간의 흐름으로 분석

핵심 포인트:
- main 스레드가 run()을 직접 실행하는 게 아니다
- start() 호출 → Thread-0 스레드가 run() 실행
- main 스레드는 start() 지시만 하고 바로 빠져나옴
- 이후 main 스레드와 Thread-0 스레드는 동시에 실행
스레드 이름을 안 주면 자바가 Thread-0, Thread-1 같은 임의 이름을 부여한다.
스레드 실행 순서는 보장 안 됨
// 경우 1: main이 빨리 끝난 경우
main: main() start -> main: start() 호출 전 -> main: start() 호출 후 -> main: main() end -> Thread-0: run()
// 경우 2: Thread-0이 빨리 실행된 경우
main: main() start -> main: start() 호출 전 -> Thread-0: run() -> main: start() 호출 후 -> main: main() end
// 경우 3: 중간에 끼어든 경우
main: main() start -> main: start() 호출 전 -> main: start() 호출 후 -> Thread-0: run() -> main: main() end
스레드는 순서와 실행 기간을 모두 보장하지 않는다. 이것이 멀티스레드다.
start() vs run()
run()을 직접 호출하면 별도 스레드가 실행되는 게 아니라 main 스레드가 실행한다.
// 잘못된 방법
helloThread.run(); // main 스레드가 run()을 그냥 호출
// 올바른 방법
helloThread.start(); // Thread-0 스레드를 생성하고 run() 실행
run() 직접 호출 시 실행 결과
main: main() start
main: run() 호출 전
main: run() <- Thread-0가 아닌 main이 실행!
main: run() 호출 후
main: main() end
[run() 직접 호출 메모리 구조]
메서드 영역 main 스레드 - 스택
+-----------------+
| run() frame | <- main 스택에 쌓임!
+-----------------+
| main() frame |
| args[] |
+-----------------+
-> Thread-0 스택은 아무것도 없음

구분 start() run()
| 스택 | Thread-0 전용 스택 새로 할당 | main 스택에 프레임 추가 |
| 실행 스레드 | Thread-0 | main |
| 동시 실행 | 가능 | 불가능 |
start() = 새 스레드에 스택 공간 할당 + run() 실행 명령
📎 이미지 삽입: 04_start_호출_시간흐름.png + 05_run직접호출_비교.png (PDF2 17, 20페이지 - start() vs run() 비교)
데몬 스레드 (Daemon Thread)
구분 사용자 스레드 (User Thread) 데몬 스레드 (Daemon Thread)
| 역할 | 프로그램 주요 작업 | 백그라운드 보조 작업 |
| JVM 종료 | 모든 user 스레드 종료 시 JVM 종료 | user 스레드가 모두 종료되면 자동 종료 |
| JVM 대기 여부 | JVM이 완료 대기 | JVM이 완료 안 기다림 |
| 예 | main 스레드 | GC, 자동 저장 등 |
어원: 그리스 신화에서 데몬(Daemon)은 신과 인간 사이의 중간 존재로 보이지 않게 일상적인 일을 도왔다.
코드 예시
package thread.start;
public class DaemonThreadMain {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName() + ": main() start");
DaemonThread daemonThread = new DaemonThread();
daemonThread.setDaemon(true); // 반드시 start() 전에 설정!
daemonThread.start();
System.out.println(Thread.currentThread().getName() + ": main() end");
}
static class DaemonThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run() start");
try {
Thread.sleep(10000); // 10초 실행
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + ": run() end");
}
}
}
setDaemon(true) 결과
main: main() start
main: main() end
Thread-0: run() start
// run() end는 출력 안 됨 -> main 종료 시 프로그램 종료
setDaemon(false) 결과
main: main() start
main: main() end
Thread-0: run() start
Thread-0: run() end <- user 스레드이므로 JVM이 완료를 기다림
setDaemon(true/false)는 반드시 start() 이전에 호출해야 한다. 기본값은 false (user 스레드).
스레드 생성 - Runnable 인터페이스
Runnable 인터페이스
package java.lang;
public interface Runnable {
void run();
}
자바가 제공하는 스레드 실행용 인터페이스다.
구현 예시
// Runnable 구현
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
// 사용
public class HelloRunnableMain {
public static void main(String[] args) {
HelloRunnable runnable = new HelloRunnable();
Thread thread = new Thread(runnable); // Runnable을 Thread에 전달
thread.start();
}
}
Thread 상속 vs Runnable 구현 비교
구분 Thread 상속 Runnable 구현
| 구현 편의성 | 간단 (run만 재정의) | 약간 복잡 (객체 생성 후 전달) |
| 상속 제한 | 단일 상속으로 다른 클래스 상속 불가 | 자유 (인터페이스이므로) |
| 유연성 | 낮음 | 높음 |
| 코드 분리 | 스레드와 작업이 결합 | 스레드와 작업 분리 |
| 재사용성 | 낮음 | 여러 스레드가 동일 Runnable 공유 가능 |
| 권장 | 아니오 | 권장 |
결론: Runnable 인터페이스 구현 방식을 사용하자. 스레드와 실행 작업을 명확히 분리하고 더 유연하다.
여러 스레드 만들기
스레드 3개 생성
public class ManyThreadMainV1 {
public static void main(String[] args) {
log("main() start");
HelloRunnable runnable = new HelloRunnable();
Thread thread1 = new Thread(runnable);
thread1.start();
Thread thread2 = new Thread(runnable);
thread2.start();
Thread thread3 = new Thread(runnable);
thread3.start();
log("main() end");
}
}
3개의 스레드 모두 같은 HelloRunnable 인스턴스(x001)의 run()을 실행한다. 각 스레드는 자신만의 스택을 가지고, 힙의 Runnable 객체를 공유한다.

Runnable을 만드는 다양한 방법
① 정적 중첩 클래스
public class InnerRunnableMainV1 {
public static void main(String[] args) {
Runnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
}
static class MyRunnable implements Runnable {
@Override
public void run() {
log("run()");
}
}
}
특정 클래스 안에서만 사용되는 경우 적합하다.
② 익명 클래스
Runnable runnable = new Runnable() {
@Override
public void run() {
log("run()");
}
};
Thread thread = new Thread(runnable);
thread.start();
③ 익명 클래스 직접 전달
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
log("run()");
}
});
thread.start();
④ 람다 (Lambda)
Thread thread = new Thread(() -> log("run()"));
thread.start();
메서드(함수) 코드 조각을 직접 전달하는 방식으로 가장 간결하다.
방법 특징 권장 상황
| 정적 중첩 클래스 | 재사용 가능, 명시적 | 여러 곳에서 재사용 시 |
| 익명 클래스 | 한 번만 사용 | 단순한 로직 |
| 익명 클래스 직접 전달 | 변수 없이 전달 | 더 간결하게 |
| 람다 | 가장 간결 | 람다 학습 후 권장 |
3. 스레드 제어와 생명 주기1
스레드 기본 정보
Thread mainThread = Thread.currentThread(); // 현재 실행 중인 스레드 반환
Thread myThread = new Thread(new HelloRunnable(), "myThread");
메서드 반환값 설명
| threadId() | long | JVM 내 유일한 스레드 ID (직접 지정 불가) |
| getName() | String | 스레드 이름 (이름은 중복 가능) |
| getPriority() | int | 우선순위 1~10, 기본값 5 |
| getThreadGroup() | ThreadGroup | 스레드 그룹 (부모 스레드와 동일한 그룹) |
| getState() | Thread.State | 현재 상태 (NEW, RUNNABLE 등) |
실행 결과 예시
09:55:58.716 [ main] mainThread.getState() = RUNNABLE
09:55:58.717 [ main] myThread.getState() = NEW
우선순위: setPriority()로 변경 가능하지만, 실제 실행 순서는 JVM 구현과 OS에 따라 달라질 수 있다.
스레드의 생명 주기
상태 다이어그램

각 상태 설명
상태 설명 진입 방법
| NEW | 스레드 생성됨, 아직 시작 안 됨 | new Thread(...) |
| RUNNABLE | 실행 중이거나 실행 준비 완료 | thread.start() |
| BLOCKED | 동기화 락을 기다리는 상태 | synchronized 블록 진입 시 락 없을 때 |
| WAITING | 무기한으로 다른 스레드 작업 완료 대기 | wait(), join() 호출 |
| TIMED_WAITING | 일정 시간 동안 대기 | sleep(ms), wait(ms), join(ms) 호출 |
| TERMINATED | 실행 완료 (다시 시작 불가) | run() 메서드 종료 |
자바에서 "일시 중지 상태"라는 공식 상태는 없다. Blocked/Waiting/Timed Waiting을 묶어서 설명하는 용어다.
스케줄러 실행 대기열에 있든, 실제 CPU에서 실행 중이든 모두 RUNNABLE이다. 자바에서 둘을 구분할 수 없다.
상태 전이 흐름
1. New -> Runnable : start() 호출
2. Runnable -> Blocked : synchronized 블록 진입 시 락 없을 때
3. Runnable -> Waiting : wait(), join() 호출
4. Runnable -> Timed_Waiting : sleep(ms), wait(ms), join(ms) 호출
5. Blocked/Waiting/Timed_Waiting -> Runnable : 락 획득 or 대기 완료
6. Runnable -> Terminated : run() 메서드 완료
생명 주기 코드 확인
public class ThreadStateMain {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new MyRunnable(), "myThread");
log("state1 = " + thread.getState()); // NEW
thread.start();
Thread.sleep(1000);
log("state3 = " + thread.getState()); // TIMED_WAITING
Thread.sleep(4000);
log("state5 = " + thread.getState()); // TERMINATED
}
static class MyRunnable implements Runnable {
@Override
public void run() {
log("state2 = " + Thread.currentThread().getState()); // RUNNABLE
try {
Thread.sleep(3000); // TIMED_WAITING 상태가 됨
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log("state4 = " + Thread.currentThread().getState()); // RUNNABLE
}
}
}
실행 결과 및 흐름
[main] state1 = NEW
[myThread] state2 = RUNNABLE
[main] state3 = TIMED_WAITING <- 1초 후 확인 (myThread는 3초 sleep 중)
[myThread] state4 = RUNNABLE <- 3초 sleep 완료
[main] state5 = TERMINATED <- 4초 후 확인

run()이 스택에 남은 마지막 메서드이므로, run() 종료 시 스택이 완전히 비워지고 스레드도 종료된다.
체크 예외 재정의
왜 run()에서 체크 예외를 던질 수 없나?
public interface Runnable {
void run(); // 체크 예외를 던지지 않음
}
자바 메서드 재정의 예외 규칙
구분 규칙
| 체크 예외 | 부모 메서드가 체크 예외를 던지지 않으면 자식도 던질 수 없음 |
| 언체크(런타임) 예외 | 제한 없이 던질 수 있음 |
// 컴파일 오류 - run()에서 체크 예외 던질 수 없음
static class MyRunnable implements Runnable {
public void run() throws InterruptedException { // 오류!
Thread.sleep(3000);
}
}
// 올바른 방법 - try-catch로 처리
static class MyRunnable implements Runnable {
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e); // 언체크 예외로 변환
}
}
}
이유: 클라이언트 코드는 부모 메서드(Runnable.run())가 던지는 예외만 처리하도록 작성된다. 자식이 더 넓은 예외를 던지면 클라이언트가 처리하지 못할 수 있기 때문이다.
Sleep 유틸리티 (반복 코드 줄이기)
// 매번 try-catch 작성이 번거로움 -> 유틸리티로 해결
package util;
public abstract class ThreadUtils {
public static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
log("인터럽트 발생, " + e.getMessage());
throw new RuntimeException(e);
}
}
}
// 사용법
import static util.ThreadUtils.sleep;
void run() {
sleep(3000); // 간결하게 사용 가능
}
join - 시작
WAITING 상태란?
스레드가 다른 스레드의 특정 작업이 완료되기를 무기한 기다리는 상태다.
join이 필요한 상황
문제: main 스레드가 thread-1, thread-2에 작업을 지시하고 결과를 합산하려고 할 때
// 문제 코드 - main이 결과를 너무 일찍 조회
thread1.start();
thread2.start();
log(task1.result); // -> 0 출력됨! 아직 계산 안 끝남
log(task2.result); // -> 0 출력됨!
원인: start()는 스레드 실행을 지시만 하고 즉시 리턴한다. thread-1, thread-2가 계산(2초 소요)을 완료하기 전에 main이 결과를 조회해버린다.

join - sleep 사용 (임시방편)
thread1.start();
thread2.start();
sleep(3000); // main이 3초 기다림
log(task1.result); // -> 1275 출력
log(task2.result); // -> 3775 출력
문제점
- 대기 시간을 알고 있어야 함 → 작업 시간이 달라지면 정확한 타이밍 맞추기 어려움
- 무작정 기다리면 불필요한 대기 시간 발생
- while(thread.getState() != TERMINATED) {} 반복문도 가능하지만 CPU 낭비
join - join 사용
thread1.start();
thread2.start();
// 스레드가 종료될 때까지 대기
thread1.join(); // main은 WAITING 상태가 됨
thread2.join(); // thread-1 종료 후, thread-2도 거의 동시 종료
log(task1.result); // -> 1275
log(task2.result); // -> 3775
log(task1.result + task2.result); // -> 5050
실행 결과
[main] Start
[thread-1] 작업 시작
[thread-2] 작업 시작
[main] join() - main 스레드가 thread1, thread2 종료까지 대기
[thread-2] 작업 완료 result = 3775
[thread-1] 작업 완료 result = 1275
[main] main 스레드 대기 완료
[main] task1 + task2 = 5050
join() 동작 원리
- join() 호출 스레드는 대상 스레드가 TERMINATED가 될 때까지 WAITING 상태로 대기
- 대상 스레드 종료 시 호출 스레드는 다시 RUNNABLE로 복귀


join - 특정 시간만큼만 대기
join() vs join(ms)
메서드 대기 방식 호출 스레드 상태
| join() | 대상 스레드 종료까지 무한 대기 | WAITING |
| join(ms) | 지정한 시간만큼만 대기 후 자동 복귀 | TIMED_WAITING |
thread1.start();
thread1.join(1000); // 1초만 대기 (thread-1 작업은 2초 소요)
log(task1.result); // -> 0 출력됨 (아직 계산 안 끝남)
// 이후 thread-1이 계산을 끝내지만 main은 이미 진행 중
실행 결과
[main] Start
[main] join(1000) - main 스레드가 thread1 종료까지 1초 대기
[thread-1] 작업 시작
[main] main 스레드 대기 완료 <- 1초 후 대기 중단
[main] task1.result = 0 <- 아직 계산 안 됨
[thread-1] 작업 완료 result = 1275 <- 2초 후 완료
기다리다 중간에 나오는 경우 결과가 없을 수 있으므로, 추가적인 오류 처리가 필요할 수 있다.
출처 : 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성
'JAVA' 카테고리의 다른 글
| [TIL] Java 멀티스레드 - synchronized와 임계 영역 (2026.05.04) (0) | 2026.05.04 |
|---|---|
| [TIL] Java 멀티스레드 - 인터럽트, yield와 메모리 가시성 (2026.4.23) (1) | 2026.04.24 |
| getter 언제, 어떻게 사용해야 하는가!!! (0) | 2025.11.06 |
| [자료구조] 자료구조 기본 이해하기 (0) | 2025.11.04 |
| 좋은 객체 7가지 덕목 (0) | 2025.04.04 |