☰
Java线程API与守护线程:从基础方法到虚拟线程实战
2026/10/3 4:30:34 网站建设 项目流程

并发编程一直是Java面试的重灾区,也是日常开发绕不开的坎。今天不聊玄乎的理论,就实打实拆解线程里最常用的一批方法API,顺带把守护线程这茬彻底讲明白。不管是刚入门准备面试,还是写了好几年业务代码想补补基础,这篇都能给你点干货。我会按照实际开发中的使用频率,把start、join、sleep、yield、interrupt这些方法一个个过一遍,看看它们到底怎么用、踩过什么坑,再把守护线程的应用场景和注意事项讲透。最后,顺便聊聊线程池、虚拟线程这些进阶玩法,帮助你把并发这把双刃剑握稳。

1. 线程的基础与创建方式

1.1 两种创建线程的方式

创建线程是并发编程的第一课,也是最容易让人想当然的地方。很多新手上来就继承Thread类,写一个子类然后重写run方法,代码倒是能跑,但稍微一重构就发现很别扭。原因很简单:Java是单继承,一旦你继承了Thread,这个类就不能再继承任何别的类了。而且继承Thread会把任务代码和线程耦合在一起,逻辑上并不清晰。

我更推荐实现Runnable接口的方式。把任务本身和线程分离——Runnable只定义你要做的事情,至于谁来执行、怎么调度,交给线程去管。这样任务可以被复用到多个线程里,也可以和线程池配合,非常实用。写起来也简单:

public class MyTask implements Runnable { @Override public void run() { System.out.println("任务执行中,线程:" + Thread.currentThread().getName()); } } // 启动 Thread t = new Thread(new MyTask()); t.start();

到了JDK 5以后,又多了Callable和Future这套组合,主要解决一个痛点:Runnable的run方法没有返回值,也无法抛出受检异常。如果你需要线程执行完返回一个结果,用Callable更合适:

