
去年帮同事排查一个线上小问题记忆挺深刻一个定时任务到点后接口特别慢前端一直转圈。查了半天发现他在方法里直接写了一个耗时的 for 循环去调用第三方接口整个请求被拖住三四秒。我提醒了一句“你开个线程跑啊”他倒是改了改成new MyTask().run()点完按钮照样卡死。问题不在于动不动用线程而在于根本没搞清楚start()和run()的区别。这篇就围绕 Java 线程最基础、最容易混淆的四件事展开线程怎么创建、怎么启动、怎么终止以及让线程“睡一会儿”的sleep到底有哪些讲究。内容适合三类人刚学完 Java 基础想补并发知识的新手、正在准备 Java 面试的求职者以及每天写业务代码但很少亲手控制线程的开发者。我会用最朴素的方式讲——先说明原理再给完整可跑的代码最后把我在实际项目中踩过的坑一并说出来。1. 线程是什么先把这个模型刻在脑子里1.1 从进程聊到线程一个车间模型一个 Java 程序启动之后操作系统会为它创建进程进程是操作系统分配资源的基本单位。线程是进程内的执行路径多个线程共享进程的内存空间同时拥有各自私有的栈和程序计数器。拿车间打比方特别直观进程是一个大车间线程是车间里的工人。材料堆、生产图纸这些是所有工人共享的但每个工人有自己的工具箱工具箱里的东西别人动不了。多线程就是让多个工人同时干不同的活理想情况下能提高整个车间的产出但麻烦也出在“共享材料”上——两个工人同时想用同一把锤子就会有竞争。理解了共享和私有这个概念后面再看线程安全、锁、sleep这些知识点就有一条主线了。1.2 线程不是越多越好创建线程是要成本的。JVM 默认的线程栈大小通常在 1MB 左右用的是虚拟内存虽然不会立刻划满物理内存但频繁创建和销毁线程的开销仍然不小。更麻烦的是线程数量一旦上去操作系统线程切换的消耗就会变得很明显程序反而不快了。你写一个循环开一万个线程去处理一万个数大概率比单线程还慢。这也是我后面在系列文章里会重点讲ThreadPoolExecutor的原因——生产环境里我们几乎不会手动new Thread去跑任务而是把任务交给线程池统一管理。这篇先把手工创建线程的根底打好后面讲池化才不悬空。1.3 Java 线程对象和操作系统的线程不是一回事有个很容易被忽略的点new Thread()只是创建了一个 Java 对象此刻操作系统里并没有出现对应的原生线程线程状态还是NEW。只有调用了start()JVM 才会通过底层方法去创建真正的操作系统线程并让它执行run()方法里的代码。所以很多人以为new完线程就马上在一个新线程里跑了这是个误解。判断一个线程是否真正“活起来”了标准是调没调start()而不是有没有new Thread()。可以跑一下public class ThreadStateDemo { public static void main(String[] args) { Thread t new Thread(() - System.out.println(子线程执行中)); System.out.println(刚创建完状态 t.getState()); t.start(); System.out.println(start 之后状态 t.getState()); } }首次输出是NEWstart()之后大概率是RUNNABLE如果时机恰好也有可能已经执行完变成TERMINATED。2. 线程创建的三种姿势以及我推荐的组合2.1 方式一继承 Thread 类重写 run()最原始也最好理解的方式写一个类继承Thread重写run()方法把业务代码放进去。class PrintThread extends Thread { Override public void run() { System.out.println(子线程名称 Thread.currentThread().getName()); } } public class CreateThreadDemo { public static void main(String[] args) { PrintThread t new PrintThread(); t.start(); } }这里有个细节要注意Thread.currentThread().getName()拿到的是当前正在执行这行代码的线程对象名称因为Thread自己实现了Runnable接口run()被系统线程调用时当前线程自然就是子线程本身。这种方式的优点是实现直观但缺点也很明显Java 中一个类只能继承一个父类你继承了Thread就不能再继承其他业务基类了。而且线程类里往往是业务代码这等于把“任务”和“任务载体”耦合在了一起。2.2 方式二实现 Runnable 接口把任务交给线程我平时最推荐这种写法实现Runnable接口再把实例传给Threadclass PrintTask implements Runnable { Override public void run() { System.out.println(子线程名称 Thread.currentThread().getName()); } } public class CreateThreadDemo2 { public static void main(String[] args) { Thread t new Thread(new PrintTask(), print-task); t.start(); } }这样“任务”和“执行者”就分开了。PrintTask只关心业务逻辑不关心它跑在什么线程上。以后想把任务交给线程池执行直接executor.submit(new PrintTask())就行不需要再继承任何类。Java 8 以后Runnable是函数式接口可以用 Lambda 简写Thread t new Thread(() - { System.out.println(子线程名称 Thread.currentThread().getName()); }); t.start();再进一步如果你想给线程起个有意义的名字方便排查可以直接传第二个参数Thread t new Thread(task, order-sync-worker);千万不要小看线程命名线上排查时堆栈里出现order-sync-worker和出现Thread-12体验天差地别。2.3 方式三Callable FutureTask带返回值的创建Runnable有个局限run()方法不能返回值也不能抛出受检异常。如果你需要线程执行完拿到一个结果就要用Callableimport java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public class CreateThreadDemo3 { public static void main(String[] args) throws Exception { CallableInteger task () - { Thread.sleep(500); return 1 1; }; FutureTaskInteger futureTask new FutureTask(task); Thread t new Thread(futureTask, calc-thread); t.start(); Integer result futureTask.get(); System.out.println(计算结果 result); } }FutureTask实现了RunnableFuture接口本质上也是一个Runnable所以可以传给Thread。执行完成之后调用futureTask.get()会阻塞当前线程直到拿到返回值。要注意get()抛出的异常并不是业务方法里抛的那个原始异常而是被包装成的ExecutionException。如果你在Callable里抛了业务异常需要解包ExecutionException的cause才能看到原异常这是新手很容易踩的坑。2.4 三种方式怎么选面试和工程实践的答案我把三者的差异整理成一张表方便你对照对比维度继承 Thread实现 RunnableCallable FutureTask是否继承/实现接口继承 Thread实现 Runnable实现 Callable扩展性占用继承位不佳良好可继续继承其他类良好可继续继承其他类返回值无无有通过 FutureTask 获取异常处理只能内部捕获只能内部捕获可抛出受检异常由上层统一处理配合线程池不方便方便最方便可拿到完整结果工程实践中我的选择很简单需要返回值用Callable不需要返回值用Runnable尽量不要继承Thread。线程是任务的载体不应该被具体业务代码污染。实际生产代码我连new Thread都很少用而是交给线程池但这是系列后面的话题了这里先把基础姿势搞清楚。3. 线程启动的门道start() 和 run() 是两码事3.1 直接调用 run()问题出在哪里回到开头提到的同事事故。他写的是new MyTask().run()并不是不行而是这个run()是在当前线程里执行根本没有创建新线程。验证代码非常直白public class RunMethodDemo { public static void main(String[] args) { Thread t new Thread(() - { System.out.println(当前线程 Thread.currentThread().getName()); }); System.out.println(--- 直接调用 run() ---); t.run(); System.out.println(--- 调用 start() ---); t.start(); } }输出大致是--- 直接调用 run() --- 当前线程main --- 调用 start() --- 当前线程Thread-0看到没直接调用run()时代码是在main线程里执行的这就是为什么同事开完线程界面照样卡死。start()才是启动新线程的唯一正确方式。3.2 start() 到底做了什么start()这个方法做了三件核心的事将线程状态从NEW变为RUNNABLE通过 JVM 底层方法创建真正的操作系统线程让操作系统调度这个新线程去执行run()方法。这里要强调一个约束每个线程只能start()一次。第二次调用start()会抛出IllegalThreadStateException。有些同学在实现重试逻辑时想让同一个线程再跑一次会写thread.start()两次这是不对的。线程跑完就结束了想再执行任务请新建线程对象而不是多次调用start()。3.3 线程状态流转面试爱问排错也有用Java 的线程状态定义在Thread.State枚举里一共六种状态含义NEW线程刚创建还没 startRUNNABLE可运行状态可能正在执行也可能在等待 CPU 时间片BLOCKED等待监视器锁进不了同步块/同步方法WAITING无限期等待如 wait()、join()TIMED_WAITING有等待时间的等待如 sleep(1000)、wait(1000)、join(1000)TERMINATED线程执行结束一个常见的排查场景怀疑线程卡住了你在 dump 文件里看到TIMED_WAITING第一反应应该是“有线程正在 sleep 或执行带超时的等待”。如果是BLOCKED那基本就是在抢锁很可能有死锁或者锁竞争激烈。看状态还可以通过getState()方法我经常在线程里临时打日志来观察状态切换public class StateTraceDemo { public static void main(String[] args) throws Exception { Thread t new Thread(() - { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); t.start(); System.out.println(sleep 之前的状态 t.getState()); Thread.sleep(200); System.out.println(sleep 中的状态 t.getState()); Thread.sleep(1500); System.out.println(执行结束后的状态 t.getState()); } }输出会看到RUNNABLE、TIMED_WAITING、TERMINATED的切换过程。有了这些基础后面讲线程池、锁、死锁会顺畅很多。4. 优雅地终止线程放弃 stop()学会协作式中断4.1 stop() 为什么被抛弃初学多线程的人看到Thread.stop()这个名字会觉得特别顺手直接调一下线程就停了多省事。但这方法早被标记为废弃强烈不建议使用。问题在于stop()是强行终止线程它不管线程此刻在干什么直接把线程的栈展开抛出一个ThreadDeath异常。这一步会导致非常危险的结果线程正在写一个共享数据结构写到一半被停掉数据处于“写了一半”的状态持有的锁被强制释放其他线程读到不完整的数据finally块不一定执行资源来不及释放。我习惯用转账场景举例扣了转出账户的钱还没来得及增加接收账户的余额线程被stop()了账就对不上了。这种破坏性操作生产环境必须彻底避开。4.2 interrupt() 是发信号不是砸场子正确终止线程的方式是协作式的核心就是用interrupt()方法给目标线程发一个“你该停下来了”的信号。至于要不要停、什么时候停目标线程自己可以做决定。interrupt()做的事情本质上是设置线程的中断标志位它并不会像stop()那样暴力终止线程。但有两类情况需要特别注意如果目标线程正在执行sleep()、wait()、join()等可中断方法那么它会立即抛出InterruptedException并且清除中断标志如果目标线程在普通循环里跑不检查中断标志也不调用可中断方法那么interrupt()发出去也没用线程会照常执行。所以正确的终止逻辑是在线程内部主动检查中断状态决定是否退出。4.3 一个可优雅停止的循环任务完整示例先看一个错误的示范。很多新手写循环任务会在循环里用sleep然后 catch 到InterruptedException只打日志结果线程怎么都停不下来Thread worker new Thread(() - { while (true) { try { Thread.sleep(500); System.out.println(正在处理任务...); } catch (InterruptedException e) { System.out.println(收到中断但我没有退出); } } }); worker.start(); Thread.sleep(2000); worker.interrupt();这里interrupt()发出后sleep会抛出InterruptedException但因为你 catch 之后没有退出循环线程会继续跑看起来就是“线程停不下来”。这个坑我在代码评审里见过太多次。正确写法是这样public class StopThreadDemo { public static void main(String[] args) throws Exception { Thread worker new Thread(() - { try { while (!Thread.currentThread().isInterrupted()) { System.out.println(正在处理业务...); Thread.sleep(500); } } catch (InterruptedException e) { System.out.println(sleep 期间被中断准备退出); // 恢复中断标志让上层逻辑也能感知到中断 Thread.currentThread().interrupt(); } System.out.println(工作线程退出); }, worker-thread); worker.start(); Thread.sleep(2000); worker.interrupt(); } }这个例子的关键点有几个while (!Thread.currentThread().isInterrupted())表示一旦中断标志位为 true就结束循环sleep抛出InterruptedException时会清除中断标志所以要在 catch 里重新设置中断标志否则循环条件检查会判断不到中断catch 里做的不是吞异常而是恢复标志位并退出这才是协作式终止的精髓。4.4 除了 interrupt还可以用 volatile 标志位有些场景用interrupt()不太方便比如任务里根本没有可中断方法或者你在维护一个长期存活的任务。这时可以用一个volatile boolean标志位让外部代码通过修改标志来通知线程停止。public class VolatileStopDemo { private static volatile boolean running true; public static void main(String[] args) throws Exception { Thread worker new Thread(() - { while (running) { System.out.println(执行清理任务...); try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } System.out.println(任务停止); }); worker.start(); Thread.sleep(1000); running false; worker.join(); } }这里用volatile修饰running是为了保证一个线程修改标志后其他线程能立刻看到最新值。如果不加volatile在极端情况下工作线程可能一直在旧值里打转没法及时退出。这也是面试中常被追问的可见性问题。我个人的习惯是如果任务里有大量阻塞调用sleep、io等待等优先用interrupt()如果是纯计算型任务配合volatile标志位更直观。两者也可以组合使用。5. sleep让线程“睡一会儿”但锁还在手里5.1 Thread.sleep 的基本用法调Thread.sleep(2000)当前线程会暂停执行进入TIMED_WAITING状态等时间到了再变成RUNNABLE。这里特别要注意它是让“当前正在执行的线程”睡不是让“某个指定线程”睡。public class SleepDemo { public static void main(String[] args) { System.out.println(main 线程开始 sleep); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(main 线程 sleep 结束); } }为什么InterruptedException必须处理因为sleep是“可中断方法”如果其他线程对你当前线程调了interrupt()sleep会提前醒来并抛出异常。你不处理编译都过不去。处理时最常见的坏味道是吞掉异常不处理正确姿势是恢复中断标志或者记录下来后继续后续逻辑。5.2 sleep 不释放锁这个坑要记牢很多同学刚接触多线程时会下意识以为“我都睡了锁总该还回去了吧”这是个致命误解。sleep只是让出 CPU 时间片并没有释放线程持有的监视器锁。什么意思呢比如你写一个同步方法在里面调Thread.sleep(3000)这 3000 毫秒内其他线程想进入这个同步方法会一直卡在锁外面class SyncSleepDemo { private int count 0; public synchronized void increment() { System.out.println(已获得锁开始 sleep); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } count; System.out.println(释放锁count count); } }这时候另一个线程调用increment()会阻塞在BLOCKED状态直到当前线程sleep结束并退出同步块锁才会释放。如果你需要“睡了但还把锁让出去”的效果应该用wait()那才是会让出锁的方法。wait和sleep的区别我会在这系列后面讲锁和等待唤醒机制时详细对比。5.3 推荐用 TimeUnit 替代裸 Thread.sleep写代码时用Thread.sleep(1000)阅读的人得心里换算一下才知道这是 1 秒。一旦数字变大比如Thread.sleep(86400000)我还得数一数这是不是一天。推荐用TimeUnit枚举来替代原始毫秒写法import java.util.concurrent.TimeUnit; TimeUnit.SECONDS.sleep(1); // 1 秒 TimeUnit.MINUTES.sleep(5); // 5 分钟 TimeUnit.MILLISECONDS.sleep(500); // 500 毫秒语义一眼就清楚不需要注释也能看懂。TimeUnit底层也是调Thread.sleep但可读性好了一个档次。它还有HOURS、DAYS等粒度非常齐全。5.4 sleep(0) 也有用Thread.sleep(0)在某些场景下是个有意思的技巧。从名字上看是睡 0 毫秒实际作用类似于“让出一次 CPU 调度机会”给其他线程一个执行窗口。在自旋等待、测试并发场景里有时候会用到但它并不是严格的并发控制手段不能依赖它保证某个线程一定被执行。5.5 sleep 和 wait 到底差在哪面试中被问烂的一组对比先看看它们的区别对比维度sleep()wait()是否必须持有锁否是必须在同步块/同步方法中调用是否释放锁否是会释放监视器锁唤醒方式时间到自动醒来或被 interrupt 唤醒依赖 notify/notifyAll或带超时的 wait所属类Thread 静态方法Object 实例方法主要场景暂停当前线程一段时间线程间协作等待条件满足一句话总结sleep是“自己休息一下东西我占着”wait是“我等某个条件先把资源让出来”。wait的细节涉及锁和条件队列我放到后面讲线程协作时展开。6. 实操中的坑和排查速查6.1 坑一主线程先结束了子线程还会执行吗会。JVM 退出条件是所有非守护线程都执行完毕而不是只有main线程结束。所以主线程main跑完返回只要程序里还有用户线程在跑JVM 都会等它跑完再退出。但如果你把线程设置成了守护线程情况就不一样了Thread daemonThread new Thread(() - { // 长时间任务 }); daemonThread.setDaemon(true); daemonThread.start();主线程结束守护线程会被 JVM 直接终止不会继续执行。守护线程适合做后台任务比如心跳检查、监控统计。但注意守护线程里不要放关键业务逻辑因为 JVM 不会等它。6.2 坑二sleep 被中断后错误吞异常导致线程停不下来前面举过例子这里再给一个错误写法加深印象while (true) { try { Thread.sleep(1000); doWork(); } catch (InterruptedException e) { // 什么都不做线程永远不会退出 } }排查这种问题时看到 catch 块是空的或者只打印日志没有退出逻辑基本可以断定中断信号被吞了。正确做法是至少恢复中断标志} catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }我个人的经验是“中断标志就是接力棒谁接住了都要继续往后传。”即使你自己不想处理这个中断也应该把标志重新设置上让上游调用方能够感知到。6.3 坑三一个共享变量改了别的线程看不到如果不加volatile或同步锁线程 A 修改了一个普通变量的值线程 B 不一定能在第一时间看到更新。这不是玄学而是每个线程在 CPU 缓存里可能保留着变量的副本。用volatile可以保证变量修改对所有线程可见。新手常见的迷惑是“我明明改了值循环里就是不跳出来”。这时候先检查目标变量是否用volatile修饰或者操作是否在同步块内。这一块属于 Java 内存模型的范畴系列后面会专门写。6.4 快速自查清单环节核心要点创建优先 Runnable/Callable不要继承 Thread能 Lambda 尽量 Lambda启动必须调 start()不要直接调 run()一个线程只能 start 一次终止用 interrupt() 协作式终止不要用 stop()catch 到 InterruptedException 要恢复中断标志sleepThread.sleep 让当前线程进入 TIMED_WAITING不释放锁推荐 TimeUnit 写法状态六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED排查线程名一定要起好别用 Thread-0 这种默认名dump 文件里靠线程名定位问题最后分享一个我坚持了很多年的习惯每次写多线程代码我都会先给线程起一个有价值的名字比如order-export-worker、cache-refresh-thread。看起来只是传一个字符串而已但在线上排查问题时一份带清晰线程名的堆栈能省掉一两个小时的定位时间。另外sleep里的时间单位我基本都用TimeUnit来写不是为了炫技是真的能减少“这是秒还是毫秒”的脑力开销。代码是写给下一个维护它的人看的而这个人很可能是三个月后的自己。系列第一篇先聊到这里下一篇我会接着讲线程的优先级、守护线程、yield和join这些都是面试高频点也是日常排查定位离不开的基础。如果你正准备用多线程处理一批定时清理任务希望今天关于终止和sleep的经验能帮你少踩几个坑。