ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

并发编程核心:并发与并行、线程模型与锁优化全解析

并发编程核心:并发与并行、线程模型与锁优化全解析

1. 并发不是并行:先搞清楚这两个词再往下学

"3.16并发学习"这个标题,看起来像是一个平平无奇的学习日志。但我恰恰是在经历了无数次"并发改完变串行""并行一上就崩溃"之后,才意识到并发这门课的入门第一关,不是学会某个API,而是先分清一个被反复混淆的概念:并发(Concurrency)和并行(Parallelism)。

很多人把这两个词混着用,面试被问就背"并发是逻辑上的同时,并行是物理上的同时",背完就忘。真正的问题在于:如果你的脑子里没有一幅清晰的执行模型图,后面学锁、学队列、学协程全都是空中楼阁。

我打个比方,并发像是只有一个厨师的厨房,你同时接了三个订单,厨师不可能真的同时炒三个菜,他只能切一下菜A,翻一下锅B,又把汤C的火调小,来回切换着推进。这就是并发:一个执行单元,多个任务交错推进。并行则像是三个厨师各占一个灶台,三锅菜同时起油,互不干扰,这是物理层面的同时执行。

这个区别有多重要?直接决定了你后续选型时看的是"并发数"还是"并行度"。这两者对应完全不同的性能瓶颈和排查手段。比如一个用户请求本身是串行逻辑,你把它拆成三段丢进线程池,表面上并发度上去了,但如果三段之间有强依赖关系,这个"并发"就是假的,执行时间并不会缩短,甚至因为线程切换、排队、上下文切换的开销,反而变慢。

所以并发学习的第一步,不是写代码,而是静下来想清楚:我面对的是一组可以乱序交错执行的任务,还是一组必须同时推进才能缩短总耗时的任务。前者走并发控制路线,后者走并行拆分路线。两条路线对锁的依赖、对调度器的理解、对调试手段的要求,是很不一样的。

我自己的学习路径是这样的:先建立了进程、线程、协程的三层认知框架,再逐个攻克"锁、队列、调度、原子性"这几个硬骨头,最后落到实际场景里反复压测、复盘。下面我把这套路径拆开,一封一封讲清楚。

2. 进程、线程、协程:三层模型里藏着并发问题的根源

2.1 三个层级的本质区别

进程是操作系统分配资源的最小单位,线程是操作系统调度的最小单位,协程则是用户态自己实现的更轻量级的调度单元。很多人背到这里就停了,但没有深入一问:为什么协程能轻量?凭什么协程切换比线程切换快?

答案藏在内核与用户态的边界上。线程的创建、切换、销毁,都要经过内核,每次切换涉及用户态到内核态的模式切换,还要保存和恢复寄存器状态、栈指针、程序计数器等一堆上下文。而协程的切换完全在用户态完成,它不需要陷入内核,切换动作只是保存当前函数栈和恢复另一个协程的栈,代价小得多。

用数据来说话:一个线程的栈空间通常默认是1MB到8MB,一个协程的栈可以小到几KB甚至能做到动态伸缩。同样一台2GB内存的机器,你要创建成千上万个线程是不现实的,但创建成千上万个协程完全没压力。

我踩过一个很典型的坑:接手某跨平台系统时,为了追求高并发,直接开线程池怼请求,每个请求分配一个线程,结果内存直接爆炸。后来把高频IO操作改成协程模型,吞吐马上上来了,内存占用反而降了。这个教训让我彻底理解了:线程和协程不是谁替代谁的关系,而是不同量级的工具,解决的规模完全不同。

2.2 为什么并发问题大多出在共享资源上

并发之所以难,不是因为"多任务"本身难,而是因为多任务一旦共享某种资源,就必然产生竞争。这种竞争的本质是:多个执行流在同一时刻读取同一个变量,然后各自基于旧值做了修改,最后写回去的时候互相覆盖。

举一个最经典的例子:计数器自增。你以为是"一次操作",其实是三步——从内存读值到寄存器、在寄存器里加1、把新值写回内存。两个线程同时执行这三步,完全可能都读到初始值0,各自加1,最后写回的都是1。结果本该是2,却变成了1。

这就是原子性缺失的问题。原子性意味着"从外部看,这个操作要么没发生,要么完整发生,不存在中间状态"。我见过不少刚入门的同学,觉得"加个锁就行了",但对为什么需要加锁、锁保护的是什么边界的理解很模糊。锁保护的不是某一行代码,而是"代码对应的临界区"——也就是说,在锁的保护下,同一时刻只有一个执行流能进入这块代码区域,其他执行流必须在外面排队等待。