Callable<Integer> task = () -> { // 模拟耗时计算 Thread.sleep(500); return 42; }; ExecutorService executor = Executors.newFixedThreadPool(1); Future<Integer> future = executor.submit(task); Integer result = future.get(); // 阻塞等待结果

把Callable和Runnable放在一起对比会更直观:

对比项RunnableCallable
返回值无,void有,泛型指定
异常处理只能内部处理可以抛出受检异常
启动方式Thread或线程池submit线程池submit,返回Future
底层原理run()是voidcall()返回结果

实际开发中,Runnable用得最多,因为大部分线程任务是"干完就算";Callable适合那种需要拿结果做后续判断的场景,比如并行计算一批数据,汇总排名结果。不过要提醒一点:future.get()会阻塞当前线程直到任务结束,如果任务迟迟不完成,记得加超时参数,比如future.get(2, TimeUnit.SECONDS),避免把主线程卡死。

1.2 线程的生命周期状态

线程不是一出生就一直跑的,它会在几种状态之间来回切换。Java官方定义了六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。iOS开发的朋友可能会联想到进程状态机,但其实思路是一样的,就是给你一张地图,知道线程现在到底在哪,才好排查问题。

用一个生活场景来记:线程就像一个人在办事大厅里办事。

  • NEW:这个人刚刚创建了档案,还没去取号。
  • RUNNABLE:已经叫到号了,可能在排队,也可能正在窗口办理。在Java里RUNNABLE其实是"可运行"的统一状态,不细分CPU是否在执行。
  • BLOCKED:他想进同步区,但发现锁被别人拿走了,只能在门口等着。对应synchronized没抢到锁的情况。
  • WAITING:他办完一部分手续后,自己选择等别人通知。对应wait()这类操作,没有时间限制。
  • TIMED_WAITING:设置了等待时间,比如等5秒、等3分钟,对应sleep(时间)、join(时间)、wait(超时时间)。
  • TERMINATED:事情办完了,状态结束。

状态之间不是随便跳的,比如WAITING只能靠notify或interrupt唤醒。我见过不少新手排查死锁时,看jstack打印出一堆WAITING,就懵了,其实只要对照这张状态转换关系,就能快速定位是哪个线程在等什么。

如果想在代码里亲眼看到这些状态,可以用一个小例子跑一遍:

public class ThreadStateDemo { public static void main(String[] args) throws Exception { Thread t = new Thread(() -> { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println("before start: " + t.getState()); t.start(); System.out.println("after start: " + t.getState()); Thread.sleep(500); System.out.println("while sleeping: " + t.getState()); t.join(); System.out.println("after finish: " + t.getState()); } }

跑完你会发现,状态从NEW到RUNNABLE到TIMED_WAITING再到TERMINATED,变化一目了然。理解这些状态,对后面讲join、interrupt以及守护线程的终止机制都特别重要。

2. 核心方法API完整解析

2.1 start()与run()的区别

这是线程方法里最最基本的区别,但隔三差五还是有人踩坑。记住一句话:start()才是真正启动一个新线程,而run()只是普通方法调用。

如果你直接调用run(),比如:

Thread t = new Thread(() -> System.out.println("执行线程:" + Thread.currentThread().getName())); t.run();

控制台会打印出"main",根本没启动新线程。因为run()就是线程执行体里的那段逻辑,你直接调它,和调用一个普通方法没有任何区别。而start()会做一系列底层操作,创建一条独立执行路径,然后自动调用你的run()。

这背后的原理涉及JVM和操作系统层面:start()会调用native方法,把新线程交给操作系统调度,然后由这个新线程来执行run()。所以调用顺序必须是先start(),不能重复start(),否则会抛IllegalThreadStateException。我用过一次,就是因为不小心在循环里重复启动线程,直接分布式事务被中断,排查了好久才发现是调用方对start的误用。

另外要注意,start()方法本身是synchronized的,能保证线程状态的一致性。这也是为什么官方建议不要重写start(),你只需要重写run()。

2.2 sleep()与yield()的正确姿势

sleep和yield都是让线程暂停的东西,但两者差别非常大。sleep是"躺平指定时间",yield是"礼貌地退让一下"。

Thread.sleep(long millis)的作用是让当前线程进入TIMED_WAITING状态,停止执行指定毫秒数。这个方法有个必须记住的坑:它会抛出InterruptedException,所以必须捕获或向上抛。实际开发中,我经常看到有人把它写在方法签名里,但业务代码根本没地方抛,导致程序异常退出。正规做法是捕获后根据业务决定是否恢复中断标志。

sleep的本质是"带着锁睡觉",它不会释放已经持有的synchronized锁。很多人误以为线程睡了锁就会放开,导致其他线程还是进不来,结果引发不必要的等待。举个例子:

synchronized (lock) { Thread.sleep(1000); // 锁还在手上,别人进不来 }

如果你想"让出锁并等待",那应该用wait(),不是sleep()。这俩名称听着像,机制完全不同。

yield()则更佛系,它只会让出当前线程对CPU的占用,让同优先级或更高优先级的线程先跑。不过调度器可以忽略yield,也就是说它只是给个建议,不保证发生。我在实际项目中几乎不用yield,因为现在的操作系统线程调度已经很智能了,你抢来抢去反而降低性能。它更适合那些大量自旋操作的场景,比如自旋锁里配合一下,否则意义不大。

2.3 join()与interrupt()的协作机制

join()是用来"等线程结束"的。假设主线程要等子线程计算完再继续,你就可以在主线程里调用子线程的join()。它内部会调用wait(),把当前线程放到WAITING状态,直到被join的线程终止后再唤醒。

用起来很简单:

Thread t = new Thread(() -> { /* 模拟耗时任务 */ }); t.start(); t.join(); // 主线程等待t执行完 System.out.println("t已结束");

join可以带超时参数,比如join(1000),意思是"最多等你1秒,超时我继续走"。这在配合异步任务时很实用,防止无限期等待。有个坑是join()也是抛InterruptedException的,不要把异常吞了不处理。

interrupt()可能是线程方法里最被误解的一个。它不是"强制停止线程",而是向目标线程发出中断信号,把它的中断状态置为true。目标线程怎么响应,完全取决于它自己。比如Thread.sleep()、wait()、join()这些会立刻抛出InterruptedException并清空中断状态,而普通业务代码如果不判断中断状态,就感觉不到。

所以interrupt是协作式的,目的是提供一种优雅的停止线程的方式。比如在循环里做耗时任务的线程,应该定期检查Thread.currentThread().isInterrupted(),响应后自己退出循环、释放资源。

while (!Thread.currentThread().isInterrupted()) { // 处理任务 }

这样业务线程自己掌控清理时机,比强制打断安全得多。旧版有个stop()方法能直接杀死线程,但早已废弃,因为它可能导致数据不一致、锁不释放。从那以后,Java团队就一直在推广"协作式中断"。

2.4 获取线程信息的常用方法

线程API里还有一批"小工具"方法,不显眼但用得多。

  • Thread.currentThread():静态方法,拿到当前执行这段代码的线程对象。想在run方法里打印线程名,就靠它。
  • getName() / setName():获得或设置线程名称。强烈建议给线程起个有意义的名字,不要线程-0、线程-1。否则线上日志排查时你根本不知道是哪个业务线程在报错。
  • getId():返回线程唯一ID,通常从0开始递增,底层是线程的标识。
  • isAlive():判断线程是否已经启动且尚未终止。在调用join前可以先判断一下,避免一些无效等待。
  • setPriority(int):设置优先级,范围1-10,默认5。但这只是一个建议,操作系统未必理会,别指望它能决定绝对优先级。
  • isDaemon():判断线程是否守护线程,和下文要讲的守护线程直接相关。

还有一对特殊的方法:isInterrupted()和静态方法Thread.interrupted()。前者返回中断状态但不清除标志;后者返回当前线程的中断状态并清除标志。两者差别极关键,处理中断时千万别用混。

3. 守护线程:幕后英雄的正确用法

3.1 什么是守护线程

守护线程,英文名daemon thread,听着挺神秘,其实可以理解为"工具人线程"。它存在的意义是为其他用户线程服务,比如垃圾回收线程、JIT编译线程、定时监控线程,都是典型守护线程。

最关键的特性是:当进程中所有用户线程都结束了,JVM会自动退出,剩下的守护线程也会被强杀。换句话说,守护线程不会阻止进程退出,它会随着主流程的消亡而消亡。这和"用户线程不死、JVM不退出"形成鲜明对比。

举一个生活化的例子:你(用户线程)进入一家餐厅点菜,后厨里一直有一个帮厨(守护线程)在备菜。如果你吃饱离开了,这家散伙了,帮厨也就不需要存在。帮厨不会拦着你说"等一下我还没备完菜"。这就是守护线程。

3.2 如何创建守护线程

创建守护线程非常简单,只需要在调用start()之前调用setDaemon(true):

Thread daemon = new Thread(() -> { while (true) { // 做一些后台清理工作 } }); daemon.setDaemon(true); daemon.start();

这里有个强制规范:必须在start()之前调用setDaemon(true),否则会抛出IllegalThreadStateException。原因也不难理解,线程一旦启动,它的状态已经给JVM注册过了,你到底是不是守护线程,必须得先确定。

另外,守护线程里创建的子线程默认也是守护线程。这个继承规则在源码里有体现,Thread构造时会继承父线程的daemon状态。所以你要不要在守护线程里再开子线程,得想清楚——那些子线程也可能在你主流程结束的时候被强制停止。

3.3 守护线程的应用场景

守护线程适合做"可有可无"的后台任务。如果任务没做完,但用户线程已经全部退出了,那这个任务就算做了一半也无所谓。常见的落地场景有:

  • 定时清理临时文件:每隔一段时间清理一次临时目录,主程序退出了,清理任务自然也就该停了。
  • 心跳检测:客户端向服务端发送心跳包,保持连接,如果客户端退出,心跳线程当然不需要继续。
  • 日志采集和监控指标上报:比如你需要在应用运行时采集一些指标,但应用停了这些采集线程还活着就没意义了,所以设为守护线程很合理。
  • 本地缓存刷新:比如每隔5分钟拉一次远端配置刷新本地缓存,主进程退出了,刷新也停止。

以心跳检测为例,写一个简化的守护线程:

Thread heartbeat = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { // 发送心跳包到注册中心 System.out.println("发送心跳,时间" + System.currentTimeMillis()); Thread.sleep(3000); } catch (InterruptedException e) { break; } } }); heartbeat.setDaemon(true); heartbeat.start();

注意,既然main函数本身也是一个用户线程,如果main线程结束,守护线程也会跟着终止。所以在演示时,你最好让main循环sleep一段时间,否则守护线程还没来得及跑就被杀掉了。

3.4 守护线程的注意事项与坑

守护线程用得好是帮手,用不好是隐患。我在实际项目里踩过几个很典型的坑,这里给大家提个醒。

第一,不要在守护线程里执行关键业务。比如订单状态更新、数据持久化、事务提交,这些一旦被强制中断,可能造成数据不一致。守护线程的猝死可以发生在用户线程退出后的任意时刻,不会给你机会优雅地完成清理。如果你有必须做完的最后一步,比如把缓冲区的数据落盘,应该用普通线程,并且在退出前做好flush。

第二,不要在finally块里做必须完成的工作。很多程序员习惯在finally里释放资源、记录日志,这在普通线程里没问题,但在守护线程里可能不会执行——因为JVM退出时不会等守护线程的finally块跑完,它可能刚停在半截就被强杀。

第三,守护线程不能覆盖所有业务,尽量不要用做"核心中间件"的线程池。比如Kafka消费者、Netty的EventLoop,如果你把它们设置为守护线程,一旦主线程闪退,流量直接断掉,连重试的机会都没有。

第四,注意线程计数和资源泄漏。守护线程不阻止JVM退出,但也意味着它可能在你根本不需要的时候还在跑。比如一个循环里不断创建守护线程,没有正确停止公共的资源线程,内存照样会涨。所以该加的终止条件还是一定要加,别因为它是守护线程就放任不管。

4. 线程协作与同步:避免死锁的实战经验

4.1 线程互斥与同步基础

线程之间如果要共享变量、一起操作同一个对象,不加控制就会出现数据错乱。比如两个线程同时执行count++,最终结果可能少加了几次,这就是典型的并发安全问题。

解决思路就是"互斥",同一时刻只允许一个线程进入临界区。Java提供了两种基础方案:synchronized关键字和Lock接口。synchronized是内置锁,用简单但不够灵活;Lock是显式锁,可以超时、可中断、支持公平锁。

举一个经典的账户取钱例子:

class Account { private int balance = 1000; public synchronized void withdraw(int amount) { if (balance >= amount) { balance -= amount; System.out.println("取款成功,余额:" + balance); } else { System.out.println("余额不足"); } } }

synchronized加在方法上,锁的是this对象。如果两个线程同时调用同一个Account对象的withdraw,第二个线程会被挡在门外,等第一个线程执行完再进去。只要锁住的是同一个对象,互斥才能成立。

Lock的写法更灵活:

Lock lock = new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }

用Lock时,千万别忘了finally里解锁,否则一旦出现异常,锁就永远不释放,别的线程也永远进不来。这也是为什么很多资深开发说"宁可synchronized也不要乱用Lock"——省事,还安全。

4.2 死锁的产生与排查

死锁是并发编程里的魔鬼。简单说,就是一群线程互相持有对方等待的锁,谁也没法推进,卡死不动了。经典的生活场景就是"过独木桥":两个人迎面走,互不相让,谁都过不去。

死锁需要满足四个条件:互斥条件、持有并等待条件、不可剥夺条件、循环等待条件。只要打破任何一个,死锁就能解除。实际操作中,我们一般破坏的是循环等待——也就是保证所有线程获取锁的顺序一致。

下面是一段会产生死锁的演示代码:

public class DeadlockDemo { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println("t1拿到了B锁"); } } }); Thread t2 = new Thread(() -> { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println("t2拿到了A锁"); } } }); t1.start(); t2.start(); } }

两个人一个拿A等B,一个拿B等A,互相等待,程序就永远卡在那里。

遇到死锁怎么排查?记住一个万金油工具:jstack。先用jps找到Java进程PID,然后执行jstack PID,它会打印所有线程的栈轨迹。如果看到明显的循环等待,比如两个线程都在等某个锁,后面都会标注deadlock。看多了你会很快定位到是哪个方法顺序不一致导致的。

还有一个重要原则:尽量缩小锁的范围,不要在锁内做耗时操作,比如IO、sleep。锁保护的是共享数据,不是整段方法。把耗时操作挪到锁外,既能降低死锁概率,也能提升并发度。

4.3 线程通信:wait/notify与LockCondition

互斥解决的是"同时访问同一资源"的问题,但业务里还有一类问题叫"调度协作"。比如一个生产者往队列里放元素,消费者取元素,队列满了生产者要停下来等,队列空了消费者要停下来等,这就需要线程之间发信号。

经典写法是wait/notify。注意wait和notify必须持有同一个对象的锁,也就是必须写在synchronized代码块内,否则会抛IllegalMonitorStateException。

class MessageQueue { private final LinkedList<String> queue = new LinkedList<>(); public synchronized void put(String msg) throws InterruptedException { while (queue.size() >= 10) { // 用while而不是if,因为唤醒后要重新检查 wait(); } queue.add(msg); notifyAll(); } public synchronized String take() throws InterruptedException { while (queue.isEmpty()) { wait(); } String msg = queue.removeFirst(); notifyAll(); return msg; } }

这里有两处容易踩坑。第一,wait()会释放锁,sleep不会,这一点必须记死;第二,条件判断要用while循环,不能用if,不然被唤醒后,条件可能已经被别的线程改变了,产生假唤醒。

如果用了Lock,就没法直接用wait/notify,得用Condition。它把wait/notify拆成了更细的多个等待队列,支持公平等待和中断。

Lock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition();

生产者在notFull上等待,消费者在notEmpty上等待,唤醒时只唤醒对应那一批线程,效率更高。

5. 线程池与虚拟线程:并发编程的进阶选择

5.1 ThreadPoolExecutor核心参数配置

线程池是管理线程的大管家,用得好能大幅提升性能,用不好反而拖垮系统。核心是ThreadPoolExecutor,七个参数读懂后就很好掌控。

  • corePoolSize:核心线程数。线程池初始化后,即使空闲也会一直保留的线程数。
  • maximumPoolSize:最大线程数。当队列满了,线程数才会往这个最大值扩。
  • keepAliveTime:非核心线程空闲多久回收,配合时间单位生效。
  • workQueue:任务队列,当核心线程全部忙碌,新任务就先进队列。
  • threadFactory:线程工厂,常用于自定义线程名和守护状态。
  • handler:饱和策略,队列和最大线程都满了时的处理方式。

执行流程是这样的:提交任务时,如果当前线程数小于corePoolSize,直接创建核心线程执行;线程数达到corePoolSize后,任务进入队列;队列满了,再创建非核心线程直到maximumPoolSize;再满,就触发拒绝策略。

配置参数是一个经典难题。我一般不会盲目套公式,而是先分析任务是CPU密集型还是IO密集型。

  • CPU密集型:比如大量计算、加密解密,线程数最好不要超过CPU核数,设为核心数+1或2。
  • IO密集型:比如网络请求、文件读写,线程大部分时间在等IO,可以放宽到CPU核数×2或更多。

用一个简单的估算:IO密集型线程数 = CPU核心数 × (1 + 平均等待时间 / 平均计算时间)。以Tomcat为例,默认值往往配置较大,因为请求大多是IO等待。

另外,优先使用有界队列,比如ArrayBlockingQueue,防止无限堆积打爆内存。拒绝策略一般选CallerRunsPolicy——不是直接把任务丢弃,而是交给提交任务的线程去执行,相当于"退回给调用方自己处理"。这能保证任务不丢,也有背压的效果。

5.2 阻塞队列的选择策略

线程池的workQueue选什么类型,直接影响任务排队的行为和资源消耗。常见的阻塞队列有这几种:

队列类型是否有界特点适用场景
ArrayBlockingQueue有界固定大小,内存可控适合需要严格限制任务量的场景,比如短信推送
LinkedBlockingQueue可配置有界/无界链表结构,支持大量任务默认无界时要小心内存爆炸
SynchronousQueue容量为0不存任务,直接交给线程适合要求立即执行的场景,吞吐量高
PriorityBlockingQueue无界支持优先级排序任务有优先级时使用
DelayQueue无界定时延迟执行适合延迟任务、定时器

实际工程里,我最常用的组合是核心线程数+有界ArrayBlockingQueue,再加CallerRunsPolicy。因为无界队列一旦请求暴增,队列会无限膨胀,最终导致OOM。比如曾经有个同事用Executors.newFixedThreadPool,底层就是无界LinkedBlockingQueue,高并发下来直接把内存撑爆了。这也是为什么官方并不推荐直接用Executors的便捷方法,而是建议手动创建ThreadPoolExecutor。

SynchronousQueue很有意思,它本身不存任务,每个插入操作必须等一个取出的操作。普通直连,没有缓冲,适合大量短任务、希望快速交接给线程执行的场景。但它配合maximumPoolSize使用,否则容易产生大量瞬时线程。

5.3 虚拟线程原理与使用心得

JDK 21正式带来了虚拟线程,这是并发编程的一件大事。简单说,虚拟线程是JVM层面模拟出来的"轻量级线程",并不是每个虚拟线程都对应一个操作系统线程。操作系统线程,可以理解成真正的员工;而虚拟线程,是一个任务清单,由JVM自己调度员工去执行。

传统线程池的核心问题是:线程是稀缺资源,每个线程都要占栈内存,动辄几MB。当并发量达到成千上万时,传统线程根本扛不住。而虚拟线程的栈可以在堆外动态扩展,创建和阻塞几乎不占用额外系统资源,所以你可以创建十万个甚至百万个虚拟线程,这叫"自由线程"的思路。

使用方式很简单:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); executor.submit(() -> { // 每个任务都是一个虚拟线程 System.out.println("虚拟线程执行:" + Thread.currentThread().getName()); });

对于大量IO密集型任务,虚拟线程特别合适。以前你得小心翼翼调线程池参数,比如DB连接池、外部API调用、文件读写,每个并发点都怕把线程池打爆。用虚拟线程后,你可以为每个请求单独创建一个虚拟线程,写起来像同步代码,却能承受极高的并发量。

但虚拟线程也不是银弹。CPU密集型任务就别凑热闹了,因为虚拟线程最终还是在操作系统线程上跑,CPU本来就不多,虚拟线程再多也不会快。另外,锁竞争的问题依然存在,如果大量虚拟线程争抢同一个锁,反而可能比传统线程池更慢。我实际用下来,最稳的场景就是那种"一个请求里要并发调多个外部服务然后聚合结果"的IO密集场景,替换成本低、收益极其明显。

6. 常见问题与排查技巧实录

6.1 线程启动失败或重复启动

最常见的错误是对同一个Thread对象调用两次start(),会抛出IllegalThreadStateException。线程一旦启动,状态就不是NEW了,不能重新启动。你想再次执行同一任务,重新new一个Thread。

还有一类情况是线程变量被当作局部变量,在循环中创建后没有启动。仔细检查一下代码,确认start()确实被调用,不要只写new Thread(runnable)就完了,它不会自动开始。

6.2 InterruptedException处理不当

sleep、join、wait都会抛InterruptedException,很多人直接catch后空着,或者简单printStackTrace。这种处理非常糟糕,因为异常被吞掉后,中断状态也被清除了,上层代码根本不知道线程被中断过。

正确做法是,在catch块里恢复中断标志:

try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断标志 // 根据需要处理或返回 }

如果你不在catch里恢复标志,线程池或其他协作方就不知道你被中断了,可能造成任务继续运行或资源不释放。这个细节,在很多线上问题排查里都能看到。

6.3 守护线程意外终止

守护线程最大的特点就是不阻止JVM退出,所以很多人在main函数结束后,守护线程刚跑到一半就没了。如果你发现守护线程的任务不执行,先检查是不是主线程提前退出了。

另外,守护线程会被强制中止,因此不要在里面做任何必须在退出前完成的操作。如果做了,就会莫名丢失数据。

6.4 如何优雅地停止线程

很多人还在用Thread.stop(),这里再强调一次,stop已经废弃,不要用。正确的方式是协作式中断:用volatile标志或interrupt方法。

volatile标志写起来直观:

class MyTask implements Runnable { private volatile boolean running = true; @Override public void run() { while (running) { // 执行任务 } } public void stop() { this.running = false; } }

这个flag必须用volatile,保证其他线程能看到修改后的值。如果线程处在sleep或wait状态,volatile标志帮不上忙,就必须用interrupt来唤醒再退出。两者经常搭配使用。

6.5 常见问题速查表

现象可能原因排查命令/建议
线程不启动忘了调用start()检查代码逻辑
重复start抛异常对同一Thread调用两次start重新创建Thread对象
sleep没生效在锁内sleep,锁没释放改用wait或缩短锁范围
死锁锁顺序不一致或嵌套jstack排查,统一锁顺序
线程池内存暴涨用了无界队列改有界队列
任务的finally没执行在守护线程被捕杀重要操作不用守护线程
主线程提前退出没join或没awaitTermination添加join或shutdown管理

排查并发现场,我最常用的三板斧:先看日志,再抓线程转储(Thread Dump),最后看资源占用。抓线程转储的命令很关键,jstack PID直接打印所有线程的栈轨迹,死锁、阻塞都能看得清清楚楚。

我个人在实际操作中的体会是,并发编程最难的往往不是那些眼花缭乱的API,而是搞清楚每一条线程当下的状态、在等什么资源、由谁唤醒。把线程方法和守护线程重新梳理一遍以后,再去用线程池和虚拟线程,你会发现很多概念其实都是相通的。建议你动手跑一跑上面的示例代码,自己打几杯死锁日志,用jstack抓一抓,那种感觉比看一百篇文章都强。最后再分享一个小技巧:写并发代码前,先把"哪些数据会被多个线程访问、如何保证可见性和原子性"想清楚,再去选同步手段,你的代码会稳得多。

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

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

立即咨询