去年帮同事排查一个线上小问题,记忆挺深刻:一个定时任务到点后接口特别慢,前端一直转圈。查了半天,发现他在方法里直接写了一个耗时的 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()); } }首次输出是NEW,start()之后大概率是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接口,再把实例传给Thread:
class 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()方法不能返回值,也不能抛出受检异常。如果你需要线程执行完拿到一个结果,就要用Callable:
import java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public class CreateThreadDemo3 { public static void main(String[] args) throws Exception { Callable<Integer> task = () -> { Thread.sleep(500); return 1 + 1; }; FutureTask<Integer> 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 | 实现 Runnable | Callable + 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 | 线程刚创建,还没 start |
| RUNNABLE | 可运行状态,可能正在执行,也可能在等待 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 要恢复中断标志 |
| sleep | Thread.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的经验,能帮你少踩几个坑。