深蓝黑背景封面。左上角标注「面试经典 · Java 后端」,右上角圆角胶囊写「Day 03 · 11-30」。左侧是两组执行路径对照图:第一组标题「场景一:main 线程直接调用 t.run()」,一条灰色主线代表 main 线程,「run() 方法体」方块就压在这条线上,下方标注「同步执行:run() 跑完才返回,全程没有新线程 —— 打印 Thread[main]」;第二组标题「场景二:main 线程调用 t.start()」,main 主线继续水平向前并标注「main 线程不等 run() 跑完,继续往下走」,中途分叉出一条青色支线代表 JVM 新建的 Thread-0,支线上的方块标注「run() 方法体」,支线标注「JVM 新建线程执行 run() —— 打印 Thread[Thread-0]」。左下注释条写「附加陷阱:start() 只能调用一次,重复调用抛 IllegalThreadStateException;run() 想调几次调几次,但永远只是普通方法」。右侧大标题「t.run() 和 t.start() 有什么区别」,副标题「一个是普通方法调用,一个才是真正的开线程」,小注「run() 定义做什么,start() 决定在哪个线程做」。 图 1:本文封面,两种调用方式的执行路径对照。

先说明属性:这是一篇笔试真题复盘,题目出自本地历史笔试资料整理,与任何公司的当前招聘无关。这道题以选择题形式出现:给一段 main 方法里 t.run() 再打印字符串的代码,问输出是什么。它考的是 Java 并发里最基础也最容易想当然的一个语义陷阱。

原题还原:输出为什么是 DPMT。

原题大意如下(匿名内部类继承 Thread,重写 run 打印 DP):

public static void main(String args[]) {
    Thread t = new Thread() {
        public void run() {
            print();
        }
    };
    t.run();
    System.out.print("MT");
}
static void print() {
    System.out.print("DP");
}

选项里有 DPMT 和 MTDP 两种顺序。正确答案是 DPMT:t.run() 就是一次普通的方法调用,在 main 线程里同步执行,DP 打印完才轮到 MT。MTDP 只有在换成 t.start() 时才可能出现——而且只是「可能」,因为两个线程的调度顺序不确定。笔者用 JDK 17 实际跑了一遍,输出确定为 DPMT。

run() 只是普通方法:定义「做什么」。

Java 官方文档(Thread 类 Javadoc)对 run() 的定义平淡无奇:如果线程构造时传入了 Runnable 对象,就调用它的 run 方法,否则什么都不做。它就是一个普通的实例方法,没有任何线程语义。直接 t.run(),方法体压在当前调用线程的栈上同步执行,跑完才返回,全程没有新线程诞生。把它当成「启动线程的另一种写法」,就是这题要筛掉的错误直觉。

start() 才是开线程:决定「在哪个线程做」。

Javadoc 对 start() 的描述则是:使这个线程开始执行,由 Java 虚拟机调用该线程的 run 方法,结果是两个线程并发运行——当前线程从 start 调用返回继续向下走,新线程执行 run。完整链路是:调用 start() → JVM 创建线程 → 状态从 NEW 转为 RUNNABLE → 等待 CPU 调度 → 回调 run() 方法体。直接调 run() 则跳过中间全部步骤。

写个对照实验把语义钉死(JDK 17,javac RunVsStart.java && java RunVsStart):让 run 方法体打印当前线程名,分别用两种方式调用:

Thread t1 = new Thread(() ->
    System.out.println("t.run() 内执行线程: " + Thread.currentThread().getName()));
t1.run();
Thread t2 = new Thread(() ->
    System.out.println("t.start() 后 run() 执行线程: " + Thread.currentThread().getName()));
t2.start();
t2.join();
try { t2.start(); }
catch (IllegalThreadStateException e) {
    System.out.println("重复 start() 抛出: " + e.getClass().getSimpleName());
}
t1.run(); // run() 可反复调

实测输出:

t.run() 内执行线程: main
t.start() 后 run() 执行线程: Thread-1
重复 start() 抛出: IllegalThreadStateException
t.run() 内执行线程: main

t.run() 打印 main——方法跑在调用者线程里;t.start() 打印 Thread-1——JVM 新建的线程在执行(编号按 Thread 对象的创建顺序分配,t1 是 Thread-0,所以 t2 是 Thread-1)。输出差异就是执行线程的差异。

两个附加陷阱,追问必考。

其一,start() 是一次性的。Javadoc 写得很直白:一个线程启动超过一次永远不合法,执行完毕后也不能重启,重复调用抛 IllegalThreadStateException——上面的实测第三行就是证据。其二,run() 想调几次调几次,但它永远是调用者线程里的同步普通方法,调一百次也不会产生任何并发。想复用逻辑又要并发,正确姿势是重写 run 或传入 Runnable,然后调 start()。

深蓝黑背景概念图。顶部小字「概念图 · Thread 启动语义」,主标题「start() 造线程,run() 只是方法体」。左上是对照实验代码卡:同一个线程对象 t,打印当前线程名,t.run() 打印 main,t.start() 打印 Thread-0,右侧标注「输出差异就是执行线程的差异」。右上是对照结论卡:run() 是同步调用,占用调用者线程;start() 是异步启动,JVM 新建线程后才回调 run()。中部是 start() 内部链路流程:调用 start() → JVM 创建线程 → 状态 NEW 转 RUNNABLE → 等待 CPU 调度 → 回调 run() 方法体,下方补一行「直接调 run() 跳过中间全部四步:就在当前线程栈里同步执行」。下部是三张易错点卡:「start() 一次性」重复调用抛 IllegalThreadStateException;「run() 可反复调」但永远是调用者线程里的同步方法;「想并发只能 start()」,逻辑复用靠重写 run() 或传入 Runnable。底部总结条写「run() 定义『做什么』,start() 决定『在哪个线程做』—— 想并发,调 start()」。 图 2:对照实验、start() 内部链路与两个易错点。

答题一句话收尾。

run() 定义「做什么」,start() 决定「在哪个线程做」——想并发,调 start()。笔试遇到代码输出题,先问自己 run 方法体跑在哪个线程上,顺序题就迎刃而解。你还见过哪些「看着像开线程其实不是」的写法?欢迎补充。