把并发作为Java进阶主线:线程池、锁与内存模型的实战复盘

发布时间:2026/10/13 4:51:43
把并发作为Java进阶主线:线程池、锁与内存模型的实战复盘 之前有个朋友问我Java进阶到底该学什么他给我列了一串自己刚学会的新语法Lambda、Stream、Optional、var模板字符串……但真到线上出问题的时候他发现自己连ThreadLocal为什么会内存泄漏、线程池参数为什么不能瞎填都说不清楚。我一直觉得Java进阶不是靠堆语法点堆出来的它更像一条能解释清楚“为什么”的主线。这条主线我认为是并发相关的那一整套东西线程、线程池、锁、内存模型、并发容器、异步工具外加一套排障手段。这篇文章就是我对这条主线的完整整理和复盘适合准备面试的开发者也适合工作三五年但一直停留在“会用”层面的工程师。我尽量不写废话每一节都会结合真实项目中能碰到的场景来展开。1. 为什么我建议把并发作为Java进阶的第一条主线1.1 进阶不是多认识几个新语法新语法当然要学Lambda、Stream这些确实能简化代码但它们改变的是“怎么写”没有改变“Java运行起来为什么会出现奇怪问题”。真正的进阶是能解释这些问题两个线程同时改一个HashMap为什么会导致CPU飙高volatile关键字到底保证了什么、不保证什么线程池核心参数调高之后为什么下游数据库突然被拖垮这些问题全部落在并发这条主线上。业务系统到了某个规模之后性能瓶颈很少出现在单线程的写法上更多是出现在资源竞争、任务排队、锁等待、线程失控这些并发场景里。理解并发才算真正开始理解Java作为一个多线程平台的设计哲学。1.2 并发主线能把知识点串成一张网我比较喜欢用一条链路去串知识一个请求从进入线程池开始到被某个工作线程处理再到用锁保护共享资源最后通过异步化手段提升吞吐量。这条链路会自然引出十几个进阶考点线程池的参数、执行流程、拒绝策略CPU缓存与JMM内存模型volatile、synchronized、ReentrantLockCAS、AQS、并发容器ThreadLocal的底层结构与泄漏问题CompletableFuture异步编排与线程池协作jstack、jmap、Arthas等排障工具每学一个新知识点都能把它挂到这张网上。我接下来的整理顺序就按照实际项目里从“创建线程”到“异步化改造”的时间线来展开这样读起来更像一次真实的技术演变过程而不是八股文式的名词列表。2. 线程池最容易被滥用也最值得深挖的起点2.1 业务代码里为什么不该随便new Thread很多初级开发者在业务代码里写new Thread(() - doSomething()).start()觉得简单直接。但这样做的代价是每次请求都创建线程线程创建和销毁本身就有开销。线程数量没有上限高并发时可能创建几百上千个线程直接把内存和CPU拖垮。缺少统一管理出问题之后连线程是谁创建的都查不到。之前在某公司看到过一个事故一个定时任务每5分钟扫描一批数据每扫描到一条就new Thread去同步。数据量偶发上涨之后线程数直接冲到了四五千服务频繁Full GC最后只能用重启来恢复。线程池的意义本质上就是“复用线程 控制资源使用上限”。Java进阶的第一个关卡就是学会用线程池来替代无脑的new Thread。2.2 线程池的核心参数与生产环境估算方法ThreadPoolExecutor有7个构造参数真正要理解的是其中5个参数作用我的理解corePoolSize核心线程数平时一直活着的线程数量maximumPoolSize最大线程数任务特别多时最多能创建多少线程keepAliveTime非核心线程空闲存活时间超出核心线程数的部分空闲多久后回收workQueue任务队列线程都在忙时新任务先排队等待threadFactory线程工厂给线程取名字、设置是否守护线程handler拒绝策略队列也满了、线程也满了新任务怎么办参数估算没有绝对公式但有一个很实用的思路。CPU密集型任务核心线程数一般设为CPU核数1IO密集型任务因为线程大部分时间在等待IO可以适当调大常见做法是核心线程数设为CPU核数 / (1 - 阻塞系数)阻塞系数在0.7到0.9之间。举个例子模拟项目X是一个订单查询服务跑在8核机器上接口内部有大量远程调用和数据库查询很明显是IO密集型。按阻塞系数0.8来算大概是8 / (1 - 0.8) 40。我实际会把核心线程数设在16到24之间最大线程数40队列容量控制在1000既能扛住突发流量又不会让任务无界堆积。2.3 Executors工厂方法的隐藏坑很多教程喜欢推荐Executors.newFixedThreadPool(10)但这个便捷方法在线上环境是有隐患的。newFixedThreadPool内部用的队列是LinkedBlockingQueue默认容量是Integer.MAX_VALUE属于无界队列。任务积压时队列会无限增长最终内存溢出。newCachedThreadPool更夸张最大线程数是Integer.MAX_VALUE极端情况下会创建出海量线程直接把服务拖死。所以我的建议是业务代码中尽量手动创建ThreadPoolExecutor明确指定队列容量和拒绝策略。多写几行参数换来的是可控性和可预测性。2.4 线程池命名的工程价值这个细节非常容易被忽略但排查问题时价值巨大。默认的线程工厂创建出来的线程名是pool-1-thread-1这种一旦线上线程数异常你拿jstack一看满屏都是pool-x-thread-y根本分不清是哪个业务模块的线程。我踩过一次坑之后开始强制要求所有线程池传入自定义ThreadFactory线程名格式类似order-pool-1、sync-task-pool-1。之后再做线程相关排查一眼就能看出是哪个池子在膨胀定位时间从小时级缩短到分钟级。3. 线程池完整执行流程任务到底是怎么被处理的3.1 execute方法内部的四条分支ThreadPoolExecutor.execute()的逻辑网上一搜一大把但真正记住的人不多。它的处理顺序是这样的当前线程数小于核心线程数时新建核心线程执行任务。当前线程数不小于核心线程数且任务队列能放得下时任务入队等待。队列放不下且当前线程数小于最大线程数时新建非核心线程执行任务。队列放不下且线程数已经达到最大线程数时触发拒绝策略。这里有一个常见的误解很多人以为核心线程满了马上去创建新线程其实不是是先让任务排队。队列满了之后才会增加线程到最大线程数。这个顺序对资源控制非常重要。它保证了在任务量不大的时候系统不会额外创建一堆线程只有在队列真的被塞满、压力持续上升时才会启动非核心线程去分担压力。3.2 队列选型决定线程创建时机队列类型的选择直接决定了线程池在什么情况下会“变胖”。SynchronousQueue不存储任务提交的任务必须马上有线程接收否则就新建线程。适合任务量小、要求低延迟的场景但要注意线程数可能快速上涨。LinkedBlockingQueue默认无界队列无边界意味着线程数永远不会超过核心线程数。任务堆积时队列无限增长容易造成内存问题。ArrayBlockingQueue有界队列可以限制任务积压量。推荐线上使用。如果你想让“当流量超过一定程度时线程数自动扩容到最大值”那么队列一定要有界而且要配合合理的容量。容量太小会导致频繁扩容和拒绝容量太大会导致线程扩容形同虚设。3.3 拒绝策略的选择不是小事拒绝策略有内置的四种策略行为适用场景AbortPolicy直接抛异常默认策略适合明确告知调用方系统过载CallerRunsPolicy让提交任务的线程自己执行不想丢任务可以变相削峰DiscardPolicy直接丢弃新任务不推荐容易无感知丢数据DiscardOldestPolicy丢弃最老的任务适合可丢弃过期数据的场景CallerRunsPolicy是我比较常用的一种。它的效果是当线程池已经饱和时提交任务的线程比如某个请求的处理线程直接来执行这个任务。这样一来任务不会被丢弃但当前请求的处理时间会变长。在某些对数据完整性要求高的场景里这是“用响应时间换数据不丢”的取舍值得好好体会。拒绝策略没有绝对优劣关键是你要知道自己选的那一项在极端流量下会产生什么后果。选AbortPolicy就要确保上层有兜底逻辑选CallerRunsPolicy就要能接受调用线程被阻塞。4. ThreadLocal状态隔离利器与内存泄漏陷阱4.1 先看一个最常见的线程安全问题SimpleDateFormat是出了名的线程不安全多个线程共用同一个实例时解析结果会错乱。之前有个同事在模拟项目X里用了一个全局的SimpleDateFormat格式化日期压测时发现偶尔会抛出NumberFormatException排查了很久才找到原因。解决办法有两种一种是每次新建SimpleDateFormat简单但浪费另一种就是用ThreadLocal给每个线程存一份自己的格式化器。ThreadLocal的核心价值就是让同一个线程在不同代码位置之间共享同一个对象同时让不同线程之间互不干扰。在实际业务中ThreadLocal更常用来传递上下文信息。比如一个请求从Controller进来经过Service、Mapper、远程调用每一层都需要当前登录用户的ID或跟踪ID靠参数一层层传太繁琐放进ThreadLocal里就可以随时取用。4.2 ThreadLocalMap与弱引用ThreadLocal底层不复杂每个Thread对象内部有一个ThreadLocalMapMap的key是ThreadLocal实例本身value是你存入的值。这里有个关键设计Entry的key是WeakReferenceThreadLocal也就是弱引用。为什么要用弱引用假如key是强引用那么只要线程还活着ThreadLocalMap就一直持有对ThreadLocal对象的引用。即使业务代码里已经没有任何地方再引用这个ThreadLocal它也无法被回收这就是一类内存泄漏。换成弱引用之后外部不再引用ThreadLocal时key会在下一次GC时被回收key变成了null。但问题又来了value还是强引用。如果线程本身生命周期很长比如线程池里的线程会一直存活那么即使key已经变成了nullvalue依然被Entry引用着无法被回收。这就是ThreadLocal内存泄漏最常见的来源。4.3 一次线程池内存泄漏的排查过程之前在模拟项目X里遇到过一个问题订单处理线程池长期运行内存缓慢增长几天后触发Full GC接口响应明显变慢。第一反应是用jmap做堆转储然后用MAT打开。在ThreadLocalMap里看到大量key为null但value还存着完整用户对象的Entry。一看代码果然是在请求处理入口往ThreadLocal里放了用户信息但没有在finally块里清理。修复方法很简单try { UserContext.set(user); // 业务处理 } finally { UserContext.remove(); }remove()会把整个Entry清掉避免value泄漏。从那以后我在代码规范里加了一条使用ThreadLocal存入重要数据时必须配合try-finally清理不能只做set不做remove。这里也想提醒一句ThreadLocal不是缓存池别拿它存大对象或者高频变动的数据。它适合存的是一些轻量的、和线程生命周期相关的上下文信息。5. 锁的进阶从synchronized到AQS再回到锁优化5.1 synchronized的锁升级与JDK版本变化synchronized在JDK 1.6之后引入了锁升级机制无锁状态 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁只有一个线程反复进入同步块时通过记录线程ID避免重复竞争。轻量级锁多个线程交替获取锁时通过CAS自旋来尝试获取不立即阻塞。重量级锁竞争激烈时线程真正进入阻塞状态涉及操作系统层面的线程挂起和唤醒。JDK 15之后官方开始废弃偏向锁主要原因是现代应用里锁竞争普遍比早期更激烈偏向锁的维护成本已经超过了它带来的收益。理解锁升级有什么实际价值价值在于你不需要在代码里刻意做什么优化但看到“synchronized性能还行”这些说法时你不会觉得玄学。它背后是一套完整的自适应策略从偏向到自旋到阻塞每一步都是为了在“低竞争时省开销、高竞争时保正确”。5.2 ReentrantLock和synchronized怎么选ReentrantLock在JDK 1.5引入和synchronized的差别主要体现在灵活性上可中断lockInterruptibly()允许线程在等待锁的过程中响应中断。可超时tryLock(timeout, unit)可以设置获取锁的最大等待时间。公平性new ReentrantLock(true)创建公平锁按请求顺序分配锁。多条件队列Condition允许同一个锁上创建多个等待队列。我的选择原则很简单如果synchronized能胜任优先用synchronized。它写法简洁、自动释放锁、JVM层面一直在优化已经够应对大多数场景。只有在需要超时、中断、公平或者多条件时才换ReentrantLock。这里要提醒一个使用细节ReentrantLock的锁必须在finally中释放否则锁异常时会永久卡住。很多人背了“lock在try前unlock在finally”但不知道为什么其实就是为了保证任何异常路径下锁都能被释放。5.3 CAS与AQS看懂锁的本质再往下挖一层就到了CAS和AQS。CAS是Compare-And-Swap翻译过来是“比较并交换”。它的核心是一个原子操作某个值等于预期值时才把它更新成新值否则不更新。整个过程由CPU指令保证原子性。CAS是很多并发工具的基础但有一个经典问题ABA问题。线程1把值从A改成B再改回A线程2比较时发现值还是A就继续操作了但实际上这个值已经变化过。处理方式是加版本号比如AtomicStampedReference。AQSAbstractQueuedSynchronizer是Java并发包的基石。ReentrantLock、Semaphore、CountDownLatch等一堆工具内部都是基于AQS实现的。AQS的核心是一个volatile int state表示同步状态加上一个FIFO等待队列。以ReentrantLock为例获取锁的过程简化理解就是先尝试CAS把state从0改成1成功就拿到锁失败就进入等待队列阻塞等锁释放时再被唤醒。state在不同的同步器里含义不同在Semaphore里是剩余许可证数量在CountDownLatch里是还剩多少事件未完成。看AQS源码并不需要把每个方法的每一行都读懂。我建议先抓住三条主线看state、看队列、看CAS在哪里发生。掌握之后并发包里的其他工具基本都是同一套思路的变体。6. volatile与JMM并发可见性这座山一定要翻6.1 一个可能永远停不下来的循环先看一个很反直觉的例子public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (!flag) { // 空转 } System.out.println(thread exit); }).start(); Thread.sleep(1000); flag true; } }直觉上主线程1秒后把flag改成true子线程的循环应该结束。但实际上在很多环境下这个子线程永远跑不完。原因在于子线程在读flag时可能一直读的是自己CPU缓存里的旧值主线程对flag的修改还没有被同步到子线程所在CPU的缓存里。如果没有加volatileJVM和CPU都会把它当作一个普通变量允许缓存不一致。把flag改成private static volatile boolean flag false;之后问题立刻消失。volatile告诉JVM这个变量每次读写都必须直接操作主内存不允许只停留在工作内存。6.2 JMM、volatile语义与happens-beforeJMMJava内存模型描述的是Java程序在多线程下的可见性规则。它把内存抽象成主内存和线程工作内存共享变量存在主内存每个线程有自己独立的工作内存线程对变量的操作必须先把变量从主内存读到工作内存再写回主内存。工作内存和主内存之间同步时机的不可控就产生了可见性问题指令重排则产生了顺序性问题。volatile解决两件事第一保证单个volatile变量的读写在多线程之间是可见的第二禁止对该变量周围的指令进行重排序。但它不保证原子性比如count这种读-改-写操作多个线程同时执行照样会丢更新。JMM里还有一套happens-before规则用来判断某个操作的结果是否对另一个操作可见。其中最常见的几条程序顺序规则一个线程内代码的执行顺序看起来是顺序的。volatile规则一个volatile写操作happens-before于后续对同一个变量的读操作。锁规则一个锁的解锁happens-before于后续对这个锁的加锁。传递性如果A happens-before BB happens-before C那么A happens-before C。6.3 双重检查锁单例为什么必须加volatile单例模式里的双重检查锁DCL是最经典的JMM考题public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }如果有人把volatile去掉代码在高并发下会有极低概率出问题。原因是instance new Singleton()不是原子操作它大致分三步分配内存空间初始化对象把引用赋值给instanceJVM和CPU允许指令重排可能变成分配内存 → 把引用赋值给instance → 初始化对象。线程A执行到第二步时线程B进入getInstance()看到instance不为null直接返回了一个还没有初始化完成的对象使用它时就会出错。synchronized保证了互斥volatile在这里用来禁止第二步和第三步之间重排。两个关键字配合起来才是一个真正安全的懒加载单例。这个例子只要真的理解透了JMM、重排序、可见性、互斥效果就都串起来了。7. 从线程池到CompletableFuture一次异步化改造复盘7.1 改造前的串行链路长什么样模拟项目X的订单详情接口需要同时从几个地方拼数据查订单基础信息约80毫秒查用户信息约70毫秒查商品信息约90毫秒查库存状态约40毫秒最后组装结果约30毫秒如果不做任何并行处理整个接口串行耗时约310毫秒。这个耗时在单量小时时候没什么感觉但随着调用量上涨线程池里的线程被长时间占用接口吞吐量很快就成了瓶颈。这类场景特别适合做异步化改造把互不依赖的几个查询任务并行执行最后再汇总结果。7.2 CompletableFuture并行化的正确打开方式CompletableFuture最简单的用法是配合supplyAsyncCompletableFutureOrderInfo future1 CompletableFuture.supplyAsync( () - orderService.getOrderInfo(orderId), executor); CompletableFutureUserInfo future2 CompletableFuture.supplyAsync( () - userService.getUserInfo(userId), executor); CompletableFutureProductInfo future3 CompletableFuture.supplyAsync( () - productService.getProductInfo(productId), executor); CompletableFutureVoid all CompletableFuture.allOf(future1, future2, future3); all.join(); OrderInfo order future1.join(); UserInfo user future2.join(); ProductInfo product future3.join();这里有一个非常关键的细节supplyAsync如果不传线程池默认使用的是ForkJoinPool.commonPool()。这是一个公共线程池整个JVM内很多异步任务都会共用它。一旦某个任务阻塞时间过长会影响所有使用它的任务。我在压测中就碰过这种情况多个业务模块同时用commonPool一个慢任务把整个池子的线程全部占住其他模块的异步调用全部排队。正确的做法是给每个核心业务场景准备自己的线程池核心参数按任务的IO和CPU密集程度来定并且把线程池名称和大小在配置中心里留好方便随时调整。7.3 改造效果与三个容易踩的坑改造之后的效果很直接串行310毫秒的接口并行后大约在95到110毫秒之间整体响应时间降了差不多三分之二。接口吞吐量也没有因为线程被长时间占用而继续下滑。但有几个坑我在改造过程中踩得很深刻。第一个坑线程池大小不是越大越好。有一版我把线程池最大线程数调到了80结果下游数据库的连接池只有20。高峰期并发达到一定程度时80个线程同时去请求数据库数据库连接池先被打满请求排队紧接着出现连接获取超时整个链路反而变得更慢。线程池参数必须和下游系统能力联动考虑不能只顾上游。第二个坑忘记处理异常。某个远程服务在高峰期偶发超时CompletableFuture里的任务抛出异常后如果直接join()会包装成CompletionException抛出来如果没捕获整个请求就挂了。后来我给每个任务都加了exceptionally兜底返回一个默认值或者标记失败同时把异常信息记录下来。第三个坑异步任务里丢了上下文。项目里的日志跟踪ID放在ThreadLocal里改成异步后新线程里的ThreadLocal是空的日志链路上查不到同一个请求的完整调用链。解决方式是写了一个上下文传递的工具类实现Runnable和Supplier接口包装把父线程的上下文在提交任务时传递到子线程同时在线程池任务结束后清理防止线程复用导致上下文串号。8. 进阶路上常用的排障工具与我的学习路径建议8.1 jstack与jmap把线上问题变成可见的证据先说jstack。它是JVM自带的线程转储工具执行jstack pid就能看到某个进程所有线程的当前状态。最经典的场景是死锁排查。线上偶发请求卡死先用jstack看线程状态如果存在两个线程互相持有对方需要的锁线程栈里会出现明显的deadlock提示。我记得某一次排查就是两个服务在调用彼此的接口时各自持有自己的锁再去获取对方的锁形成了循环等待。jstack一出来两个线程的栈信息清清楚楚马上就能定位到是哪两个类。jmap则用于内存分析。jmap -dump:formatb,fileheap.bin pid可以把Java堆导出来然后用MAT分析哪些对象占了大量内存、是否被不合理的引用持有。ThreadLocal泄漏定位就是靠这一套完成的。Arthas是在线诊断工具不需要重启服务就能看方法调用耗时、查看堆信息、反编译等。我通常会在测试环境跑着Arthas观察新的代码改动是否引入了性能问题。有一个心得排障工具不能等出问题再学平时就要熟悉命令。最好找一台测试机把线程dump、堆dump的过程都跑两三遍真到线上出了事故才不至于手忙脚乱。8.2 我推荐的源码阅读顺序与实验方法如果要把并发这套知识学扎实我建议从三个源码类目入手ThreadPoolExecutor理解线程复用的实现、任务提交的完整流程、拒绝策略的作用点。ReentrantLock理解AQS的获取与释放过程把state、队列、CAS三个概念落到具体代码里。ConcurrentHashMap理解分段设计、CAS synchronized如何配合以及扩容时如何保证并发安全。读源码不要一遍从头啃到尾。我的做法是先跑一个小的Demo观察行为和预期是否一致再回头到源码里去查关键的方法做了什么。还有一个很值得做的实验写一个简单的计数器多个线程同时执行count然后分别试三种情况不加锁、加synchronized、用AtomicInteger。你会直观看到无并发控制时结果偏小synchronized能保证结果正确但吞吐低AtomicInteger既正确又在低竞争下更快。这个实验把原子性、可见性、锁竞争、CAS一次全验证了一遍比背十篇文章都有用。最后分享一个我自己的习惯我整理Java进阶笔记从来不是一次性完成的。每遇到一次线上问题就往笔记里补一节“根因分析”每读一段源码就把核心方法画成简单的手绘流程。时间长了那些零散的知识会自己连成网。这个网就是进阶真正发生的地方。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询