做Java面试辅导这些年,如果让我选一个出现频率最高而且最容易把候选人水平拉开差距的基础题,我会投给这道:start()和run()哪个才是正确的线程启动方式?这道题看似简单,实际上覆盖了线程生命周期、JVM底层机制、操作系统线程模型、并发编程的基本素养,甚至还能延伸出异常处理、线程安全等一系列问题。很多Java面试者背了八股文,知道选start(),但被追问一句“为什么直接调用run()不行”就卡住了,这恰恰说明对多线程的理解停留在表面。
这篇博文不打算只给结论,我会把Thread类的源码、JVM的native调用、线程状态流转以及实际编码中容易踩的坑全部串起来讲清楚。无论你是刚开始学Java的新人,还是准备跳槽刷面试题的开发者,这篇文章都能帮你把这道题彻底吃透,属于那种“看一遍就能在面试现场直接复述”的硬核干货。
1. 为什么这道题成了Java面试的“标配”
1.1 表面问方法,实际考三件事
先统一说一下结论:正确答案是start()。真正需要让你理解的是,start()的作用是启动一个新线程,而run()只是新线程启动后要执行的代码体。如果直接调用run(),它不会创建新线程,只是普通方法调用,依旧运行在当前调用线程中。
那面试官为什么不直接问“线程怎么启动”,非要绕一下问start()和run()的区别?因为这个问题背后有三个考察维度:
第一,你是否理解线程的生命周期。一个线程从new出来到真正运行,中间要经过NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED这些状态。start()的调用是状态从NEW变成RUNNABLE的关键动作,如果这层逻辑不清楚,后面谈线程池、锁、并发协作都会飘。
第二,你是否有阅读源码的习惯。start()内部调用了一个native方法start0(),这个方法是JVM层面的线程创建入口。JDK源码里为什么把run()的调用放在start0()之后?这一步设计是有讲究的,不读源码的人答不出来。
第三,你是否踩过并发编程的常见坑。开发中真正因为分不清start()和run()而导致的bug并不少见,尤其是刚接触多线程的人,把run()直接写在main方法里,看起来代码没报错,实际上整个程序还是单线程在跑,问题却非常隐蔽。
理解这三点,你就明白为什么面试官喜欢拿这道题“试水”了:它不是考记忆,而是考你有没有真正地“用”过线程。
1.2 背答案容易,听懂追问才见功力
你随便去搜Java面试大全、Java八股文,都能找到类似说法:“start()方法启动线程,run()方法只是普通方法调用”。但很多人在面试时被继续追问就露馅了。常见的追问包括:
- Thread类里run()的实现是什么?为什么我们通常要重写run()?
- start()为什么不能被调用两次?底层是怎么阻止的?
- 线程启动后是立即执行run()吗?会不会有延迟?
- 如果run()方法里抛异常,线程会怎样?
- 为什么Java要设计成“启动线程走start(),业务代码写run()”?
这些问题单靠背一句八股并不能回答。回到面试场景里,能流畅答完这些追问的人,即使没写过多线程相关的业务,也会让人觉得“基础很扎实”。这篇文章后面的内容就是围绕这些追问展开的,我尽量讲人话,配合代码演示,保证你能真正“懂”而不是“背”。
2. 先搞懂线程的创建方式与Thread的核心API
2.1 Java创建线程的几条路
Java里创建线程表面上有很多种方式,但本质上都绕不开Thread类。常见写法有三种:
第一种,继承Thread类并重写run()方法:
public class MyThread extends Thread { @Override public void run() { System.out.println("线程运行中:" + Thread.currentThread().getName()); } } // 使用 MyThread t = new MyThread(); t.start();第二种,实现Runnable接口并传给Thread对象:
public class MyTask implements Runnable { @Override public void run() { System.out.println("任务执行中:" + Thread.currentThread().getName()); } } // 使用 Thread t = new Thread(new MyTask()); t.start();第三种,使用Callable和FutureTask,能拿到线程执行结果,底层同样依赖Thread:
Callable<String> callable = () -> "执行结果"; FutureTask<String> futureTask = new FutureTask<>(callable); Thread t = new Thread(futureTask); t.start(); String result = futureTask.get();这三种方式里,继承Thread的方式最直观,但因为Java是单继承,扩展性差,实际项目中更推荐实现Runnable或Callable。这不是本文重心,但你需要知道一点:不管用哪种方式,start()都是那个不可绕过的入口。
顺带一提,很多人在刚开始学Java时会把重点放在“实现Runnable和继承Thread哪个好”上,这当然也重要,但对理解线程启动来说,真正的关键不在于你如何定义任务,而在于你如何把这个任务交给一个独立的执行单元。
2.2 Thread对象只是一个“线程说明书”
“线程”这个词其实有两层含义。一层是操作系统层面的真实线程,由内核创建和管理,有独立的执行栈和执行状态;另一层是Java层面的Thread对象,它更像一个“说明书”或“操控把手”。
当你new Thread()的时候,Java只是在堆内存里创建了一个对象,操作系统并不知道这件事,也没有创建任何真实线程。这个时候的线程状态是NEW。只有调用了start(),JVM才会通过底层native方法向操作系统申请创建真实线程。
run()方法则相当于写在说明书里的“任务清单”。当操作系统把真实线程创建好,并且调度到CPU之后,JVM会自动去调用这个run()方法。所以run()里面的代码才是真正在线程中执行的业务逻辑,而start()只是触发这一切的开关。
我见过不少新人把new Thread()当成线程已经开始运行了,这是非常典型的误解。看下面这段代码:
Thread t = new Thread(() -> System.out.println("hello")); System.out.println("线程创建完毕");如果不调用start(),这段代码只会输出“线程创建完毕”,“hello”永远不会打印。原因很简单,你只是造了一个Thread对象,真实线程根本还没出生。
3. start()和run()的本质区别与JVM视角
3.1 start()源码追踪:不是“启动线程”这么简单
想要彻底弄清start(),我建议你直接看JDK源码,我用常见的OpenJDK版本来说明。Thread类里的start()代码如下:
public synchronized void start() { if (threadStatus != 0) throw new IllegalThreadStateException(); group.add(this); boolean started = false; try { start0(); started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) { // 不处理 } } } private native void start0();这段代码信息量很大,我给你拆开讲。
第一,方法上加了synchronized。这意味着start()是线程安全的,同一个线程的start()不会被多个线程并发执行进入两次。这种细节很多人没注意到,但它解释了为什么一个线程只能被启动一次的部分原因。
第二,threadStatus != 0会抛出IllegalThreadStateException。threadStatus字段维护了线程当前状态。初始状态为0,也就是NEW。调用start()成功后,JVM会更新threadStatus。如果再次调用start(),就会检测到状态已经不是0,直接抛异常。你只要自己试一下,对一个已经start()的线程再次调用start(),运行时一定会报IllegalThreadStateException。
第三,start0()是native方法。它才是真正向底层操作系统申请线程的函数。JVM在加载Thread类时会注册native方法,进入start0()之后,JVM会尝试创建一个新的Java线程,同时分配虚拟机栈和程序计数器等运行时数据区。
这里有一个非常关键的字节码层面的规则:调用start()的线程并不会去执行run()。最终执行run()的,是新创建出来的那个线程。这一点是“谁是执行者”这个问题的分水岭。
3.2 run()的本质:一个再普通不过的实例方法
接着看run()方法的源码。Thread类的run()方法实现大概是这样的:
@Override public void run() { if (target != null) { target.run(); } }如果创建Thread时传入了Runnable接口的target对象,run()会回调target的run();如果没有传target,而创建的是Thread的子类,那么run()被子类重写,执行子类的run()逻辑;如果既没传target,也没有子类重写run(),那这个方法体就是空的,什么也不做。
看到这里你应该明白了,run()本质上就是一个普通的Java方法。它不认识什么线程调度,甚至不知道自己是“被线程执行”还是“被主线程直接调用”。你把它当作普通的对象方法,写在哪都能调用,没有任何魔法。
而start()则完全不同,它是一个带native方法调用、带状态校验、带线程组管理逻辑的“启动器”。两者在JVM层面的地位天差地别。之所以面试题要拿这两个方法做对比,就是因为它们在名字上迷惑性太强——一个叫“运行”,一个叫“开始”,不熟悉的人很容易觉得run()才是让线程跑起来的方法。
3.3 线程状态与流程:一张流程解释清楚
我可以把从创建到执行的过程用文字流程展现出来,方便你记忆。
- 执行Thread t = new Thread(() -> task),Java堆里多了一个Thread对象,此时线程状态为NEW,操作系统一无所知。
- 执行t.start(),JVM检测threadStatus状态合法,调用native start0(),请求操作系统创建真实线程。
- 操作系统分配内核线程资源,并把线程放入就绪队列。
- 当线程获得CPU时间片后,JVM回调run()方法。
- run()方法内容执行结束,线程进入TERMINATED状态,生命周期走完。
从这个流程中能看到两个关键点。第一,start()的返回值是void,它只负责“发起创建”,不负责“等线程跑完”。也就是说,执行完t.start()之后,主线程不会停下来等新线程执行,而是继续向下执行。如果你要在主线程里等子线程执行完,得额外调join()之类的方法。
第二,run()并不保证会被立即调用。线程什么时候真正获得CPU时间片,由操作系统调度器决定。这也是面试时经常会问到的“start()之后run()一定会执行吗”这个问题的答案——不会,它只能保证线程进入可运行状态,不能保证立即执行。
4. 实战演示:这样写代码,问题马上就表露出来
4.1 直接在main里调用run()会发生什么
我先给你一个最典型的错误示范,很多初学者都会犯:
public class ThreadDemo { public static void main(String[] args) { Thread t = new Thread(() -> { System.out.println("当前线程:" + Thread.currentThread().getName()); }); t.run(); System.out.println("主线程:" + Thread.currentThread().getName()); } }你运行一下就会发现,输出结果是:
当前线程:main 主线程:main看到了吗?当我们直接调用t.run()时,run()里的代码并没有在一个名为“Thread-0”的新线程里执行,而是在main线程里执行了。因为t.run()只是一个普通方法调用,执行这个方法的是当前正在运行的main线程,而不是新线程。
也就是说,如果你在代码里把start()写成了run(),程序不会报错,但并发效果直接消失了。原本想让多个任务并行处理,结果还是排队顺序执行。在一个并发场景中,这种bug相当隐蔽,因为代码看起来没什么问题,但性能就是上不去。
4.2 同一个线程调用两次start()会怎样
我前面谈到start()的源码时提到了IllegalThreadStateException,这里用代码验证一下:
public class ThreadStartTwiceDemo { public static void main(String[] args) { Thread t = new Thread(() -> { System.out.println("执行线程:" + Thread.currentThread().getName()); }); t.start(); t.start(); // 这里会抛异常 } }运行时报错如下:
Exception in thread "main" java.lang.IllegalThreadStateException at java.lang.Thread.start(Thread.java:708)这个异常的含义就是当前线程状态已经不是NEW,无法再次启动。线程生命周期是不可逆的,一个线程一旦启动过,就不能回头再启动,哪怕这个线程已经执行结束也不行。这也是Java线程模型的一个重要约束。
如果面试官追问“为什么不能重复start”,你可以从两个层面回答:一是设计层面,线程是一次性执行单元,第二次启动没有意义;二是JDK源码层面,threadStatus状态校验阻止了非法启动,同时synchronized保证并发场景下也不会被重复启动。
4.3 加上延迟输出,观察线程交错和时序
正确调用start()之后,新线程和主线程会并发执行,你很难预测哪个输出先出现。下面的代码可以直观感受这一点:
public class ThreadScheduleDemo { public static void main(String[] args) { Thread t = new Thread(() -> { for (int i = 0; i < 5; i++) { System.out.println("子线程输出:" + i); } }); t.start(); for (int i = 0; i < 5; i++) { System.out.println("主线程输出:" + i); } } }多运行几次,你会发现“主线程输出”和“子线程输出”的交错顺序每次都可能不同。这是因为start()只负责把线程加入调度队列,具体谁先执行完全由操作系统调度器决定。线程调度不是确定的,所以依赖执行顺序来写业务逻辑是并发编程中的大忌。
这一点在面试时非常加分:你能主动说出“start()之后run()的执行时机不确定,因此多线程代码不应该依赖执行顺序”,面试官会觉得你真的处理过并发问题。
4.4 一个更容易忽视的坑:this逃逸与未启动线程的引用传递
还有一种比较隐蔽的问题,面试官偶尔会拿“对new出来的Thread对象直接调用start()和先把它传到另一个线程再调用”来考察你对线程安全的理解。比如:
public class BadPublishDemo { public static void main(String[] args) throws InterruptedException { Thread t = new Thread(() -> System.out.println("new thread")); // 错误的做法:在其他线程中启动这个线程 new Thread(() -> t.start()).start(); new Thread(() -> t.start()).start(); } }这段代码里,两个新线程同时去调用t.start(),虽然Thread类内部是synchronized的,但其中一个会成功,另一个会等到锁释放后再次尝试,最终抛出IllegalThreadStateException。这类问题提醒我们,Thread对象要避免被多个执行流共享启动,否则会带来难以排查的异常。
5. 面试官追问的进阶问题,全在这里
5.1 为什么Thread类要单独设计run()让子类重写
面试中常见追问是:“如果start()才是启动线程的方法,那为什么我们要重写run(),不重写start()?”
答案其实很清晰:start()里面涉及了JVM native调用和线程生命周期管理,这套流程是所有线程都必须经历的标准动作,子类如果重写它,非常容易破坏线程启动机制。而run()只是线程启动后要执行的业务代码,把它留成可重写的方法,让每个使用者定义“线程做什么”,而不是“线程怎么启动”。这是模板方法模式在JDK源码中的一种体现。
这也是为什么更推荐实现Runnable接口而不是继承Thread类的原因之一——Task(任务)和Thread(执行单元)解耦之后,我们关注的是如何去执行任务,而不是如何重写线程本身。
5.2 线程池里的execute和submit为什么不能直接传run()
线程池实际上是帮你管理Thread创建和生命周期的一套框架。当你调用executor.execute(task)时,线程池内部已经有存活的Worker线程在循环获取任务并执行task.run()。
这个设计和直接调用run()有本质区别:线程池里的run()确实在Worker线程中执行,不是执行execute()的那个主线程。所以线程池的使用不能推导出“run()也能开线程”的结论,因为线程池早就把线程开好了。
如果面试官把话题引到线程池,你可以顺手答一下execute()和submit()的区别:submit()可以接收Callable并返回Future,而execute()只能接收Runnable,没有返回值。这个扩展能体现你知识面不局限于Thread类。
5.3 run()方法内抛出异常,线程会怎么处理
这个问题考的是try-catch的作用范围。当一个线程的run()内部抛出未捕获异常时,该线程会终止,其他线程不受影响。默认情况下,异常会交给线程的UncaughtExceptionHandler处理,控制台通常能看到堆栈打印,但程序不一定会崩溃退出。
从异常处理的角度看,主线程try-catch是捕获不到子线程异常的。因为子线程的异常发生在它自己的执行栈中,不属于主线程调用栈。想要处理子线程异常,可以给Thread设置UncaughtExceptionHandler,或者使用FutureTask配合get()去捕获ExecutionException。这个知识点在有异步任务的项目中极其实用。
5.4 为什么说“一条线程只能start一次”是保护机制
从设计角度理解:线程执行完run()后就会进入TERMINATED状态,这个状态没有“复活”通道。一个已经结束的线程,再次启动没有意义,而且会增加状态管理的复杂性。JDK通过IllegalThreadStateException把这个限制直接暴露给调用者,比静默失败要好得多。
这一点对我们写代码也有启发,线程不是函数,不能多次调用后复用。需要周期性执行任务时,应该考虑使用线程池、ScheduledExecutorService或者循环创建新线程,而不是尝试复用同一个Thread实例。
6. 面试官一眼看穿的代码风格与自查清单
6.1 这样的代码会被扣分:把线程类写得像普通类
我模拟一个反面例子,你在面试写代码时如果写出类似结构,很容易被追问:
public class WrongStyle extends Thread { public void startThread() { // 这里其实是调用了本类的run(),并不会启动新线程 run(); } @Override public void run() { System.out.println("do something"); } }调用startThread()方式执行时,程序不会产生并发效果,一切都在当前线程串行执行。面试官如果看出你在“变相直接调用run()”,可能会直接判定基础不够扎实。
另一个扣分点是,很多人在执行线程任务后,立即就期望共享变量已经被修改完毕,没有做任何同步或join,导致读到旧值还不明白为什么。这个问题不是start()本身造成的,但它说明你对线程调度的不确定性理解不深。
6.2 建议记住的面试引导话术
把下面的关键词植入到你的回答中,能极大提升专业度:
- NEW状态:new Thread()之后线程还没启动。
- start0():native方法,向JVM请求创建真实线程。
- 线程调度:start()之后线程进入可运行状态,不保证立即执行。
- 模板方法:run()留给使用者定义任务,start()框架固定不可破坏。
- 普通调用:直接调用run()只是方法调用,执行者仍是调用线程。
- 生命周期:一条线程只能start一次,不能复用或重启。
把这些话串起来,就能形成一段流畅的面试回答。你不需要死记硬背,核心思想只有一句:start()管线程的诞生与调度,run()管线程执行的任务内容。
6.3 真实项目中该怎么做
开发中直接new Thread的情况越来越少,基本都走线程池,但理解start()与run()依然是必要的底层功底。因为线程池的本质是复用若干个“已启动”的线程,你提交进去的Runnable任务最终会在线程池的线程里执行run()。如果你不懂线程生命周期,就很难理解线程池为什么能复用线程。
真要手动启动线程时,请牢记这几点:
- 一定用start()启动线程,不要手工调用run()。
- 启动后不要保留Thread对象的长生命周期引用,避免无法回收。
- 尽量使用Executors或ThreadPoolExecutor来管理线程。
- 业务代码里不要依赖多个线程的执行顺序。
- 子线程异常要单独处理,主线程无法直接捕获。
我见过一个真实案例:有个项目要并发处理一批文件,代码写成了thread.run(),结果一长串文件还是按顺序处理,处理耗时几乎翻倍。后来排查半天发现,问题仅仅出在把start写成了run。这段经历告诉我,越是基础的东西,越容易在忙乱中出错,面试时丢掉这种分,真的很可惜。
7. 常见问题与排查技巧实录
7.1 典型问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 调用start()后没有任何输出 | 没重写run(),或者传给Thread的Runnable为null | 检查run()是否被正确重写或target是否赋值 |
| 输出全部在main线程中 | 直接把run()当普通方法调用 | 改为start()调用 |
| 再次调用start()抛IllegalThreadStateException | 线程已启动过 | 不要重复start,使用新线程实例或线程池 |
| 多线程运行但变量修改后读不到 | 未做内存可见性处理 | 考虑volatile、synchronized或并发工具类 |
| 子线程异常导致程序异常退出 | 缺少UncaughtExceptionHandler | 给线程设置异常处理器或用FutureTask捕获 |
| 线程反复创建销毁性能差 | 大量短生命周期任务 | 改用线程池 |
这张表覆盖了日常使用中最常见的几个坑,你可以保存下来,工作中遇到类似问题直接对照。
7.2 一次线上问题复盘:因为run()串行导致批处理超时
我当时接手过一个数据同步服务,使用方反馈处理一批数据的时间突然翻倍。看日志发现虽然打印了每条数据的处理记录,但线程名始终保持同一个,根本没有并发效果。最终定位到代码里写的不是thread.start(),而是thread.run()。因为run()执行时同样会打印日志,完全不会报错,所以这种问题极难通过错误日志发现。
这提醒我,排查并发类问题时,第一个动作就是确认业务代码到底是在哪个线程中执行的。你可以用Thread.currentThread().getName()打印线程名,或者借助jstack等工具查看线程栈。如果预期中的多个线程没有出现,那十有八九是启动方式用错了。
7.3 如何把这道题的答案背得不像背的
如果你希望面试时表达得更自然,不要直接背小结,可以按下面这个思路展开:先抛出结论“用start()启动线程,run()只是任务入口”,然后提到“因为start()内部有native调用会创建真实线程,线程状态从NEW变成RUNNABLE;而直接调用run()只是普通方法调用,执行它的还是调用线程”。
接着补一句“一个线程只能start一次,多次会抛异常”,最后主动提“所以我们一般用线程池提交任务,线程池内部会复用线程去执行run()”。这个逻辑闭环会让面试官觉得你不是在背题,而是在讲述一个你真正理解运行的机制。如果追问到底层,再把JVM状态、native方法、程序计数器这些概念抛出来,就足够让多数面试官满意了。
写到这里,我发现这道“基础题”其实能牵出Java并发编程的半个地图。很多人觉得八股文没用,但如果真能把一个概念刨根问底到源码级别,它带来的收益远超死记硬背几十个题目。面试前与其焦虑刷题,不如抽时间把像start()和run()这种高频知识点彻底打通,性价比真的很高。