콘텐츠로 이동

Thread

프로세스 내에서 실제로 작업을 수행하는 실행 단위로, 모든 프로세스는 최소 하나 이상의 스레드를 가지고 있다.

  • 프로세스: 운영체제로부터 자원을 할당받는 작업의 단위 (Code, Data, Heap, Stack 영역 독립)
  • 스레드: 프로세스가 할당받은 자원을 이용하는 실행 단위 (Stack, PC Register만 독립, 나머지 영역은 공유)

구현 방법으로는 아래 두 개의 방법이 있으며 큰 차이는 없으나 Thread 클래스를 상속 받으면 다른 클래스를 상속 받을 수 없기 때문에 Runnable 방법을 권장한다.

Thread 클래스를 직접 상속받아 run() 메서드를 오버라이딩한다.

public class MyThread extends Thread {
@Override
public void run() {
// Do something
}
}

Java는 다중 상속을 지원하지 않으므로, 다른 클래스를 상속받을 수 없다는 단점이 있다.

Runnable 인터페이스는 run() 메서드 하나만 정의되어 있는 함수형 인터페이스다.

public class MyThread implements Runnable {
@Override
public void run() {
// Do Something
}
}

다른 클래스를 상속받을 수 있으며, Java 8부터는 람다식으로도 구현할 수 있다.

스레드를 실행할 때는 run()이 아닌 start() 메서드를 호출해야 한다.

void example() {
// Runnable 인터페이스 구현체 실행
Runnable r = new MyRunnable();
Thread t1 = new Thread(r); // 생성자: Thread(Runnable target)
// 람다식 활용
Thread t2 = new Thread(() -> System.out.println("Lambda Thread"));
t1.start();
t2.start();
}
  • start() 메서드 호출 시, 대기 상태 진입 후 스레드 스케줄러에 의해 실행 (바로 실행 X)
  • 한 번 실행된 스레드는 다시 실행 불가능 (두 번 이상 실행 시 IllegalThreadStateException 예외 발생)
  • run(): 생성된 스레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출
  • start(): 새로운 스레드를 생성하기 위한 준비 작업 수행
    1. 새로운 실행 흐름을 위한 호출 스택 (Call Stack) 생성
    2. OS 스케줄러에게 실행 요청
    3. 새로 생성된 호출 스택에 run() 메서드를 올리고 실행

호출 스택을 강제로 출력하는 코드 사용하여 결과를 확인해보면 start() 메서드를 호출한 경우 main 스레드와 별도의 스레드가 생성되어 실행되는 것을 확인할 수 있다.

스레드는 priority라는 속성 (멤버변수)를 가지고 있으며 이 속성은 스레드의 우선순위를 나타낸다.

  • 스레드의 우선순위는 1 ~ 10 사이의 값을 가짐 (기본값 5)
  • 우선순위는 JVM이 OS 스케줄러에게 주는 힌트일 뿐이며, 실제 실행 순서를 보장하지 않음
메서드역할설명
sleep(long millis)일시 정지지정된 시간 동안 스레드 일시 정지 상태로 만듬
join()대기다른 스레드의 작업이 끝날 때까지 대기
interrupt()깨움일시 정지 상태(sleep, join, wait)인 스레드에게 예외(InterruptedException)를 발생시켜 실행 대기 상태로 만듬
yield()양보실행 중에 자신에게 주어진 실행 시간을 다른 스레드에게 양보하고 자신은 실행 대기 상태로 변경

이 외에 suspend(), resume(), stop()은 교착 상태 (Deadlock)를 유발할 가능성이 있어 deprecated 되었다.