这里有一个很多人忽略的层次关系:原子性、可见性、有序性,是并发编程的三大核心问题。原子性解决"操作不被穿插"的问题,可见性解决"一个线程的修改对另一个线程是否可见"的问题,有序性解决"编译器或CPU对代码重排序导致的逻辑错乱"问题。不要以为只要你写对了一个变量叠加逻辑就行,Java里volatile修饰变量的读写、内存屏障的插入、指令重排序,这些都是为了在更底层解决可见性和有序性问题。我学的时候是拿一个并发测试框架反复跑了多轮,才真正看到"不加volatile时,线程间读到过期值"的复现场景,那种直观的震撼比看十篇理论文章都管用。

2.3 锁的粒度:你是在保护临界区,还是在跟临界区较劲

锁的基本形态大家都懂:加锁、操作、解锁。但真正决定并发效率的,是锁的粒度,也就是你锁住的代码区域有多大。锁粒度太粗,比如把整个方法或者整个循环都锁住,那并发几乎变成串行,性能自然上不去。锁粒度太细,比如只锁住一行赋值,可能又没法保证整个逻辑的原子性,导致数据错误。

我见过一个线上案例:模拟项目X的库存扣减接口,原本只扣库存一行UPDATE,但后来加了一个"扣库存前检查库存是否充足"的判断,有人图省事把判断和扣减放在一个锁里,锁范围扩大到整个方法。结果并发一上来,大量请求排队,吞吐率直接腰斩。排查的时候把锁范围缩小到"检查+扣减"这个原子操作内,吞吐率才恢复。

所以正确做法是:先用粗粒度锁保证正确性,跑通后再看性能瓶颈在哪里,逐步缩小锁的范围。并发调优永远是"正确性优先,性能其次",顺序不能反过来。

3. 队列与调度:理解"谁先谁后"才能驾驭高并发

3.1 队列是缓冲,不是万能药

高并发场景下最常见的设计就是"把请求丢进队列,然后慢慢处理"。队列确实能削峰填谷,平滑流量冲击,但队列并不是免费午餐。队列的深度、消费者的处理能力、队头阻塞,这些都是隐藏的问题点。

我在做某跨平台系统的消息推送模块时,最初方案是收到一条推送就立刻处理一条。上线后发现某个时段消息量突增,服务直接被打崩。后来改成Kafka队列:生产者把消息写到队列,消费者按固定速率拉取处理,系统果然稳了很多。但没多久就发现,消费端的处理速度跟不上了,队列里积压的消息越来越多,最终消费者队列积压到超过内存限制。真正的瓶颈不在队列,而在消费者处理能力。队列帮你把峰值缓冲了,但如果没有监控告警、没有消费者扩容策略,积压照样能磨死人。

这里有一个很关键的思维转变:用队列时,你要关注的指标不是"入队速度",而是"消费速度"和"队列积压数"。我后来把监控重点放在这两项上,并给消费者加了横向扩容的机制,一是通过动态调整消费者数量来匹配流量,二是对下游依赖做超时报降级,问题才算真正解决。

3.2 多线程调度的公平性与饥饿问题

调度器决定了多个任务谁先拿到CPU,谁先拿到锁。这背后有两个核心概念:公平性和饥饿。公平性意味着每个任务等待获取资源的时间大致均匀,饥饿则是一些任务长期得不到执行。

我见过一种很典型的饥饿场景:在Java里用ReentrantLock的默认非公平锁,后来为了"公平"改成公平锁,结果性能下降,因为公平锁要维护等待队列的排队开销。于是又改回非公平锁,结果又出现了线程饿死的情况——某个线程长时间无法获取锁,迟迟得不到执行。

其实这两种锁本质上是"吞吐量优先"和"公平性优先"的取舍。真实场景的选择原则是:如果任务是短小且频繁的,非公平锁通常更好;如果任务类型差别很大,有的任务极短有的极长,则公平锁能避免短任务被长任务反复压制。而协程调度里,只要你的调度器能做到"先到先得"且没有优先级反转,一般不太会饿死,但CPU密集型任务多的场景,仍然要小心某些协程抢不到时间片。

3.3 线程池参数调优的实操思路

线程池的参数看起来是固定的几项:核心线程数、最大线程数、任务队列容量、拒绝策略。但真正调优时,发现各项之间互相影响非常大。我自己的经验是:先测算任务的IO密集度,再设定线程数。

一个粗略经验公式:CPU密集型任务,线程数设置为CPU核数+1;IO密集型任务,线程数可以设置成CPU核数*(1+等待时间/计算时间),甚至更高。如果你拿不准,可以先用一个保守的核心数,再通过压测逐步往上加,观察响应时间和吞吐率的变化曲线。

