Skip to main content

Command Palette

Search for a command to run...

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Updated
17 min read

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는 내부 알고리즘을 OpenJDK jdk21 소스와 공식 javadoc 기준으로 정리해요. 스레드 풀을 직접 튜닝해야 하는 개발자를 대상으로 해요.

ExecutorService를 처음 만나면 약속이 단순해 보여요. execute(Runnable)로 작업을 던지면 알아서 어떤 스레드가 실행해 준다는 거죠. 그런데 이 "알아서"가 생각보다 까다로워요. 스레드를 몇 개까지 만들지, 남는 작업은 어디에 보관할지, 더 이상 받을 수 없을 땐 어떻게 할지를 전부 ThreadPoolExecutor가 결정하는데, 그 규칙이 직관과 어긋나는 지점이 꽤 많거든요.

특히 "코어 스레드가 다 차면 스레드를 더 만들겠지"라는 흔한 기대는 큐 종류에 따라 완전히 빗나가요. 이 글에서는 execute()가 작업 하나를 받아 처리 경로를 고르는 3단계 결정, 그 결정을 떠받치는 ctl 상태값, 그리고 스레드가 태어나고 사라지는 getTask()/runWorker()까지 소스를 따라가며 풀어볼게요.

ExecutorService 뒤에 숨은 단순한 뼈대

ThreadPoolExecutor의 본체는 의외로 단출해요. 작업을 보관할 큐 하나워커 스레드 N개, 그리고 이 둘을 묶는 규칙이 전부예요. 그 규칙을 정하는 게 생성자의 일곱 개 파라미터예요.

public ThreadPoolExecutor(
    int corePoolSize,                 // 평소 유지할 스레드 수
    int maximumPoolSize,              // 최대로 늘릴 수 있는 스레드 수
    long keepAliveTime,               // 코어 초과 스레드의 유휴 허용 시간
    TimeUnit unit,                    // keepAliveTime 단위
    BlockingQueue<Runnable> workQueue,// 대기 작업을 담는 큐
    ThreadFactory threadFactory,      // 워커 스레드 생성기
    RejectedExecutionHandler handler  // 거부 정책
) { ... }

이 일곱 개 중에서 풀의 성격을 가장 크게 흔드는 건 corePoolSize, maximumPoolSize, 그리고 workQueue예요. 뒤에서 보겠지만, 세 값이 어떻게 조합되느냐에 따라 같은 코드가 "스레드를 적극적으로 늘리는 풀"이 되기도 하고 "스레드를 절대 안 늘리는 풀"이 되기도 해요. handler는 그 마지막 안전망이고요.

ctl 하나에 담긴 두 가지 상태

규칙을 따라가기 전에, 풀이 자기 상태를 어떻게 들고 있는지부터 봐야 해요. ThreadPoolExecutor는 **풀의 생명주기 상태(runState)**와 **현재 워커 수(workerCount)**라는 두 값을 단 하나의 AtomicInteger에 같이 욱여넣어요.

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // 29
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;

private static int runStateOf(int c)     { return c & ~COUNT_MASK; }
private static int workerCountOf(int c)   { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc)  { return rs | wc; }

상위 3비트가 runState, 나머지 29비트가 workerCount예요. 그래서 워커는 약 5억 개(2^29 - 1)까지만 가능하지만, 현실에서 문제될 일은 없죠. runState는 다섯 가지예요.

상태 의미
RUNNING 새 작업을 받고, 큐의 작업도 처리해요
SHUTDOWN 새 작업은 거부하지만, 큐에 남은 작업은 마저 처리해요
STOP 새 작업도, 큐의 작업도 처리하지 않고 진행 중 작업은 인터럽트해요
TIDYING 모든 작업이 끝나고 워커가 0이 되어 terminated() 훅을 부르기 직전이에요
TERMINATED terminated() 훅까지 완료된 최종 상태예요