상태설명
NEW스레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE실행 중 또는 실행 가능한 상태
BLOCKED동기화 블럭에 의해 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING다른 스레드가 통지(notify)하거나 작업이 완료될 때까지 기다리는 상태
TIMED_WAITING주어진 시간 동안 기다리는 상태 (sleep, timeout이 있는 join 등)
TERMINATED스레드의 작업이 종료된 상태
stateDiagram-v2
direction LR
state "생성 (NEW)" as NEW
state "실행 대기 (RUNNABLE)" as RUNNABLE
state "실행 (RUNNING)" as RUNNING
state "일시 정지 (WAITING, BLOCKED)" as WAITING
state "소멸 (TERMINATED)" as TERMINATED
[*] --> NEW
NEW --> RUNNABLE: ① start()
RUNNABLE --> RUNNING: ② 스케줄링(실행)
RUNNING --> RUNNABLE: ③ yield()
RUNNING --> WAITING: ④ suspend(), sleep(),\nwait(), join(), I/O block
WAITING --> RUNNABLE: ⑤ time-out, resume(),\nnotify(), interrupt()
RUNNING --> TERMINATED: ⑥ stop()
TERMINATED --> [*]
  1. 스레드 생성하고 start() 호출하여 실행 대기열에 저장되어 실행 대기 상태로 만듬
    • 실행대기열은 큐 (queue)와 같은 구조로 먼저 실행대기열에 들어온 스레드가 먼저 실행됨
  2. 실행 대기열에 있다가 차례가 되면 실행상태로 변경
  3. 주어진 실행시간이 다되거나 yield()를 만나면 실행대기상태가 되고 다시 실행대기열에 들어감
  4. 실행 중 suspend(), sleep(), join(), wait(), I/O block 의해 일시정지 상태가 될 수 있음
  5. 지정된 일시정지시간이 다 되거나 time-out(), notify(), resume(), interrupt() 등의 메서드를 호출하면 다시 실행대기상태가 됨
  6. 실행을 모두 마치거나 stop()을 호출하면 종료상태가 됨

ExecutorService는 스레드 풀 (Thread Pool)을 통해 스레드를 재사용하고 개수를 제한하는 인터페이스다.

미리 생성된 스레드를 재사용함으로써 시스템 자원과 성능을 최적화한다.

  • 자원 절약: 매 작업마다 OS에 스레드 생성을 요청하는 오버헤드 제거
  • 안정적 응답 시간: 스레드 생성 대기 없이 즉시 작업을 큐에 넣고 처리
  • 코드 구조 단순화: 작업 제출 (submit)과 실행 방식을 분리

스레드 풀 미사용 시 발생하는 문제

섹션 제목: “스레드 풀 미사용 시 발생하는 문제”

요청마다 스레드를 무한정 생성할 경우 심각한 시스템 장애로 이어진다.

  • 메모리 고갈 (OOM): Java 스레드 생성 시 약 1MB의 스택 메모리가 점유되며, 임계치 초과 시 시스템 메모리 고갈 유발
  • 컨텍스트 스위칭 오버헤드: CPU 코어 수보다 많은 스레드가 경합하며 스레드 전환에 막대한 CPU 자원 낭비
  • 연쇄 장애 유발: 제한 없는 트래픽을 DB나 외부 API로 그대로 전달하여 전체 시스템 셧다운 초래

Executors 팩토리 클래스로 목적에 맞는 풀을 생성한다.

메서드스레드 수특징주의점
newFixedThreadPool(n)고정 (n개)처리량 예측 가능, 초과 작업은 큐에 대기과부하 시 내부 큐 무한 증가 가능
newCachedThreadPool()무제한 (유동)유휴 스레드 재사용, 짧은 작업에 유리순간 부하 시 스레드 수 폭발 위험
newSingleThreadExecutor()1개 고정작업 순서 보장처리 속도가 제출 속도를 못 따라가면 큐 누적

shutdown()을 호출하지 않으면 풀 스레드가 종료되지 않아 애플리케이션이 정상 종료되지 않으니 주의해야한다.

void example() {
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 10; i++) {
int taskId = i;
executor.submit(() -> System.out.println("Task " + taskId));
}
} // try-with-resources로 shutdown() 자동 호출
}

ThreadPoolExecutor 내부 동작 메커니즘

섹션 제목: “ThreadPoolExecutor 내부 동작 메커니즘”