线程池拒绝策略同样关键,四种策略各有使用场景:

  • AbortPolicy(直接抛异常)适合对可靠性要求极高的场景,但你要做好异常处理。
  • CallerRunsPolicy(调用者执行)适合降级场景——让提交任务的线程自己执行,等于把压力反馈给上游,天然限流。
  • DiscardPolicy(静默丢弃)和DiscardOldestPolicy(丢弃最旧)适合实时性要求高的场景,可以接受丢部分旧数据,保证新数据能及时处理。

不要一上来就选AbortPolicy,很多同学在流量尖峰时被抛异常打懵,就是因为没有考虑好"队列满了之后怎么办"。调度和流控,本质上都是在极端情况下"兜底"的手段。

4. 原子操作与内存模型:新手和老手的差距往往在这里拉开

4.1 从硬件层次理解原子性

你写一行i++,看起来是原子操作,但在CPU执行层面,这行代码会被编译为LOAD、ADD、STORE三条指令。三条指令之间完全可能被其他线程的指令穿插。

CPU和编译器为了提升效率,会打乱指令的执行顺序(指令重排)。单线程下这没有问题,因为重排前后结果一致;但多线程下,一个线程看到另一个线程的操作顺序可能完全不同。经典的"双重检查锁单例"问题,就是指令重排导致的对象引用提前暴露。

那Java里的AtomicInteger是如何做到原子自增的?底层用的是CAS(Compare And Swap)指令,它是一条处理器级别的原子指令。CAS的逻辑是:先比较内存中的当前值是否等于我预期的值,如果相等,则把新值写入,否则不写入并返回失败。通过循环CAS,就可以在没有锁的情况下完成"安全自增"。

但CAS也不是万能药。它解决的是"单变量的原子更新",遇到"多个变量需要保持一致"的场景,就要用锁或版本号等更复杂的机制。我写过一个小例子:两个账户互相转账,A减100,B加100,如果用两个CAS分别操作,中间一旦一个成功一个失败,资金就凭空消失了。这种场景必须用锁把两个操作包起来,或者用一个只有单写者的领域对象来管理状态变更。

4.2 可见性与内存屏障:为什么一个线程改了值,另一个线程看不到

在物理层面,每个CPU核心通常都有自己的高速缓存。一个线程修改了变量,它写入的是自己核心的缓存,另一个线程在自己的缓存里读到的可能是旧值。解决这个问题的核心机制就是"缓存一致性协议"和"内存屏障"。

Java里volatile关键字就是靠内存屏障来保证可见性的:写volatile变量时,会插入一个Store屏障,强制把当前核心的缓存刷新到主内存;读volatile变量时,会插入Load屏障,强制从主内存重新读取。所以volatile能保证"一个线程写、多个线程读"的可见性,但不能保证多个线程同时写的原子性。

网上很多文章说"volatile能保证原子性",这个说法是错的。举例来说,如果两个线程同时对同一个volatile变量自增,结果依然可能出错。因为自增是"读-改-写"三步,三步之间无法靠volatile保证互斥。真正要保证原子性,需要用synchronized或AtomicInteger那类CAS工具。

这一块我当初学的时候特别容易绕晕,后来总结了一个判断口诀:多个线程同时写同一个共享变量,用锁或CAS;一个线程主导写、其他线程只读,用volatile就够了。如果你不确定,宁可上锁,先保证正确,再考虑后续优化。

4.3 DCL(双重检查锁)到底哪一步出了问题

双重检查锁是并发学习里最经典的案例。这段代码看似无懈可击:先判断是否为空,为空就加锁,加锁后再次判断。但实际上在早期JVM里,它存在一个严重的"半初始化对象"问题。

原因在于:instance = new Singleton()这行代码,在字节码层面分为三步:1. 分配内存空间;2. 在内存上调用构造器初始化对象;3. 将引用指向该内存。由于指令重排,步骤2和步骤3可能被调换顺序。设想线程A执行到步骤1、步骤3后,对象引用已经不是null,但对象内部的字段还没有初始化;此时线程B进来,发现instance != null,直接使用这个半初始化的对象,就会出问题。

JDK 5之后,volatile可以解决这个问题,因为在写volatile变量时,JMM禁止了这种重排。所以现代的DCL写法是private static volatile Singleton instance;,加了volatile才能保证对象初始化完成后引用才对外可见。

我为什么要把这个经典问题单独拿出来讲?因为很多并发问题的根源根本不是"锁用错了",而是"锁之外的可见性和有序性问题"。你看再多框架源码,如果底层这个模型没建立,遇到一些看起来"莫名其妙的偶发bug"时,根本无从下手。

