
做系统的时候总有一类需求绕不开每隔几秒做点事情轮询数据库状态、刷新缓存、发送心跳包或者界面上憋着一个不断跳动的倒计时。大多数人第一反应是while Thread.sleep()讲究一点的会用Timer结果线上踩了几个坑之后才愿意回头把多线程基础补扎实。这篇文章就拿“计时器”当案例带你把Java多线程从“能跑”走到“能用”覆盖线程创建、中断、可见性、原子性、协作等待、定时调度这些核心点。计时器这个例子虽然小但它能把JMM的内存可见性、线程协作的阻塞唤醒、定时任务的工程选型全串起来特别适合刚学完基础语法、正卡在“线程到底怎么落地”这一关的同学反复拆解。后面所有代码你都能直接复制到本地跑改几个参数就能用到自己的项目里。1. 为什么拿“计时器”练多线程而不是更复杂的东西1.1 计时器天然要求“时间推进”和“主流程”分离你写一个下单页面的倒计时主导线程在等用户输入界面上的数字却要一秒一秒往前走。这两件事如果写成同一段函数谁都没法干活。把“计时推进”抽成一个独立线程让它在后台每秒更新一次状态主线程该干嘛干嘛这就是并发编程最典型的职责拆分。很多新手写时序逻辑时一上来就卡在这里不知道哪些东西该放进线程体哪些该留在主流程。计时器恰好把边界画得清清楚楚——推进动作进线程业务操作留在主流程两个世界通过共享状态对话。1.2 它能干净地复现线程三大核心问题线程学起来抽象是因为三大问题通常散落在这篇文章里、那篇例子里很难真实感受到“为什么我的多线程程序结果不对”。计时器可以把它们全部曝光线程安全性多个线程同时读改写剩余秒数不加同步会出现莫名其妙的值。可见性一个线程修改了运行标志另一个线程看不见程序不退出。线程协作暂停、恢复、停止本质是多个线程之间协调推进节奏得靠wait/notify或者更高级的锁工具。也就是说一个计时器做出来你基本已经过了“纸上谈兵”的阶段直接见过并发编程最常见的三个坑。这也是我推荐进阶阶段用这个案例练手的原因覆盖面广代码量又不会大到劝退。1.3 这个案例适合什么人、需要哪些基础如果你是那个“能写完整类但没系统碰过线程”的人最合适。建议先熟悉Thread和Runnable两个基本接口知道sleep会让线程暂时停下知道synchronized可以给方法加锁。不理解细节没关系文里用到的地方都会解释得比较细但要能跟着代码跑。我还遇到不少工作了两三年、平时只用过Executors封装类的朋友并不知道定时任务底层还有这么多讲究这篇对他们同样值得读。基础没打牢的时候直接去看并发框架只会觉得“好像会用不知道原理”本项目正好补齐中间断层。2. 第一个能跑的计时器Thread继承与中断陷阱2.1 先跑起来直接继承Thread实现每秒计时先来一个能运行的版本感受一下最原始的姿态。public class TimerThread extends Thread { private int count 0; Override public void run() { try { while (true) { Thread.sleep(1000); count; System.out.println(已过 count 秒); } } catch (InterruptedException e) { System.out.println(计时器线程被中断退出); } } public static void main(String[] args) throws InterruptedException { TimerThread timer new TimerThread(); timer.start(); Thread.sleep(5000); System.out.println(主线程休息完毕尝试停止计时器); timer.interrupt(); } }这段代码描述的是最朴素的模型一个独立线程负责计时主线程五秒后发出中断信号让计时线程结束。运行一下你会发现interrupt()之后线程确实退出了主线程也继续走完。但这里藏着一个巨大的隐患我在很多生产代码里都见过InterruptedException被当成“暂停一下再继续”的信号。真正的中断语义是“有人请求你停止当前工作快点响应”你要是把异常悄悄吞掉整个线程可能永远不会退出。2.2 Thread和Runnable怎么选换用Runnable改造上面例子继承Thread能跑但属于“反面典型”。原因用两句话能说清楚任务代码和线程生命周期耦合在一起你没法把这个计时逻辑拿到线程池里复用一个类继承了Thread就失去了继承其他类的机会。改成Runnable才是正确姿势。public class TimerTask implements Runnable { private int count 0; Override public void run() { try { while (!Thread.currentThread().isInterrupted()) { Thread.sleep(1000); count; System.out.println(已过 count 秒); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println(计时任务被中断当前计数 count); } } public static void main(String[] args) throws InterruptedException { Thread worker new Thread(new TimerTask(), 计时器线程); worker.start(); Thread.sleep(5000); worker.interrupt(); } }这里我把Thread和Runnable的职责分开看Thread是工人Runnable是工作内容。工人可以换工作内容不会变。你之后想把同样的计时逻辑丢给线程池执行直接executor.submit(new TimerTask())就行不需要再造一个线程。很多老项目里那种“一个业务一个Thread子类”的写法改造成本高就是早期没有分清楚这两个角色。现在在团队里看到这种代码我通常建议趁早重构越晚越被动。2.3 中断标志与sleepInterruptedException不是用来吞的上面代码里的Thread.currentThread().interrupt()这行新手很容易漏掉。要理解它得先搞清楚一件事Thread.sleep()在被中断时会直接抛出InterruptedException同时把线程的中断状态清掉。听起来没问题但如果你不手动再设置一次中断标志线程后续即使还想处理中断信号也没办法通过isInterrupted()感知到曾经有中断发生过。换句话说catch块里如果不补回标志位等于把“有人喊你停下”这个消息弄丢了。我见过最坑的版本是这样的catch (InterruptedException e) { /* 什么都不做 */ }外面用while (true)包住。你以为中断能停线程实际它会一直空转。排查这种问题最费时间因为代码看起来“没错”甚至没人会怀疑sleep被中断后标志位会被清掉。所以我的习惯是只要捕获了InterruptedException第一行就写Thread.currentThread().interrupt()要么直接return退出任务两种方式都保证标志位不丢。3. 让计时器能真正“停下来”volatile标志位3.1 为什么不能用废弃的stop方法讲停止线程很多人第一反应是“不是有个stop()方法吗”这个方法确实存在但它已经被标记为废弃生产环境千万别碰。stop()的粗暴程度相当于直接拔电源它不管当前代码执行到哪一行、锁有没有释放、资源有没有清理直接让线程带着残局消失。最典型的问题是假如线程正在写一笔订单数据写到一半被stop()剩下那半条数据就永久留在内存里了其他线程读到的状态完全不可预测。所以Java官方给的态度很明确停止一个线程得靠协作不是靠暴力。协作的意思是别的线程发出“请停止”的请求目标线程在合适的检查点自行退出。3.2 用volatile布尔变量实现协作式停止最容易写也最实用的协作方式是加一个运行开关。public class VolatileTimer implements Runnable { private volatile boolean running true; Override public void run() { int count 0; while (running) { try { Thread.sleep(1000); count; System.out.println(已过 count 秒); } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println(收到中断准备退出); return; } } System.out.println(计时器正常退出); } public void stop() { this.running false; } }这个版本的思路很清晰stop()方法把running设为false线程检查到之后就退出循环。这里必须加上volatile否则一定会遇到“运行了半天还是停不下来”的灵异问题。原理是这样的在多线程环境下每个线程都可能把共享变量缓存在自己的工作内存里A线程改了running的值B线程未必能立刻看到最新值它可能一直用旧缓存继续跑。volatile保证running的修改对任何线程都可见相当于在所有读写点之间建立一条直通通道。这段代码里我保留了中断处理。其实有了volatile就算sleep被打断也不影响退出逻辑但保留中断处理能让代码更健壮。实战中建议标志位和中断两条腿走路因为有些阻塞操作比如等待锁、等待IO只认中断信号不认你的running标志。3.3 volatile只解决一半问题可见性不等于原子性volatile好用但不能神话。它只解决“我对变量的修改别人能看见”并不解决“多个线程同时改写一个变量时的竞争问题”。拿计时器举例如果两个线程同时对count执行count就崩溃了。因为count实际上是“读取-加一-写回”三步两个人同时读到同一个旧值各自加一再写回结果只加了一次。volatile根本不拦这种竞争。遇到需要“读改写”的共享状态就得靠原子类或者加锁。这也是为什么下一章要引入AtomicInteger。4. 给计时器加上暂停、恢复、重置原子类与wait/notify4.1 倒计时场景重新设计现在我把例子升级一下做一个支持“暂停、恢复、重置、停止”的倒计时器。这个版本比单纯秒表实用得多也更贴近真实业务里的交互场景。设计数据模型时有两个共享状态需要处理剩余秒数多线程会读会写用AtomicInteger保证原子更新。运行状态暂停、运行、停止用volatile boolean控制可见性。同时线程在暂停状态下不能傻等要主动让出CPU这里用wait/notify配合一个pauseLock对象来实现。import java.util.concurrent.atomic.AtomicInteger; public class CountdownTimer { private final AtomicInteger remaining new AtomicInteger(0); private final Object pauseLock new Object(); private volatile boolean running false; private volatile boolean paused false; private Thread workerThread; public void start(int seconds) { if (running) { System.out.println(计时器已经在运行请先停止再启动); return; } remaining.set(seconds); running true; paused false; workerThread new Thread(this::run, countdown-worker); workerThread.start(); } public void pause() { paused true; } public void resume() { if (!paused) return; paused false; synchronized (pauseLock) { pauseLock.notifyAll(); } } public void stop() { running false; synchronized (pauseLock) { pauseLock.notifyAll(); } } private void run() { while (running) { while (paused) { synchronized (pauseLock) { try { pauseLock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } int current remaining.get(); System.out.println(剩余秒数 current); if (current 0) { System.out.println(倒计时结束); running false; return; } try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } remaining.decrementAndGet(); } } }调用方式也很直观CountdownTimer timer new CountdownTimer(); timer.start(5); Thread.sleep(2000); timer.pause(); Thread.sleep(3000); timer.resume(); Thread.sleep(5000); timer.stop();原子类在这里的价值就是让remaining.decrementAndGet()这个“读-改-写”动作变成一步到位不再担心两个线程同时减一导致丢数据。真正写业务代码的时候只要涉及计数、序号、开关这种单值状态优先考虑原子类简单高效不用背上Lock的复杂心智负担。4.2 wait/notify协作要注意的三类问题第一次写wait/notify的人十有八九会踩到下面几个坑我先替你排掉。第一wait()必须在持有锁的代码块里调用否则直接抛IllegalMonitorStateException。这个设计是为了保证wait检查条件与释放锁之间是原子操作避免“线程将要等待时另一个线程已经完成唤醒”的竞态。所以上面代码里每次调用wait()前都会synchronized (pauseLock)。第二notify只能唤醒正在等待的线程唤醒后它还要重新抢锁才能继续执行。我看到新手写的暂停恢复逻辑经常只记得paused false忘了notifyAll()结果线程在wait()里睡死过去怎么点恢复都没反应。上面代码里resume()和stop()两个方法都主动做了notifyAll()就是为了把这个“睡死”的线程捞起来。第三暂停语义和剩余秒数要小心边界。倒计时到0的判断必须放在打印之后、继续循环之前不然可能出现“明明显示0秒还在继续打印-1秒”的尴尬情况。我在上面run()里把判断放在打印后面就是这样考虑的先输出当前值再判断是否结束逻辑走到decrementAndGet()时剩余秒数已经不是屏幕上显示的那个值了正好形成完整的一秒节奏。4.3 为什么我推荐先把wait/notify练熟再上锁工具现在很多同学上来就直接用ReentrantLock和Condition觉得wait/notify太原始。我的观点相反Object自带的wait/notify虽然简陋但它能逼你理解“条件、锁、阻塞、唤醒”四个概念的本质。Condition只是把同一套语义包装得更顺手你如果连wait为什么要进synchronized块、为什么用while而不是if来检查条件都没想明白换Condition照样写出死锁。破案快的秘诀不是在高级工具里找答案而是把底层机制摸清。用了两三次wait/notify之后你再看Condition源码基本就是翻译题了。5. 定时任务的工程化选择Timer与ScheduledExecutorService5.1 Timer为什么通常被劝退看到这里你已经能写一个相当灵活的计时器了。但如果回到真实项目中直接用while sleep或者自己造轮子并不是最高效的方案。Java自带java.util.Timer可以做定时调度但实战里我几乎不推荐原因有两条。第一Timer内部只有一个工作线程。你同时注册多个TimerTask它们全在一条线程里排队执行。任何一个任务耗时过长后面的任务全部被拖慢。这个特性在响应性要求高的业务里是灾难。第二TimerTask抛出的异常会直接终结整个Timer。一个任务挂了其他所有任务跟着遭殃而且没有任何日志提示排查起来特别懵。还有一个行为细节值得注意Timer.schedule()和Timer.scheduleAtFixedRate()对“错过执行时间”的处理不同。前者按相对间隔推迟执行后者会试图补上落下的执行次数在系统繁忙时可能造成任务瞬间爆发。这俩差异不仔细看文档的话很容易在压测环境里暴雷。5.2 用ScheduledExecutorService替代Timer线程池出现之后Timer基本成了历史包袱。ScheduledExecutorService从语义上更清晰它底层是线程池多个任务之间天然隔离单个任务异常穿透时只影响那个任务自身不会拖垮整个调度器。日常写定时任务我首选这个方案。import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class SchedulerDemo { public static void main(String[] args) { ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); scheduler.scheduleWithFixedDelay(() - { System.out.println(心跳任务执行 System.currentTimeMillis()); }, 1, 2, TimeUnit.SECONDS); // 模拟十分钟后关闭 scheduler.schedule(() - { System.out.println(调度器关闭); scheduler.shutdown(); }, 10, TimeUnit.MINUTES); } }写业务代码时我一般这样区分两个方法方法行为适用场景scheduleAtFixedRate以上一次任务开始时间为基准固定频率触发不关注任务实际耗时尽量少用容易造成任务重叠执行scheduleWithFixedDelay以上一次任务结束时间为基准固定延迟间隔触发默认推荐避免任务堆叠行为更好预判选scheduleWithFixedDelay的理由很直接现实中的任务耗时往往有抖动如果你用固定频率触发一旦任务执行时间超过预设周期下一个任务就会紧跟而来很快可以看到线程池被打满。而固定延迟模式保证任务之间永远有缓冲间隔对系统更友好。5.3 任务执行时间超过间隔怎么办这是一个特别容易被忽略的工程问题。假设你注册了一个周期10秒的任务但任务内部某次请求卡了30秒等到它返回时scheduleAtFixedRate会立刻补跑之前落下的执行。如果补跑的任务本身也卡系统很快进入“疯狂重试”的恶性循环。所以我会在真实项目里做两层保护调度层用scheduleWithFixedDelay从根上避免密集补跑。任务内部再做一层超时控制比如用CompletableFuture加get(timeout)兜底防止任务自身长时间霸占线程。另外如果你的任务既重又频繁建议把“需要长时间执行的工作”丢到一个独立的业务线程池调度线程只负责任务编排。否则一条调度线程池再智能也算不清它拖了多少后续任务的启动时间。6. 计时器落地的典型业务场景与精度陷阱6.1 三个真实业务中离不开计时器的场景很多同学学完线程不知道多线程到底可以用在哪。计时器这个形态其实渗透得很广。第一个是接口耗时监控。写一个切面记录方法调用前的nanoTime方法执行完再算一次差值超过阈值就告警。这里“计时器”就是一段计算耗时逻辑不需要独立线程但要把测量起点和终点包得足够可靠。第二个是心跳与保活。后台调度线程每N秒向服务端发送一个心跳包或者检查某个连接是否还在这本质上就是scheduleWithFixedDelay的业务化身。第三个是定期刷新缓存。常见的“本地缓存五秒刷新一次”就是一个典型的定时任务。这类场景不需要自己做线程协作重点是选对调度工具并且保证任务内部异常不泄漏。6.2 用nanoTime测耗时不要拿currentTimeMillis硬算要在计时器项目里养成一个好习惯测量时间间隔用System.nanoTime()不要用System.currentTimeMillis()。原因很微妙currentTimeMillis返回的是“墙上时钟时间”它可能因为系统对时、用户改时间、NTP同步而前后跳变。如果刚好在改时间的那一秒做耗时统计计算出来的耗时可能是负数或者瞬间暴涨到几小时。而nanoTime是单调递增的计时器专门用来测量时间差不受系统时间调整影响。long start System.nanoTime(); // 执行需要监控的业务逻辑 long costNs System.nanoTime() - start; long costMs TimeUnit.NANOSECONDS.toMillis(costNs); System.out.println(接口耗时: costMs ms);注意nanoTime虽然精度高但它没有绝对时间语义不能用来换算成某年某月某日只适合算差值。6.3 超时控制的经典姿势Future get(timeout)有种“计时器”不用自己数秒而是让框架帮你等发一个异步任务出去限制它多少毫秒内必须返回超时就按失败处理。这种模式在RPC调用、数据库查询、外部API请求里特别常见。import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; public class TimeoutDemo { public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(2); FutureString future pool.submit(() - { Thread.sleep(5000); return 任务结果; }); try { String result future.get(2, TimeUnit.SECONDS); System.out.println(拿到结果: result); } catch (TimeoutException e) { System.out.println(任务超时做降级处理); future.cancel(true); } finally { pool.shutdown(); } } }future.get(timeout)是并发编程里最实用的“闹钟”之一。它帮你自动完成“计时检查异常处理”的闭环不需要你手动维护秒数变量。我个人的使用偏好是凡是让外部资源干活的地方都加一层超时控制宁可快速失败也别让请求服务无限期挂起。7. 多线程计时器调试经验与常见问题速查7.1 排查计时器问题时的日志套路多线程程序调试最难的是信息不对称你根本看不到哪个线程在哪一步停住了。我自己的做法是在三个关键节点打日志状态变更、循环开始、循环结束。比如pause()方法一被调用立刻记录“paused 置为 true当前剩余秒数xxx”resume()被调用记录“paused 置为 false已发出 notifyAll”。这样一旦行为不对回看日志就能还原出时间线。日志格式上建议带上线程名和时间戳不要只打一句话。像上面代码里我给workerThread起的名字是countdown-worker真到线上排查时main、pool-1-thread-1这种默认名字会让你对人不对事。日志里把线程名带出来配合jstack抓线程快照基本能一眼定位是哪个环节卡住。7.2 常见问题速查表现象常见根因处理方式计时线程永远停不下来catch InterruptedException后没有补回中断标志位或线程在阻塞中无法响应volatile标志在catch块里调用Thread.currentThread().interrupt()并考虑主动return界面假死、按钮无响应在GUI事件线程里直接sleep或死循环阻塞把计时任务放到独立线程或线程池绝不在事件派发线程里阻塞暂停之后无法恢复只改了暂停标志位没有调用notifyAll()唤醒wait()的线程在resume()和stop()中都同步唤醒阻塞线程一个定时任务出错所有任务都不执行了使用Timer且任务内部没有异常兜底改用ScheduledExecutorService任务内部catch所有Exception这张表是我做计时器案例时踩坑记录的浓缩版。前两类最容易出现在学习阶段后两类基本只有写真实业务才会遇到。每次排查完顺手把现象整理成这种格式攒上十几条你对多线程的理解会比读十篇博客都扎实。7.3 一个扩展玩法计时结束之后自动回调基础版计时器只能在控制台打印真实项目里往往需要“倒计时结束之后自动执行某件事”比如跳转页面、发送通知、触发一个异步流程。这个需求可以用CompletableFuture优雅地完成不需要重新造轮子。import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; public class CountdownCallback { public static void main(String[] args) { CompletableFutureVoid countdown CompletableFuture.runAsync(() - { for (int i 5; i 0; i--) { System.out.println(倒计时: i); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }); countdown.thenRun(() - System.out.println(倒计时结束执行回调)); // 主线程干点别的 System.out.println(主线程继续执行其他逻辑); } }runAsync把倒计时过程放到公共线程池thenRun注册一个回调倒计时结束自动触发。组合式写法比手动维护“计时线程结束后去调哪个方法”清爽得多。这也是我目前处理“定时后处理”最常用的结构代码简单可读性强后续扩展多个连续动作也方便。我个人在实际操作中的体会是计时器看似简单但它把并发编程里最容易翻车的几个点集中放大了。你能把一个计时器从“while循环能跑”一步一步改成“可控、可停、可恢复、可回调”说明你对线程模型、共享状态、调度策略已经建立了直觉。学到这里的同学建议自己动手补齐一个功能在倒计时结束前5秒让控制台输出一次警告顺便处理掉main线程直接退出导致的计时器中断问题。这个扩展不大但能把本文提到的大部分知识点再完整走一遍比收藏三遍博文有用得多。希望这篇能成为你深挖并发编程的起点而不是终点。