왜 굳이 두 값을 한 정수에 합쳤을까요. 핵심은 원자적 일관성이에요. "지금 RUNNING 상태이고 워커가 3개"라는 두 사실을 한 번의 CAS로 동시에 읽고 갱신할 수 있어야, "상태는 바뀌었는데 워커 수는 옛날 값"인 어긋난 순간이 생기지 않아요. execute()가 내리는 모든 결정은 이 ctl 한 번 읽기에서 출발해요.

execute()의 3단계 — 가장 자주 오해하는 부분

이제 본론이에요. 작업 하나가 들어왔을 때 execute()가 하는 일은 소스의 주석이 직접 "3단계로 진행한다"고 못 박고 있어요.

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    int c = ctl.get();
    // 1단계: 워커가 corePoolSize 미만이면 새 스레드를 만들어 첫 작업으로 준다
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    // 2단계: RUNNING이면 큐에 넣어 본다
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    // 3단계: 큐가 가득 차 못 넣었으면 스레드를 더 만들어 본다. 그것도 실패면 거부
    else if (!addWorker(command, false))
        reject(command);
}

세 단계를 말로 풀면 이래요.

  • 1단계: 현재 워커 수가 corePoolSize보다 적으면, 다른 스레드가 놀고 있더라도 새 스레드를 만들어서 이 작업을 첫 작업으로 맡겨요.
  • 2단계: 1단계를 못 했다면(이미 코어가 다 찼다면) 작업을 큐에 넣어 봐요. 넣은 뒤에는 상태를 다시 확인해서, 그 사이 풀이 멈췄으면 작업을 빼서 거부하고, 워커가 0개로 비어 있으면 처리할 스레드를 하나 만들어요.
  • 3단계: 큐에 넣는 것마저 실패했다면(큐가 가득 찼다면) maximumPoolSize까지 스레드를 더 만들어 봐요. 그것도 안 되면 거부 정책을 호출해요.

여기서 가장 많이들 오해하는 지점을 콕 집을게요. 스레드를 corePoolSize 너머로 늘리는 조건은 "작업이 많아서"가 아니라 "큐가 가득 차서"예요. 1단계와 3단계 사이에 2단계(큐에 넣기)가 끼어 있기 때문이에요. 코어가 다 찼는데 큐는 아직 자리가 있으면, 작업은 무조건 2단계에서 큐로 들어가고 3단계는 영영 실행되지 않아요. 즉 큐가 가득 차기 전까지 스레드는 절대 corePoolSize를 넘지 않아요.

flowchart TD
    A[execute task] --> B{workerCount < corePoolSize?}
    B -- yes --> C[addWorker as core thread]
    C -- success --> Z[done]
    C -- fail --> D
    B -- no --> D{queue.offer succeeds?}
    D -- yes --> E[task waits in queue]
    D -- no --> F{addWorker up to maximumPoolSize?}
    F -- success --> Z
    F -- fail --> G[RejectedExecutionHandler]

이 그림이 머릿속에 박히면, 뒤에 나오는 큐 선택과 거부 정책이 전부 자연스럽게 따라와요.

숫자로 따라가 보는 execute()

말로만 보면 헷갈리니, 구체적인 풀 하나를 정해 작업 열 개를 던져 볼게요. corePoolSize=2, maximumPoolSize=4, 큐는 용량 2짜리 ArrayBlockingQueue라고 해 봐요. 작업은 전부 오래 걸려서, 먼저 들어온 작업이 아직 실행 중인 상태에서 다음 작업이 도착한다고 가정할게요.

  • 작업 1: 워커 0개 < core 2 → 1단계. 새 코어 스레드 A 생성, A가 실행. (스레드 1, 큐 0)
  • 작업 2: 워커 1개 < core 2 → 1단계. 새 코어 스레드 B 생성, B가 실행. (스레드 2, 큐 0)
  • 작업 3: 워커 2개, core 미만 아님 → 2단계. 큐에 들어감. (스레드 2, 큐 1)
  • 작업 4: 2단계. 큐에 들어감. (스레드 2, 큐 2 — 큐 가득)
  • 작업 5: 2단계 offer 실패(큐 가득) → 3단계. 워커 2개 < max 4 → 비코어 스레드 C 생성, C가 실행. (스레드 3, 큐 2)
  • 작업 6: 2단계 offer 실패 → 3단계. 비코어 스레드 D 생성, D가 실행. (스레드 4, 큐 2)
  • 작업 7: 2단계 offer 실패 → 3단계. 워커 4개 = max 4 → addWorker 실패 → 거부. RejectedExecutionHandler 호출.