5. 几大经典的并发Bug与排查链路

5.1 死锁的形成条件与复现过程

死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。这四个条件缺一不可,所以打破死锁的办法就是破坏其中任意一个条件。

我最早复现死锁是写了一个最简单的"两个锁交叉"场景:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两个线程僵持在那里,程序挂起。排查起来其实不难,难点在于线上环境里,死锁往往不是每次都发生,而是取决于线程调度的时序。高负载下时容易偶现,低负载时反而稳定。

排查死锁我是这样做的:先拿线程转储(thread dump),看哪些线程处于BLOCKED状态,各自在等哪把锁,锁又被谁持有。一套流程走下来基本能定位。所以强烈建议在项目里保留"线程转储 + 日志埋点"的习惯,遇到疑似死锁时第一时间留证,而非急着重启。

5.2 活锁与饥饿:两个看起来"没死"却"跑不动"的问题

活锁比死锁更隐蔽。死锁是所有人都等在那里不动,活锁是所有人都在动,但都在互相避让,以至于谁都无法推进。我遇到过的一个案例是:两个线程发生资源冲突时,各自尝试"后退一步再重试",结果两个线程看到对方后退,自己也跟着后退,不断重复,始终抢不到资源。

活锁的经典解法是"加入随机退避",让两个线程以不同的节奏重试,而不是对称地互相避让。或者用"优先级/序号"机制,保证某一个线程在冲突时拥有优先权,从而打破对称局面。

饥饿就更常被忽视。有些高并发系统里,请求处理线程永远处理不完某些低优先级任务,这些任务就像被遗忘在队尾,一直在等。此时你从监控面板上看,任务队列一直是满的,但整体吞吐率并不低,说明高强度请求一直在抢占资源,但低优先级任务的延迟指标却一路飙升。这种情况下,需要设计"优先级队列 + 低优先级任务限流"的组合策略,保证经典的低优先级任务也能定期获得处理窗口。

5.3 上下文切换开销:为什么并发线程加多了反而更慢

增加线程数一定提升性能吗?不一定。当活跃线程数超过CPU核心数时,操作系统就必须通过时间片切换来让所有线程都有机会运行。每次切换,CPU要保存当前线程的寄存器状态、程序计数器、栈信息,再加载另一个线程的这些数据。这个切换动作本身是有开销的,而且如果线程数量增大到切换开销大于实际计算量,系统吞吐率反而会下降。

我曾在压测时故意把Java线程池的最大线程数从200调到2000,结果吞吐率不升反降。原因是大部分线程都处于阻塞等待状态,频繁切换到它们身上,白白消耗了CPU。后来我把线程数压回到接近CPU核心数*(1+等待时间/计算时间)的水平,吞吐率才回归正常。

这里的核心经验是:线程池的大小不是"越大越好",而是"匹配你的任务模型"。你在调并发参数时,一定要先算清楚自己的任务到底属于IO密集型还是CPU密集型,等待时间和计算时间的比是多少。

6. 内存与数据一致性:并发bug的重灾区

6.1 缓存一致性协议简说

多核CPU设计中,为了减少对主内存的访问,每个核心都有多级缓存。缓存一多,不同核心之间怎么保证数据一致,就成了硬件层面的难题。

常见的缓存一致性协议(如MESI协议)会为每个缓存行标注状态,在某个核心修改数据时,通过"失效消息"通知其他核心使它们对应的缓存行失效。这样,其他核心在读数据时,如果发现缓存行已失效,就要去主内存加载最新值。

这个机制的代价是:频繁的缓存失效和同步会让性能折损。所以在写并发代码时,一个很常见的优化思路就是"避免多个线程频繁写同一个共享字段",甚至通过ThreadLocal或分片,把每个线程的数据隔离开,减少缓存同步压力。

6.2 volatile与synchronized的适用边界

很多人分不清volatile和synchronized该用哪个。我个人的经验判断标准是两条:

第一,如果存在"多线程读一个可变状态,同时一个线程修改它",且写操作不需要依赖当前值(比如开关标志位),用volatile就够。但如果是"读历史值再改再写",就必须用synchronized或CAS,因为volatile不保证读-改-写三步的原子性。

第二,如果临界区很长,超过简单的字段赋值,或者需要保证多个字段同时更新后对别的线程可见,用synchronized更合理。

简而言之,volatile面向"单变量可见性",synchronized面向"临界区互斥性"。两者可以结合使用,但不要把volatile当成"轻量级锁",它的语义比锁弱得多。

6.3 数据竞争与内存模型的兜底手段

