ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java线程join()深度解析:为什么阻塞的是主线程?

Java线程join()深度解析:为什么阻塞的是主线程? 1. 先把概念纠正join() 等的是子线程阻塞的却是主线程在主线程里写t.join()的时候十个人有八个第一反应是“让子线程 t 等一等”可代码跑起来之后反向的却经常是主线程卡在那一行子线程自顾自地跑得正欢。这个反直觉的现象几乎每个写过多线程的程序员都碰到过也是面试里高频的被问懵的问题调用子线程的 join() 方法为什么阻塞的是主线程结合最近群里又有人拿join(1000)超时、线程池里等任务、以及“跨库 join”和线程 join 撞名来说事我干脆把这个问题从现象、源码到实战排查完整写一遍。1.1 一个十秒就能复现的实验先不看任何源码直接跑一段最小实验。代码很简单主线程启动一个子线程子线程睡两秒后退出主线程在启动之后调用t.join()。public class JoinDemo { public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { System.out.println(子线程开始执行 System.currentTimeMillis()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(子线程执行结束 System.currentTimeMillis()); }); t.start(); System.out.println(主线程准备调用 join System.currentTimeMillis()); t.join(); System.out.println(主线程从 join 返回 System.currentTimeMillis()); } }输出顺序会非常清楚主线程先打印“准备调用 join”然后子线程打印“开始执行”两秒后子线程打印“执行结束”紧接着主线程打印“从 join 返回”。主线程打印“从 join 返回”的时间点一定在子线程结束之后。这说明调用t.join()之后被“按住”的是当前正在执行这段代码的主线程而不是子线程 t。子线程从头到尾没有被阻塞它该睡就睡该跑就跑。1.2 方法签名和注释说明一切join()是Thread类上的一个实例方法官方注释写得非常直白Waits for this thread to die.翻译过来就是“等待这个线程死掉”。注意这个“等待”的主体是谁是“调用这个方法的线程”。换句话说t.join()表达的意思是当前线程愿意在这里等着直到t 这个线程终止然后当前线程才继续往下走。这个语义跟某些人理解的“让 t 线程阻塞”完全是两回事。Thread.join()的底层实现最终会走到join(long millis)上JDK 里的大致逻辑是这样public final synchronized void join(long millis) throws InterruptedException { if (millis 0) { while (isAlive()) { wait(0); } } else { long base System.currentTimeMillis(); while (isAlive()) { long now System.currentTimeMillis(); long delay millis - (now - base); if (delay 0) { break; } wait(delay); } } }这里的关键是while (isAlive()) { wait(0); }。isAlive()检查的是子线程 t 是否还活着wait(0)是当前线程在 t 的对象上无限等待。所以哪怕不看注释只看这段源码也能得出结论join()是让调用者线程去等待目标线程终止而不是去操作目标线程让它停下来。1.3 C 的 std::thread::join 也是一样的语义很多从 C 转过来的朋友会觉得熟悉因为 C11 的std::thread::join()语义完全一致调用thread.join()的线程会被阻塞直到thread执行完成为止。Java 和 C 在这个设计上是统一的原因也很简单一个线程本身没有办法“命令”另一个线程立即停止或者阻塞线程的启停调度权在操作系统手里应用层能做的只是让自己等待某个条件成立。这是线程协作的基本逻辑谁调用谁等待谁想要结果谁付出等待的代价。2. 阻塞的本质不是“卡死”而是状态切换很多人一听到“阻塞”两个字就脑补成程序死机了其实阻塞在操作系统的线程模型里是一个非常正常的运行状态。主线程调用t.join()之后主线程会从RUNNABLE状态切到WAITING状态而子线程 t 该是什么状态还是什么状态。这个状态切换是操作系统和 JVM 一起完成的不是靠某个线程去“压住”另一个线程。2.1 用 jstack 亲眼看一看线程状态理论说再多不如抓一个现场。上面的JoinDemo跑起来之后在子线程 sleep 的这两秒里打开终端执行jstack pid就能看到主线程和子线程各自的状态。主线程大概是这样main #1 prio5 os_prio0 cpu... tid... java.lang.Thread.State: WAITING (on object monitor) at java.base/java.lang.Object.wait0(Native Method) at java.base/java.lang.Object.wait(Object.java:...) at java.base/java.lang.Thread.join(Thread.java:...) at JoinDemo.main(JoinDemo.java:...)子线程大概是这样Thread-0 #14 prio5 os_prio0 cpu... tid... java.lang.Thread.State: TIMED_WAITING (sleeping) at java.base/java.lang.Thread.sleep(Native Method)主线程的状态是WAITING (on object monitor)栈顶清清楚楚停在Thread.join而子线程的状态是TIMED_WAITING (sleeping)。这组状态直接证明主线程在等待子线程对象上的一个唤醒信号子线程自己则在休眠计时。两个线程的状态完全不一样谁也不“卡”着谁。2.2 wait(0) 是无限等待join(1000) 是超时等待join()无参版本里调用的是join(0)这里的wait(0)是无限等待的意思而不是“等待 0 毫秒”。这是一个非常容易误解的细节。Object.wait()里传 0 代表没有超时上限只有目标对象被notify或者notifyAll之后才会返回。对应到join上就是“子线程不死主线程就一直等”。带毫秒参数的join(1000)就温和很多。它会在while (isAlive())循环里用wait(delay)一段一段地等最多等 1000 毫秒。如果超时之后子线程还活着主线程也不再等了而是继续往下执行。一个很实用的写法是t.join(1000); if (t.isAlive()) { System.out.println(子线程还没跑完主线程先继续了); }这里的isAlive()判断不能省。因为join(1000)返回之后有两种可能一种是子线程正好在 1 秒内结束另一种是超时了但子线程还活着。不判断一下后面处理的数据可能就是不完整的。2.3 一个外卖比喻外加和 SQL JOIN 的区分我把这个机制讲给同事听的时候常用一个外卖比喻主线程是坐在家里的你子线程是正在路上送货的外卖员join()就是你坐在门口等他敲门。阻塞的是你不是外卖员。你等他的时候他该骑车骑车、该爬楼爬楼完全不受你影响。他到了之后敲个门也就是 JVM 在子线程结束那一刻发出唤醒通知你才起身继续干自己的事。另外最近总有人把线程join和多表LEFT JOIN、跨库 JOIN 混在一起问。这里必须说清楚SQL 里的JOIN是把多张表的数据按关联条件横向拼成一张结果集是数据层面的组合操作线程里的join()是让调用线程等待目标线程结束是执行流层面的同步原语。两个词只是撞了名字底层逻辑没有任何关系。以后看到“跨库 join”的文章别往里套多线程的等待语义。3. 主线程等子线程几种正确的打开方式理解了原理之后更重要的是在实际代码里用对。很多场景确实是“主线程必须等所有子线程干完活才能做汇总”这种需求用join()是合理的。但怎么等、等多久、要不要超时这里面的讲究不少。3.1 最朴素的循环 join先全部启动再逐个等待假设我要启动 5 个子线程每个子线程并行计算一段数据最后主线程汇总。很多人会这样写for (int i 0; i 5; i) { Thread t new Thread(task); t.start(); t.join(); }这个写法有问题。start()之后立刻join()等于先等第一个线程结束再启动第二个线程再等第二个结束。表面上创建了 5 个线程实际上 5 个任务变成串行执行了总耗时是 5 个任务耗时之和。正确姿势是先把 5 个线程全部start()让它们并行跑起来然后再循环里逐个join()ListThread threads new ArrayList(); for (int i 0; i 5; i) { Thread t new Thread(task); t.start(); threads.add(t); } for (Thread t : threads) { t.join(); }为什么先全部 start 再 join 能并行因为线程一旦start()就进入可运行状态操作系统会尽量分配 CPU 给它们。这时候主线程再去join等第一个线程并不影响其他子线程在后台继续执行。等到最后一个join()返回才意味着所有子线程都结束了。这个小细节是新手最容易写错的地方。3.2 给 join 加超时是生产环境的保命手段无参join()虽然简单但有一个致命弱点如果子线程因为某些原因迟迟不结束主线程就会无限期等下去。最常见的原因包括子线程里发生了死锁、调用了阻塞的网络 IO 没有超时、线程池任务被排队排到天荒地老。等一旦出问题只能靠外部手段 dump 线程非常被动。所以我在生产代码里几乎不用无参join()而是统一用join(timeout)。比如最多等 5 秒long start System.currentTimeMillis(); t.join(5000); long cost System.currentTimeMillis() - start; if (t.isAlive()) { // 记录日志标记任务超时做降级处理 System.err.println(任务超时t 还活着已耗时 cost ms); }超时之后主线程不能傻等结果必须要有一个降级策略。比如把数据标记为不完整、把失败任务丢到重试队列、或者取消子线程里能感知中断的任务。加了超时之后即使子线程出问题主线程也不至于被无限期拖住这是大型应用里非常关键的一个习惯。3.3 线程池场景下不要再找 join 了Future 才是正统如果你已经用了ExecutorService就没法像操作一个Thread对象那样去join()了因为线程池里执行任务的不是某个你能拿到的线程。这时候要等任务完成应该用FutureExecutorService pool Executors.newFixedThreadPool(4); FutureString future pool.submit(() - { Thread.sleep(2000); return done; }); String result future.get(5, TimeUnit.SECONDS);Future.get()和join()一样会阻塞当前调用线程但它比join()强在两点第一能拿到任务返回值第二get(timeout)超时之后可以抛出TimeoutException处理方式更明确。还有一个更轻量的并发工具CountDownLatch适合“不关心子线程返回值只关心它们是否都结束”的场景。我把三者的特点放在一起对比过方式阻塞调用方获取结果超时控制适合场景Thread.join是不能直接拿返回值join(timeout) 可以手动创建线程、任务数量固定且少Future.get是能拿返回值get(timeout) 可以线程池提交单个任务CountDownLatch是不能直接拿返回值await(timeout) 可以多个线程同时准备/同时结束的协作一句话能不用手写Thread就别用手写线程池加Future是主流但如果你继承了一个老项目里面到处是手动new Thread那么正确使用join()仍然是一项必须掌握的技能。4. 那些年我们踩过的坑join() 卡死、不生效、误用现场还原join()本身不复杂但它在真实项目里引发的幺蛾子特别多。我排查过好几次“主线程卡死”的问题最后发现锅多半不在join()本身而是它和锁、线程池、启动顺序这些因素搅在了一起。下面这几个坑每一个都有真实的现场原型。4.1 持锁调用 join()死锁就是这么来的最典型的死锁现场长这样主线程先进入一个synchronized代码块拿到了锁 A然后在这个锁保护的区域里调用了t.join()而子线程 t 的任务又恰好需要拿到锁 A 才能继续执行。结果主线程等子线程结束子线程等锁 A 释放两个线程互相等待程序永远卡住。public class JoinDeadLock { private static final Object lock new Object(); public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { synchronized (lock) { System.out.println(子线程拿到锁 A); } }); t.start(); synchronized (lock) { // 主线程先拿走锁 A System.out.println(主线程拿着锁 A 调用 join); t.join(); // 主线程等子线程子线程等锁 - 死锁 } } }这个例子跑起来之后子线程不会打印“子线程拿到锁 A”主线程也不会从join()返回。解决办法很简单不要在持有锁的时候调用join()。如果确实需要在锁保护下等待子线程那就应该重新设计同步方式比如把等待挪到锁外面或者改用wait/notify自己做条件等待。排查的时候用jstack能看到两个线程互相持有对方需要的锁是特别标准的死锁报告。4.2 线程还没 startjoin 一点反应都没有另一个高频问题不是卡死而是“join 好像没生效”。最常见的原因是忘了调用start()。一个还没启动的线程isAlive()返回falsejoin()里的while (isAlive())条件一开始就不满足于是直接跳过wait(0)瞬间返回。Thread t new Thread(() - System.out.println(run)); t.join(); // 忘了 t.start()这行立即返回 System.out.println(主线程继续);这种“调用即返回”的现象很容易造成错觉看起来join()没阻塞实际上是因为线程压根没活着。所以看到join()秒过的时候第一反应应该是回头检查有没有start()而不是怀疑join()失效。线程已经结束之后再调用join()同样会立即返回。这不是错误只是意味着你错过了等待时机。比如主线程序先Thread.sleep(3000)再调join()而子线程两秒就结束了那join()确实什么都不会等。4.3 线程池和阻塞队列容易造成命名混淆还有一个很有意思的混淆来源线程池的阻塞队列。很多同学第一次看ThreadPoolExecutor源码时看到workQueue是一个BlockingQueue又听说“线程池会阻塞”就会下意识把join()的阻塞和队列的阻塞搅在一起。其实这是两个完全不同的东西。线程池的阻塞队列是用来存放暂时没被消费的任务当核心线程都在忙的时候新任务就排在队列里这是任务调度层面的排队。而join()是线程之间的等待协作是执行流层面的同步。你可以用ArrayBlockingQueue或LinkedBlockingQueue控制线程池的任务排队策略但这些队列不会帮你实现“主线程等待所有任务结束”——那个功能要靠Future.get()或pool.shutdown()awaitTermination()来做。4.4 排查现场jstack 锁定 waiting 栈是基本功遇到主线程疑似卡死第一步永远是抓线程栈。命令就一句话jstack pid thread_dump.txt抓下来的文件里重点看两样东西所有WAITING状态线程的栈顶在哪个方法以及它们有没有都在同一个锁对象上排队。如果主线程栈顶停在java.lang.Thread.join旁边子线程栈顶停在某个synchronized方法或者停在BLOCKED状态等锁那八成就是“主线程持锁 join”或者“子线程拿不到锁”的死锁格局。顺着这两个栈往上翻就能找到是哪两把锁出了问题。5. 再深入一层join() 在 JVM 层面到底怎么实现“阻塞释放”既然join()主要靠wait实现那就需要一个底层机制保证子线程结束的那一刻JVM 会唤醒正在wait的主线程。这个唤醒动作是谁做的我最初也以为是在子线程的run()方法最后偷偷调了一个notifyAll其实不是它发生在 JVM 的线程退出路径上。5.1 子线程结束时谁来唤醒等待的主线程HotSpot 虚拟机里一个 Java 线程执行完run()方法之后会走进JavaThread::exit这条天然的退出路径。在这个路径上JVM 会找到线程对象本身进入该对象对应的 monitor然后执行类似notifyAll的操作把那些因为Thread.join()而wait在这个线程对象上的线程全部唤醒。之后这个线程对象才被标记为终止状态。这个设计是join()能稳定工作的地基。它不要求在业务代码里手动通知也不依赖子线程自己写一行神奇的代码。只要子线程正常退出或者因为未捕获异常退出JVM 的退出流程都会走一遍唤醒逻辑。所以join()的语义是“保证等到最终状态”而不是“大概率等到”。5.2 join 方法为什么用 synchronized锁会不会一直攥在手里我见过一个特别较真的问题join(long)方法本身是synchronized的那主线程进入join()之后是不是一直占着子线程对象的锁如果占着子线程结束的时候又怎么执行 notifyAll答案藏在一个关键动作里wait(0)被调用的一瞬间当前持有的 monitor 锁会被释放。Object.wait()的协议本就是“释放锁然后挂起自己”。所以主线程进入join()时确实拿到了子线程对象的锁但它马上就在wait(0)上把锁交出去了。等到被唤醒之后它会重新去竞争这把锁然后重新检查while (isAlive())条件。这就形成了一个很干净的循环等不到就释放锁挂起被唤醒就抢回锁再判断。整个过程并不会死攥着一把锁不放这也是它不会阻塞子线程终止流程的原因。5.3 阻塞释放的本质WAITING 状态的线程不消耗 CPU在我们常用的操作系统线程模型里WAITING状态的线程会被移出可运行队列放进某个等待集合。它不会参与 CPU 调度也不会占着 CPU 空转。举个反例如果join()用while (isAlive()) {}这种自旋来实现主线程虽然也“等”了子线程但会一直占满一个 CPU 核心线上环境肯定炸。wait这种方案才是真正的阻塞释放主线程把执行权还给了调度器把锁也还给了其他竞争者自己安安静静待在等待集合里直到被唤醒。这正是join()以及所有基于wait/notify的同步工具能在生产环境大规模使用的原因。5.4 GUI 主线程慎用 join阻塞的不只是逻辑还有交互join()虽然机制优美但有一个绝对不能踩的场景UI 主线程。在 Java Swing 的 EDT、Android 的 main looper或者任何一种带消息循环的 UI 线程里直接调用join()都会导致界面事件无法处理。用户点了按钮没反应窗口不能拖动最后系统弹 ANR 或者界面卡死。正确的做法是不要在 UI 线程里join()。子线程跑完以后通过Handler.post、SwingUtilities.invokeLater或者postValue把结果投回 UI 线程再更新控件。很多桌面开发语言里也有类似的主线程限制比如易语言里经常有人问子线程怎么让主线程操作 UI 控件核心思路同样是“子线程通知主线程刷新”而不是主线程死等子线程。记住一条原则主线程如果还承担着交互任务那它就没有资格把自己阻塞掉。6. 最后分享我在排查 join 相关问题时用的几个习惯写了这么多年多线程join()本身不是我最怕的东西我真正怕的是它藏在复杂业务里被误用。所以最后分享几个自己常用的排查习惯算是给新同学一个可以直接抄作业的清单。6.1 排查栈时先看三个关键点第一栈顶是否真的停在Thread.join确认不是Future.get或者LockSupport.park伪装的“等待”。第二目标子线程当前是什么状态如果子线程已经结束了而主线程还停在join那就是 JVM 唤醒异常或者有多个线程在反复 join 同一个对象。第三看锁关系凡是join和synchronized同时出现在栈里的先怀疑持锁等线程的死锁。6.2 默认给所有 join 加超时除非你非常确定线程能退出我个人的底线是无参 join 只允许出现在 demo 和单元测试里生产代码一律写join(timeout)。子线程里可能有未被中断的 IO、可能因为 bug 进入死循环、也可能在等一个永远等不到的资源。给join加上超时相当于给整个系统上了保险丝即便子线程出问题主线程也有机会及时脱身去做降级或报警。这个习惯帮我避免过至少两次线上事故成本却只是一行参数非常划算。6.3 能用并发工具解决的事不要自己手动拼 Threadjoin()虽然经典但现代 Java 并发编程的首选早就不是手写Thread了。线程池、Future、CompletableFuture、CountDownLatch、CyclicBarrier这些工具每一个都把“何时阻塞、如何超时、怎么拿结果”这些细节封装得更完整。join()更适合用在学校作业、小型脚本、或者维护老代码的场景。遇到新项目哪怕只是“多线程并发计算再汇总”我也会先想想线程池和CompletableFuture把join()留给真正需要直接控制线程生命周期的场景。根据我个人经验把join()理解透彻之后再去用那些更高层的并发工具你会更容易看懂它们背后的等待通知逻辑也更容易在出问题时一眼定位到真正的阻塞点。
返回列表