작업 3과 4가 작업 5보다 먼저 큐에 들어갔는데도, 실행은 작업 5가 먼저 시작돼요. 큐에 있는 작업은 워커가 한가해져야 꺼내 가니까요. 이 "큐의 작업보다 나중에 온 작업이 먼저 실행되는" 비직관적 순서가 바로 3단계 구조에서 나와요. 그리고 작업 7이 거부된 시점에 큐에는 여전히 작업 3, 4가 남아 있다는 점도 눈여겨볼 만해요. 큐에 자리가 없는 것과 풀이 한가한 것은 별개예요.

2단계의 double-check가 막는 사고

execute() 2단계를 다시 보면, 큐에 넣은 직후에 상태를 한 번 더 확인하는 코드가 있어요.

if (isRunning(c) && workQueue.offer(command)) {
    int recheck = ctl.get();
    if (! isRunning(recheck) && remove(command))
        reject(command);
    else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
}

왜 굳이 다시 확인할까요. offer()로 큐에 넣는 동작과, 다른 스레드가 shutdown()을 부르거나 마지막 워커가 죽는 동작이 동시에 일어날 수 있기 때문이에요. 소스 주석이 이 두 가지 위험을 직접 설명해요.

  • 큐에 넣은 사이 풀이 멈췄을 수 있어요. 그러면 remove(command)로 방금 넣은 작업을 도로 빼서 거부해야 해요. 안 그러면 멈춘 풀의 큐에 작업이 영원히 갇혀요.
  • 워커가 하나도 안 남았을 수 있어요. 큐에 작업은 있는데 꺼내 줄 스레드가 0개인 상황이죠. 이때 addWorker(null, false)로 작업 없이 워커만 하나 띄워서 큐를 소비하게 해요.

이 double-check가 있어서, corePoolSize=0인 풀도 멀쩡히 동작해요. 코어가 0이면 1단계는 항상 건너뛰고 작업이 바로 큐로 가는데, 그때 워커가 0이면 2단계 후반이 워커를 하나 만들어 주거든요. 작은 코드지만 풀의 정합성을 떠받치는 핵심이에요.

큐 선택이 정책을 결정한다

execute()의 2단계가 workQueue.offer(command)라는 건, 어떤 BlockingQueue를 넣느냐가 풀의 행동을 통째로 바꾼다는 뜻이에요. 공식 javadoc은 큐 전략을 세 가지로 정리해요.

Direct handoff — SynchronousQueue

SynchronousQueue는 용량이 0인 큐예요. offer()는 그 순간 받아 갈 스레드가 대기 중일 때만 성공하고, 아니면 즉시 실패해요. 그래서 2단계가 거의 항상 실패하고 곧장 3단계(스레드 추가)로 넘어가요. 작업이 오는 족족 스레드가 생기는 셈이라 응답성이 좋지만, 작업 도착 속도가 처리 속도를 넘어서면 스레드가 무한정 늘어나요. 그래서 이 전략은 maximumPoolSize를 사실상 무제한으로 둬야 거부가 안 나요.

Unbounded queue — LinkedBlockingQueue

용량을 안 정한 LinkedBlockingQueueoffer()가 절대 실패하지 않아요. 그 말은 2단계가 항상 성공하고 3단계는 결코 실행되지 않는다는 뜻이에요. 결과적으로 스레드는 corePoolSize를 넘지 못하고, maximumPoolSize는 아무 효과가 없어요. 순간적인 트래픽 폭주를 큐가 흡수해 주는 장점이 있지만, 처리보다 도착이 계속 빠르면 큐가 무한히 자라서 메모리를 다 먹어요.

