개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › Java

스레드 풀

스레드 풀은 무엇이고, 스레드를 매번 만드는 것과 무엇이 다른가

일을 스레드에게 시키는 방법은 두 가지입니다. 하나는 일이 생길 때마다 new Thread(() -> work()).start() 줄로 새 스레드를 만드는 것이고, 다른 하나는 스레드 풀에 일을 맡기는 것입니다. 스레드 풀은 코드에서 Executors.newFixedThreadPool(2) 줄로 스레드 수를 정해 만드는 ExecutorService 객체이고, pool.submit(() -> work()) 줄로 일을 맡기면 풀이 가진 스레드가 그 일을 가져가 실행합니다. 풀의 스레드가 모두 일하는 중이면 남은 일은 줄을 서서 기다리고, 일을 마친 스레드는 끝나지 않고 줄의 다음 일을 이어서 합니다. 마지막의 pool.shutdown() 줄은 더 이상 새 일을 받지 않고 맡긴 일을 끝까지 한 뒤 풀을 닫습니다. 이 실습은 맡기는 방법을 바꾸고 풀의 스레드 수를 바꿔 가며 같은 코드를 컴파일하고 20번씩 실행해, work() 메서드를 실행한 스레드의 이름이 몇 가지 나오는지와 같은 이름이 되풀이되는지를 봅니다. (어느 스레드가 어느 일을 하는지는 실행마다 달라질 수 있어서, 이름의 개수와 목록으로만 비교합니다.)

실행으로 대조 · JDK 21에서 여섯 가지 조합을 javax.tools로 컴파일하고 20번씩 실행해 대조

이 실습에 나오는 개념

