多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从计时器项目吃透Java多线程:线程生命周期与中断机制实战

从计时器项目吃透Java多线程:线程生命周期与中断机制实战 1. 为什么多线程练手一定要做计时器很多人学 Java 多线程的时候都会经历一个特别尴尬的阶段Thread 的构造函数会写Runnable 接口知道是怎么回事synchronized 和 volatile 也反复背过但真要自己动手写一个小功能却完全不知道从哪里下手。我当年也是这样直到后来认真做了一遍“计时器”这个小项目才把多线程那些零散的知识点真正串成了一条线。计时器这个题目看起来简单做起来其实很有讲究。它本质上是“一个后台任务需要周期性执行同时又要响应外部指令”的模型。开始计时、暂停、继续、复位、停止这几个操作恰好对应了线程的运行、阻塞等待、中断退出等经典状态。也就是说你只要把计时器做明白线程生命周期、线程间通信、中断机制、共享变量的可见性这些最核心的东西就全过了一遍。这也是为什么它特别适合 Java 进阶阶段的人拿来练手比单纯看理论强得多。这个项目同时也算是一次“查漏补缺”。很多人在面试的时候能背出线程池参数但一问到“如何优雅地停止一个线程”就答不上来。真去写计时器你一定会遇到这个问题而且必须亲手解决它。所以这篇文章不只是给你一份能跑的代码更想把每一步操作背后为什么这么做讲清楚让你看完之后能自己往别的场景里迁移。2. 需求分析与实现方案选型2.1 计时器的核心功能拆解在动手写代码之前先把需求理清楚。一个能用于练习的计时器至少要支持这几个动作开始从 0 开始累计时间每一秒刷新一次暂停让计时中途停下来累计的秒数保持不变继续从暂停时的数值继续累加而不是从头开始停止彻底结束计时重新回到初始状态复位把计时数值清零等待下一次开始这五个功能看起来不多但每一个都在给多线程出题。开始意味着你得专门开一个线程去跑“每秒刷新”的逻辑暂停和继续意味着主线程要能够通知工作线程“先别干活了”“继续干活”停止意味着你不能粗暴地杀掉线程要让线程自己优雅退出。这正好覆盖了多线程编程中最常遇到的几类问题。第一版我建议先不做暂停只做最基础的“开始”和“停止”。等基础版本跑通了再往上加暂停和继续。否则一上来就把所有功能堆到一个类里出了问题很难定位到底是哪块的逻辑错了。2.2 三种常见实现思路对比实现一个计时器主流的路子有三条方案核心思路优点缺点手写线程 Thread.sleep开一个线程循环 sleep 1 秒然后刷新显示最基础能把线程原理看得最清楚暂停/继续需要自己处理标志位精度略差java.util.Timer TimerTaskJDK 提供的定时任务工具API 简单上手快单线程执行任务异常会影响后续任务暂停逻辑同样要自己写ScheduledExecutorService线程池定时调度线程池安全支持周期任务和延迟任务可优雅关闭概念稍多需要了解调度行为这三条路我都实际试过。如果是为了学习多线程底层机制第一条路必须走一遍不然你对线程调度没有体感。但在实际项目里我会优先选 ScheduledExecutorService它更接近生产环境里真正在用的方式。Timer 类现在用得越来越少了因为它有个明显的坑如果 TimerTask 里抛出未捕获异常整个 Timer 线程就挂了后面的定时任务全部失效。光这一点就够你在线上项目里吃大亏。所以这篇文章的安排是先用最基础的 Thread 方式实现一版“能用的计时器”让你理解背后发生了什么然后再升级到 ScheduledExecutorService告诉你生产环境里为什么更推荐它。3. 先补一遍线程生命周期与中断机制3.1 线程状态切换是怎么回事要写计时器你必须对线程的六个状态有清晰认识。Java 里线程的状态定义在Thread.State这个枚举里分别是 NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。拿计时器来对照理解就很直观你 new 出一个 Thread 对象但还没调用 start 时它是 NEW 状态start 之后run 方法里的代码开始执行哪怕它在睡眠状态也是 RUNNABLE虽然底层可能是休眠但 JVM 层面的状态可能显示为 RUNNABLE 或 TIMED_WAITING你调用 wait 让线程等待它是 WAITING你调用 sleep(1000)它是 TIMED_WAITING线程跑完 run 方法它是 TERMINATED。很多初学者会把 sleep 和 wait 搞混。sleep 是“我自己睡够时间再醒来”它不会释放锁wait 是“我要等别人来唤醒”它会释放锁。在计时器这个场景里sleep 用来控制每秒刷新一次而 wait 正好可以用来实现暂停。线程执行到 lock.wait() 之后就老老实实等着直到有人调用 lock.notifyAll()它才重新醒来继续跑。这就是暂停和继续能做到丝滑切换的底层原理。3.2 中断不是强制停止你还需要理解中断机制。很多人刚接触时以为 interrupt 就能把线程掐死其实不是。interrupt 只是给线程设置了一个“中断标志位”真正负责响应中断的是线程自己。比如线程正卡在 sleep 里别人调用 interrupt 会让它立刻抛出 InterruptedException但如果线程在普通循环里没有做任何检查interrupt 对它是没有直接影响的。因此写计时器的停止逻辑正确做法是主线程调用 worker.interrupt()工作线程在循环里捕获 InterruptedException 并重置中断标志然后退出循环。注意捕获异常后最好再用Thread.currentThread().interrupt()把中断标志位恢复一下这是很多老手写代码的习惯目的是让上一层的调用者还能感知到中断发生过。这个细节在简单代码里看不出差别但在大型项目里很有用。4. 第一版实现用 Thread 让秒数跑起来4.1 基础正向计时器的核心代码先写一个最简版本功能只有两个开始和停止。public class SimpleTimer { // 用 volatile 保证 running 标志的可见性 private volatile boolean running false; public synchronized void start() { if (running) { return; } running true; Thread worker new Thread(() - { long startTime System.currentTimeMillis(); while (running) { long elapsed (System.currentTimeMillis() - startTime) / 1000; System.out.println(已计时 elapsed 秒); try { Thread.sleep(1000); } catch (InterruptedException e) { // 线程被中断时恢复标志位并退出循环 Thread.currentThread().interrupt(); return; } } }, timer-worker); worker.setDaemon(true); worker.start(); } public synchronized void stop() { running false; } public static void main(String[] args) throws InterruptedException { SimpleTimer timer new SimpleTimer(); timer.start(); Thread.sleep(5000); timer.stop(); } }这段代码有几个点要特别留意。第一running 用了 volatile原因是 stop() 方法可能在主线程里调用而读取 running 的是工作线程。如果不加 volatile工作线程有可能永远读到旧值停不下来。这是 Java 内存模型里的可见性问题写多线程代码时非常容易踩。第二start() 和 stop() 用 synchronized 修饰是防止出现“重复启动”和“停止与启动并发”的情况。如果你连续点了两次开始按钮没有这一层保护会出现两个线程同时在跑秒数会一个刷新两次甚至互相覆盖。4.2 这里埋了一个时间精度的问题上面代码里的时间计算我用了System.currentTimeMillis()和标准时间差而不是去数 sleep 了多少次。原因很简单sleep(1000) 并不能保证精确睡够 1000 毫秒。线程被唤醒后还要经过调度才能继续执行而且 sleep 期间如果系统发生 GC也会造成偏差。如果你用“每 sleep 一次就 count”这种做法跑十分钟下来显示的时间会比真实时间少好几秒因为每次 10 毫秒的误差会不断累积。用时间戳差值计算本质上是一种“校准”。每次循环都拿当前时间减去开始时间真实过了几秒就算几秒。sleep 只是为了控制刷新频率它不参与数值累计。这样即使某一次循环晚了 20 毫秒下一秒显示的时间也会自动对齐。不过这里还有个细节取整到秒会导致最后一秒的丢失。比如当前耗时是 990 毫秒显示出来的还是“已计时0 秒”直到超过 1000 毫秒才变成 1。这其实是合理的因为你在显示整数秒。如果想更平滑可以把耗时精确到 0.1 秒来展示。4.3 实测后会发现什么问题第一版跑起来后你会明显发现一个不方便的地方根本没法暂停。虽然 stop() 能停但再 start() 又会从 0 开始。想临时离开座位接个电话回来之后刚才的累计全没了这不符合真实计时器的使用习惯。还有一个隐藏问题如果你在主线程里调用 stop()工作线程什么时候退出看起来是立刻设置 runningfalse但有可能工作线程正在 sleep 里它要等 sleep 结束、进入下一次循环判断时才发现 running 变成 false。也就是说最多会延迟 1 秒才退出。像这种“不要求立刻终止”的场景延迟退出是可以接受的但如果业务里要求快速响应停止指令就得用 interrupt() 去结合 sleep 的响应机制让线程从睡梦中直接醒来。这个版本算是把 Thread 的基本用法打通了。下一步给它加上暂停和继续难度会提升不少这才是真正考验多线程功夫的地方。5. 第二版实现加入暂停、继续与复位5.1 用 wait 和 notify 实现暂停要支持暂停常见的做法有轮询标志位和 wait/notify 两种。轮询标志位虽然简单但线程在暂停期间会空转白白浪费 CPU。更好的做法是让工作线程在暂停时进入 WAITING 状态等继续的通知。下面这段代码是核心逻辑。我定义了一个锁对象 lock工作线程每轮循环都会先检查 paused 标志。如果为 true就进入lock.wait()等待直到有人调用lock.notifyAll()才继续。public class PausableTimer { private final Object lock new Object(); private volatile boolean running false; private volatile boolean paused false; // 线程上一次开始计时的时刻 private long segmentStartTime 0; // 已经累计的毫秒数 private long accumulatedMs 0; private long displayMs 0; public synchronized void start() { if (running) { return; } running true; segmentStartTime System.currentTimeMillis(); Thread worker new Thread(() - { while (running) { // 每秒刷新一次 displayMs accumulatedMs (System.currentTimeMillis() - segmentStartTime); System.out.println(已计时 (displayMs / 1000.0) 秒); synchronized (lock) { while (running paused) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }, pausable-timer-worker); worker.setDaemon(true); worker.start(); } public synchronized void pause() { if (!running || paused) { return; } paused true; // 把刚才这一段时间段累计进来 accumulatedMs System.currentTimeMillis() - segmentStartTime; } public synchronized void resume() { if (!running || !paused) { return; } paused false; // 重新记录起始时刻 segmentStartTime System.currentTimeMillis(); synchronized (lock) { lock.notifyAll(); } } public synchronized void reset() { running false; paused false; accumulatedMs 0; displayMs 0; synchronized (lock) { lock.notifyAll(); } } }这段代码里值得好好解释的是 pause() 和 resume() 里对时间的处理。暂停的瞬间我先算出“从本段开始到现在”的耗时把它累加进 accumulatedMs。然后 resume 的时候再重新记录一个 segmentStartTime。这样暂停期间流逝的时间就不会被算进去。相当于把时间轴拆成了若干段暂停点把时间轴切了一刀继续点重新打开一段新的时间轴。第一次写的时候我容易犯的错误是把 accumulatedMs 直接累加秒数。但你想想如果累加的是已经取过整的数误差就会一次一次累积最后显示的结果会越来越不准。所以在内部计算里永远用毫秒只有显示的时候才转成秒。5.2 注意 notifyAll 的时机这里有个坑notifyAll 和锁的关系。你不能在 synchronized 代码块外面直接调用 lock.notifyAll()因为那样可能会被 JVM 抛出 IllegalMonitorStateException。所以 resume() 里我先在 synchronized 方法级别拿到了方法锁再去拿 lock 的同步代码块。几个线程之间要访问同一个锁对象否则 wait 和 notify 就对应不上了。另外工作线程里 wait 的判断条件是while (running paused)这里用了 while 而不是 if。这是个老生常谈但极其重要的细节。线程被唤醒后不能保证唤醒它的原因一定是你想要的那个。Java 官方的文档也反复强调要在循环里检查等待条件防止“虚假唤醒”和状态变化竞态。5.3 把刷新间隔从 1000 改成 100 的原因第一版里 sleep 用的是 1000 毫秒但第二版我故意改成了 100 毫秒。原因很简单暂停发生后用户期望看到界面尽快做出响应。如果你每 1 秒才判断一次线程是否在等待那从点击“暂停”到页面停止刷新最多会延迟 1 秒体验非常差。把刷新周期缩短到 100 毫秒界面响应最多延迟 100 毫秒体感上就是“立刻停住”。代价也很明显线程醒得更频繁了每秒会产生 10 次判断和打印。在真实项目里如果你要做的不是控制台计时器而是带界面的应用这种 10Hz 的刷新频率对性能影响很小完全能接受。但如果场景是每秒钟写一次数据库那就没必要 100 毫秒一刷应当根据业务需求来定。练习项目里这个改动主要是为了让你体会“刷新频率谁说了算”。6. 第三版实现用 ScheduledExecutorService 重构6.1 为什么要换掉手写线程手写线程能学到东西但代码量并不少而且暂停、重置这些操作都得自己维护标志位。在真实项目中我几乎不用裸线程做周期任务而是直接用调度线程池。ScheduledExecutorService 是 JDK 自带的定时调度服务使用起来干净也好关闭。先看一个最简单的例子ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { System.out.println(每秒执行一次); }, 0, 1, TimeUnit.SECONDS);注意scheduleAtFixedRate 后面的参数顺序不能搞混分别是 initialDelay、delay 和 unit。意思是任务刚开始延迟 0 毫秒执行之后每隔 1 秒执行一次。6.2 scheduleAtFixedRate 与 scheduleWithFixedDelay 怎么选这两个方法名字很像行为完全不同。scheduleAtFixedRate 强调的是“固定速率”。它会把任务排到一个时间表上第一次执行后第二次的开始时间尽量控制在初始时间 1 秒第三次是初始时间 2 秒以此类推。如果某一次任务执行时间超过了间隔那么下一次任务不会被推到“结束后 1 秒”而是会尽量追赶排定的时间点。scheduleWithFixedDelay 则是“固定延迟”。它不管绝对时间只管这次任务结束以后再休息 1 秒执行下一次。如果你的任务本身耗时不固定用 fixed delay 更稳避免任务像追债一样连续堆积。放到计时器这个场景里刷新任务非常轻几乎不会超过 100 毫秒用 scheduleAtFixedRate 就行能让“每秒更新一次”的节拍更准确。6.3 基于调度线程池的计时器完整实现在调度线程池的基础上重新实现计时器public class ScheduledTimer { private final ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); private volatile boolean running false; private volatile boolean paused false; private final Object lock new Object(); private long accumulatedMs 0; private long segmentStartTime 0; public void start() { if (running) { return; } running true; segmentStartTime System.currentTimeMillis(); scheduler.scheduleAtFixedRate(this::refresh, 0, 100, TimeUnit.MILLISECONDS); } private void refresh() { if (paused) { return; } long displayMs accumulatedMs (System.currentTimeMillis() - segmentStartTime); System.out.println(已计时 (displayMs / 1000.0) 秒); } public void pause() { if (!paused) { paused true; accumulatedMs System.currentTimeMillis() - segmentStartTime; } } public void resume() { if (paused) { segmentStartTime System.currentTimeMillis(); paused false; } } public void shutdown() { scheduler.shutdown(); running false; } public static void main(String[] args) throws InterruptedException { ScheduledTimer timer new ScheduledTimer(); timer.start(); Thread.sleep(3000); timer.pause(); System.out.println(已暂停); Thread.sleep(2000); timer.resume(); Thread.sleep(3000); timer.shutdown(); } }这个版本比手写线程的版本清爽太多了。暂停逻辑只剩一个标志位和累计时间不需要再去管 wait、notify、锁。因为调度线程池里的 worker 线程始终醒着只是 refresh 方法内部判断一下要不要干活这个成本极低。shutdown() 这边也有讲究。shutdown() 调用后线程池不再接受新任务但已经在队列里的任务会继续执行完。对于计时器这种场景调用 shutdown 后那最后一个任务可能还会再刷一次影响不大。如果想要立刻停止可以调用 shutdownNow()它会尝试中断正在执行的任务。但在正式项目里如果任务里正在处理重要数据shutdownNow 可能会把任务打断到一半需要你自己处理 InterruptedException 的逻辑不能无脑用。7. 线程安全细节逐项拆解7.1 volatile 到底管住了什么很多人写多线程代码习惯性地给所有共享变量加 volatile但问为什么又说不上来。在计时器这个例子里running、paused 这两个变量是主线程和工作线程都要读写的加上 volatile 是为了防止可见性问题。简单说Java 里一个线程对变量的修改在另一个线程里不保证立刻能看到。编译器可能做优化把变量读进 CPU 缓存里别的线程改了它也不知道。volatile 的作用是强制每次都从主内存读取并且禁止指令重排。但 volatile 解决不了复合操作的原子性。比如代码里if (!paused) { paused true; ... }这种“先判断再修改”的逻辑光有 volatile 就不够了因为两个线程可能同时判断成功然后都走进来。这个问题在计时器这种场景里没那么严重因为你通常会在同一个 UI 线程里处理用户操作不会出现两个线程同时点“暂停”。但为了严谨我仍然把 pause 和 resume 设计成 synchronized 方法。7.2 long 类型变量的原子性问题在第二版代码里accumulatedMs 是 long 类型。在 32 位 JVM 上对 long 的读写不是原子操作因为它占了 64 位可能被拆成两次 32 位操作。但这在 64 位 JVM 上基本不是问题现代 JDK 都按 64 位来跑。不过你要有这个意识跨平台的多线程代码对共享的 long/double 变量要小心。更保险的写法是用 AtomicLong 代替 long。比如把 accumulatedMs 定义成 AtomicLong 对象调用 addAndGet 来更新。这个类底层用 CAS 实现既保证可见性也保证原子性。如果你的项目将来要跑到嵌入式环境或者旧版 JVM 上用 AtomicLong 会更稳。不过在这个练习里我保留 long 就是为了把“这个变量可能不安全”的点讲出来实际项目中遇到积累型数字我会直接用 AtomicLong。7.3 展示层与计时状态的关系还有一个很多人会忽视的地方你打印或者显示出来的数字到底代表哪个时刻的状态如果 UI 线程从共享变量里读 displayMs 显示而工作线程又在同一时刻更新它你看到的值可能“差一帧”。这个问题在单线程写的控制台代码里不明显但放到 Swing 或 Web 后端就很重要。解决思路是让状态统一在一个地方修改不要两个线程同时改同一个值。比如计时器内部维护自己的累计状态UI 或输出层只负责拿到最终结果。这样虽然不能保证绝对实时但能避免数据被写乱。8. 常见问题与排查技巧实录8.1 暂停后继续时间突然跳了几秒这是很多初学者第一次加暂停功能时常踩的坑。现象是暂停前显示 5 秒暂停 10 秒后继续显示直接跳到 15 秒。原因通常是你在继续时忘了更新 segmentStartTime。继续的那一行正确做法是把“当前系统时间”重新赋给 segmentStartTime这样接下来计算时暂停那段空白被彻底排除在外。如果漏掉这一步累计时间就会把暂停期间也算了进去。排查方法也很简单暂停前后各打一条日志记录 accumulatedMs 和 segmentStartTime 的值。看一眼就能定位是哪一段逻辑没对上。8.2 点了停止打印还在继续这个问题的根源通常是 running 标志没有生效。可能的情况有两种一是 running 没加 volatile另一个线程修改后工作线程看不到二是工作线程卡在 sleep 里还没进入下一次循环的判断。第一种叫可见性问题第二种叫休眠延迟。无论是哪种结果都是停止指令不能立刻生效。处理方式就是在工作线程的 sleep 上做文章。比如用Thread.sleep(100)把单次休眠时间缩短停止指令最迟 100 毫秒后就能被处理体感上几乎没有延迟。如果想要真正“秒停”就得用 interrupt 去打断 sleep但这一步也要求你在 catch 块里给出退出的路径否则线程只是醒来又接着算了。所以你在设计停止接口时需要决定是“优雅停止”还是“快速停止”。8.3 重复 start 导致秒数翻倍如果你在界面里多次点击“开始”按钮没有在 start 方法开头加判断就会 new 出多个线程。多个线程同时跑打印自然会乱。解决办法是加一个 running 的标志判断如果已经在跑了就直接 return。更进一步还要考虑“线程池关闭之后又启动”的情况。ScheduledExecutorService 一旦 shutdown就不能再 submit 新任务了如果业务上需要“停止后还能重新开始”你要么每个周期重新 new 一个线程池要么用别的方式管理状态不能指望同一个线程池死而复生。这一点很容易被忽视。我见过有人在项目里把线程池定义成全局静态变量调用过一次 shutdown第二次想复用直接报 RejectedExecutionException。查了半天才发现线程池已经关闭了。8.4 刷新任务堆积日志越打越多使用 scheduleAtFixedRate 时如果任务本身很慢比如在刷新方法里连了数据库单次耗时超过了调度周期线程池队列里就会积压很多待执行任务。调度器会尽量追赶排定时间一次性把积压的任务全部执行掉日志瞬间爆炸。我在以前的一个监控项目里遇到过一次原因就是某个定时任务里做了一次远程调用网络卡顿导致单次任务耗时到了 5 秒而调度周期是 1 秒于是队列里压了四个任务远程调用一恢复四个任务几乎同一时刻全部执行。从那以后我对周期任务有一个原则任务内部耗时必须远小于调度间隔否则就换 scheduleWithFixedDelay或者干脆放到工作线程里异步处理。9. 关于计时器设计的几条个人经验总结做完这三个版本我自己的体会还是很深的。第一个感受是计时器这个案例虽然小但几乎把多线程的基础考点全部串起来了。你不需要再找什么复杂的项目只要能把这个案例吃透线程创建、可见性、等待唤醒、中断、线程池关闭这些面试里高频出现的东西就都有了实际经验支撑。第二个感受是线程代码写完之后一定要手动跑一遍“暂停 10 分钟再继续”的测试。很多在短时间里看不出来的问题时间一长就暴露了。比如累计误差、任务堆积都是长时间运行才会浮现。短时间测试里一切正常往往只是因为你运气好没撞上竞态窗口。第三个建议是这个案例还能继续扩展。比如做成倒计时模式就需要处理“到点了怎么唤醒等待线程”做成多个计时器并行运行就需要额外考虑每个计时器之间如何隔离是该各自一个线程还是共享一个线程池。还有更进一步的方向是把计时器的状态变更做成事件通知让界面层、日志层、统计层都能感知到暂停、继续、归零这些动作那就涉及到观察者模式了。每次往外扩展一层你对多线程的理解就会再深一层。我自己就是从计时器这个小项目开始一点一点把异步、并发、调度这些东西在真实代码里磨出来的。也建议你把代码里每个版本都保留一份反复对比它们的演进过程比单纯背任何理论都管用。
返回列表