Java并发编程核心要点解析:从JMM到线程池与锁实践 1. Java并发要解决的本质问题1.1 为什么并发问题这么难先聊聊JMM先说一个很多新人容易踩的误区并发问题不是“多线程同时跑”这么简单真正的难点在于共享内存的可见性和操作的有序性。Java为了解决跨平台的内存访问差异在语言层面定义了一套规范叫Java内存模型Java Memory ModelJMM。这个模型规定了什么时候一个线程对变量的修改另一个线程“一定能看到”。我一般给团队新人举一个生活化例子公司会议室的方案白板是主内存每个员工的笔记本是线程的工作内存。你在笔记本上写了一版方案别人手里拿的是旧版本除非你把内容同步回白板或者对方主动来看白板否则你们俩做出来的东西一定不一致。多线程环境下每个线程会把共享变量复制一份到自己的“工作内存”然后在工作内存里读写刷回主内存的时机并不确定。JMM还规定了两个线程之间通信的底层机制是共享主内存但对应用开发者来说真正要遵守的是几条核心规则一个线程解锁释放锁之前对共享变量的修改对之后获取到同一个锁的线程可见。对volatile变量的写操作会立即刷回主内存读操作则会强制从主内存读取。线程启动、终止、中断等操作都有对应的内存可见性语义。如果违反这些规则程序就会表现出“看起来完全没逻辑”的诡异现象。比如两个线程同时对同一个int变量做一万次自增最后结果不是20000而可能是10000多甚至更少。这是我在培训时最爱用的开场案例。1.2 三个核心特性与volatile的边界把并发问题拆到底就三个维度原子性、可见性、有序性。原子性指的是一个操作要么全部执行要么全部不执行中间不能被其他线程打断。比如i它看起来是一行代码但字节码层面其实是“读变量、加一、写回”三步在并发环境下这三步可能被打散。可见性指的是一个线程修改了共享变量其他线程能不能立刻看到。刚才说的JMM工作内存机制就是导致可见性问题的主因。有序性指的是代码执行的顺序。JVM和CPU为了提高性能会做指令重排编译期重排、处理器乱序执行只要最终结果在单线程内保持一致重排就被允许。但在多线程环境下重排可能让另一个线程读到“还没初始化完成的中间状态”。很多初学者以为加个volatile就能解决所有并发问题这是大忌。volatile能保证可见性能禁止部分指令重排但它不保证原子性。经典的例子是volatile int count多个线程同时执行count最终结果依然不对。我见过一个比较经典的代码双重检查锁单例DCL在JDK 5之前因为指令重排会出现拿到半初始化对象的情况后来加了volatile禁止重排才算修复。这是面试高频考点也说明了有序性问题在实际工程里的杀伤力。1.3 从一段代码看并发“翻车”来看一个非常典型的并发问题public class RaceConditionDemo { private static int count 0; private static final int THREADS 4; private static final int TIMES 10000; public static void main(String[] args) throws InterruptedException { Thread[] threads new Thread[THREADS]; for (int i 0; i THREADS; i) { threads[i] new Thread(() - { for (int j 0; j TIMES; j) { count; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count count); // 期望值是 40000但实际几乎不可能等于 40000 } }这段代码在大多数机器上跑出来的结果都会小于40000。问题就出在count不是原子操作。单纯给count加volatile问题仍然存在因为volatile不解决原子性。正确的修复方式有几种给自增操作加synchronized串行化修改。使用AtomicIntegerCAS自旋修改。使用LongAdder高并发下分段累加。顺带提一个小细节很多人分不清StringBuffer是不是线程安全的。是的它内部方法加了synchronized同一时刻只有一个线程能修改某个StringBuffer实例。但因为锁竞争有开销单线程下StringBuilder反而更快。这其实引出了一个通用原则并发安全是有代价的不要为一个根本不存在竞争的场景付出锁的代价。2. 从线程到线程池并发实现的第一层演进2.1 线程的创建与生命周期从Thread到CallableJava里创建线程有几种方式但本质上都在说同样的事让一段代码跑在一个独立的执行路径上。继承Thread类重写run()。实现Runnable接口传给Thread。实现Callable接口配合FutureTask可以拿到返回值。很多面试官会问start()和run()的区别。start()才是真正创建一个新线程并进入就绪状态由JVM调用run()直接调用run()只是在当前线程里执行一个普通方法根本没有新线程。线程的生命周期状态包括NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。我见过不少新人以为Thread.sleep()会让线程不占用CPU实际它只是让线程进入TIMED_WAITING让出CPU调度但锁资源不会释放。如果拿着锁去sleep其他线程依然会被卡住这在写代码时经常被忽略。2.2 为什么不推荐裸new线程线程池的必要性直接new Thread去执行异步任务在小Demo里没问题但在生产环境就是灾难。第一个问题是创建和销毁线程有开销包括操作系统分配内核资源、JVM分配线程栈等。频繁创建销毁线程耗时可能比业务执行本身还大。第二个问题是无边界创建线程会拖垮系统。每个线程默认要占用几百KB到1MB的栈内存如果某个接口被大量并发请求打进来系统可能直接OOM。JVM进程能创建的线程数不是无限的除非你显式调过-Xss否则一个进程撑到几千个线程时系统就已经很危险了。线程池的价值在于复用固定数量的工作线程把任务放到队列里排队线程吃饱了继续从队列取任务。这样既能控制并发度又能避免频繁创建线程的开销。这也是Java并发实现里最实用、最常用的一块。2.3 ThreadPoolExecutor核心参数与拒绝策略我们在生产环境里用的最多的还是ThreadPoolExecutor它的核心参数有七个每一个都可能成为性能瓶颈参数作用corePoolSize核心线程数即使空闲也保留maximumPoolSize线程池允许的最大线程数keepAliveTime非核心线程空闲存活时间unitkeepAliveTime的时间单位workQueue任务等待队列BlockingQueuethreadFactory创建线程的工厂建议自定义线程名前缀handler线程池和队列都满时任务拒绝策略最简单的执行流程可以这样记任务提交后如果当前线程数少于corePoolSize直接创建核心线程执行如果核心线程都在忙任务进队列排队如果队列满了开始创建非核心线程执行如果线程数已经到maximumPoolSize队列也满了触发拒绝策略。四种拒绝策略里ThreadPoolExecutor.AbortPolicy是默认的直接抛RejectedExecutionException。我在工作里反而更喜欢CallerRunsPolicy它会让提交任务的线程自己来执行任务。这样好处是任务不会丢而且相当于一种天然的背压机制如果线程池忙不过来调用方也会被拖住整体系统就不会疯狂堆积。关于线程池参数怎么设置没有一个万能公式但可以按场景粗估。CPU密集型任务线程数可以设为CPU核心数1IO密集型任务比如大量查询数据库、调用远程接口线程数可以设成CPU核心数乘2左右再根据实际压测结果调整。我的经验是先保守设一个值再通过监控指标动态调整比任何公式都靠谱。3. 锁与同步机制并发实现的核心武器3.1 synchronized的底层故事synchronized是Java里最基础的同步手段但从JDK 1.6之后它的实现已经非常复杂不能简单理解成“重量级锁”。synchronized在字节码层面是通过monitorenter和monitorexit指令实现的。锁对象会有一个Monitor对象持有monitor的线程才能进入临界区。JVM对synchronized做了多级优化按竞争激烈程度锁会从无锁膨胀到偏向锁、轻量级锁、重量级锁。无锁没有线程竞争时普通对象直接无锁访问。偏向锁同一个线程反复进入同步块时锁会偏向这个线程记录线程ID后续再进就不用重新竞争。轻量级锁一旦有第二个线程来竞争偏向锁会撤销改用CAS在对象头上记录锁记录线程通过自旋等待。重量级锁自旋也搞不定就升级为依赖操作系统互斥量的重量级锁未获得锁的线程会进入阻塞状态。需要特别注意的是JDK 15之后偏向锁已经被标记为废弃在高版本JDK里默认被禁用。所以网上很多讲偏向锁的文章在新版本环境下已经不太适用看源码时要注意版本。synchronized可以加在实例方法、静态方法、代码块上锁对象各不相同实例方法锁的是this静态方法锁的是Class对象代码块锁的是括号里的对象。如果不小心用了不同的锁对象根本起不到互斥效果。我也犯过这类低级错误。3.2 Lock接口与AQSsynchronized虽然方便但功能上有短板不能响应中断、不能设置超时、无法尝试非阻塞获取锁。从JDK 5开始java.util.concurrent.locks.Lock接口提供了一套更灵活的锁方案。ReentrantLock是最常用的实现它支持公平锁和非公平锁。非公平锁性能更好因为刚释放锁的线程立刻再竞争不需要做线程切换公平锁则按申请顺序分配避免线程饥饿但吞吐量会低一些。谈到ReentrantLock就绕不开AQSAbstractQueuedSynchronizer它可以说是整个juc包的基石。AQS内部维护一个volatile int state变量和一组等待线程队列。像ReentrantLock只重写了tryAcquire和tryRelease用来决定state如何变化Semaphore、CountDownLatch、ReentrantReadWriteLock等都是基于AQS改出来的只是对state的语义不同。用ReentrantLock时我建议在finally块里释放锁否则一旦业务代码抛异常锁不会被释放线程会一直卡住。最基本的模板是Lock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }3.3 原子类与CAS除了加锁还有一种更轻量的并发实现思路CASCompare And Swap。它的核心逻辑是“先比较再交换”只有当内存中的值和预期值一致时才把新值写进去否则就一直重试。java.util.concurrent.atomic包下面的AtomicInteger、AtomicLong、AtomicReference都是基于CAS实现的。比如AtomicInteger.incrementAndGet()底层是Unsafe类提供的compareAndSwapInt方法。CAS避免了线程上下文切换的开销但高并发下如果竞争很激烈自旋重试会大量消耗CPU。CAS有个经典问题叫ABA问题一个线程把变量从A改成B再改回A另一个线程执行CAS时发现值还是A就以为没有任何变化。只关心值的情况一般没问题但如果要关心“是否被改过”就需要用AtomicStampedReference加上版本号。在高并发的计数场景LongAdder比AtomicLong更好用。LongAdder内部维护了一个基础计数器和若干个Cell多个线程各自累加到不同的Cell最后再求和相当于分片缩减了竞争。我们团队之前做抢券系统的计数压力测试显示LongAdder的提升比AtomicLong明显不少。4. 并发容器多线程下的数据基础设施4.1 ConcurrentHashMap是怎么做到高性能并发的并发场景下最常用的容器非ConcurrentHashMap莫属。在JDK 7时代ConcurrentHashMap使用分段锁机制把整个Map分成一段一段每个Segment自己有一把锁多线程访问不同段时互不干扰。到了JDK 8实现改成了CAS synchronized数组里的每个桶第一个节点加锁锁粒度更细并发度也更高。put流程大概是计算key的hash定位到桶。如果桶为空用CAS直接放入节点。如果桶不为空对桶头节点加synchronized再插入链表或红黑树。如果链表长度超过阈值8且数组长度达标转成红黑树。ConcurrentHashMap有个设计上的限制不允许null key和null value。原因是它不能区分“key不存在”和“key对应的value是null”在多线程环境下避免了一次额外的查询。网上有专门的面试题问这个值得注意。和它形成对比的是Hashtable它把所有方法直接加synchronized锁的粒度是整个表并发效率很低。还有Collections.synchronizedMap同样是把所有操作串行化适合并发量极小的场景。4.2 处理“读多写少”场景CopyOnWriteArrayList和ConcurrentLinkedQueue有一种并发场景是读操作远多于写操作比如配置缓存、白名单列表这时候用CopyOnWriteArrayList很合适。CopyOnWriteArrayList的核心思路是每次修改add、remove都会复制一个新的底层数组修改在新数组上完成然后把volatile的数组引用指向新数组。读操作完全不用加锁因为读的是不可变的旧数组快照。听着很美好但写成本很高如果频繁写会反复复制整个数组内存和GC压力都很大。所以它只适合“读多写极少”的场景这一点我反复提醒团队别用错了场景。ConcurrentLinkedQueue则是基于CAS实现的一个无锁队列适合高并发下的生产者消费者模式。因为用的是无锁算法不会有锁竞争导致的阻塞但实际运用时它的queue.size()是O(n)遍历不高效如果只是取大小做限制判断建议用AtomicInteger自己维护一个计数器。4.3 线程之间的“数据管道”BlockingQueue如果说并发容器里有一个“神器”我觉得是阻塞队列。它不仅是一份数据集合还天然承担了线程间的协调功能队列为空时消费者线程会被阻塞等待队列满时生产者线程会被阻塞等待。常用的实现各有适用场景ArrayBlockingQueue有界数组队列适合作为线程池的任务队列可以避免线程池在请求峰值时背太多任务。LinkedBlockingQueue链表队列可以方便设置容量上限默认容量是Integer.MAX_VALUE不设的话很容易堆积。SynchronousQueue不存储元素每次put必须等到take适合直接交付的生产者消费者模式。DelayQueue延迟队列可以按到期时间出队适合做定时任务调度。前文说的线程池workQueue底层就是BlockingQueue所以理解阻塞队列对配置线程池非常有帮助。我们在压测时发现不同队列策略对线程池表现影响极大ArrayBlockingQueue容量设得太小任务就容易被拒绝设得太大高并发下又会积压大量任务导致业务响应延迟变高这两者要平衡。5. 异步编程从Future到CompletableFuture5.1 Future的短板与CompletableFuture登场用线程池执行任务时我们经常需要拿到任务的执行结果。JDK 5提供的Future可以做到但它有个很大的毛病future.get()是一个阻塞方法如果任务还没执行完当前线程会一直在那儿等效果相当于把异步又变回了同步。另外当你需要把多个异步任务编排起来比如“等两个接口都返回后再汇总数据”用Future写起来特别别扭。你不得不逐个get然后串行处理期间可能还有线程被白白阻塞。CompletableFuture解决的正是这两个痛点。它实现了CompletionStage接口可以像流水线一样组合异步操作的阶段。常用API包括thenApply把前一个阶段的结果做转换。thenCompose把两个有依赖关系的异步任务扁平化组合。thenCombine组合两个无依赖的异步任务两个都完成后执行回调。allOf等待所有任务完成。anyOf任意一个任务完成就触发。exceptionally异常时的兜底处理。whenComplete阶段完成时执行不改变结果。5.2 基于CompletableFuture的异步实践案例举个我在业务中实际遇到的例子老订单列表页需要同时展示用户昵称、订单状态文案、商品缩略图这三个信息分别来自三个不同的微服务假设每个服务耗时100ms。如果串行调用总耗时至少300ms用户会明显觉得卡。用CompletableFuture实现CompletableFutureString userFuture CompletableFuture.supplyAsync( () - userService.getNickname(order.getUserId()), executor); CompletableFutureString statusFuture CompletableFuture.supplyAsync( () - orderStatusService.getStatusText(order.getStatus()), executor); CompletableFutureString imageFuture CompletableFuture.supplyAsync( () - productService.getThumbnail(order.getProductId()), executor); CompletableFuture.allOf(userFuture, statusFuture, imageFuture).join(); String nickname userFuture.join(); String statusText statusFuture.join(); String thumbnail imageFuture.join();这里必须注意supplyAsync如果不传线程池默认用ForkJoinPool.commonPool()。这个公共池在异步任务里隐式使用一旦某个任务里有阻塞IO会影响所有使用commonPool的代码。我在生产上坚持用独立的业务线程池避免公共池被拖垮。5.3 聊聊新版本里的虚拟线程JDK 21正式发布了虚拟线程Virtual Threads这个东西对Java并发实现的影响非常大。虚拟线程由JVM调度不再是一比一绑定操作系统线程每个虚拟线程的栈可以动态扩张所以一个进程可以启动几十万个虚拟线程。如果写的是IO密集型任务虚拟线程带来的收益是肉眼可见的你可以用同步阻塞代码风格不用写异步回调也能获得极高的并发吞吐。我们团队在做外部接口调用的压测时用虚拟线程替换了传统线程池性能提升非常明显。但要注意虚拟线程不是万能的。CPU密集型计算场景下虚拟线程并不能比平台线程跑得更快反而可能因为切换过于频繁带来额外开销。另外在synchronized块里阻塞和锁竞争较多的场景虚拟线程的优势会被削弱JDK还在持续优化这一块。现阶段还是建议新项目可以尝鲜但核心链路要先用压测验证。6. 并发问题排查实战场景实录6.1 死锁定位与jstack实战死锁是多线程开发里最经典、也最容易“完全卡死”的问题。两个线程各自持有一把锁又都去等对方释放锁就会永远互相等待。我建议在本地写一个简单死锁代码然后用工具实际走一遍排查流程public class DeadLockDemo { private static final Object A new Object(); private static final Object B new Object(); public static void main(String[] args) { new Thread(() - { synchronized (A) { System.out.println(thread1 get A); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (B) { System.out.println(thread1 get B); } } }).start(); new Thread(() - { synchronized (B) { System.out.println(thread2 get B); try { Thread.sleep(100); } catch (InterruptedException ignored) {} synchronized (A) { System.out.println(thread2 get A); } } }).start(); } }运行后程序卡住不动这是典型的死锁。排查步骤jps找出Java进程PID。jstack PID导出线程快照。在输出里搜“Found one Java-level deadlock”下面会明确列出线程A持有什么锁、正在等待什么锁、线程B持有什么锁、正在等待什么锁。我还会注意看线程栈里的调用链确认是不是业务逻辑里把锁顺序写反了。修复死锁最常用的手段是所有线程都按同一个顺序加锁或者用tryLock带超时拿不到锁就放弃避免无限期等待。6.2 线程池拒绝与队列堆积排查生产环境里线程池最容易出两类问题一类是任务被拒绝一类是队列不断堆积。如果线上日志频繁抛RejectedExecutionException说明线程池已经严重过载。这时要先看线程池的几个运行指标活跃线程数是多少、队列容量还剩多少、任务平均耗时多长。最简单的方式是用ThreadPoolExecutor自带的getPoolSize()、getActiveCount()、getQueue().size()定期采样或者在监控平台上做埋点。队列堆积是个更隐蔽的问题。看起来任务没被拒绝但很多任务一直排队响应时间越来越高。出现这种情况我会优先排查是不是某个下游依赖变慢了比如数据库慢查询、第三方接口超时重试。有时候调线程池参数只是缓解症状真正的病根在下游。我也踩过一次坑为了提升吞吐把线程池的maximumPoolSize调得很大结果下游数据库连接池被打爆整个服务雪崩。后来我对下游服务尽量做“连接数控制 熔断降级”线程池参数反而不用往大了调。6.3 区分“假并发问题”与真实并发问题排查线上问题久了会发现很多看起来像并发问题的问题根本不是线程竞争导致的。比如项目启动时报错找不到类热点搜索里经常出现的java.lang.NoClassDefFoundError: java/applet/Applet大概率是JDK版本和项目依赖不一致或者引用了新版本JDK中已删除的类。这类问题和并发毫无关系但新人经常用并发思路去排查走了很多弯路。再比如Lombok报错“you arent using a compiler supported by lombok”常见原因是用了新版本JDK但Lombok版本太老需要升级或者改用注解处理器配置。还有环境变量配置问题很多人照着博客配了JAVA_HOME但忘记配PATH或者PATH里多个JDK版本冲突导致运行时版本和编译版本不一样。我给团队的建议是先确认问题发生在“编译期”还是“运行期”再确认是“构建环境”还是“业务代码”导致不要一看到异常就怀疑线程安全。只有能稳定复现、并且代码路径里确实存在共享可变状态时才值得往并发方向深挖。6.4 并发面试高频点速查表并发相关知识点几乎是Java后端面试的必考模块我整理过一份高频速查对工作也有帮助问题关键回答要点为什么ConcurrentHashMap不能存null无法区分key不存在和value为null的情况避免多线程下再次查询产生歧义volatile和synchronized的区别volatile保证可见性和有序性不保证原子性synchronized保证互斥兼具可见性和原子性Thread.sleep和wait的区别sleep不释放锁wait释放锁并进入等待队列需要notify唤醒线程池核心线程会被回收吗默认不会设置了allowCoreThreadTimeOut(true)后核心线程也会在空闲超时后回收什么是CAS比较并交换乐观锁实现多用于无锁并发需要处理ABA问题ReentrantLock和synchronized怎么选功能复杂、需要超时/中断/公平锁时用ReentrantLock一般场景synchronized够用且实现不断优化这份表格看着像八股文但每个点背后都有真实的应用场景。我平时带新人时会让他们拿着这些问题去对应调过的代码而不是死背结论。写在最后的一点体会做Java并发开发这些年我最大的感受是锁、线程池、并发容器这些技术单独拎出来都不难难的是组合使用的时候怎么保持“并发安全”和“性能”的平衡。我自己就吃过乱用锁的亏也给线上服务填过线程池过载的坑。如果让我给一个最实用的建议那就是写并发代码前先问自己三个问题这段代码真的有并发访问吗共享状态能不能避免临界区能不能更小一点很多时候减少共享、减少竞争比掌握多少高深API都管用。数据结构和工具类的选型也永远先考虑场景再考虑技术。希望这篇文章能帮你少走一些弯路至少知道从哪里入手用什么思路去排查问题。