☰
Java线程基础全解析:创建、启动、终止与sleep的6大坑
2026/10/10 9:43:46 网站建设 项目流程

去年帮同事排查一个线上小问题,记忆挺深刻:一个定时任务到点后接口特别慢,前端一直转圈。查了半天,发现他在方法里直接写了一个耗时的 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实现 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线程刚创建,还没 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 要恢复中断标志
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的经验,能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询