스레드 풀
스레드를 필요할 때마다 만들고 없애는 대신 일정 수의 스레드를 유지하며 재사용하는 구조다. 스레드 생성 비용을 줄이고, 동시에 실행되는 스레드 수를 제한해 과도한 메모리 사용과 컨텍스트 스위칭을 막는다. 처리할 작업은 큐에 쌓이고 빈 스레드가 가져가 실행한다. 톰캣의 요청 처리, DB 커넥션 풀도 같은 발상이다.
ExecutorService와 newFixedThreadPool
ExecutorService는 맡긴 일(Runnable이나 Callable)을 스레드에서 실행해 주는 java.util.concurrent의 인터페이스이다. Executors.newFixedThreadPool(2)처럼 쓰면 정해진 수의 스레드를 재사용하고, 공유하는 무제한 줄(큐)에서 일을 가져가 실행하는 스레드 풀이 만들어진다. 문서에 따르면 어느 때든 일을 처리 중인 스레드는 정한 수 이하이고, 모든 스레드가 일하는 중에 일이 더 맡겨지면 그 일은 줄에서 스레드가 빌 때까지 기다린다. 이 풀의 스레드는 shutdown할 때까지 남아 있고, 풀이 만든 스레드의 이름은 pool-N-thread-M 꼴이다(N은 풀 번호, M은 그 풀이 만든 스레드의 번호). JDK 21에서 실행해 보면 스레드는 풀을 만들 때가 아니라 일을 맡길 때 정한 수까지 하나씩 만들어진다.
submit()
submit()은 ExecutorService에 일 하나를 맡기는 메서드이다. pool.submit(() -> work())처럼 Runnable을 넘기면 일이 풀에 맡겨지고, 그 일을 나타내는 Future를 돌려준다. 일을 실행하는 것은 submit을 부른 스레드가 아니라 풀의 스레드이다. 풀의 스레드가 모두 일하는 중이면 맡긴 일은 줄에서 기다렸다가, 스레드가 비면 줄 앞에서부터 꺼내 실행한다.
shutdown()
shutdown()은 ExecutorService에게 이미 맡긴 일은 끝까지 실행하되 새 일은 받지 않도록 알리는 메서드이다. 맡긴 일이 끝나기를 기다리지 않고 바로 돌아오며, 기다리려면 awaitTermination을 쓰라고 문서에 적혀 있다. 쓰지 않는 ExecutorService는 자원을 돌려받을 수 있도록 shutdown하라고 문서가 말한다. 풀이 만드는 스레드는 데몬이 아닌 스레드이고, JDK 21에서 shutdown 줄을 지우고 실행하면 맡긴 일을 모두 마치고도 프로그램이 끝나지 않았다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. java Main으로 실행합니다. main 스레드가 main 메서드를 위에서부터 실행합니다. 이 프로그램이 해야 할 일은 work() 호출 6번이고, work() 메서드는 자기를 실행한 스레드의 이름을 출력하고 0.05초 쉽니다. 이 실습은 6개의 일을 스레드 풀에 맡기고, 풀의 스레드가 일을 하게 합니다. 지금은 main 스레드뿐이고, 일은 하나도 시작되지 않았습니다.
  2. ExecutorService pool = Executors.newFixedThreadPool(2); 줄: 스레드 풀을 만듭니다. 스레드 풀은 스레드를 정해진 수까지만 만들어 돌려 쓰며 일을 받는 장치이고, 이 풀에 정한 수는 2개입니다. 풀을 만든 이 순간에는 스레드가 아직 하나도 만들어지지 않았습니다. 풀의 스레드는 일을 맡길 때 하나씩 만들어집니다.
  3. for의 첫 바퀴(i가 1)입니다. pool.submit(() -> work()); 줄: submit 메서드는 일을 풀에 맡기는 것이고, 괄호 안의 () -> work() 부분이 맡기는 일입니다. 풀에는 아직 스레드가 없으므로 풀이 스레드를 하나 새로 만들어 1번 일을 시킵니다. 풀이 만든 스레드에는 풀 번호와 스레드 번호가 들어간 pool-1-thread-1 같은 이름이 붙습니다. 그 스레드가 work() 메서드를 실행해 일한 스레드: pool-1-thread-1 라고 출력합니다. main 스레드는 일이 끝나기를 기다리지 않고 다음 줄로 갑니다.
  4. for의 둘째 바퀴(i가 2)입니다. 같은 줄 pool.submit(() -> work());이 다시 실행됩니다. 스레드가 1개뿐이고 풀에 정한 수(2개)에 아직 못 미치므로, 풀이 두 번째 스레드 pool-1-thread-2를 만들어 2번 일을 시킵니다. 이제 일하는 스레드는 2개입니다.
  5. for의 3번째 바퀴(i가 3)입니다. 같은 줄이 실행되지만 이번에는 스레드를 만들지 않습니다. 풀에 정한 수(2개)만큼 스레드가 이미 있고 모두 일하는 중이기 때문입니다. 풀은 3번 일을 줄에 세웁니다. 줄은 맡긴 일이 스레드가 비기를 기다리는 곳이고, 먼저 선 일부터 꺼냅니다.
  6. for의 나머지 바퀴(i가 4~6)에서도 같은 줄이 실행됩니다. 스레드가 모두 일하는 중이어서 4번 일부터 6번 일까지도 줄에 차례로 섭니다. 줄에서 기다리는 일은 4개가 됩니다.
  7. for가 끝났습니다. 6개의 일을 모두 맡겼습니다. 지금 2개의 일은 스레드가 하고 있고, 4개의 일은 줄에서 기다립니다. 만들어진 스레드는 2개이고, 일이 더 늘어도 풀은 이 수를 넘겨 스레드를 만들지 않습니다.
  8. pool.shutdown(); 줄: main 스레드가 풀에 "새 일은 더 받지 않고, 이미 맡긴 일은 끝까지 하라"고 알립니다. shutdown 메서드는 맡긴 일이 끝나기를 기다리지 않고 바로 돌아오므로, main 메서드는 여기서 끝납니다. 프로그램은 풀의 스레드가 남은 일을 마칠 때까지 계속됩니다. (이 줄을 지우고 실행해 보면, 일을 모두 하고도 풀의 스레드가 남아 프로그램이 끝나지 않았습니다.)
  9. 스레드가 일을 끝내는 대로 줄 맨 앞의 일을 이어서 가져가 실행합니다. 이 그림에서는 1번 일과 2번 일이 끝나고 3번 일과 4번 일이 시작되는 모습으로 그렸습니다. 새 스레드는 만들어지지 않고, 이미 있는 pool-1-thread-1 스레드와 pool-1-thread-2 스레드 중 하나가 일을 가져갑니다. 어느 스레드가 어느 일을 가져가는지는 실행마다 다를 수 있어서 그림에 정하지 않았습니다. 같은 스레드가 두 번째 일을 하므로 출력에 같은 이름이 다시 나옵니다.
  10. 남은 5번 일부터 6번 일까지도 같은 방식으로, 스레드가 일을 끝내는 대로 줄에서 가져가 실행합니다. 여기서도 새 스레드는 만들어지지 않습니다.
  11. work() 6번이 모두 끝났습니다. 줄이 비었고 shutdown 메서드가 불려 있으므로 풀의 스레드도 끝납니다. 일한 스레드는 2개(pool-1-thread-1, pool-1-thread-2)뿐이고, 일 6개 중 4개는 이미 일한 적이 있는 스레드가 다시 했습니다. 새로 만들어진 스레드는 모두 2개입니다.

실행 결과

코드

import java.util.concurrent.*;

public class Main {
    // 일: 이 줄을 실행한 스레드의 이름을 출력하고 0.05초 쉰다
    static void work() {
        String who = Thread.currentThread().getName();
        System.out.println("일한 스레드: " + who);
        try { Thread.sleep(50); } catch (InterruptedException e) { }
    }

    public static void main(String[] args) {
        // 스레드 풀: 정해진 수의 스레드를 만들어 돌려 쓴다
        ExecutorService pool = Executors.newFixedThreadPool(2);
        for (int i = 1; i <= 6; i++) {
            // 맡기기: 일을 풀에 넘기면 풀의 스레드가 가져가 실행한다
            pool.submit(() -> work());
        }
        // 정리: 맡긴 일은 끝까지 하고, 그다음 풀의 스레드를 끝낸다
        pool.shutdown();
    }
}