Bounded queue — ArrayBlockingQueue

용량을 정한 ArrayBlockingQueue는 둘 사이의 절충이에요. 큐가 다 차야 비로소 3단계가 살아나 스레드가 corePoolSize에서 maximumPoolSize로 늘어요. 큐를 크게·풀을 작게 잡으면 CPU와 컨텍스트 스위칭은 아끼지만 처리량이 떨어지고, 큐를 작게·풀을 크게 잡으면 처리량은 오르지만 스케줄링 부담이 커져요. 자원 고갈을 막으면서 균형을 잡고 싶을 때 쓰는 전략이에요.

이 세 전략을 표준 팩토리 메서드에 대응시키면 Executors의 정체가 한눈에 보여요.

팩토리 core / max keepAlive
newFixedThreadPool(n) n / n 0 무제한 LinkedBlockingQueue
newSingleThreadExecutor() 1 / 1 0 무제한 LinkedBlockingQueue
newCachedThreadPool() 0 / Integer.MAX_VALUE 60s SynchronousQueue

newFixedThreadPool(10)이 11번째 작업에서 스레드를 안 늘리는 이유가 이제 명확해요. 큐가 무제한이라 2단계가 늘 성공하고, 3단계가 영원히 실행되지 않으니까요. 반대로 newCachedThreadPool()SynchronousQueue 덕에 작업마다 스레드가 생기지만, 그래서 폭주 시 스레드가 끝없이 늘어날 위험을 안고 있어요.

addWorker() — 워커가 실제로 태어나는 곳

execute()가 "스레드를 만들자"고 판단하면 실제 생성은 addWorker(Runnable firstTask, boolean core)가 맡아요. 이름과 달리 이 메서드는 단순히 스레드를 new 하지 않아요. 두 단계로 나뉘어요.

첫 단계는 워커 수 자리를 예약하는 CAS 재시도 루프예요. ctl을 읽어서 runState가 새 워커를 받아도 되는 상태인지 확인하고(RUNNING이거나, SHUTDOWN이면서 큐 정리용인 경우), 한도(corecorePoolSize, 아니면 maximumPoolSize)를 안 넘는지 보고, compareAndIncrementWorkerCount로 워커 수를 원자적으로 1 올려요. 이 CAS가 실패하면 다른 스레드가 동시에 워커를 만든 거라 처음부터 다시 읽고 재시도해요. 그래서 여러 스레드가 동시에 execute()를 불러도 워커 수가 한도를 넘지 않아요.

자리를 예약한 다음에야 진짜 Worker(와 그 안의 스레드)를 만들고, mainLock을 잡은 채 workers 집합(HashSet)에 등록해요. 이때 지금까지 본 최대 풀 크기를 largestPoolSize에 갱신하고, runState를 한 번 더 확인해요. 등록이 끝나면 mainLock을 풀고 워커 스레드를 start() 해요.

만약 스레드 시작이 실패하면 addWorkerFailed()가 롤백을 맡아요. workers에서 방금 넣은 워커를 빼고, 예약했던 워커 수를 도로 1 줄이고, tryTerminate()를 불러요. 워커 수를 먼저 예약하고 실패 시 정확히 되돌리는 이 패턴 덕분에, "워커 수 카운터는 늘었는데 실제 스레드는 없는" 어긋난 상태가 남지 않아요. ctl 하나에 워커 수를 같이 담은 설계가 여기서 빛을 발해요.

getTask() — 스레드는 어떻게 사라지는가

스레드가 어떻게 생기는지는 봤어요. 그럼 남는 스레드는 어떻게 정리될까요. 워커는 작업이 없을 때 getTask()에서 큐를 기다리는데, 바로 이 메서드가 "이 스레드를 살릴지 죽일지"를 판단해요.