数据竞争的定义是:多个线程同时访问同一变量,且至少有一个线程是写操作,且这些访问没有通过同步机制来排序。数据竞争在JMM里是不被允许的,因为它是未定义行为。

解决数据竞争的兜底手段,第一就是彻底避开共享可变状态,用不可变对象、线程封闭(ThreadLocal)等手段。第二是同步访问,让访问在锁的保护下排序。第三是用原子变量和并发容器来最小化临界区。

我在团队里推行过一条规则:默认情况下,新写的并发代码不允许直接对共享HashMap做读写,必须先考虑用ConcurrentHashMap;不允许随意new Thread,必须走线程池。这类底线规则省掉了大量低级并发bug。

7. 学习路线与实践建议:我在并发学习里踩过的坑

7.1 理论学习与实践验证的节奏怎么安排

很多人学并发是"先啃理论,再动手实践",但我的体会是必须"边学边实验"。比如你刚学完volatile,写一个小程序:起两个线程,一个写变量,一个读变量,不加volatile时观察读线程能否及时感知变化,加上volatile后再观察差异。这些实验花不了几分钟,却能把抽象概念变成肌肉记忆。

另一个高效方法是:故意写"错"的并发代码,复现死锁、可见性问题、数据竞争。犯错后再排查,记忆远比看正确代码深刻。我自己复现"双重检查锁的指令重排问题",是通过在高频循环里不断触发,并配合JIT参数调整,才真正观察到半初始化对象的出现。

7.2 一个可以复用的排查思路框架

进入生产环境后,I遇到并发问题的排查思路我总结为"四步走":

第一步,确认问题范围:是偶发还是必现?单线程是否复现?并发量增大后是否加剧?这决定了是并发问题还是逻辑问题。

第二步,收集现场证据:线程转储、火焰图、日志时间线、GC状态、CPU占用。这一步的价值是让问题可回溯,而不是靠猜。

第三步,定位共享资源:代码里哪些变量、哪些集合、哪些外部资源(数据库记录、文件、连接)是被多个线程共享的?查看访问路径是否都经过同一把锁。

第四步,修复并回归压测:改完不是跑一次就完,要用并发压测工具做多轮验证,同时对比修复前后的吞吐率和延迟指标。

这套框架我用了很久,它不能替代你对JMM的理解,但能让你在面对"偶发奇怪问题"时有一个稳定的推进路径。

7.3 并发性能优化的顺序建议

性能优化的顺序,应该严格遵循"先正确后性能":先保证逻辑正确、无死锁、无语义错误,再去看性能指标。不要在还没保证正确性的情况下,就追求加锁粒度最小化、线程数最大化。

当性能确实有问题时,优化的优先级我一般这么排:先看数据结构和算法是否能减少共享写(比如用分片、用读写分离),再看锁竞争是否频繁(通过锁竞争监控来判定),然后调整锁竞争策略(粗锁改细锁、读写锁分离、乐观锁替代悲观锁),最后才考虑线程数量和调度参数。

千万不要一上来就调整线程池大小,那往往是最后一步才做的事。先消除无谓竞争,再谈扩大并发。

8. 惠及实践:一个典型并发场景的复盘

最后用一个我亲历的典型场景做总结:某跨平台系统的用户积分变动接口,同时被多个服务调用,积分变更必须原子且实时生效。初期方案是在内存里直接用一个HashMap存用户积分,更新时整个方法加锁。上线后一旦有高峰流量,锁竞争非常严重,接口响应时间时不时飙高。

第一次优化:把整个方法锁改成细粒度锁,按用户ID分片加锁,每次只锁当前用户对应的锁对象,大大降低了竞争频率。第二次优化:把内存积分改成Redis的原子自增/自减命令,利用Redis单线程命令天然原子性,避免了应用层锁的排队。第三次优化:针对用户积分查询,用本地缓存+过期时间,减少对Redis的读压力。

最终效果是:并发量提升了一个数量级,响应时间稳定下来,且数据始终一致。这个场景想说明的核心是:并发调优往往不是"找一把更大的锁",而是"从设计上减少锁的冲突"。如果你能从数据模型、资源访问模式入手,把共享变分区,把加锁变原子操作,性能提升会非常明显。

关于3.16并发学习的笔记,就到这里。我自己在反复实验、踩坑和复盘里的最大体会是:并发编程没有银弹,所有方案都是权衡。你选择的无锁数据结构、细粒度锁、队列缓冲,背后都是正确性、性能、复杂度三者之间的取舍。建议你学习时多动手复现问题,多压测真实场景,积累属于自己的排查直觉,比背诵任何结论都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进