이 모형의 전제

컴파일러와 실행 환경은 JDK 21.0.12.1(javac 21)입니다. 여섯 가지 조합을 모두, 화면의 코드와 같은 소스(줄 단위로 같은 글)를 javax.tools로 컴파일하고, 이 JDK의 java를 자식 프로세스로 띄워 조합마다 20번씩 실제로 실행해 대조했습니다. 120번의 실행 모두 종료 코드는 0이고 표준 오류는 비어 있었습니다. 대조한 것은 출력된 "일한 스레드" 줄의 수(끝난 일), 그 줄에 나온 스레드 이름의 서로 다른 것의 수와 정렬한 목록(20번 모두 같았습니다), 그리고 자식 프로세스의 시작 클래스가 Main.main을 부르기 전과 뒤에 ThreadMXBean의 getTotalStartedThreadCount() 메서드로 읽어 잰 새로 시작된 스레드의 수(스레드 풀 2개, 1개, 6개일 때 각각 2, 1, 6이고 new Thread일 때 6)입니다. 어느 스레드가 3번 일부터 6번 일을 하는지는 실행마다 달라서 비교하지 않았고, 화면의 그림은 1번 일과 2번 일이 끝난 뒤 3번 일과 4번 일이 시작되는 식의 한 가지 모습으로 그렸습니다. 단계 문장이 기대는 사실은 따로 만든 소스를 20번씩 실행해 확인했습니다. 풀을 만든 직후에는 시작된 스레드가 0개이고 일을 맡길 때마다 풀의 스레드 수까지 하나씩 만들어지는 것, 일 6개를 맡긴 직후 줄에서 기다리는 일이 스레드 2개일 때 4개, 1개일 때 5개, 6개일 때 0개인 것, 1번 일과 2번 일은 pool-1-thread-1과 pool-1-thread-2가 맡고 풀 스레드가 6개이면 번호 k인 일을 pool-1-thread-k가 맡고 new Thread이면 번호 k인 일을 Thread-(k-1)이 맡는 것, 일이 시작되는 차례가 (스레드 수)개씩 묶음으로 번호 순서를 따르는 것, 동시에 일한 일의 최대 수가 스레드 2개일 때 2, 1개일 때 1, 6개일 때 6, new Thread일 때 6인 것, shutdown 뒤의 "main 끝" 줄이 일의 출력보다 먼저 찍혀 shutdown이 일이 끝나기를 기다리지 않고 돌아오는 것, shutdown 줄을 지우면 일 6개를 다 하고도 5초 안에 프로그램이 끝나지 않아 강제로 끝내야 했던 것입니다. 일이 끝난 뒤 풀의 스레드가 0개가 되는 것도 확인했습니다. 문서는 Java SE 21 API 문서의 Executors(newFixedThreadPool, defaultThreadFactory)와 ExecutorService(shutdown, submit, 클래스 설명), Java Tutorials의 "Thread Pools"를 직접 열어 확인했습니다. 어느 때든 일하는 스레드는 정한 수 이하이고 남는 일은 줄에서 기다린다는 것, 풀의 스레드는 shutdown할 때까지 남는다는 것, 풀이 만드는 스레드의 이름이 pool-N-thread-M 꼴이라는 것, shutdown이 일이 끝나기를 기다리지 않는다는 것은 문서에 있는 내용입니다. 스레드가 풀을 만들 때가 아니라 일을 맡길 때 하나씩 만들어진다는 것은 문서가 아니라 이 JDK에서 관찰한 것이고(Tutorials에는 고정 풀이 항상 정해진 수의 스레드가 실행 중이라고 적혀 있어 표현이 다릅니다), 줄에서 먼저 선 일부터 꺼낸다는 것도 문서가 아니라 관찰입니다. 같은 이유로 일이 6개를 넘어도 풀이 6개를 넘겨 스레드를 만들지 않는다는 것은 문서의 설명이고 7개 이상의 일로 실행해 보지는 않았습니다. 스레드를 만들고 시작하는 비용의 크기는 재지 않았습니다. 일을 마친 스레드가 끝난다는 그림은 코드의 구조에서 오는 것입니다. 이 밖에 풀의 스레드 수와 줄의 크기를 따로 정하는 ThreadPoolExecutor, 일이 끝나기를 기다리는 awaitTermination, 결과를 돌려받는 Future와 Callable, 일이 예외로 끝난 경우는 다루지 않았습니다. 화면의 코드에서 import 줄은 스레드 풀을 쓸 때만 보입니다. 스레드 풀에서 줄을 서는 일의 처리와 풀 크기에 따른 변화는 "세부 동작 실습: 스레드 풀"에서, 같은 생각으로 데이터베이스 연결을 돌려 쓰는 것은 "커넥션 풀과 OSIV"에서 다룹니다(이 실습에서는 커넥션 풀을 실행하지 않았습니다).

앞 실습: 락(synchronized) · 다음 실습: 컴파일과 JVM

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요