private Runnable getTask() {
    boolean timedOut = false;
    for (;;) {
        int c = ctl.get();
        // SHUTDOWN 이상이고 (STOP이거나 큐가 비었으면) 워커 수 줄이고 종료
        if (runStateAtLeast(c, SHUTDOWN)
            && (runStateAtLeast(c, STOP) || workQueue.isEmpty())) {
            decrementWorkerCount();
            return null;
        }
        int wc = workerCountOf(c);
        // 코어 타임아웃을 허용했거나, 코어 초과 스레드라면 timed
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

        if ((wc > maximumPoolSize || (timed && timedOut))
            && (wc > 1 || workQueue.isEmpty())) {
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }
        try {
            Runnable r = timed ?
                workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                workQueue.take();
            if (r != null)
                return r;
            timedOut = true; // poll이 시간 안에 아무것도 못 받음
        } catch (InterruptedException retry) {
            timedOut = false;
        }
    }
}

핵심은 timed라는 한 줄이에요. 워커가 큐를 기다리는 방식이 두 가지라는 게 여기서 갈려요.

  • timedfalseworkQueue.take() — 작업이 올 때까지 무한정 기다려요. 코어 스레드의 기본 동작이에요.
  • timedtrueworkQueue.poll(keepAliveTime, ...)keepAliveTime만큼만 기다리고, 그 안에 작업이 안 오면 timedOut = true가 돼요.

timedallowCoreThreadTimeOut이 켜져 있거나 현재 워커 수가 corePoolSize를 초과할 때 true가 돼요. 즉 평소엔 코어 초과로 만들어진 스레드만 시간 제한을 받아요. 한 번 timedOut이 된 워커는 다음 루프에서 compareAndDecrementWorkerCount로 워커 수를 줄이고 null을 반환해요. 이 null이 워커에게 "이제 그만 죽어라"라는 신호예요.

allowCoreThreadTimeOut(true)를 부르면 코어 스레드까지 이 시간 제한의 대상이 돼요. 단 javadoc은 이때 keepAliveTime이 0보다 커야 한다고 못 박아요. 0이면 작업 사이에 스레드가 끝없이 죽고 살아나니까요. 반대로 타임아웃 종료를 사실상 끄고 싶으면 keepAliveTimeLong.MAX_VALUE 나노초로 두면 돼요.

종료 조건의 디테일 하나만 더 짚을게요. (wc > 1 || workQueue.isEmpty()) 조건 덕분에, 큐에 아직 작업이 남아 있으면 마지막 워커 한 명은 타임아웃이어도 죽지 않아요. 일감이 있는데 처리할 스레드가 0이 되는 사고를 막는 안전장치예요.

Worker — 인터럽트를 위한 비재진입 락

runWorker()를 보기 전에 Worker가 왜 그런 모양인지 짚고 갈게요. WorkerAbstractQueuedSynchronizer를 상속하고 Runnable을 구현해요. 자기 안에 작은 비재진입(non-reentrant) 뮤텍스를 직접 만들어 들고 다니는데, ReentrantLock을 안 쓰고 굳이 직접 만든 이유가 소스 주석에 적혀 있어요. 작업이 setCorePoolSize 같은 풀 제어 메서드를 호출했을 때, 그 락을 다시 잡지 못하게 하려는 거예요.

이 락의 상태가 곧 워커의 상태예요. 락이 잠겨 있으면(state = 1) 그 워커는 지금 작업을 실행 중이라는 뜻이고, 풀려 있으면(state ≥ 0) getTask()에서 한가하게 기다리는 중이라는 뜻이에요. 그래서 shutdown() 같은 메서드가 부르는 interruptIdleWorkers()는 각 워커에 tryLock()을 시도해서, 락을 잡는 데 성공한 워커(=한가한 워커)만 인터럽트해요. 작업을 한창 실행 중인 워커는 락이 안 잡혀서 인터럽트를 면해요. 종료 신호 때문에 멀쩡히 돌던 작업이 중간에 끊기는 사고를 이 한 줄짜리 트릭이 막아 줘요.

runWorker() — 워커 한 마리의 일생

워커 스레드가 시작되면 runWorker() 안에서 평생을 보내요. 구조는 "첫 작업을 처리하고, 그다음부터는 getTask()로 계속 받아 처리하는 무한 루프"예요.