스레드 풀은 코어 스레드가 모두 사용 중일 때 스레드 개수를 즉시 늘리지 않고 대기 큐 (Queue)를 먼저 채운다.

flowchart TB
Start[작업 제출] --> Core{corePoolSize<br>도달?}
Core -- No --> CreateCore[새 코어 스레드 생성 및 할당]
Core -- Yes --> Queue{대기 큐<br>가득 참?}
Queue -- No --> Enqueue[내부 BlockingQueue 대기]
Queue -- Yes --> Max{maximumPoolSize<br>도달?}
Max -- No --> CreateMax[추가 스레드 생성 및 할당]
Max -- Yes --> Reject[작업 거절 정책 실행]
classDef default fill: #f9f9f9,color: #000,stroke: #333
classDef decision fill: #e1f5fe,color: #000,stroke: #0277bd
classDef action fill: #e8f5e9,color: #000,stroke: #2e7d32
classDef error fill: #ffebee,color: #000,stroke: #c62828
class Core,Queue,Max decision
class CreateCore,Enqueue,CreateMax action
class Reject error

스레드 풀의 동작과 확장 한계는 세 가지 핵심 파라미터로 결정된다.

  • corePoolSize: 유휴 상태 (Idle)에서도 종료되지 않고 풀이 유지하려는 최소한의 기본 스레드 개수
  • BlockingQueue: 코어 스레드가 모두 작업 중일 때 새로운 요청을 대기시키는 큐
  • maximumPoolSize: 큐마저 가득 찼을 때 풀이 확장할 수 있는 최대 스레드 개수

이 파라미터들을 바탕으로 스레드 풀은 다음과 같은 순서로 작업을 처리한다.

  1. 코어 스레드 할당: 현재 스레드 수가 corePoolSize 미만이면 새 스레드를 즉시 생성하여 작업 할당
  2. 큐 대기 (Queueing): 스레드가 corePoolSize에 도달하면 새 작업은 BlockingQueue에 대기
  3. 임시 스레드 확장: 대기 큐마저 가득 차면, 비로소 스레드 수를 maximumPoolSize까지 늘려서 작업을 처리
  4. 작업 거절 (Rejection): 큐가 가득 차고 스레드도 maximumPoolSize에 도달하면 RejectedExecutionHandler 정책에 따라 작업 거부

기본 팩토리 메서드들은 특정 파라미터를 무한대로 설정하여 각기 다른 장애를 유발할 수 있다.

  • newFixedThreadPool: 큐 크기가 무한대 (Integer.MAX_VALUE)이므로 과부하 시 큐에 작업이 끝없이 쌓여 힙 메모리 부족 (Heap OOM) 발생
  • newCachedThreadPool: maximumPoolSize가 무한대이므로 스레드를 무한정 생성하다가 OS 스레드 한계 도달 (Native OOM) 발생
  • 결론: 프로덕션 환경에서는 큐의 크기와 최대 스레드 수를 모두 명시적으로 제한한 커스텀 ThreadPoolExecutor를 직접 생성하여 시스템을 보호할 것

ExecutorService.submit(Callable)Future<V>를 반환하며, Future를 통해 비동기 작업의 결과를 나중에 수집할 수 있다.

void example() {
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Integer> future = executor.submit(() -> {
// 시간이 걸리는 연산
return 42;
});
// 다른 작업 수행 가능 ...
Integer result = future.get(); // 작업 완료 시까지 블로킹
executor.shutdown();
}

Future의 주요 메서드는 다음과 같다.

  • get(): 작업 완료 시까지 현재 스레드를 블로킹하고 결과 반환
  • get(timeout, unit): 지정 시간까지만 대기, 초과 시 TimeoutException 발생
  • isDone(): 작업 완료 여부 확인 (블로킹 없음)
  • cancel(mayInterruptIfRunning): 작업 취소 요청

get() 호출 시점까지 결과가 준비되지 않으면 블로킹되므로, 여러 Future를 순서대로 get()하면 직렬 대기와 다르지 않다는 점에 주의해야 한다.

마지막 업데이트:

Java