Thread 생성과 start
Java 25 환경에서 `Thread` 상속 방식과 스택·힙 배치를 확인하고 `run` 직접 호출 오류를 교정합니다.
Java 프로그램은 main 스레드에서 시작하지만 필요하면 새 Thread 객체를 만들고 별도 호출 스택을 열 수 있습니다.
핵심은 객체 생성, 실행 등록, run() 수행이 서로 다른 사건이라는 점입니다.
개발 환경을 먼저 고정하고 이름이 보이는 짧은 프로그램으로 구분을 확인합니다.
run 직접 호출에 대한 오해
다음 코드는 Thread를 두 개 만들지만 start()가 아니라 run()을 직접 부릅니다.
실제 출력은 main, main입니다.
메서드 본문은 실행됐어도 새 실행 흐름은 한 개도 생기지 않았습니다.
public final class RunMethodCreatesNoThreadBug {
public static void main(String[] args) {
Thread first = new NamedWork("alpha");
Thread second = new NamedWork("beta");
first.run();
second.run();
System.out.println("wrong-result=both calls used " + Thread.currentThread().getName());
}
private static final class NamedWork extends Thread {
NamedWork(String name) {
super(name);
}
@Override
public void run() {
System.out.println(
"requested=" + getName() + ", actual=" + Thread.currentThread().getName());
}
}
}run()은 일반 인스턴스 메서드 호출입니다.
현재 main 스택에 프레임을 하나 더 쌓고 반환합니다.
start()는 JVM에 새 스레드 시작을 요청하고, 새 스택이 준비된 뒤 그 스레드가 run()을 호출하게 합니다.
따라서 동시 실행이 목적이면 호출 지점을 눈으로 확인해야 합니다.
재현 가능한 실행 환경
이 트랙의 예시는 Java 25에서 javac와 java로 확인합니다.
터미널에서 java --version, javac --version이 같은 JDK 계열인지 먼저 봅니다.
IDE 언어 수준과 빌드 tool 도구 체인도 25로 맞춥니다.
한글 언어팩이나 화면 배치는 코드 의미와 무관하므로 IDE 메뉴를 외우기보다 명령행 기준을 남기는 편이 재현에 유리합니다.
소스 파일 이름은 public class와 같아야 합니다.
기본 패키지 예제는 학습 폴더에서 바로 컴파일할 수 있지만 실제 프로젝트는 패키지 경로를 일치시킵니다.
이전 class 파일이 결과를 가리는 상황을 막으려면 깨끗한 산출물 디렉터리에 컴파일하고 실행 클래스 경로를 명시합니다.
public final class JavaEnvironmentProbe {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
System.out.println("feature=" + Runtime.version().feature());
System.out.println("vm=" + System.getProperty("java.vm.name"));
System.out.println("processors=" + runtime.availableProcessors());
System.out.println("main-thread=" + Thread.currentThread().getName());
}
}버전 출력은 컴파일 환경이 아니라 현재 실행 JVM을 보여 줍니다.
CI와 로컬 결과가 다르면 먼저 실행 경로의 java가 어느 설치를 가리키는지 확인합니다.
Thread를 상속해 작업 단위 구성
가장 직접적인 생성법은 Thread를 상속하고 run()을 override하는 것입니다.
다음 예제는 두 구간 합을 서로 다른 스택에서 계산하고 join 뒤 결합합니다.
public final class RangeSumThread extends Thread {
private final int startInclusive;
private final int endInclusive;
private long result;
public RangeSumThread(String name, int startInclusive, int endInclusive) {
super(name);
this.startInclusive = startInclusive;
this.endInclusive = endInclusive;
}
@Override
public void run() {
long sum = 0;
for (int n = startInclusive; n <= endInclusive; n++) {
sum += n;
}
result = sum;
System.out.println(getName() + " stack finished " + startInclusive + ".." + endInclusive);
}
public long result() {
return result;
}
public static void main(String[] args) throws InterruptedException {
RangeSumThread left = new RangeSumThread("left-sum", 1, 500);
RangeSumThread right = new RangeSumThread("right-sum", 501, 1000);
left.start();
right.start();
left.join();
right.join();
System.out.println("total=" + (left.result() + right.result()));
}
}두 객체의 startInclusive, endInclusive, result는 힙에 있습니다.
각 run()의 지역 변수 sum과 반복문 변수는 해당 스레드의 스택 프레임에 놓입니다.
main은 두 객체 참조를 공유하지만 join 전에 결과를 읽지 않습니다.
이 배치 차이가 공유 상태와 스레드-지역 상태를 가르는 출발점입니다.
상속 방식은 짧고 직관적이지만 클래스가 다른 부모를 상속할 수 없고 작업과 실행 정책이 결합됩니다.
뒤 문서에서 Runnable을 분리하는 이유를 비교합니다.
기존 Thread 하위 타입을 확장해야 하는 특별한 경우가 아니라면 보통 작업 객체 분리를 선호합니다.
Thread.start의 일회성
종료된 작업자를 재사용하려고 같은 객체에 start()를 다시 호출하면 IllegalThreadStateException이 발생합니다.
이 실패는 실행 여부가 아니라 Thread 인스턴스의 생명 주기 규칙입니다.
public final class StartTwiceFailure {
public static void main(String[] args) throws InterruptedException {
Thread oneShot = new Thread(() -> System.out.println("one run"), "one-shot");
oneShot.start();
oneShot.join();
System.out.println("state-before-restart=" + oneShot.getState());
oneShot.start();
}
}작업을 다시 수행하려면 새 Thread 객체를 만들거나 실행기에 같은 Runnable을 다시 제출합니다.
TERMINATED에서 NEW로 돌아가는 전이는 없습니다.
예외를 catch해 억지로 재사용하려 해도 작업은 실행되지 않습니다.
객체와 호출 스택을 추적하는 방법
Thread 인스턴스와 실제 current 스레드를 혼동하면 로그도 잘못 해석합니다.
this.getName()은 객체 이름이고 Thread.currentThread().getName()은 지금 코드를 실행하는 흐름의 이름입니다.
정상적인 start() 경로에서는 둘이 같지만 앞의 run 직접 호출에서는 다릅니다.
각 스레드는 독립 Java 스택을 가져 같은 메서드를 동시에 호출해도 지역 변수는 겹치지 않습니다.
반면 인스턴스 필드, static 필드, 배열과 컬렉션 원소는 힙에 있어 참조를 전달하면 공유됩니다.
지역 변수 안의 참조 값은 스택에 있어도 그 참조가 가리키는 객체는 힙이라는 점을 함께 기억합니다.
스택 size는 무한하지 않습니다.
스레드를 너무 많이 만들면 네이티브 메모리와 스케줄러 비용이 커지고, 깊은 recursion은 각 스레드에서 StackOverflowError를 낼 수 있습니다.
플랫폼 스레드 개수를 입력 데이터 개수와 일대일로 연결하지 않습니다.
명시적 이름으로 작업 실행을 래핑
이름은 동시 로그와 스레드 덤프에서 문제를 찾는 가장 싼 관찰 수단입니다. 업무 범주와 번호를 포함하고 사용자 개인정보는 넣지 않습니다. uncaught 예외 처리 정책도 스레드 생성 지점에서 함께 정할 수 있습니다.
import java.util.List;
public final class NamedImportWorkers {
public static void main(String[] args) throws InterruptedException {
List<String> files = List.of("java.md", "thread.md", "memory.md");
Thread[] workers = new Thread[files.size()];
for (int i = 0; i < workers.length; i++) {
String file = files.get(i);
workers[i] = new Thread(() -> importFile(file), "import-" + (i + 1));
workers[i].setUncaughtExceptionHandler(
(thread, error) ->
System.err.println(
thread.getName() + ":" + error.getClass().getSimpleName()));
workers[i].start();
}
for (Thread worker : workers) {
worker.join();
}
System.out.println("imported=" + workers.length);
}
private static void importFile(String file) {
System.out.println(Thread.currentThread().getName() + " -> " + file);
}
}출력 순서는 달라도 각 파일이 한 번씩 등장하고 마지막 imported는 항상 뒤에 옵니다.
제품 코드에서는 Thread 배열 관리보다 실행기의 생명 주기 API를 쓰는 편이 낫지만, 이 예제는 객체 생성과 start 구분을 드러내기 위해 낮은 수준 API를 사용합니다.
연습 문제
생성자에서 작업자 이름과 메시지를 받고 run()에서 실제 current 스레드 이름과 메시지를 출력하는 하위 타입을 작성하세요.
main은 두 인스턴스를 start하고 join한 다음 종료 문장을 출력해야 합니다.
정답과 실행 확인
생성자에서 super(name)을 먼저 호출해 Thread 객체 이름을 정합니다.
run 안에서는 current 스레드를 조회해 실제 실행 흐름도 확인합니다.
public final class MessageThreadSolution {
private static final class MessageThread extends Thread {
private final String message;
MessageThread(String name, String message) {
super(name);
this.message = message;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " says " + message);
}
}
public static void main(String[] args) throws InterruptedException {
Thread first = new MessageThread("message-a", "memory");
Thread second = new MessageThread("message-b", "stack");
first.start();
second.start();
first.join();
second.join();
System.out.println("message workers terminated");
}
}앞의 두 줄 순서는 보장하지 않지만 둘 다 main이 아닌 이름을 출력합니다.
마지막 문장은 join 뒤라서 두 작업이 끝난 다음에만 보입니다.
Thread 생성 학습의 판별 기준은 명확합니다.
new는 NEW 상태 객체를 만들고, start()가 새 스택을 가진 실행 흐름을 등록하며, JVM이 그 흐름에서 run()을 호출합니다.
같은 객체를 다시 시작할 수 없고 공유 힙 변경에는 별도의 동시성 규칙이 필요합니다.