final void runWorker(Worker w) {
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // 인터럽트 허용
    boolean completedAbruptly = true;
    try {
        while (task != null || (task = getTask()) != null) {
            w.lock();
            // STOP 이상이면 이 스레드를 인터럽트
            if ((runStateAtLeast(ctl.get(), STOP) ||
                 (Thread.interrupted() &&
                  runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
                beforeExecute(wt, task);
                try {
                    task.run();
                    afterExecute(task, null);
                } catch (Throwable ex) {
                    afterExecute(task, ex);
                    throw ex;
                }
            } finally {
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
        processWorkerExit(w, completedAbruptly);
    }
}

루프 조건 task != null || (task = getTask()) != null 이 한 줄이 워커의 운명을 쥐고 있어요. getTask()null을 돌려주는 순간 루프가 끝나고, processWorkerExit()가 워커를 정리해요. 앞 절에서 본 종료 신호가 여기서 실제 죽음으로 이어지는 거예요.

작업 하나를 실행할 때마다 beforeExecute(wt, task)afterExecute(task, ex)라는 두 훅이 끼어들어요. 이 메서드들을 오버라이드하면 작업 실행 직전·직후에 끼어들 수 있어요. ThreadLocal을 재초기화하거나, 통계를 쌓거나, afterExecute로 작업이 던진 예외를 한곳에서 잡아 로깅하는 식으로요. 풀이 완전히 종료되면 마지막으로 terminated() 훅이 한 번 불려서, 거기서 마무리 정리를 할 수 있어요.

completedAbruptly 플래그도 눈여겨볼 만해요. task.run()이 예외를 던지면 그 예외가 루프 밖으로 다시 던져지는데, 그러면 completedAbruptlytrue인 채로 finallyprocessWorkerExit()에 도달해요. 풀은 이 플래그를 보고 "비정상 종료된 워커"라고 판단해서, 필요하면 대체 워커를 새로 띄워요. 그래서 작업에서 던진 예외가 풀의 스레드 수를 조용히 갉아먹지는 않아요.

shutdown() vs shutdownNow() — 풀은 어떻게 멈추나

스레드가 어떻게 태어나고 사라지는지를 봤으니, 풀 전체가 멈추는 길도 짚어 볼게요. 앞에서 본 ctl의 runState 다섯 단계가 여기서 실제로 쓰여요.

shutdown()은 runState를 RUNNING에서 SHUTDOWN으로 옮겨요. 그 뒤로 새 작업은 거부되지만(execute()isRunning(c) 검사에서 걸려요), 이미 큐에 들어와 있던 작업은 끝까지 처리해요. 한가하게 대기 중인 워커들은 interruptIdleWorkers()로 깨워서, 큐를 다 비우면 자연스럽게 빠져나가게 해요.

shutdownNow()는 더 단호해요. runState를 STOP으로 옮기고, 실행 중인 작업까지 인터럽트로 끊으려 시도하고, 큐에 남아 아직 시작 못 한 작업들은 처리하지 않고 리스트로 꺼내 돌려줘요. 호출자는 그 리스트를 받아 어떤 작업이 못 돌았는지 알 수 있어요. 다만 인터럽트에 반응하지 않는 작업은 즉시 멈추지 않는다는 한계는 있어요.

두 경우 모두, 큐와 풀이 다 비면 runState가 SHUTDOWN(또는 STOP)에서 TIDYING으로 넘어가고, 거기서 terminated() 훅이 한 번 불린 뒤 TERMINATED가 돼요. 종료를 기다리려면 awaitTermination(timeout, unit)을 쓰면 되는데, 이건 종료 요청을 보낸 뒤 모든 작업이 끝나거나 타임아웃이 되거나 호출 스레드가 인터럽트될 때까지 블록돼요. 그래서 정석적인 종료 절차는 "shutdown() 호출 → awaitTermination()으로 잠시 기다림 → 그래도 안 끝나면 shutdownNow()"의 3박자예요.

여기까지 오면 글 앞에서 본 runState 표가 다르게 읽혀요. 그 다섯 상태는 단순한 enum이 아니라, 풀이 멈추는 과정을 한 방향으로만 흐르게 강제하는 상태 기계예요. 그리고 그 상태가 워커 수와 한 AtomicInteger에 같이 들어 있어서, "지금 SHUTDOWN인데 워커가 0"이라는 종료 판정을 단 한 번의 원자적 읽기로 내릴 수 있는 거예요.

거부 정책 4가지

execute()의 3단계마저 실패하면 reject(command)가 불리고, 여기서 RejectedExecutionHandler가 등판해요. 표준 구현이 네 개 있어요.

  • AbortPolicy — 기본값이에요. RejectedExecutionException을 던져서 호출자에게 거부 사실을 명확히 알려요.
  • CallerRunsPolicy — 거부된 작업을 execute()를 호출한 그 스레드가 직접 실행해요. 제출 측이 작업을 처리하느라 잠시 멈추니, 자연스럽게 제출 속도가 느려지는 피드백 제어 효과가 생겨요.
  • DiscardPolicy — 거부된 작업을 조용히 버려요. 작업 완료 여부가 정말 중요하지 않을 때만 안전해요.
  • DiscardOldestPolicy — 큐의 맨 앞(가장 오래된) 작업을 버리고 새 작업을 다시 넣어 봐요. 명시적 취소나 로깅 없이 쓰면 데이터가 소리 없이 사라질 수 있어 신중해야 해요.

실무에서 무한 큐 대신 ArrayBlockingQueue + CallerRunsPolicy 조합이 자주 권장되는 건, 큐가 가득 찼을 때 제출 스레드가 backpressure를 받아 시스템 전체가 무너지는 대신 천천히 밀리도록 만들기 때문이에요.

런타임에 풀 크기를 바꾸면

풀 크기는 생성 시점에만 정해지는 게 아니라 setCorePoolSize, setMaximumPoolSize로 런타임에 바꿀 수 있어요. 다만 바꾼 효과가 즉시 나타나지 않는다는 점을 알아 둬야 해요.

setCorePoolSize(int)더 작게 부르면, 줄어든 만큼의 초과 스레드는 곧바로 죽지 않고 "다음에 한가해질 때" 정리돼요. 내부적으로 interruptIdleWorkers()를 불러서, 마침 getTask()에서 대기 중인 워커들만 인터럽트해 빠져나가게 하는 식이에요. 반대로 더 크게 부르면, 큐에 작업이 쌓여 있을 경우 그만큼 새 코어 스레드를 시작해서 밀린 작업을 처리하게 해요. 큐가 비면 거기서 멈추고요.

setMaximumPoolSize(int)를 현재보다 작게 잡았을 때도 똑같이, 초과 워커는 다음에 한가해질 때 interruptIdleWorkers()를 통해 정리돼요. 어느 쪽이든 "지금 실행 중인 작업을 끊지는 않는다"는 원칙은 그대로예요. 앞에서 본 비재진입 락이 여기서도 한가한 워커만 골라 건드리도록 보장해 줘요.

미리 띄우는 쪽도 있어요. prestartCoreThread()는 워커 수가 corePoolSize보다 적을 때 코어 스레드를 하나 시작하고 true를, 이미 다 차 있으면 false를 돌려줘요. prestartAllCoreThreads()는 같은 일을 반복해서 실제로 시작한 스레드 수를 돌려줘요. 첫 요청의 콜드 스타트 지연이 중요한 서비스라면 부팅 시점에 한 번 불러 두면 좋아요.

실무에서 자주 밟는 함정

지금까지의 내부 동작을 알면, 흔한 사고 몇 가지가 왜 일어나는지 바로 설명돼요.

newFixedThreadPool의 OutOfMemoryError. 큐가 무제한 LinkedBlockingQueue라 처리보다 제출이 빠르면 큐가 끝없이 자라요. 스레드 수는 그대로인데 힙이 먼저 터져요. 제어가 필요하면 ThreadPoolExecutor를 직접 생성해 용량 있는 큐를 넘기는 게 정석이에요.

newCachedThreadPool의 스레드 폭발. SynchronousQueue라 작업마다 스레드가 생기고 maximumPoolSizeInteger.MAX_VALUE예요. 느린 외부 호출이 몰리면 스레드 수천 개가 동시에 떠서 시스템이 마비될 수 있어요.

"코어를 작게 잡았는데 왜 maxPool까지 안 늘지?" 큐가 무제한이거나 충분히 크면 2단계에서 다 흡수돼 3단계가 안 깨어나기 때문이에요. 스레드를 코어 너머로 늘리고 싶으면 큐를 유한하게 잡아야 해요. 이게 execute() 3단계 구조의 가장 실용적인 교훈이에요.

시작 지연. 기본적으로 코어 스레드는 작업이 처음 들어올 때 게으르게 생겨요. 첫 요청의 지연을 줄이고 싶으면 prestartCoreThread()로 코어 스레드 하나를, prestartAllCoreThreads()로 전부를 미리 띄워 둘 수 있어요.

모니터링. getQueue()로 큐를 들여다볼 수 있지만, javadoc은 이를 모니터링·디버깅 용도로만 쓰라고 강하게 권고해요. 취소된 작업이 큐에 쌓이는 게 신경 쓰이면 remove(Runnable)이나 purge()로 정리할 수 있어요.

직접 만들 때 권장하는 형태

지금까지의 내부 동작을 종합하면, 운영 환경에서 풀을 직접 만들 때의 기본형이 자연스럽게 나와요. Executors의 팩토리 대신 생성자를 직접 부르는 이유는 단 하나, 큐를 유한하게 잡기 위해서예요.

int core = Runtime.getRuntime().availableProcessors();
ThreadFactory namedFactory = new ThreadFactory() {
    private final AtomicInteger seq = new AtomicInteger();
    @Override public Thread newThread(Runnable r) {
        Thread t = new Thread(r, "worker-" + seq.incrementAndGet());
        t.setDaemon(false);
        return t;
    }
};

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    core,                                  // corePoolSize
    core * 2,                              // maximumPoolSize
    60L, TimeUnit.SECONDS,                 // 코어 초과 스레드 유휴 정리
    new ArrayBlockingQueue<>(1000),        // 유한 큐 — 3단계를 살린다
    namedFactory,
    new ThreadPoolExecutor.CallerRunsPolicy() // 거부 대신 backpressure
);

이 한 덩어리에 글 전체가 압축돼 있어요. 유한 큐라서 큐가 차면 3단계가 깨어나 maximumPoolSize까지 스레드를 늘리고, 그래도 넘치면 CallerRunsPolicy가 제출 스레드를 잠시 붙잡아 시스템이 무너지는 대신 천천히 밀리게 해요. 스레드에 이름을 붙여 두면 스레드 덤프에서 어떤 풀의 워커인지 한눈에 보이고요. keepAliveTime을 0보다 크게 줬으니 한산할 땐 getTask()의 timed 분기를 타고 초과 스레드가 알아서 정리돼요. 각 선택지가 왜 그런지는 앞 절들에서 본 그대로예요.

정리

ThreadPoolExecutor의 행동은 결국 execute()의 3단계 하나로 수렴해요. 코어가 비면 스레드를 만들고(1단계), 코어가 차면 큐에 넣고(2단계), 큐가 가득 차야 비로소 maximumPoolSize까지 스레드를 늘리고(3단계), 그것도 안 되면 거부해요. "스레드는 작업이 많아서가 아니라 큐가 가득 차서 늘어난다"는 한 문장만 기억해도, 큐 종류 선택과 거부 정책, 그리고 newFixedThreadPool이 OOM을 내는 이유까지 전부 같은 그림 위에서 설명돼요.

그 뒤를 ctl 하나로 묶인 상태값과 getTask()의 timed/untimed 분기가 떠받쳐요. 스레드가 언제 태어나고 언제 사라지는지를 알면, 풀 파라미터를 감이 아니라 근거로 고를 수 있게 돼요.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