Java并发编程基础全解析:从JMM到线程池的实战指南
刚接触Java并发编程的人十有八九都会有这样的感受线程、锁、同步、volatile、线程池每个词都认识放到一起做题就懵写出来的程序时而正常时而出错。我在带新人和做代码评审过程中发现很多工作两三年的人对并发的基础概念也停留在“会用synchronized、知道有几个线程池方法”这个层面。这篇文章想用一次系统梳理把Java并发编程基础这条线完整串起来从JMM三大特性到底层原理从线程创建到锁机制从线程池到JUC工具最后聊几个我实际排查过的线上案例。内容偏基础但绝不水适合还没系统学过并发、或者学完就忘的开发者当作一面镜子先照照自己哪块是薄弱点。1. 并发编程到底在解决什么问题1.1 并发与并行的区别别再傻傻分不清先说一个最容易混淆的概念。并发Concurrency和并行Parallelism经常被放到一起说但含义完全不同。并发是指多个任务在同一个时间段内交替执行看起来像同时发生并行是指多个任务在同一时刻真正同时执行。打个比方你一边烧水一边看手机这是并发因为你的注意力在两者之间切换你左手拿锅右手拿铲同时开火这是并行因为在同一个时间点你干了两件事。计算机里单核CPU通过时间片快速切换实现并发多核CPU才能让多个线程真正并行。Java并发编程的舞台就是围绕这件事展开的。进程是操作系统分配资源的基本单位线程是CPU调度的基本单位。一个进程里可以有多个线程线程之间共享进程的堆内存和方法区但每个线程有自己的虚拟机栈和程序计数器。这种设计让线程切换比进程切换轻量得多也正因为共享内存线程安全问题才成了一个绕不开的话题。你在多线程环境下访问同一个变量如果不做控制轻则数据错乱重则程序崩溃。上下文切换是另一个被低估的开销。操作系统在切换线程时需要保存当前线程的执行状态、程序计数器、寄存器内容再加载下一个线程的状态。这些本钱不小如果一个线程只执行很短的时间就被切走那大部分CPU时间都可能耗在切换上。所以线程数量不是越多越好开太多反而会导致吞吐量下降。这也是后面要讲线程池的核心原因之一。1.2 JMM与线程安全的三大特性Java内存模型Java Memory ModelJMM描述了多线程环境下变量访问的规则。它的核心目标是定义什么时候一个线程对变量的修改对另一个线程可见。JMM规定所有变量存在主内存每个线程自己的工作内存中保存变量的副本线程对变量的读写都在工作内存中进行不能直接操作主内存。这个设计对应到现代CPU就是多级缓存与内存之间的数据不一致问题。由此引出了线程安全的三个基本特性原子性、可见性、有序性。原子性指一个操作或者多个操作要么全部执行成功要么全部不执行中间不能被线程调度打断可见性指一个线程修改了共享变量后其他线程能立即看到修改后的值有序性指程序执行的顺序按照代码顺序来但实际上编译器和CPU为了优化会做指令重排一定场景下可能破坏线程安全。我举一个最简单也最常见的例子。两个线程同时对int变量i执行i理论上i从0加到10000两个线程各加5000次结果应该是10000实际却可能只是几千。原因有三层i不是原子操作它包含了读取、计算、写回三步线程之间的修改互不可见指令重排可能让执行顺序和想象中不一样。要解决这个问题要么用AtomicInteger保证原子性要么用volatile加CAS配合要么直接用锁把整个操作包起来。1.3 一段代码看懂为什么需要同步下面这个场景特别典型。模拟两个线程同时操作一个共享Map一个线程做put一个线程做遍历正常运行一会儿后抛出ConcurrentModificationException。这不是玄学而是遍历过程中集合被修改导致的快速失败机制。如果在多线程环境下直接用HashMap更严重的是可能在扩容时形成环形链表导致get()死循环。这个坑在JDK 7及以前是真的会出现JDK 8虽然改进了扩容方式但数据丢失问题依然存在。所以你会看到Java并发编程的第一原则是共享可变数据必须加锁或者干脆用并发容器。什么叫共享可变数据就是多个线程都能访问并且内容会变。只要满足这两个条件就属于危险地带。反过来不可变对象天然线程安全局部变量因为存在线程自己的栈中也是安全的。很多并发问题本质上不是锁不够用而是把不该暴露的东西随意暴露了出去。2. 线程的创建与生命周期最基础也最容易忽略的细节2.1 三种创建方式到底怎么选Java里创建线程一共有三种常规方式。第一种是继承Thread类重写run()方法第二种是实现Runnable接口把任务对象传给Thread第三种是实现Callable接口配合FutureTask获取返回值。很多新手问是不是第三种更好其实没有绝对的对错看场景。继承Thread的写法通常出现在教学示例里实际项目中我建议少用因为Java是单继承一旦继承Thread这个类就没法再继承别的类。而且重写run()方式把任务和线程绑定死不利于多线程执行同一份任务。实现Runnable接口是使用率最高的方式因为任务和线程解耦配合线程池使用非常自然。Callable和Runnable的区别在于它有返回值还会抛出受检异常适合需要拿到执行结果的场景。public class ThreadDemo { public static void main(String[] args) throws Exception { // 方式一继承Thread Thread t1 new Thread() { Override public void run() { System.out.println(thread created by extending Thread); } }; t1.start(); // 方式二实现Runnable Thread t2 new Thread(() - System.out.println(thread created by runnable)); t2.start(); // 方式三实现Callable FutureTaskInteger task new FutureTask(() - 42); Thread t3 new Thread(task); t3.start(); Integer result task.get(); System.out.println(callable result: result); } }这三种方式创建出来的线程本身没有本质区别区别在于任务的定义形式。实际开发中大家几乎不会直接new Thread来跑任务而是扔给线程池。线程池内部也是通过ThreadFactory创建线程底层还是这些机制。2.2 线程状态流转jstack输出全靠它定位线程状态是排查问题的一个重要入口。Java线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人疑惑RUNNABLE和BLOCKED的区别。RUNNABLE并不代表线程正在执行它可能正在等待CPU时间片BLOCKED是线程在等待进入synchronized同步块或方法时被阻塞。WAITING是线程执行了wait、join或LockSupport.park之后无限期等待需要别人唤醒TIMED_WAITING则是sleep、带超时的wait、join或park时间到了自动唤醒。线程刚new出来还没调用start()时是NEW调用start()后进入RUNNABLErun()方法执行完毕变成TERMINATED。每次用jstack命令看线程栈这些状态会直接体现在输出里。如果大量线程停在BLOCKED多半是锁竞争严重大量WAITING但没想象中忙很可能是任务等待条件不满足。这些状态的名字不是死概念是线上排障时的第一手信息。线程的常用方法也要搞清楚。start()和run()是新手最先踩的坑start()是向操作系统申请创建线程run()只是普通方法调用在本线程里执行不会起新线程。sleep()的作用是让线程进入TIMED_WAITING但睡醒后不一定会马上恢复执行得看CPU调度。yield()是礼貌地让出CPU让其他同优先级线程先跑但不保证一定让出去用意是提升公平性。join()则是让当前线程等待被调用的线程结束。2.3 wait和notify最原始的线程间通信方式synchronized与wait、notify是一对经典组合。wait()的作用是让获取了锁的线程释放锁并进入等待状态notify()是唤醒一个正等待该锁的线程notifyAll()唤醒所有等待线程。这里有个关键点wait和notify必须放在synchronized代码块里否则会抛出IllegalMonitorStateException因为wait操作的前提就是持有对象的监视器锁。public class WaitNotifyDemo { private final Object lock new Object(); private boolean ready false; public void produce() throws InterruptedException { synchronized (lock) { while (ready) { lock.wait(); } System.out.println(produce one item); ready true; lock.notifyAll(); } } public void consume() throws InterruptedException { synchronized (lock) { while (!ready) { lock.wait(); } System.out.println(consume one item); ready false; lock.notifyAll(); } } }示范代码里用的while循环判断条件而不是if这是面试也爱问的细节。因为线程被唤醒后不一定马上能执行它还需要重新竞争锁。在抢锁期间可能其他线程已经修改了条件如果醒过来直接用旧条件就会出错。所以等待条件必须放在循环中唤醒了检查不满足继续等。这个套路俗称“等待-通知”标准范式。还有一个困扰很多人的问题sleep和wait有什么区别两个方法都会让线程暂停但sleep不释放锁wait释放锁。如果线程持有某个对象的锁调用sleep睡觉别人想进同一同步块照样进不来调用wait别人就能抢到锁进去了。理解了这一点很多死锁代码就看得懂了。3. 锁与同步synchronized和Lock怎么选3.1 synchronized的三种用法与锁升级synchronized有三种用法修饰实例方法锁的是当前实例对象this修饰静态方法锁的是Class对象修饰代码块锁的是括号里指定的对象。选择哪种取决于你要保护什么资源。保护的是某个实例的属性就用实例方法锁保护的是所有实例共享的静态变量就得用静态方法或Class锁。锁错了对象等于没锁。从JDK 6开始synchronized做的优化非常重要。无锁、偏向锁、轻量级锁、重量级锁这条升级路线代表了JVM对锁的不断优化。偏向锁假设只有一个线程进来只在对象头里做个标记轻量级锁用CAS自旋适合锁竞争不激烈的情况自旋失败才升级为重量级锁由操作系统管理线程阻塞唤醒。线上评估锁竞争这个升级过程可以直接通过JFR或一些工具看到。需要注意的是JDK 15中偏向锁已被默认禁用后续版本也在逐步废弃原因是现代应用大多数场景下锁竞争激烈偏向锁的收益已经很小。synchronized的优点在于简单可靠自动释放锁不需要手动解锁就算代码抛异常锁也会归还。缺点是功能上比较受限不能中断正在等待锁的线程不能设置超时时间无法实现公平锁也不支持多条件队列。如果需求只要求基本互斥用它准没错。3.2 Lock接口与ReentrantLock的进阶能力Lock是JDK 5引入的显式锁接口最常用的实现是ReentrantLock。和synchronized相比它最突出的能力有几点可以响应中断lockInterruptibly可以设置等待超时tryLock带时间可以指定公平策略还可以配合多个Condition实现更细粒度的线程协调。这些能力在业务复杂时非常有用。Lock lock new ReentrantLock(); boolean acquired false; try { acquired lock.tryLock(3, TimeUnit.SECONDS); if (acquired) { // 这里操作共享资源 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired) { lock.unlock(); } }不能只说优点。ReentrantLock比synchronized复杂手动加锁解锁就意味着必须在finally里unlock否则一旦方法中途抛异常锁可能永久不释放。还有一个公平性问题ReentrantLock默认的非公平锁允许线程插队吞吐量更高但是有可能造成后面等待线程饿死公平锁按顺序排队吞吐量低但更公平。实际项目中我很少用公平锁除非业务上对等待时间有强要求。读写锁ReentrantReadWriteLock也是常见选择。它把锁拆成了读锁和写锁读锁可以被多个线程同时持有写锁独占。适合读多写少的场景比如配置中心、缓存刷新。但要注意读锁和写锁的相互排斥是严格的写锁持有期间任何读都不能进来。还有一种改进版叫StampedLock提供乐观读JDK 8后可用但使用姿势要慎重读多写极少的场景可以研究。3.3 死锁的产生与排查思路说到锁就不得不提死锁。死锁的经典条件有四个互斥、持有并等待、不可剥夺、循环等待。代码里最常见的死锁是交叉加锁比如线程A持有锁1等待锁2线程B持有锁2等待锁1。排查死锁我推荐一套流程先通过jstack打印线程栈找到状态为BLOCKED的线程然后看它们分别持有什么锁、等待什么锁如果形成循环基本可以锁定。解决死锁的方向通常是破坏循环等待条件。最简单粗暴的方法是保证所有线程按同一个全局顺序加锁比如两个资源都按ID大小排序后再锁。也可以用tryLock加超时拿不到就释放已持有的锁再重试。一旦在项目里遇到多个锁嵌套需要特别警惕。我在代码评审时看到嵌套synchronized的第一反应就是问这个顺序是全局统一的吗能合并成一个锁吗4. volatile与final轻量级并发控制手段4.1 volatile能保证什么不能保证什么volatile在Java中是一个轻量级的同步机制。它有两个作用保证变量在多个线程间的可见性防止代码里的指令重排序。但它不保证原子性这个结论无数面试题都在反复考。为什么因为volatile不会锁住变量两个线程同时读取、修改、写回该丢数据还是丢数据。它只保证一点一旦某个线程把新值写回主内存其他线程立刻能看到这个新值不会读旧数据。适用场景在哪里状态标志就是一个典型。比如一个boolean标志控制线程是否继续跑的循环用volatile修饰后另一个线程把它改成false循环线程很快就能发现并退出。这种场景没有复合操作用volatile足够。但如果是个计数器i请老老实实用AtomicInteger或者synchronized。我经常看到一个误区觉得volatile比synchronized轻量就什么都用它结果计数不准。记住轻量的前提是使用场景匹配。还有一个容易被忽略的点volatile只能保证引用本身可见不能保证引用指向的对象内部状态可见。也就是说volatile一个Map引用Map内容变了其他线程未必能感知。如果共享的是复杂对象要么用并发容器要么用锁。4.2 单例模式里为什么非要volatile有名的双检锁Double-Checked Locking单例就是一个最能解释volatile价值的例子。它的写法是先判空再同步块再判空最后new对象。很多人有疑问已经有了两层判空和synchronized为什么instance还要加volatile关键就在new Singleton()这行代码上。它看起来是一条语句实际拆成三件事分配内存空间、初始化对象、把引用指向这块内存。编译器和CPU可能把它重排序成“先指向内存再初始化对象”这在单线程下没有问题但多线程场景下线程A刚把引用指向半初始化的对象线程B通过判空发现引用不为null直接拿去用就拿到一个还没构造好的对象。volatile在这里的作用是禁止这个重排序保证引用指向完整对象才能被其他线程看到。说到底volatile是保证有序性的利器。这段代码在面试里出现频率极高把它彻底想明白你对JMM理解会上一个台阶。没有volatile的双检锁是教科书级的隐患很多老项目里躺着这种bug。4.3 final的安全发布final修饰的字段也很有讲究。不可变对象有一个好处只要对象正确构造并且引用被正确发布那么其他线程就能看到完整的对象不需要额外同步。final字段在构造完成后JVM会保证其他线程看到的一定是被final修饰的最新值。这对不可变对象设计是一个有力支持。把字段尽量设计成final是我在代码评审时经常做的事。所谓安全发布就是让初始化完整的对象对其他线程可见而不是暴露一个“半成品”。通过volatile引用、通过静态初始化、通过线程池提交、通过原子类包装都算安全发布。直接new一个对象并赋值给普通共享变量严格来说未必安全。理解这个概念不只是为了面试题更是为了养成设计并发安全类的直觉。5. 线程池企业开发中的标配5.1 ThreadPoolExecutor的核心参数与工作流程线程池要回答一个核心问题任务是直接执行、排队等待、还是直接拒绝ThreadPoolExecutor用七个参数完整地定义了这套策略corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。corePoolSize是核心线程数默认情况下核心线程创建后即使空闲也不会销毁maximumPoolSize是最大线程数线程池最多能创建多少个线程keepAliveTime是线程空闲存活时间超出默认会被回收如果设置了allowCoreThreadTimeOut为true核心线程也可以回收。workQueue是任务队列线程都忙时新任务先排队。threadFactory用于创建线程可以自定义线程名方便排查问题。handler是拒绝策略线程池满且队列满时触发。工作流程并不复杂。提交任务后先判断当前线程数是否小于corePoolSize小于则新建线程执行如果已经达到corePoolSize任务入队如果队列也满了再判断是否小于maximumPoolSize小于则新建临时线程执行如果临时线程数也满了就执行拒绝策略。临时线程空闲时间超过keepAliveTime就会被回收。ThreadPoolExecutor executor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(biz-worker- seq.getAndIncrement()); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );这个例子里的配置比较常规核心4最大8队列100线程命名方便定位拒绝策略用CallerRunsPolicy保证任务不丢。CallerRunsPolicy的含义是提交任务的线程自己去执行这个任务牺牲调用方的性能来兜底比简单抛异常友好一些。5.2 为什么Executors的快捷方法不够用Executors类提供了好几个快捷方法比如newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor。方便是真方便但很多公司规范里直接禁止在线上用它们根本原因是它们的参数选择太激进。newFixedThreadPool和newSingleThreadExecutor使用了无界的LinkedBlockingQueue任务排到队列里不设上限。如果请求冲过来队列会无限膨胀最终内存溢出OOM就是这么来的。newCachedThreadPool的最大线程数是Integer.MAX_VALUE每个任务都会临时创建一个线程短期内大量任务会导致线程数量爆炸同样有OOM风险。我推荐的做法是根据业务场景手动配置ThreadPoolExecutor。CPU密集型任务核心线程数可以设为CPU核数加一IO密集型任务线程数可以适当调大比如CPU核数的两倍加一。这些公式不是金科玉律只是一个起点真正准确的结果要通过压测来验证。第一版配置宁可保守也不能让队列无限大。线程池里的线程名是个细节但价值很高。默认线程名是pool-1-thread-1一旦你把线程池日志打到线上看到这个名字基本不知道是哪条链路发的。自定义ThreadFactory给线程加上业务前缀例如biz-order-worker排查问题时一眼就知道是哪块代码在作怪。5.3 线程池调优与监控线上线程池怎么调优确实是个深度话题。最直接的手段是监控线程池的运行状态。ThreadPoolExecutor提供了一些可获取的指标getPoolSize是当前线程数getActiveCount是活跃线程数getQueue size是队列积压数getTaskCount是历史任务总数getCompletedTaskCount是完成总数。把这些值定期采样就可以画出线程池的繁忙度曲线。如果发现核心线程长时间跑满队列持续增长说明corePoolSize配置小了如果线程池长期空闲队列基本为零说明线程开多了或者业务本身并发量就不高。这两种都是配置与实际负载不匹配。比较理想的状态是日常负载维持在60%到80%之间留出应对瞬时高峰的空间。线程池里的坑还有一个容易被忽略线程池提交任务时Runnable的run方法里如果抛出非受检异常线程池会默默吞掉线程本身直接走完日志里可能什么都没留下。解决办法有两个一种是在run方法里用try-catch兜底另一种是自己传入ThreadFactory给线程设置一个UncaughtExceptionHandler把异常记录到日志。这个细节很多资深开发也容易漏。6. JUC并发工具与并发容器速览6.1 CountDownLatch、CyclicBarrier与Semaphorejava.util.concurrent包下的工具类是除了锁之外解决协作问题的一批利器。CountDownLatch是倒计时门闩一个或多个线程等待多个事件完成。经典场景是主线等待多个子线程都处理完再继续执行。CountDownLatch的计数器只能减不能增也不能重置。CountDownLatch latch new CountDownLatch(3); for (int i 0; i 3; i) { new Thread(() - { try { // 模拟执行任务 Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println(all tasks completed);CyclicBarrier的字面意思是循环栅栏它是让一组线程互相等待全部到达之后同时突破。和CountDownLatch的区别在于CyclicBarrier可以循环使用并且支持一个barrierAction在所有线程到达后先执行这个动作再继续后面的流程。模拟会议中所有人员到齐后开始开会就很适合用它。Semaphore是信号量用来限制同时访问某类资源的线程数。比如一个接口限制最多10个线程同时查询数据库Semaphore(10)就能做到。acquire和release要成对出现最好放在try-finally里确保释放。这三个工具各有各的适用场景不难但选错也很常见。我见过有人拿CountDownLatch当Semaphore用功能上勉强能实现代码却别扭得多。6.2 ConcurrentHashMap的演进与选型并发容器里最高频的选手是ConcurrentHashMap。JDK 7时代它用分段锁把数据分成一段一段的每段配一把锁并发度取决于段的数量JDK 8直接用Node数组加CAS和synchronized锁粒度更细并发性能更强。日常使用时你不要过度关心内部实现细节但要知道它在多线程下安全并且性能优于给整个HashMap加锁。还有一个常被搞混的点ConcurrentHashMap并不允许null键和null值。这是设计上回避二义性的做法。HashMap可以put null是因为HashMap根本不知道null键存在与否ConcurrentHashMap设计上为了避免并发判断时的二义性直接不允许。这与今天要强调的线程安全模型息息相关。如果你的Map允许null值只能谨慎地用Collections.synchronizedMap。另外还要区分集合的线程安全方法。Collections.synchronizedMap是把所有方法加锁简单粗暴但容易造成瓶颈CopyOnWriteArrayList适合读多写极少的场景每次写都复制底层数组ConcurrentSkipListMap是跳表实现的有序Map适合有序并发访问。选型逻辑很简单读多写少优先考虑CopyOnWrite写多读也多ConcurrentHashMap需要排序ConcurrentSkipListMap。6.3 并发队列的选择思路BlockingQueue接口下面有好几个实现选哪个往往让新手头疼。LinkedBlockingQueue是基于链表的无界或有序队列默认无界如果没指定容量任务可以一直堆积有OOM风险。ArrayBlockingQueue是有界数组队列容量固定满了就阻塞或拒绝线程池用它比较稳。SynchronousQueue不存储任何任务每个put必须等待take相当于线程直接交接。PriorityBlockingQueue支持按优先级出队适合任务本身有先后权重的场景。这些队列背后都实现了生产者消费者模式。以ArrayBlockingQueue举例生产者put消费者take队列满时put阻塞队列空时take阻塞不需要你手动用wait和notify实现。使用的时候要关注它的容量该怎么设置。太大会导致堆积延迟增大太小又会频繁拒绝任务中间值要靠压测来量。有一个经验是队列大小至少能容纳峰值期间的积压量再留一点缓冲。7. 常见问题与排查技巧实录7.1 线程安全问题从哪开始查遇到并发bug第一步不是猜而是把问题场景固定下来。先看这段代码里哪些变量是多个线程共享的哪些是只读的哪些是可变的。共享可变数据就是嫌疑最大的源头。接着看操作这些数据的代码路径是不是存在“先读后写”的复合操作。最后再确认是否加了合适的锁或者用了并发容器。一个比较典型的案例业务系统里用static List缓存了一些配置多个线程往里写。日志有时能看到部分配置变成半截数据排查下来就是List本身不安全又用了普通add方法。解决办法很直接换成CopyOnWriteArrayList或者干脆用不可变配置对象。很多线程安全问题这么一梳理就发现不是锁不够而是用了不合适的容器。7.2 锁竞争导致的性能瓶颈怎么看线上服务CPU居高不下线程dump一打出来发现大量线程都在同一个synchronized块的入口处阻塞状态是BLOCKED。这通常意味着锁竞争已经非常激烈。锁竞争的本质是并发度超过了一把锁能承载的带宽。常见手段有几种缩小锁代码块的范围用读写锁把读操作并行化改用CAS原子类或者用分片思路把一把大锁拆成多把小锁。有一次排查一个库存扣减接口所有请求串行在一个synchronized方法里QPS永远上不去。后来分析业务发现真正的临界区只是库存余量扣减那几行就把它从方法锁改成代码块锁然后把库存字段换成了AtomicInteger核心操作用CAS重试。QPS翻了几倍代码结构反而更简单了。这件事给我的感受是锁的粒度一定要够小保护的范围一定要明确。排查工具方面jstack是基本功。它能打印线程栈和锁信息。多打几次jstack如果多次采样中同一个线程总在同一个锁点阻塞说明锁竞争概率很高。再配合jstat看GC情况排除Full GC导致停顿基本能定位到瓶颈。更进一步的可以使用JFR录制一段时间的飞行记录去看锁延迟区间分布分析哪个锁最频繁。7.3 关于线程安全的一些实用检查清单下面这几条是我做代码评审时必看的点列出来供大家自查静态变量和单例对象的成员变量是否被多个线程可变访问如果是是否做了同步或使用并发容器。非线程安全的集合HashMap、ArrayList是否被并发读写如果是立即替换。synchronized锁的对象是否是同一个如果锁的是不同的对象实例互斥就无效。使用ReentrantLock时unlock是否一定在finally里执行没做到就是定时炸弹。volatile修饰的变量是否存在i之类的复合操作有则改用Atomic类或锁。线程池的队列是否无界无界队列在业务高峰是否有OOM风险。线程名称是否自定义没有自定义出了事故连哪个线程池都查不出来。这条检查清单看起来简单但能覆盖最常见的并发事故源。很多线上问题都是这些问题排列组合产生的。一次扎实的代码评审比事后排查节约的时间多得多。再讲一个细节日期格式。很多人喜欢写SimpleDateFormat作为静态字段复用因为它创建成本高。但SimpleDateFormat本身是不线程安全的并发调用parse或format经常出现错乱甚至异常。解决方案包括改用ThreadLocal包装或者直接用DateTimeFormatter它是线程安全的。这个问题太经典了只要线上日志里出现NumberFormatException或者时间错乱第一反应就该检查日期格式工具。最后再分享一点个人体会Java并发编程这块理论门槛和实战门槛是两回事。看书做题觉得自己会了真去调线上问题时才发现经验很重要。我强烈建议新手用几个小项目把基础打扎实模拟多线程抢票验证原子性问题、用线程池批量处理大文件、用CountDownLatch协调多个异步任务再做一个小型生产者消费者Demo。这三个项目做完并发编程基础就算真正入门了。从面试角度看synchronized与Lock的区别、volatile语义、线程池参数、ConcurrentHashMap原理都是高频考点。从实战角度看排查线程问题和性能调优能力的价值更高。先掌握本文讲到的基础概念和排查思路再去啃AQS源码、CompletableFuture、虚拟线程这些进阶内容会比较顺。最后分享一个我踩过多次坑后养成的习惯写并发代码之前先定义清楚哪些数据是共享的、哪些状态是可变的然后决定加锁还是使用不可变设计。代码写完后再用jstack连续采样几次看线程状态而不是一测通过就以为万事大吉。这个习惯救了我的线上系统不止一次。