Java并发安全硬核解析:从JMM、锁机制到线程池实战避坑

发布时间:2026/9/28 7:39:11
Java并发安全硬核解析:从JMM、锁机制到线程池实战避坑 Java并发安全这块基本是面试绕不开的硬骨头。我自己也面过不少人发现很多候选人能把八股背得滚瓜烂熟但一问到你们项目里到底哪里用了并发、出了什么问题、怎么排查的就卡壳了。所以这篇不只是把线程安全的概念罗列一遍我会结合平时实际踩过的坑把那些面试里最爱问、项目中又最容易出问题的点掰开揉碎了讲清楚。无论你是准备面试的应届生还是工作几年想补补短板的开发都可以对照着自查一遍。1. 先从最基础的可见性说起为什么synchronized不是万能的很多人在回答线程安全怎么保证时第一反应就是加锁、用synchronized。这个方向没错但只答到这一层远远不够。面试官真正想听的是你知不知道锁到底解决了什么问题又解决不了什么问题。1.1 Java内存模型JMM与可见性缺失Java内存模型规定了所有变量存在主内存中每个线程有自己的工作内存。线程对变量的所有操作读取、赋值都必须在工作内存中完成然后再同步回主内存。这就引出一个核心问题线程A修改了一个变量线程B什么时候能看到这个修改如果没有任何同步机制B可能很长时间内看到的都是旧值这就是可见性问题。我遇到过一个真实案例。项目里有个配置开关用一个boolean变量控制后台线程根据这个变量决定是否执行某些任务。开发同学觉得反正就是一个开关又不是复合操作应该不会有问题于是没用volatile也没加锁。结果呢线上偶现任务不执行或者执行完不停止的情况排查了很久才发现是可见性问题——修改开关的线程是定时任务线程池而读取开关的是业务工作线程两个线程之间没有任何happens-before关系导致工作线程一直读到旧值。这里要特别强调synchronized其实是可以保证可见性的因为它底层依赖monitor enter/exit会触发内存屏障让线程工作内存中的缓存失效。但问题在于synchronized太重了而且很多场景其实只需要可见性保证并不需要互斥。比如上面说的配置开关如果多个线程同时写这个boolean其实只需要一个线程负责改其他线程只是读那用volatile就足够了。1.2 volatile的适用边界与典型误区volatile的核心语义就两条保证可见性、禁止指令重排序。但很多初学者容易把它理解成并发安全的万能钥匙这是大坑。volatile解决不了原子性问题。最经典的例子就是count这个操作分三步读取count、加1、写回count。volatile只能保证每次读取都能拿到最新值但如果在读-改-写的过程中有其他线程也做了同样的操作依然会出现覆盖丢失。两个线程同时读到count5各自加1后写回最终结果可能是6而不是7。那volatile到底适合什么场景状态标志位、单例模式的双重检查锁中的实例引用、以及一些不依赖先前值的简单写入。我自己常用的一个场景是写一个轻量级的发布订阅发布者线程更新一个共享的配置对象引用订阅者线程直接读这个引用。因为配置对象一旦发布就不再修改所以只需要volatile保证引用可见即可完全不需要加锁。实际工作中还有个细节容易被忽略volatile修饰的是引用类型时只能保证引用本身的可见性不能保证引用指向的对象内部字段的可见性。也就是说volatile MapString, Object config能保证config这个变量对所有线程立即可见但如果另一个线程修改了config指向的Map里的内容其他线程是不一定能立即看到的。要小心这种看起来加了volatile就安全了的错觉。2. 锁的核心机制从synchronized到AQS面试官到底想听什么锁这块是并发面试的重头戏。synchronized怎么优化的、ReentrantLock和synchronized的区别、AQS的原理几乎是必问三连。我自己的经验是单纯背结论没用得理解锁设计背后的性能考量。2.1 synchronized的锁升级过程别只答偏向锁、轻量级锁、重量级锁很多人能把无锁、偏向锁、轻量级锁、重量级锁这串名词背下来但一问什么时候从偏向锁升级成轻量级锁就含糊了。我把这个过程用一个生活类比来解释锁升级就像餐厅占座。无锁状态餐厅没人排队随便坐。偏向锁座位上写着这个位置默认归某个客人只要这个客人来直接坐下不需要任何额外操作。JDK 15之前synchronized默认开启偏向锁就是为了处理同一线程反复进入同步块的场景省掉CAS操作。轻量级锁来了另一个客人也想坐这个位置发现位子被偏向了于是通过CAS自旋尝试抢座。CAS失败且自旋次数达到阈值默认10次或者自旋等待的线程数超过CPU核数一半就会膨胀为重量级锁。重量级锁进入真正的互斥未抢到锁的线程被挂起阻塞唤醒需要操作系统内核态切换这也是性能损耗最大的状态。面试时我建议把重点放在为什么要设计这些状态上因为绝大多数锁竞争其实是很短暂的比如一个方法里几行代码的操作线程很可能在执行完前就释放锁了。用自旋代替挂起可以省掉线程上下文切换的开销。但如果一个锁被长时间持有大量线程在那里空转自旋反而浪费CPU所以自旋次数到了阈值就得升级成阻塞。这里有个很重要的使用建议同步代码块尽量只包住需要保护的少量代码不能一大段业务逻辑全塞进去。我见过有些同事把整个方法体都用synchronized包起来里面做了数据库查询、远程RPC调用结果其他线程全被堵在门外并发能力直线下降。正确的做法是缩小锁粒度比如只对共享变量的修改操作加锁计算和IO放到锁外。2.2 ReentrantLock与AQS从state到CLH队列ReentrantLock和synchronized最大的区别在于它更灵活支持公平锁/非公平锁、可中断、可超时、多条件变量。这些是synchronized不具备的。但底层核心都是AQSAbstractQueuedSynchronizer理解了AQS很多并发工具类的原理就通了。AQS的核心是一个volatile int类型的state字段和一个FIFO双向等待队列CLH队列的变种。state的意义由子类自行定义ReentrantLock里state表示锁持有次数Semaphore里state表示剩余许可数CountDownLatch里state表示还需要等待多少个事件完成。以ReentrantLock获取锁为例非公平锁的流程是新来的线程先直接CAS尝试将state从0改成1成功了就拿到锁失败则进入acquire流程先是再尝试一次CAS还不行就新建节点挂到CLH队尾然后循环检查前驱节点是否是头节点、尝试获取锁拿不到就park挂起。等持有锁的线程释放锁时会唤醒头节点的后继节点让它重新参与竞争。这里有个高频考点公平锁和非公平锁的实现差异。公平锁在尝试获取之前会先检查等待队列里是否有前驱节点如果没有或自己就是队头才去尝试CAS非公平锁则不管队列状态直接先抢一把。所以非公平锁的吞吐量通常更高因为减少了线程挂起和唤醒的开销但代价是有可能造成某些线程饿死——新来的线程一直插队老排队的一直等不到。面试时还常考lockInterruptibly和tryLock的区别。lockInterruptibly在等待过程中如果线程被中断会抛出InterruptedException并取消获取锁的动作而普通的lock方法即使线程被中断也只是设置中断标志位继续等待锁。tryLock则是非阻塞的尝试获取锁拿不到立即返回false可以配合超时时间使用。这些在业务中处理我最多等500毫秒等不到就先做别的这种场景特别有用。2.3 读多写少场景ReadWriteLock和StampedLock怎么选很多候选人聊完synchronized和ReentrantLock就开始被追问如果某个共享数据读操作特别多、写操作特别少用哪种锁比较好这就要引入读写锁了。ReentrantReadWriteLock把锁拆成读锁和写锁读锁之间可以共享写锁是排他的读锁和写锁也互斥。这个设计非常适合配置中心、缓存的场景。但它在高并发下有个问题写锁饥饿。如果读锁一直被不断获取写锁可能长时间拿不到因为写锁要等所有读锁都释放才能获取。所以JDK 8引入了StampedLock它提供了一种乐观读模式线程先尝试不加锁地读取如果发现写操作期间数据被修改了通过stamp版本号判断再升级成悲观读锁重新读。StampedLock在读多写少的场景下吞吐量能比ReentrantReadWriteLock高出不少。不过StampedLock有个使用限制它不是可重入的而且也不支持条件变量Condition。我自己一般不轻易用因为它在数据一致性方面的模型比较复杂如果业务逻辑里需要判断读到的数据到底是不是最新的就得多写代码。用ReentrantReadWriteLock的话模型虽然保守但清晰可靠。选型还是要看团队对代码可维护性的要求并不是吞吐量最高的就一定是最合适的。3. 线程池面试最高频的实操考点如果说锁是并发的基础那线程池基本就是Java并发编程里用得最多的组件之一了。面试必问ThreadPoolExecutor的七个参数网上资料一大堆。但面试官真正分辨水平的地方是你面对一个实际场景时怎么选线程数、怎么选阻塞队列、怎么配置拒绝策略。3.1 七大参数与阻塞队列的搭配逻辑ThreadPoolExecutor构造方法里的核心参数核心线程数corePoolSize、最大线程数maximumPoolSize、空闲存活时间keepAliveTime、时间单位unit、阻塞队列workQueue、线程工厂threadFactory、拒绝策略handler。流程是这样的新任务提交进来如果当前线程数小于核心线程数直接创建核心线程执行如果核心线程数已满任务进阻塞队列排队如果队列也满了继续创建非核心线程直到最大线程数如果最大线程数也满了触发拒绝策略。这里有个经常被误解的点不是任务一提交就先往队列里塞而是先尝试用核心线程执行。我之前面试一个候选人他说任务提交后先判断队列是否满了这就是流程记反了。理解了这个流程才能真正理解为什么阻塞队列这么关键。阻塞队列的类型选择直接决定了系统的行为模式LinkedBlockingQueue无界队列核心线程满了之后所有任务进队列等待永远不会创建非核心线程。优点是线程数稳定缺点是如果任务积压太多队列无限增长内存可能被打爆。而且在无界队列模式下maximumPoolSize参数形同虚设。ArrayBlockingQueue有界队列队列有容量上限满了之后才会创建非核心线程。这种组合能让线程数根据负载动态伸缩是推荐的做法。SynchronousQueue同步队列这个队列比较特殊它不存储任务提交任务的线程必须等待一个工作线程来取走任务否则阻塞。相当于直接把任务交给线程处理。如果核心线程都被占满任务提交直接就触达最大线程数的扩张边界适合对吞吐量要求高、每个任务执行时间很短、希望尽量减少排队延迟的场景。DelayedWorkQueueScheduledThreadPoolExecutor专用基于延迟时间的优先级队列。3.2 线程数配多少别再背CUP数1了线程池线程数配多少这个问题网上有各种公式比如CPU密集型配CPU核数1IO密集型配CPU核数×2。说实话这个公式只能作为初始值参考真正上线前一定要结合压测结果调整。先说理论公式。CPU密集型任务线程数不宜超过CPU核数因为线程再多也只会增加上下文切换的开销。通常配N1N为CPU核数多出来的一个用于处理偶发缺页中断等情况。IO密集型任务线程大部分时间在等待可以配置更多线程比如N×2或者通过这个公式估算线程数 CPU核心数 × (1 平均等待时间/平均计算时间)。平均等待和计算时间可以通过监控来获取但这个公式太理想化了线上环境很少能精确测出这两个值。我自己的经验是先把理论值作为基线比如一个压测环境8核机器上的IO密集应用先配16个线程然后用压测工具逐步增加并发量观察每档线程数下的TPS、平均响应时间、CPU利用率。如果线程数从16加到24时TPS没有明显提升且CPU已经到80%以上了那16到20可能才是最优区间。另外如果线程池里跑的任务有下游依赖比如调第三方接口下游的吞吐能力也是瓶颈不是线程数越高越好。还有个更实用的大原则线程数配置要留有余地不能把线程池撑到极限。因为系统里往往不止一个线程池JVM自身GC线程、业务里的其他线程都会占用CPU资源。你在面试时可以提我会先用理论公式算基础值再根据压测结果微调这个思路比直接背公式显得靠谱得多。3.3 拒绝策略怎么选以及线上最容易踩的坑ThreadPoolExecutor提供了四种拒绝策略AbortPolicy默认策略。任务被拒绝时直接抛RejectedExecutionException。这个策略其实会让上层代码措手不及如果不捕获异常可能会导致提交任务的线程异常退出。CallerRunsPolicy任务不会被执行线程池处理而是被提交任务的线程比如主线程自己执行。这相当于一种简单的限流执行速度降下来了任务也能被消费掉不会丢失。DiscardPolicy直接丢弃任务不抛异常。这种行为太隐蔽一般不建议用。DiscardOldestPolicy丢弃队列中最老的任务然后重新提交当前任务。我对这几种策略的使用体会是如果是关键业务比如订单处理、消息推送绝对不要用DiscardPolicy因为任务丢了你根本不知道排查问题就像大海捞针。如果是非核心业务比如日志上报、一些可延后的统计分析可以用CallerRunsPolicy让提交线程自己执行既不丢任务又天然限流。AbortPolicy其实也不是不能用前提是你得在提交方catch住RejectedExecutionException做成重试或者降级处理。还有一个高频坑需要提醒execute和submit在异常处理上的差异。通过execute提交任务如果任务内部抛运行时异常线程池会捕获这个异常但在没有设置UncaughtExceptionHandler的情况下会打印到std err然后线程会退出线程池会创建一个新线程补充。这个异常你从业务代码里是捕获不到的。如果通过submit提交Future.get()时会把异常包装成ExecutionException重新抛出。所以如果你用submit只是为了异步执行任务而不关心结果又没在任务内部做try-catch异常就静默丢失了这种问题排查起来会非常痛苦。3.4 线程池监控与参数动态调整在一线工作了两三年后你会发现线程池运行中最怕的是什么是线程池被打满、队列塞满、开始丢任务或触发拒绝策略而你完全不知道。所以排查问题的第一步是先把线程池看住。线程池的getActiveCount()、getQueue().size()、getCompletedTaskCount()这些方法是天然的监控指标。我习惯在封装线程池的类里定时比如30秒一次采集这些数据暴露给监控平台。核心看三个指标活跃线程数是否长期接近核心线程数如果队列也持续非空说明负载已经很高队列积压量是否持续增长如果直线增长说明消费速度跟不上生产速度任务完成耗时通过包装Runnable记录每个任务的执行时间线上异常时通过jstack取线程快照看线程池里的线程处于什么状态。如果大量线程处于WAITING状态park说明任务很少线程都闲置了如果大量处于RUNNABLE且CPU占用高说明任务在密集计算。还要看有没有线程BLOCKED在锁上这通常是死锁或者锁竞争激烈的信号。另外一个进阶玩法是动态调整线程数。ThreadPoolExecutor的corePoolSize和maximumPoolSize是可以通过setter方法动态修改的在运行时根据监控到的负载情况调整。我自己写过一个简单的逻辑如果队列积压持续超过队列容量一半且当前线程数小于最大值就把核心线程数调大如果队列空了且活跃线程数长时间低于核心线程数再把核心线程数调下来。注意不能频繁调整否则线程池内部的Worker线程创建销毁也是一笔开销。4. 死锁与线程安全容器的排查实战死锁几乎是并发编程里最经典、也是最让人头疼的问题。面试时考官基本上都会问如何排查死锁但真正动手查过死锁的人其实不多这个实操经验比背理论更值钱。4.1 死锁产生的四个必要条件互斥、占有且等待、不可抢占、循环等待这四个条件同时满足死锁就发生了。这段话几乎每个候选人都能背出来但问你项目里遇到死锁是怎么发现的答案就千奇百怪了。我自己排查过几次死锁最常见的触发场景就是两个线程互相持有对方需要的锁线程A持有锁1要锁2线程B持有锁2要锁1。这种场景在代码里非常隐蔽因为往往不是同一个类里的代码在加锁而是两个不同模块各自加了锁互相调用时形成环。还有一个常见场景是数据库事务和JVM锁叠加线程A在事务内先更新表X再更新表Y线程B在事务内先更新表Y再更新表X数据库层面就会死锁Oracle和MySQL都会报deadlock检测。这时期待的不是JVM层面的jstack能查出来的得看数据库的错误日志和InnoDB的死锁日志。4.2 用jstack快速定位死锁排查死锁的正确姿势是先拿到Java进程的PIDjps可以列出来然后执行jstack PID thread_dump.txt在线程dump文件里搜索Found one Java-level deadlock关键字JVM会直接告诉你死锁的线程ID和锁的持有情况。有一次线上服务响应全部超时CPU占用不高但请求就是卡住。我用jstack一查看到下面这样的输出Found one Java-level deadlock: pool-5-thread-3: waiting to lock 0x00000000d5b1f6a8 (a java.util.concurrent.locks.ReentrantLock) held by pool-5-thread-1 pool-5-thread-1: waiting to lock 0x00000000d5b1f6d0 (a java.util.concurrent.locks.ReentrantLock) held by pool-5-thread-3定位到是某张报表导出功能里两个缓存更新的流程各自对缓存A和缓存B加锁然后又需要访问对方的缓存形成了死锁。修复方式很简单统一加锁顺序所有线程都先锁A再锁B就永远不会成环。这里有个细节jstack输出的线程名和代码对应关系很重要。在生产环境一定要给线程池设置有业务含义的线程名否则你看到pool-5-thread-3只能知道是哪个线程池还得自己去对应代码。我用的是阿里的transmittable-thread-local工具的TtlExecutors或者干脆实现一个ThreadFactory在newThread的时候把业务模块的名字加进去比如report-export-thread、cache-refresh-thread这样排查问题效率会高好几倍。4.3 ConcurrentHashMap真的绝对安全吗很多人觉得用了ConcurrentHashMap就百分百线程安全了这个认知要纠正一下。ConcurrentHashMap保证的是每个单独的读写操作是线程安全的但不保证复合操作的原子性。经典面试题判断ConcurrentHashMap中是否存在key不存在就插入。如果用if (!map.containsKey(key)) { map.put(key, value); }在多线程下依然会有并发问题——两个线程同时判断都不存在然后都执行put造成覆盖。正确做法是用putIfAbsent方法替代它内部是原子操作。或者用computeIfAbsent可以在计算value时加锁避免重复计算。还有一类问题是ConcurrentHashMap虽然不锁整个表锁的是桶节点链表头节点或红黑树根节点但如果多个key恰好在同一个桶里依然会有一定的竞争。扩容时会有一个help resize机制多个线程可以协助扩容。这些细节面试会考到但工作中更需要注意的是不能因为用了ConcurrentHashMap就忽略编程逻辑本身的线程安全性。4.4 CopyOnWriteArrayList的写时复制机制与适用场景CopyOnWriteArrayList也是一个常见的并发容器面试时喜欢问它和普通ArrayList有什么区别什么时候用。它的实现是在写操作add、set、remove时先将底层数组复制一份在新数组上做修改修改完成后再用新数组替换旧数组引用。读操作不加锁直接读旧数组。这样做的好处是读操作完全无锁特别适合读多写少的场景比如配置信息的存储、缓存列表。但坏处也很明显每次写操作都要复制整个数组如果列表很大且写频繁复制开销非常大还会造成频繁的Young GC。所以它只能用在读远多于写、且数组不会太大的场景。如果写频繁用它就是灾难。我自己面试时还会追问一个点CopyOnWriteArrayList的迭代器和弱一致性的关系。它的迭代器在创建时会持有当前数组的快照引用所以迭代过程中即使其他线程修改了列表迭代器依然遍历旧数组不会抛ConcurrentModificationException。这种弱一致性在很多场景下是足够用的但如果你需要强一致性的遍历结果就不合适了。5. 并发工具类与常见问题排查技巧实录这一节我打算把其他核心并发工具类的使用场景和注意点整理一下最后再集中列一批常见问题的排查速查表。工作中用最多的除了锁和线程池就是CountDownLatch、Semaphore和Future这些工具类了。5.1 CountDownLatch、Semaphore、CyclicBarrier的对比先看一张对比表这些工具类的区别就很清楚了工具类核心作用典型场景是否可复用CountDownLatch让多个线程等待某个计数器归零后同时放行主线程等待多个子任务完成后汇总结果不可复用用一次就报废CyclicBarrier让一组线程互相等待全部到达后同时继续多线程并行计算每轮都对齐一次结果可复用reset后可以重新开始Semaphore控制同时访问某资源的线程数量限流、连接池管理可复用CountDownLatch最典型的用法是一个主线程提交N个任务到线程池然后用CountDownLatch的await方法等待所有任务完成。注意countDown必须在finally里调用否则子任务抛出异常时计数器永远不归零主线程就永远阻塞了这是线上事故的高发点。我自己还遇到过一种情况有人图省事用Future.get()逐个等待结果第一个任务没跑完后续的任务都没开始执行整体耗时被拉长。用CountDownLatch可以并发等待效果完全不同。CyclicBarrier和CountDownLatch最大的区别在于循环使用。CountDownLatch是一次性的CyclicBarrier可以reset重复使用且它本身可以传入一个屏障动作barrierAction在所有线程到达屏障后由最后一个到达的线程执行。这个特性在做分阶段计算如迭代算法时非常实用。Semaphore则更多用于限流比如控制某个接口的最大并发数。但它不能控制总请求量只能控制同时在执行的请求数如果队列里等待的线程过多依然会堆积。5.2 终极避坑清单并发场景常见问题速查结合我自己的经验把并发场景里常踩的坑和排查思路整理成一个速查表希望对大家有帮助。这几类问题我在代码review时见过太多次了全是可以提前规避的现象可能原因定位方法规避建议数据丢失更新被覆盖复合操作没加原子性保护看代码里有没有读-改-写逻辑用Atomic类、putIfAbsent、synchronized块某个线程永远不退出忘调用interrupt或者volatile标志失效jstack看线程状态检查标志位是否加volatile中断响应与标志位结合循环里检查Thread.currentThread().isInterrupted()偶现数据不一致重启后消失可见性问题代码review是否有共享变量未加同步共享变量逃逸分析该volatile的加上线程池任务丢失用了DiscardPolicy或submit后不看Future检查拒绝策略配置和任务异常是否被吞关键任务用try-catch记录异常使用CallerRunsPolicy死锁产生的阻塞锁顺序不一致jstack搜deadlock关键字定义全局锁顺序尽量用tryLock加超时高并发下请求全部变慢锁竞争激烈jstack看大量线程BLOCKED或WAITING缩小锁粒度、乐观锁替代悲观锁、读写分离5.3 并发问题的排查工具与方法论除了jstack还有几个非常实用的工具值得掌握。jstat -gcutil PID 1000可以实时观察GC情况看是不是因为频繁Full GC导致系统变慢。jmap -dump:formatb,fileheap.bin PID可以导出堆转储文件用MAT分析哪些对象占用了大量堆内存排查线程池队列有没有堆积大量任务对象导致的内存膨胀。也可以用Java自带的JMCJava Mission Control或者开源的Arthas来做在线诊断。Arthas的thread -n 3命令可以直接打印CPU占用最高的前3个线程栈watch命令可以观察某个方法的入参、返回值、异常信息trace命令可以分析方法调用链每层的耗时。排查并发问题的效率比盲看代码高得多。这里说一个我印象深刻的排查场景线上偶发接口超时频率大概一天几次。我先用Arthas的trace命令对接口的每个内部调用加了耗时监控发现超时往往发生在一个并发工具类的方法调用上。继续排查这个工具类里有一个静态的HashMap当作缓存使用多线程读写导致HashMap在扩容时形成环形链表线程在get时进入了死循环。这一下子就解释了CPU为什么偶发飙高、接口为什么超时。后来把HashMap换成ConcurrentHashMap问题彻底消失。这个案例的核心教训是静态集合如果会被多线程访问必须使用线程安全容器。很多开发习惯性地用HashMap做缓存没意识到静态变量的全局可见性这个坑在面试里也很容易被问成为什么HashMap不是线程安全的。HashMap在并发场景下的隐患不只是数据一致性问题更重要的是JDK 7及之前版本中resize时会形成循环链表导致后续所有get操作死循环CPU被打满。JDK 8改进了resize逻辑但依然存在脏读和覆盖问题。所以别再问我能不能在并发环境下用HashMap了。5.4 自定义锁顺序规范与团队协作最后分享一个团队协作层面的经验。并发代码坑多靠个人自觉很难守住所以我会在团队里强制要求遵守几条并发编程规范。一是所有涉及多线程共享的变量必须在代码注释里明确标注线程安全策略比如由synchronized保护、必须volatile修饰、仅初始化时写入。二是锁的使用必须统一顺序比如业务模块内约定先锁订单锁再锁用户锁防止不同模块的锁顺序不一致导致死锁。三是require代码review时重点关注并发部分看有没有无保护的共享变量、有没有在锁内做耗时操作、有没有在线程池任务里吞掉异常。这些规范看起来琐碎但能大幅降低生产事故率。并发编程的本质其实就是在约束与灵活之间找平衡约束越多越安全但也要付出性能和维护成本的代价。面试的时候如果能把这些团队层面的实践讲出来会比单纯背八股给面试官的印象深很多。我个人在实际操作中最深的一个体会是并发问题从来不是遇到再解决的而是设计时就规避的。建线程池时想一下任务量和执行时长加锁时想一下锁顺序和粒度操作共享变量时想一下有没有可见性和原子性问题——这些习惯养成之后很多线上故障根本不会发生。希望这篇整理能帮你在面试中和工作中都少踩几个坑。