与run()的区别,从源码到实战一次讲透)
Java并发编程系列写到第3篇我想把两个几乎人人都会见到、但一半人说不清区别的方法单独拎出来聊一聊start()和run()。说实话我在不少团队做过代码评审看到过太多类似new Thread(() - {...}).run()的写法——写的人觉得自己开了个新线程实际上代码只是在当前线程里老老实实地跑了一遍。这个坑不光新手会踩很多写了三五年Java的人也会下意识犯。这篇我会把这个区别从源码、JVM层面再到一次线上事故的排查链路完整过一遍。不管你是刚接触线程、准备面试还是想看完整篇直接去改代码应该都能有点收获。1. 先照个X光一段多线程代码为什么全程只有main在跑很多讲start()和run()区别的文章上来就扔结论start()开启新线程run()不开启。结论没错但太干巴了。我想换个方式先给一段代码你判断一下这里到底起了几个线程。1.1 你觉得下面的代码启动了几个线程public class StartVsRunDemo { public static void main(String[] args) { System.out.println(入口线程 Thread.currentThread().getName()); new Thread(new Runnable() { Override public void run() { for (int i 0; i 5; i) { System.out.println(Thread.currentThread().getName() 执行第 i 步); try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } } }).run(); for (int i 0; i 5; i) { System.out.println(Thread.currentThread().getName() 执行 main 第 i 步); try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } } }你猜这里有几个线程在跑答案是只有一个main。new Thread(...)确实创建了一个Thread对象但后面跟的是.run()而不是.start()所以那个Runnable里的逻辑是在main线程里被当成普通方法直接调用的。1.2 线程名不会骗人一眼看出真实执行者如果把上面的.run()改成.start()输出会变成这样第一部分先打印 入口线程main然后你会看到 Thread-0 执行第 X 步 和 main 执行 main 第 X 步 交替出现。两个线程都在跑而真正的执行者就是Thread.currentThread().getName()打印出来的名字。我之前排查问题最常用的一招就是在代码里临时加一行线程名打印。如果你以为自己在多线程执行但打印出来的线程名始终是入口线程的名字那就说明所谓的异步根本没发生大概率就是run()被直接调用了。这里再提醒一个细节为了让start()版本的差异更明显我在两个循环里都加了sleep。因为start()之后线程只是进入可运行状态如果不给它们让出CPU的机会先被创建的线程很可能一口气跑完。所以你要是用start()跑发现某一次执行时一个线程连续打印完也别怀疑start()失效那是调度结果很正常。2. 翻Thread源码run()只是普通方法start()才是点火开关现象看得差不多了现在打开Thread.java看看这两个方法在源代码层面的设计差异。这一步能帮你把区别从死记硬背变成真正的理解。2.1 start()的第一道防线线程状态校验这是JDK 8里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();这段源码藏着三个很重要的信息。第一start()是synchronized的。这意味着同一个Thread对象被并发调用start()时两个调用方会争锁只有一个人能先进来。进来之后他发现threadStatus已经不是0了就会抛IllegalThreadStateException。所以不会出现同一个线程被启动两次然后各自跑run()的诡异情况。第二threadStatus是一个线程状态标记初始是0。第一次调用start()时它是0所以放行。JVM真正把原生线程创建起来之后这个值会被改成非0。后面谁再想start()这个对象就会在入口被拦下。这就是一个线程只能start()一次的底层逻辑。第三group.add(this)是把这个线程加入构造它时所属的ThreadGroup。线程组在实战中已经不太常用了但这段源码体现了线程从创建那一刻就归属一个组组里可以统一管理未捕获异常、统一执行一些操作。这个点虽然不是今天的重点面试提一嘴会很加分。2.2 native start0()JVM到底在背后干了什么start0()是native方法真正的实现在HotSpot虚拟机的C代码里。JVM在这个方法里做的主要事情可以概括成四步为线程分配独立的线程栈内存。栈大小服从平台默认值或者-Xss参数指定的值。调用操作系统提供的线程创建API生成一个原生线程比如Linux下的pthreadWindows下的Windows线程。把这个原生线程的入口设定为JVM内部的一个thread_entry函数这个入口最终会回调Java层的Thread.run()。新线程进入JVM的线程调度体系等待CPU时间片。我打个比方你可能更好理解run()就像你在工位上喊了一嗓子开始干活听起来在干活但干活的还是你自己start()是专门去行政部申请了一个新工位配好电脑、插好网线让另一个人坐在那里真正开始干活。两者最终都执行run()里面的逻辑但模型完全不一样。这也是为什么很多人说new Thread()很廉价、start()相对更重——因为start()背后真真切切地牵扯着操作系统资源分配。2.3 run()的两种走向重写与不重写再看Thread.java里默认的run()实现短得让人怀疑人生Override public void run() { if (target ! null) { target.run(); } }逻辑就是如果创建Thread时传了Runnable对象也就是target那run()就把这个target的run()调用一下。注意Thread类本身实现了Runnable接口所以run()是它的接口方法。你可以通过两种方式提供线程要执行的逻辑继承Thread并重写run()线程启动后会直接执行你重写的run()。给Thread构造器传入Runnable线程启动后执行默认的run()而默认run()会转手去执行target.run()。如果你既没传Runnable也没重写run()那线程start()之后会把空的run()执行完立刻结束。这解释了另一个常见疑问新建一个Thread但不设置任何任务线程也能启动并马上终止。顺带说一句面试里问到继承Thread和实现Runnable哪个更好本质上也可以从run()的语义出发回答。实现Runnable相当于把任务从线程里解耦出来同一个任务可以传给多个线程执行也可以放进线程池复用而继承Thread把任务和特定线程绑死一锤子买卖。这个差别后面还会再提到。3. NEW到RUNNABLEstart()是唯一合法通道聊完源码我们把视角抬到线程生命周期上。线程有六个状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多初学者把这六个状态背得滚瓜烂熟但一问从NEW到RUNNABLE靠哪个方法反而会愣住。3.1 为什么线程建出来是NEW状态new Thread(...)之后Java堆里确实多了一个Thread对象里面存了线程名、优先级、线程组这些元数据。但操作系统层面没有任何对应的线程实体。在JVM眼里这个对象还只是一个还没启动的线程计划这就是NEW状态。处于NEW状态时调用interrupt()、join()这类控制方法基本没有实际意义因为底层线程根本不存在。唯一有意义的就是调用start()把这个对象从计划变成现实。理解了这一点你就明白进程里线程的创建是有成本的不是new一个对象那么简单。new Thread()可以new一万个不心疼但如果真的start()一万个操作系统先扛不住。3.2 start()期间发生了什么从Java栈到操作系统线程调用start()的那一刻JVM需要完成前面说过的栈分配、原生线程创建、线程注册。在这个流程成功返回之后Java层面的线程状态就变成了RUNNABLE。但RUNNABLE不保证正在运行。RUNNABLE在Java线程模型里对应两种底层情况要么正在CPU上执行要么排队等着CPU分配时间片。所以你会发现主线程调用start()返回后新线程的run()可能已经开始执行也可能还在等待。这个不确定性是初学者学习并发时最需要接受的一点start()返回代表线程已经可以参与调度不代表run()已经执行完甚至不代表run()已经开始执行。3.3 为什么threadStatus拦住了第二次start()这一节回答一个面试高频问题一个线程能调用两次start()吗不能。第二次调用会抛IllegalThreadStateException。原因就在2.1节start()第一行就检查threadStatus ! 0。第一次start()之后JVM启动线程过程中已经把threadStatus改成了非0值所以第二次进来直接异常。有意思的是run()没有这个限制。因为run()就是一个普通方法没有任何状态校验你可以在同一个线程上调用它一百次每次都只是在当前线程里执行一遍任务不会引发任何启动动作。很多新手会混淆这两点以为线程不能复用等于run()不能调用多次。实际情况是一个Thread对象的生命周期里start()只能一次run()却能调用任意多次——但run()的多次调用不会改变没有新线程这件事。4. 一不留神就翻车的场景RUNNABLE上路的假并发现场源码和状态都捋清楚了再说说实战中那些让人头秃的误用场景。标题说90%人都用错这个数据多少有点夸张但下面的场景我确实在真实项目里都见过。4.1 lambda手滑踩坑最典型Java 8普及之后大家写线程代码基本都是这个画风new Thread(() - { sendNotify(order); }).run();问题就出在run()这里。这个写法编译不报错、测试环境流量小也不暴露问题等线上并发一上来所有异步通知全部变成串行执行接口一个接一个排队等I/ORT直线飙升。这种坑之所以难防是因为代码读起来语义上好像是对的。我参与过好几次线上问题排查最后定位到的原因就是这里多了.run()少了个s。所以我的习惯是凡是手写启动线程的代码写完先盯一眼结尾是不是start()。团队规范里也应该约定不允许裸写new Thread().start()统一走线程池。不是说裸线程一定不行而是裸线程配合手滑事故率真的高。4.2 继承Thread时的启动姿势另一种高发场景是继承Threadclass ExportThread extends Thread { Override public void run() { // 报表导出逻辑 } } // 错误用法 new ExportThread().run(); // 正确用法 new ExportThread().start();出现错误的根源在于子类重写run()之后run()方法变得人人可见IDE里点一下就能看到于是很多人把调用run()当成了执行线程任务。尤其当这个导出任务本来就是要同步跑的有些人还会故意用run()来测试一把改着改着就忘了代码就带着run()上线了。如果有这种临时同步执行的需求我建议不要直接在外面调用run()而是把任务逻辑单独抽成一个方法比如doExport()。需要异步时在线程里调需要同步测试时直接调doExport()别通过run()这个歧义入口绕。4.3 run()里抛异常start()和run()的处理天差地别这个坑很多人没意识到异常处理行为完全不一样。直接调用run()时任务里的异常就在当前线程执行可以被调用方的try-catch捕获。start()启动线程后run()里的异常不会冒泡回主线程而是进入新线程的未捕获异常处理器UncaughtExceptionHandler。如果没设置处理器JVM会在新线程的栈上打印堆栈然后终止这个线程主线程毫发无损。public static void main(String[] args) { try { Thread t new Thread(() - { throw new RuntimeException(boom); }); t.setUncaughtExceptionHandler((thread, throwable) - System.out.println(线程异常 throwable.getMessage())); t.start(); } catch (RuntimeException e) { System.out.println(主线程捕获 e.getMessage()); } }上面这段代码的输出是线程异常boom主线程那个catch什么都没接到。很多刚接触并发的人会想当然地在主线程try-catch线程里的异常这在start()模型下是无效的。这个知识点在排查线上任务静默失败时特别有用。5. 面试官连环问start/run延伸出的高频考点作为面试题常客start()和run()经常被拿来当开胃菜。但面试官很少只问一个有什么区别他们会沿着这个问题不断下探。下面这几个连环追问值得挨个过一遍。5.1 直接调用run()会有什么后果标准的回答框架可以这样组织不会创建新线程run()会在当前调用线程里同步执行。线程状态不会发生任何变化因为压根没有启动这个过程。所有任务在这段逻辑跑完之前后面的代码不会继续执行。异常可以被调用方直接try-catch捕获。我放一张对比表在这里复试的时候可以照着说对比项start()run()是否创建新线程是否是否可以多次调用否第二次抛异常可以任意多次线程状态变化NEW - RUNNABLE不变化执行位置新线程当前调用线程异常冒泡走未捕获异常处理器调用方可以try-catch5.2 一个线程能start()两次吗这个问题在上面已经讲透了直接抛能或者不能都不够最好把源码逻辑带出来start()是synchronized方法方法开头就会检查threadStatus。第一次启动后JVM会把threadStatus改非0第二次调用就在入口抛IllegalThreadStateException。如果你还能补一句synchronized解决的是并发调用的竞态状态检查解决的是重复启动的语义问题——这句话会让面试官觉得你真读过源码。5.3 线程池复用的是线程还是run()方法这条是很多面试者的盲区。线程池里的Worker线程是怎么实现复用的它不是反复调用某个THREAD对象的start()而是Worker本身是一个Thread它在第一次启动时调用一次start()。Worker的run()里是一个while循环循环不断从阻塞队列里取任务。取到任务后执行任务对象的run()方法。没有任务时Worker阻塞在take()上线程不退出。所以准确地说线程池复用的是一个存活线程的执行能力而不是复用了Thread对象的start()能力。每次提交任务相当于往这个线程的执行队列里扔了一个普通的RunnableWorker线程在主循环里逐个执行这些Runnable的run()。这也再次说明任务执行本身就是一个普通方法调用真正贵的是一个线程从创建到参与调度的过程。5.4 Thread的run()和Runnable的run()哪个被执行这个问题你得看代码怎么写如果Thread的子类重写了run()执行子类的run()。如果没重写Thread构造器里又传了Runnable target执行默认run()里的target.run()。如果既重写了run()又传了Runnable重写版本优先你在重写里不调super.run()的话传进去的Runnable永远不会被执行。标准的Thread对象没有target也没有重写run()那start()后相当于执行一个空方法线程立刻结束。最后一句话可以展开多一点。如果你用匿名内部类同时做两件事——重写run()并且给构造函数传Runnable——行为往往会让人意外传的Runnable根本没跑因为run()被重写了。这种写法看着奇怪但面试官喜欢拿出来当陷阱题。6. 一次真实事故复盘把run()改成start()后接口RT降了60%讲完理论最后分享一个实际项目里的排查过程。这段经历我一直觉得很有代表性因为它很好地说明了start()和run()区别这种基础知识点一旦出错能在线上造成多大的影响。6.1 事故现场异步通知变成了同步阻塞有一段时间公司内部订单系统的支付成功通知接口频繁超时。用户付完钱页面一直转圈十几秒都不返回。从业务逻辑看支付成功之后要做的事情确实不少调用ERP创建单据、调用WMS仓库系统下发发货指令、给用户推送消息这几个操作每个都要请求外部接口正常耗时加起来小十秒。原代码的结构大概长这样new Thread(() - { sendErp(orderId); sendWms(orderId); sendMessage(orderId); }).run();写这段代码的人初衷很明确把通知逻辑丢给另外的线程去做请求线程赶紧返回。但问题就出在.run()上。这段逻辑实际上是在Tomcat的请求处理线程里同步执行等于每次支付请求都要硬扛10秒的三连调用接口RT不炸才怪。6.2 jstack揪出真凶的完整链路我当时排查的路径是这样的先看应用监控下单接口的平均响应时间从几百毫秒涨到了12秒。接着在怀疑的通知外部系统环节加入耗时日志发现时间基本都耗在这里。最后打印线程名所有通知日志的线程名都是http-nio-8080-exec-X而不是新创建的线程名。到这一步基本就能锁定是run()误用了。如果你遇到类似问题还有一个更直接的工具jstack。jstack pid在Linux上先jps找到Java进程的PID再执行上面的命令就能看到JVM里所有线程的状态和调用栈。如果你以为某个任务跑在新线程里jstack里却只在容器请求线程的栈里看到任务方法那就说明并发根本没有发生。这个例子还有个很有意思的细节写代码的人真的new Thread()了所以在没看线程名之前包括他自己都以为我在多线程。实际上new Thread()只是创建了一个对象真正决定这件事在新线程里发生的还是start()。6.3 修复验证与复盘清单修复方案就是两行改动// 错误 new Thread(() - { sendErp(orderId); sendWms(orderId); sendMessage(orderId); }).run(); // 修复 new Thread(() - { sendErp(orderId); sendWms(orderId); sendMessage(orderId); }).start();改完之后接口RT肉眼可见地掉了下来用户侧响应从12秒回到几百毫秒后续再把裸线程换成固定大小的线程池限制并发通知数量最终平均RT稳定下降了60%以上。复盘的时候我给团队总结了几条检查清单现在也写在这里凡是代码里出现new Thread(...).run()一律视为疑似bug逐行确认。启动线程统一走线程池或框架封装好的异步API不裸写new Thread().start()。异步任务内部的异常必须设置UncaughtExceptionHandler或者在任务方法内部自行try-catch避免静默失败。排查接口RT问题先加线程名日志或者用jstack看执行线程别凭感觉猜。写到这里我自己的感受是Java并发编程里越基础的问题越容易在实战中翻车。start()和run()这种入门级知识点看起来简单到不值得写一篇长文但实际项目里坑人的往往就是这类以为会了的地方。如果你在代码审查时看到new Thread(...).run()这种写法建议直接标红如果自己写代码先把start()和run()的区别刻进肌肉记忆。下一篇我打算接着聊ThreadLocal在线程池里的线程隔离问题老规矩有问题评论区见。