做了这么多年并发编程,我最大的感受是:绝大多数线上故障,都不是业务逻辑多难,而是并发场景下的细节没想透。网上流传过很多所谓“大厂高并发笔记”,但大多是把八股文抄一遍,真正能落到实处的很少。这份笔记之所以被称为“终极版”,是因为它不光讲了并发编程的体系知识,还把从锁的底层原理到线程池参数计算、从并发容器选型到线上问题排查的完整链路串了起来。适合已经写过一段时间Java、想系统梳理并发知识、或者准备面试中高级工程师岗位的开发者,也适合正在排查线上性能问题但无从下手的朋友。
我拿到这份笔记时的第一反应是“这玩意儿怎么才公开”,因为里面很多思路是我们在实际项目中反复踩坑后才总结出来的,比如伪共享对性能的影响、线程池拒绝策略选错导致的生产事故、ConcurrentHashMap在JDK 8前后的性能差异等等。这篇文章不打算逐条翻译笔记原文,而是按我自己消化和实践后的理解,把其中最有价值、最容易用错、最影响系统性能的核心知识点拆开揉碎讲一遍。文中涉及的工具、命令、参数都是我在真实项目中验证过的,你可以直接照着用。
1. 高并发问题的本质与设计思路拆解
1.1 并发编程难在哪:三条核心矛盾的博弈
高并发场景下,我们面对的无非是三个核心问题:可见性、有序性、原子性。这三个问题就像三座大山,压在所有并发系统头上。很多初学者把精力全放在API使用上,今天背一个ThreadLocal,明天抄一个CompletableFuture,但一遇到线上诡异问题就完全懵了,原因就是没有从底层逻辑上理解这三座大山是怎么来的。
可见性问题源于CPU缓存。为了弥补CPU和内存之间的速度差异,现代处理器都有多级缓存,每个核心维护自己的缓存副本。线程A修改了变量,线程B在自己的核心缓存里读到的还是旧值。典型的例子就是下面这段代码:
public class VisibilityTest { private boolean flag = true; public void run() { while (flag) { // 空循环等待 } } public void stop() { flag = false; } }在多核CPU环境下,run方法可能永远看不到flag变为false的那一刻。这是我在一个实际项目中遇到的场景:一个监控线程负责轮询外部系统状态,另一个线程负责在主系统故障时修改这个状态。因为没有对flag加volatile,导致监控线程变成了瞎子,故障一直没有被发现,直到用户投诉才反应过来。
有序性问题主要是因为编译器和CPU为了优化指令执行效率,会对代码进行重排序。在没有依赖关系的指令之间,重排序是合法的,但在多线程环境下,重排序可能导致另一个线程观察到的执行顺序和代码顺序完全不一致。最经典的例子是单例模式的延迟初始化,在没有正确同步的情况下,一个线程可能看到一个“半初始化”的对象。
原子性问题则是最直观的,count++这种看起来只有一步的操作,在字节码层面要经历取值、加一、写回三个步骤。多线程并发执行时,两个线程可能同时读到同一个旧值,各加一次后写回,最终只加了一次。
理解了这三条核心矛盾,再看任何并发工具、任何锁、任何关键字,都会觉得通透——因为它们本质上都是在解决这三个问题中的某一个或某几个。
1.2 高并发系统的设计原则:优先考虑无共享
我在团队里带新人的时候,最喜欢问一个问题:“两个线程要同时修改同一个数据,你会怎么办?” 十个人里有八个会回答“加锁”。这个答案没有错,但如果你把思路停留在“加锁”这个层面,系统的并发度就到头了。
真正优秀的高并发设计,第一原则是尽量减少共享。这就是为什么业界会有各种无锁设计、数据分片、ThreadLocal等方案。你想想看,如果每个线程操作的数据是独立的,就不存在可见性、原子性、有序性这些问题的纠结,连锁都不需要。
举个电商库存扣减的例子。如果所有请求都去修改同一个库存字段,即使你用上了分布式锁,TPS也就几千。很多团队的做法是把库存按SKU分片,每个分片持有独立的库存数,请求根据某些维度(比如用户ID哈希)路由到特定分片,最后通过异步聚合扣减结果。这种设计把单点竞争拆成多点并行,吞吐量直接翻倍。虽然实现复杂度上去了,但如果你的目标是支撑10万级TPS,这就是绕不开的路径。
另一个设计原则是能异步就异步,能削峰就削峰。高并发系统的本质瓶颈往往不在CPU,而在IO、数据库连接、外部服务响应。如果一个操作需要从数据库查数据、调外部接口、再写日志,整个链路的耗时可能是几十甚至几百毫秒。如果同步处理每个请求,系统必须为每个请求分配对应线程或连接资源。把这些非关键路径的操作异步化,比如用消息队列、或CompletableFuture串起异步链路,前端请求就能快速释放资源,系统整体吞吐量也会大幅提升。
第三点是要接受最终一致性。很多刚做高并发的人习惯用事务锁住一切,确保所有数据在任何时刻都一致。但在高并发场景下,强一致往往意味着低可用。绝大多数业务并不需要实时强一致,比如订单状态、库存扣减、积分更新,只要保证最终一致就行。把“强一致”改为“最终一致”,往往能释放数倍的并发能力。
2. 并发基础核心细节与实操要点
2.1 JMM与Happens-Before规则:你不需要把规范背下来,但必须能用来排查问题
Java内存模型(JMM)是理解并发可见性和有序性的基础。它定义了一组规则,规定了一个线程的写操作何时对另一个线程可见。这些规则被整理成Happens-Before原则,包括程序顺序规则、监视器锁规则、volatile变量规则、传递性等。
我个人认为,这些规则不需要死记硬背,但有两个点必须深刻理解到能直接用。
第一,锁之外的普通共享读写是没有顺序保证的。也就是说,你在持锁代码块里写的普通字段,当锁释放后,另一个线程获取同一把锁时,能看到这些写入——这是监视器锁规则保证的。但如果没有锁的关系,仅仅是两个线程各自读写同一个普通字段,一切都是玄学。
第二,volatile的写先行发生于后续对该volatile字段的读。这意味着一篇经典面试文章里的“双重检查锁单例”模式,只要把单例对象声明为volatile,就能防止重排序导致拿到半初始化对象。在JDK 5之后,这个写法是安全的。但同样的问题如果发生在JDK 1.4及以前,即使加了volatile也不可靠,因为旧版本JMM没有这条保证。虽然现在不会有人用那么古老的JDK,但这个细节能反映你对并发底层理解的深度。
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; } }2.2 volatile、synchronized、CAS:三种核心同步机制的使用边界
经常有人问我:“volatile能不能替代 synchronized?”“CAS是什么?它到底是不是锁?” 这三个问题其实很容易回答清楚。
volatile解决的是可见性和有序性,但它不解决原子性。所以对于读写分离的场景——比如状态开关、发布标志、缓存中的版本号——用volatile就够了。如果多个线程同时对一个变量做“读-改-写”操作,volatile就无能为力了,因为赋值操作本身不是原子的。常见误区是把volatile用在计数器上,结果发现丢数据,这就是对语义理解不透彻。
synchronized解决的是原子性,同时也能保证可见性和有序性。在JDK 6之后,synchronized经过锁升级优化,性能已经不比重入锁差。偏向锁在没有竞争时几乎无开销,轻量级锁在低竞争下通过CAS自旋,只有竞争激烈时才膨胀为重量级锁。所以,如果你的并发竞争不激烈,直接无脑用synchronized是最简单可靠的选择,不需要为了“性能”去刻意换成其他锁。
CAS(Compare-And-Swap)是CPU级别的原子指令,Java里的Atomic类基于它实现。CAS的本质是“比较再交换”,没有锁的参与,所以叫无锁编程。它的核心优势在于没有线程上下文切换开销,但在高竞争场景下会有两个问题:一是ABA问题,即变量从A变成B再变回A,CAS会认为它没有被修改过,解决方式是使用AtomicStampedReference加版本号;二是自旋消耗CPU,竞争激烈时大量线程在死循环里空转,反而比锁更慢。
CAS还有一个在Java层面的自身限制——它只能对一个变量做原子操作。如果要同时对多个变量做原子更新,AtomicReference可以包一层对象,或者直接上锁。JDK 8之后的LongAdder就是CAS的一个重要应用案例:它把一个热点值拆成多个cell,每个线程更新自己的cell,最后求和。在超高并发计数场景下,LongAdder的性能比AtomicLong好非常多,这是我在压测中实测过的结论,建议做统计类需求时优先考虑。
2.3 伪共享:一个容易被忽视的性能杀手
伪共享是高并发性能优化中的经典话题,也是很多资深工程师的加分项。先解释一下这个名词:CPU缓存是按缓存行(Cache Line)为单位的,通常64字节。如果两个不相干的变量恰好落在同一条缓存行上,当第一个变量被线程A修改时,该缓存行会被标记为失效,线程B即使修改的是另一个变量,也必须重新从内存中加载整条缓存行。这就是伪共享——表面上两个线程互不干扰,实际上因为缓存行被共享了,性能大打折扣。
代码不好演示,但现实中的典型案例是多个线程各自操作一个数组中的不同元素,这些元素在内存里是紧密排列的,很容易共享同一条缓存行。解决方式是对变量进行填充,让每个变量独占一条缓存行。Java的解决方案是使用@Contended注解(需要JVM参数-XX:-RestrictContended开启),或者手动在对象里插入无用字段作为padding。
我在项目里做过一个性能优化:多个线程各自维护自己的统计计数器,每个计数器是一个独立的Java对象。排查发现,因为对象头、引用和计数占了不到64字节,两个计数器对象被分配到了同一条缓存行,导致线程间互相拖累。加padding之后,吞吐量从每秒120万提升到160万。这个优化只花了半小时,效果却非常明显。
孝验一个并发框架是否专业,也可以看它对伪共享的处理。很多高性能队列、无锁框架的源码里都能看到缓存行填充的身影。面试时聊到这个点,比背一百道八股文都管用。
3. 锁的优化选型与线程池实战
3.1 锁的粒度与锁的选择:从synchronized到ReentrantLock再到StampedLock
锁的粒度是并发设计里最影响性能的因素之一。锁粒度太大,比如把整个方法或整个对象锁住,会导致大量线程排队;锁粒度太小,比如只锁一个字段,又可能保证不了多个相关操作的整体原子性。一个经验法则是:锁的范围只覆盖需要保护的代码,锁的时长只在必要时才持有。
在锁的选型上,我的建议排序是:
- 单线程简单的同步场景,用synchronized,简单可靠,不存在忘记释放锁的问题;
- 需要尝试获取锁、需要超时控制、需要多个条件队列、需要公平锁的场景,用ReentrantLock;
- 读多写少的场景,用ReentrantReadWriteLock,读写分离可以大幅提升读并发;
- JDK 8之后的高性能场景,可以考虑StampedLock,它的乐观读模式在读多写少时表现更好,但使用门槛也更高,容易出错。
读写锁是性价比很高的优化手段。一个配置中心系统里,配置读取频率极高、更新极少,如果对整个配置对象加互斥锁,性能非常难看。用ReentrantReadWriteLock读锁让多个线程同时读取,只有写线程获取写锁时才排他,吞吐量轻松翻倍。不过要注意,读写锁在某些实现里可能存在写线程饥饿问题,所以ReentrantReadWriteLock默认是非公平的,允许写锁在等待期间有可能被新的读锁插队,这个特性在特定场景下需要权衡。
StampedLock的乐观读模式是个大招:读线程不加锁,直接读数据,读完后检查是否有写入发生,没有就万事大吉,有则升级为悲观读锁。这个模式在读多写少且读取操作本身开销很小的情况下表现优于读写锁。但它的硬伤是不支持重入,使用不当容易死锁。我个人建议,常规项目用ReentrantReadWriteLock就够了,StampedLock更适合追求极致性能且对代码质量有十足信心的场景。
3.2 线程池七大参数与线程数计算:别再凭感觉设了
线程池是Java并发编程里使用频率最高、也最容易用错的组件。很多人创建线程池时就是复制一份网上的配置,对核心线程数、最大线程数、队列长度完全没有概念。线上故障排查多了之后,我强烈建议每个开发者都理解下面这个参数矩阵。
ThreadPoolExecutor executor = new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲存活时间 TimeUnit.SECONDS, // 时间单位 new ArrayBlockingQueue<>(capacity), // 任务队列 new CustomThreadFactory(), // 线程工厂 new AbortPolicy() // 拒绝策略 );每一个参数都不是随便写的。核心线程数决定系统在平常负载下能并行处理多少任务;最大线程数决定突发流量下的极限处理能力;队列决定等待任务的缓冲大小。线程数的计算公式业界有两条经验法则:
- CPU密集型任务:线程数约等于CPU核心数+1,多一些是为了应对偶发的缺页中断等;
- IO密集型任务:线程数 = CPU核心数 * (1 + 平均等待时间 / 平均计算时间)。比如一个任务计算耗时20ms,IO等待80ms,则比例为4,8核CPU上线程数就是8*5=40。
这个公式比网上的“两倍核数”靠谱得多。我在一个数据同步系统里按公式算出来的线程数是48,而之前凭经验设的16导致任务堆积,32导致CPU偶尔打满但IO利用率很低。调整之后,整个系统的吞吐量提升了近三倍。
拒绝策略同样重要。AbortPolicy默认抛异常,适合不允许丢弃任务的场景;DiscardPolicy和DiscardOldestPolicy适合允许丢弃非关键任务的场景;CallerRunsPolicy则适合需要削峰、让提交线程自己执行任务的场景。我用CallerRunsPolicy最多,因为当线程池满时,任务由调用线程执行,这既不会丢任务,也能通过“反压”自然降低任务提交速度。
3.3 线程池使用中的内存泄漏风险与最佳实践
线程池用不好,除了性能问题,还可能出现严重的内存泄漏。最典型的是使用Executors.newFixedThreadPool或newCachedThreadPool时,底层用了无界队列,大量任务堆积在队列里占满内存,最终OOM(内存溢出)。JDK自己也警告不要用这些快捷方法,但网上很多老教程还在推荐。
另外,使用ThreadLocal时如果线程池中的线程存活时间很长(核心线程几乎不死),ThreadLocal里的值可能一直无法被回收。之所以强调线程池场景,就是因为每次请求虽然结束了,但线程还活着,ThreadLocal里保存的对象也就一直挂在线程上,越积越多,最终导致内存泄漏。解决办法非常简单,就是在finally块里调用ThreadLocal的remove方法。
线程池还有个隐性问题是线程名称不自定义。线上排查问题时,jstack输出的线程列表全是“pool-1-thread-1”这种毫无区分度的名字,排查效率极低。强烈建议每个线程池都配一个语义清晰的ThreadFactory,把线程名带上业务含义,比如“order-async-worker”,这样jstack一眼就能看出是哪个业务线程出了问题。
4. 并发容器与异步编程实战
4.1 ConcurrentHashMap的前世今生:从分段锁到CAS+synchronized
ConcurrentHashMap是并发编程里最常用的容器,但很多人并不清楚它在JDK 7和JDK 8之间的性能差异,以及各自的适用场景。
JDK 7版本的ConcurrentHashMap采用分段锁(Segment)设计,把整个Map划分为多个Segment,每个Segment是一把独立的锁。修改操作先定位到Segment,再对Segment内部的HashEntry加锁,不同Segment之间的操作可以并行。理论上并发度等于Segment数量,默认是16。
JDK 8版本抛弃了分段锁,改用CAS + synchronized实现细粒度控制。具体来说,插入和更新元素时,先通过CAS尝试修改;如果目标桶位不为空,则对桶位中的链表头节点或红黑树根节点加synchronized锁。这里的synchronized锁只锁单个桶位,而不是整个数组或者一个Segment,因此并发度比JDK 7更高,在桶位不冲突的情况下,多个线程可以同时写不同桶位。
另外一个区别是JDK 8在链表长度超过阈值(默认8)时,会把链表转成红黑树,从而把最差查找时间从O(n)降到O(log n)。这在哈希冲突极端严重的情况下,能明显改善查询性能。
实际项目中最容易踩的坑是:使用ConcurrentHashMap的compute、putIfAbsent、merge等复合操作时,以为它们是原子操作就能高枕无忧。这些方法本身是原子的,但你去组合调用它们时,整个组合不是原子的。经典的“先检查后写入”问题依然存在。如果业务上需要“先判断是否存在再决定插入或更新”这种原子语义,直接用compute方法或直接加锁,不要自己拼步骤。
4.2 阻塞队列选型对比:不只是ArrayBlockingQueue和LinkedBlockingQueue的区别
线程池配上队列,才是完整的生产消费链路。不同队列的特性决定了系统的行为差异。很多人在项目里无脑用LinkedBlockingQueue,实际上每个队列的行为模型差别明显,选错了会出现“看似能跑,压力一大就崩”的情况。
ArrayBlockingQueue是基于数组的有界队列,容量固定,公平性可以通过构造参数指定。它适合线程池配合固定的有界队列使用,因为队列满了之后,线程池能按拒绝策略做统一处理,而不是无限堆积。
LinkedBlockingQueue底层是链表,可以设置容量也可以是无界的。Executors.newFixedThreadPool默认就用了无界队列,任务无限堆积很容易OOM。如果你要定义一个线程池,一定不要用无界队列,必须指定容量。
SynchronousQueue是另一个极端的队列,它没有任何内部缓冲,每个插入操作必须等待对应的移除操作,相当于线程间直接交接。配合CachedThreadPool使用很合理,因为来的任务都会直接创建新线程执行,前提是你能接受线程数的波动。
DelayQueue和PriorityBlockingQueue分别适合延迟任务和优先级排序的场景。我用DelayQueue做过一个延迟重试系统,任务到期后才被消费者取走,比用定时扫描数据库高效得多。
另外提一下,JDK 8之后有个LinkedTransferQueue,它实现了TransferQueue接口,支持线程直接传递数据,性能在某些场景下优于LinkedBlockingQueue。如果做高性能生产者消费者模型,值得尝试。
4.3 CompletableFuture异步编程:比FutureTask好用但要注意线程复用
CompletableFuture是JDK 8引入的异步编程利器。它最大的价值在于可以把一组异步任务串联成流水线,并且每个阶段都支持同步或异步执行。我在项目中用它重构过一套报表生成逻辑,原来每个报表数据源按顺序查询,最慢需要8秒;改造后并行查询,整体耗时降到了2秒以内。
CompletableFuture的典型用法是组合多个异步任务:
CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(() -> queryOrder(orderId), executor); CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> queryUser(userId), executor); CompletableFuture<Promotion> promotionFuture = CompletableFuture.supplyAsync(() -> queryPromotion(orderId), executor); CompletableFuture<Void> allFuture = CompletableFuture.allOf(orderFuture, userFuture, promotionFuture); allFuture.join();这里的细节是supplyAsync可以传入自定义线程池。如果不传,默认用ForkJoinPool.commonPool,这个池是全局共享的,如果被多个业务模块滥用,线程会被抢来抢去,导致线程饿死或响应延迟。线上项目里,一定给CompletableFuture显式传一个业务独立的线程池。
还有一条实践教训:不要随意使用join或get这两个阻塞方法。在异步流水线中,任何一个地方调join都会把异步变回同步,不仅浪费了前面的异步设计,还有可能在阻塞中把公共线程池的线程耗尽。
5. 高并发场景的常见问题与排查技巧实录
5.1 死锁、活锁与线程饥饿:三个导致系统静止的隐形杀手
死锁是并发编程里最出名的问题。它的四个必要条件——互斥、持有并等待、不可剥夺、循环等待——大家背得很熟,但实际排查时,真正重要的却是如何定位。我曾经遇到一个线上问题:某个核心服务突然所有线程都卡住不动,新请求全部堆积超时。排查时用jstack抓线程快照,很快就看到了经典的死锁循环:线程A持有锁1等待锁2,线程B持有锁2等待锁1。
根本原因是代码里两层嵌套锁的方向不一致。一个方法先锁订单再锁用户,另一个方法先锁用户再锁订单。两个方法分别在不同线程中执行时,就撞上了。修复方法很简单,把两层锁的加锁方向统一即可。但如何快速定位这类问题是关键技能,下面这个命令是我每次用都觉得很踏实的:
jstack <pid>线程快照里如果出现“Found one Java-level deadlock”,说明JVM已经在帮你标记死锁线程和锁对象了。如果没出现,但线程长期BLOCKED,也可以从堆栈中分析锁等待关系。
活锁则是线程不断重复尝试一个失败的操作,导致始终无法推进。比如两个线程都尝试让对方先执行,结果互相谦让,谁也完成不了。活锁在资源竞争场景下比较罕见,但通过盲目重试机制就能轻松制造。解决思路通常是让等待时间随机化,打破对称。
线程饥饿的原因则更微妙。比如ReentrantLock创建为非公平锁时,新来的线程可能不断插队,导致等待队列里的老线程长期无法获得锁。如果饥饿严重,某些线程永远得不到CPU时间或锁资源,整个功能的延迟会变得不可接受。这就是为什么需要公平锁去保证每个线程都有机会执行。
5.2 线程池队列堆积与CPU飙高:一步步定位性能瓶颈
高并发系统最常出现的运维问题是两类:一是线程池任务堆积,二是CPU使用率异常飙高。这两个问题互相之间也可能有关联。
任务堆积通常是因为生产者的生产速度大于消费能力。最直接的定位方法是看线程池内部的几个统计值:当前活跃线程数、队列大小、最大线程数是否打满。如果活跃线程一直等于核心线程数,队列在增长,说明核心线程数不够;如果活跃线程数已经达到最大线程数,队列还在增长,说明任务处理能力已经触顶,需要考虑扩容、削峰或优化任务内容。
我曾经处理过一个用户行为日志上报系统,压测时发现队列长度到了几万,延迟飙升。先看CPU,发现并不高;再看IO,发现数据库的写入等待时间很长;然后才发现是批量插入的批次太小,每次提交只有几十条,导致频繁的网络往返。改成批量积累到一千条再写库之后,吞吐量直接提升了十倍。这个案例说明,在排查性能问题时,不要一上来就调线程数,先定位瓶颈在哪一层,再对症下药。
CPU飙高的场景,首先要区分是用户态高还是内核态高。用户态高可能是业务代码死循环、大量计算、或者频繁的GC;内核态高往往是线程切换频繁、锁竞争激烈或系统调用过多。用top -Hp 可以看线程级CPU占用,再用jstack配合可以定位到具体线程执行的代码行。
频繁的GC也是并发场景下的性能杀手。大量线程同时创建临时对象,导致Young GC频繁触发,应用线程被迫暂停。排查手段是看GC日志,关注GC频率和耗时。如果young区回收频繁,说明对象分配太猛,可能需要调整新生代大小或减少并发创建的对象数量。
5.3 常见问题速查表与避坑惯例
| 症状 | 常见原因 | 快速排查方法 | 解决建议 |
|---|---|---|---|
| 接口偶尔超时 | 线程池核心线程数偏小,任务排队 | 查看线程池队列大小和活跃线程数 | 按IO密集型公式重算线程数 |
| 应用内存持续增长 | ThreadLocal未移除或队列堆积 | jmap/histo查看对象数量 | remove清理ThreadLocal,给队列设边界 |
| 死锁导致服务卡死 | 多锁顺序不一致 | jstack查看Found deadlock | 统一加锁顺序或使用tryLock超时 |
| CPU用户态飙高 | 死循环/大量计算/频繁GC | top -Hp定位线程,jstack查代码 | 优化代码,检查GC频率 |
| 并发写数据丢失 | 多线程无锁更新共享值 | 代码审查确认无同步机制 | 改用Atomic原子类或用锁保护 |
| 高并发秒杀超卖 | 检查,非原子的先查后改 | 代码审查日志确认竞争范围 | 改用数据库乐观锁或Redis扣减脚本 |
这个表里的每一项都是真实排查过的案例归类。我个人最大的经验是:生产环境的问题,九成以上不是高端理论,而是基础知识的误用。把线程池的参数理解透、把锁的语义理解透、把并发容器的特性理解透,大多数问题就能迎刃而解。
另外再分享一个小惯例:每当你写完一段多线程代码,先问自己三个问题——哪些变量是共享的?这些变量的操作是否是原子的?如果发生重排序,另一个线程可能看到什么?把这三个问题过一遍再提交代码,能少踩一半的坑。
5.4 线上问题排查工具箱盘点
除了jstack、jmap、top这些JVM自带工具,我的日常排查工具集中还有几个非常好用但不是所有人都知晓的:jstat用于观察GC情况,jcmd功能比jmap更丰富,Arthas则是在线诊断神器。特别是Arthas,在无法随意重启的应用上,可以动态查看方法执行耗时、反编译线上代码、追踪调用链路,效率非常高。举个例子,当我不确定某段热点代码是否触发了锁竞争时,可以直接用Arthas的thread命令看线程状态,再配合dashboard看实时指标,基本两分钟内就能锁定方向。
有人可能会问,这些工具是内部工具还是外部工具。我的答案是,如果你在维护生产Java应用,它们就是你的随身装备。尤其对于高并发系统,一次故障定位的时长直接决定业务损失,值得早做准备。
结尾
在我自己多年的实践中,高并发编程的进阶路径从来不是“多背几个API”“多会几个并发工具”,而是不断追问“为什么”:为什么要用这个锁而不是那个?为什么要这样设计线程池参数?为什么这个并发容器在某个版本后性能发生了质变?正是因为这些追问,我才能在排查线上问题时少走弯路,也才能在系统压测时提前发现隐患。
最后再分享一个小技巧:每当你学习一个新的并发组件,不要只停留在写Demo跑通,而是试着在同一场景下对比新旧两种方式的表现,比如用AtomicLong和LongAdder分别跑一下10万次并发自增,用数据说话。这种对比会让你对技术选型的理解远超死记硬背,也是我认为从初级进阶到高并发老手最快的一条路。希望这份笔记的解读,能帮你少踩一些我踩过的坑,多做一些真正高效的高并发系统。