Scala 并发与原子性实战:无锁编程、并发数据结构与 STM

系统覆盖 Scala/JVM 并发编程的底层与进阶:JMM 与可见性(volatile/final/synchronized)、java.util.concurrent 原子类(AtomicLong/CAS)、并发集合选型(ConcurrentHashMap/CopyOnWrite/BlockingQueue)、Scala 侧并发原语(并发集合、Future、Actor/Fiber 对比)、软件事务内存(ScalaSTM)、以及无锁数据结构的实现与陷阱,帮助读者在 Actor/效果系统之上掌握真正的并发原语。

Actor 与 Effect 是 Scala 并发的「高抽象」,但它们的底层都是内存级并发原语——可见性、原子性、锁与 CAS。本文把这些原语讲透:JVM 内存模型(JMM)怎么定义可见性、Atomic 类与 CAS 怎么做到无锁原子、并发集合怎么选型、Scala 侧有哪些并发增强,以及软件事务内存(STM)何时值得用。理解底层原语,才能判断「什么时候用 Actor、什么时候用原子类」是合理的。

前置:/actor-model-detailed-explanation/(Actor 消息模型)、/scala-functional-effects/(IO/Fiber 并发)、/scala-functional-programming/(纯函数与不可变)、/scala-akka-cluster/(分布式并发)。

目录

1. 并发问题的最小单元:可见性与原子性

并发 bug 只有三个根因:竞态、可见性、原子性:

可见性(Visibility):
线程 A 改了变量,线程 B 不一定看得到(CPU 缓存/寄存器)

原子性(Atomicity):
「读-改-写」三步之间被其他线程打断 → 结果错乱

有序性(Ordering):
编译器/CPU 重排指令 → 代码顺序 ≠ 执行顺序
// 经典竞态:两个线程同时 +1
var counter = 0
def inc() = counter += 1   // 非原子:读 → 加 → 写,三步可被打断

为什么 Scala 也会遇到:Scala 编译到 JVM,JVM 的线程共享内存由 JMM 规则约束。Scala 的不可变值(val)天然安全,但 var、Array、mutable 集合暴露在所有并发问题之下。

2. JMM:volatile、final 与 happens-before

JMM(Java Memory Model)定义了「什么情况下一个线程的写对另一个线程可见」:

happens-before 规则(可见性的依据):
□ 同一线程内:按程序顺序
□ 解锁 → 后续加锁的线程
□ volatile 写 → 后续读该 volatile 的线程
□ 线程 start/join:启动/等待关系
□ 传递性:A → B → C 则 A → C

volatile:保证可见性 + 禁止重排,但不保证原子性
  → volatile 的 i++ 依然是竞态(读改写三步不原子)

final:构造器内写 final 字段 → 发布即安全
  → 不可变对象天然安全(这就是 Scala val/case class 安全的根基)
@volatile var flag = false
// 可见:写 flag 的线程,其他线程一定能看到
// 但 flag 的「读-改-写」不是原子的 → 计数用 AtomicInteger

工程要点:不可变(val/final/case class)是 Scala 并发的第一安全手段——final 字段发布即安全,不需要任何同步。只有在确实需要可变状态时才上 volatile/原子类/锁,且要清楚 volatile「可见不原子」。

3. synchronized 与锁的代价

synchronized(监视器锁)是最直观的互斥手段,但有代价:

锁的代价:
□ 阻塞:线程竞争锁失败 → 挂起/唤醒(上下文切换,微秒级)
□ 死锁:多个锁 + 循环等待 → 卡死
□ 锁粒度:锁太大 → 并发度低;锁太小 → 竞态未消除

JVM 锁的优化:
□ 偏向锁/轻量级锁/重量级锁(自适应自旋)
□ 锁消除/锁粗化(JIT 优化)

Scala 侧:
□ synchronized 块与 Java 相同
□ 但更推荐「不可变 + 原子类」替代大部分锁
private val lock = new Object
def update(): Unit = lock.synchronized {
  counter += 1   // 临界区,互斥
}

工程要点:锁要**「细粒度 + 短临界区」**——锁内只做必要操作,不做 IO/网络。JVM 对锁有大量优化,但「锁竞争激烈」依然是并发的头号性能杀手;能用原子类/不可变替代的锁,优先替代。

4. 原子类与 CAS:无锁的原子更新

java.util.concurrent.atomic 提供无锁原子操作,靠 CPU 的 CAS 指令:

CAS(Compare-And-Swap):
读当前值 → 与期望值比较 → 相等则写入新值 → 返回是否成功
不相等 → 重试(或放弃)→ 无阻塞

AtomicInteger/AtomicLong/AtomicReference:
□ getAndIncrement:原子的 +1
□ compareAndSet(expected, new):原子的条件更新
□ updateAndGet:原子的「读-改-写」回调

代价:
□ CAS 失败会自旋重试(激烈竞争时自旋开销)
□ ABA 问题:值从 A 变 B 又变回 A → 用 AtomicStampedReference
val counter = new AtomicInteger(0)
counter.incrementAndGet()              // 原子的 +1
counter.updateAndGet(prev => prev + 1) // 原子的读-改-写

工程要点:原子类是「单变量的无锁更新」——计数器、标志位、引用替换用原子类,性能远好于锁,且天然防止死锁。注意 CAS 在高竞争下自旋的退避,以及需要「版本」才能解决的 ABA 问题。

5. 并发集合选型

并发集合的选择直接决定并发安全与性能:

线程安全集合(java.util.concurrent):
□ ConcurrentHashMap:分段/桶级锁,读无锁,写并发
□ CopyOnWriteArrayList:读多写少,写时复制(读快写慢)
□ ConcurrentLinkedQueue:无锁队列(生产者-消费者)
□ BlockingQueue(Array/Linked):带阻塞语义的队列

不安全的旧集合:
□ Hashtable/Vector:全表锁,性能差,已被取代
□ 普通 HashMap/ArrayList:并发改 → 结构损坏/无限循环

Scala 侧:
□ scala.collection.concurrent.TrieMap:无锁并发 map,支持快照
□ mutable.* 集合默认线程不安全
选型口诀:
□ 读多写少 + 频繁遍历 → CopyOnWriteArrayList
□ 高并发读写 map → ConcurrentHashMap / TrieMap
□ 生产者-消费者 → BlockingQueue(含超时/容量)
□ 需要不可变快照 → TrieMap.snapshot

工程要点:并发集合选型是「按访问模式」——读多写少用 CopyOnWrite,高并发 map 用 ConcurrentHashMap,队列用 BlockingQueue。不要用旧的全表锁集合,也不要让普通 mutable 集合暴露在并发中。

6. Scala 并发增强:并发集合与 Future

Scala 标准库在 JUC 之上加了函数式壳:

Future(scala.concurrent):
□ 回调组合:map/flatMap/zip → 链式异步
□ ExecutionContext:线程池抽象
□ 注意:Future 的回调执行顺序与线程不保证 → 依赖块内逻辑自包含

scala.concurrent.duration:超时与优雅处理
Await.result(future, 10.seconds):阻塞等待(仅限测试/启动)

并发集合增强:
□ TrieMap:无锁 + 快照(比 JUC 更 Scala 友好)
□ .par 并行集合:par.map 自动并行(内部用 ForkJoinPool)
import scala.concurrent.{Future, Await}
import scala.concurrent.duration._

val jobs: List[Future[Int]] = (1 to 10).map(i => Future { i * 2 })
val sum = Future.sequence(jobs).map(_.sum)
println(Await.result(sum, 5.seconds))  // 110

工程要点:Scala 并发的舒适层是**「Future 组合 + TrieMap + par 集合」**——它们把并发安全封装在不可变/函数式接口后。但要知道底层还是 JUC;真正的状态共享(可变计数器)仍要回到原子类/锁/STM。

7. Actor vs Fiber vs 原子类:何时用哪个

不同抽象解决不同问题,选错会付出巨大代价:

Actor(Akka):
□ 适合:跨线程传递消息、有状态服务、分布式
□ 模型:邮箱串行化 → 天然避免共享状态竞态
□ 代价:消息开销、邮箱积压

Fiber/Effect(Cats Effect / ZIO):
□ 适合:异步 IO、大量轻量任务、取消/超时
□ 模型:绿色线程,无阻塞
□ 代价:需要理解 Effect 语义

原子类/锁/STM:
□ 适合:单进程内的低层共享状态(缓存、计数器)
□ 模型:内存级原语,性能最高
□ 代价:无高层抽象,需要自己保证正确性
选择框架:
□ 共享状态跨线程且频繁 → 原子类/锁/STM
□ 逻辑分布在多个「独立状态单元」 → Actor
□ IO 密集型异步 → Fiber/Effect
□ 分布式集群 → Actor 集群

工程要点:高层抽象优先,低层原语兜底——先用 Actor/Fiber 组织「无共享」的并发结构,剩下的「必须共享的少量状态」用原子类/STM 精确处理。不要在一个应用里混用多种并发模型处理同一份状态。

8. 软件事务内存 STM

STM(Software Transactional Memory)用「内存事务」替代锁:

原理:
□ 事务块内对共享变量的读写 → 提交时校验(版本比对)
□ 冲突则重试整个事务(乐观并发)
□ 解决了「锁的顺序死锁」(事务无顺序)

Scala 侧:ScalaSTM(原 Akka STM)
□ atomic { ... } 包裹事务块
□ TRef(事务引用)替代普通引用

适用:
□ 多个共享变量需要「一起原子更新」→ 事务组合
□ 锁难编排的复杂临界区 → STM 简化
□ 读多写少、冲突少的场景(冲突多则重试爆炸)

代价:
□ 重试开销(冲突频繁时性能差)
□ 与外部 IO 混合时事务无法回滚外部副作用 → 事务内不能做 IO
import scala.concurrent.stm._
val balanceA = TRef(100)
val balanceB = TRef(50)
atomic { implicit txn =>
  balanceA() = balanceA() - 30   // 两个 TRef 一起原子更新
  balanceB() = balanceB() + 30
}

工程要点:STM 是**「多变量原子更新」的优雅解法**——事务组合比嵌套锁安全得多。但「事务内不能有 IO/副作用」和「冲突重试」两个限制决定了它适用于低频、冲突少的共享状态(如账户余额、游戏状态),不适用于高吞吐路径。

9. 无锁数据结构的陷阱

自己写无锁结构是高危操作,常见陷阱:

陷阱 1:ABA 问题
  值 A→B→A,CAS 误判「没变过」→ 用带版本号的引用

陷阱 2:内存重排(Memory Reordering)
  无锁结构依赖 volatile/CAS 的内存屏障 → 漏一个屏障就出 bug

陷阱 3:伪共享(False Sharing)
  不同线程改相邻字段 → 同一缓存行互相失效 → 性能暴跌
  → 用 @Contended 或字段填充隔离缓存行

陷阱 4:极端难测
  无锁 bug 依赖时序,单元测试跑不出 → 生产才爆

正确姿势:
□ 优先用 JDK 现成并发集合(已无数人验证)
□ 实在要写 → 用 @Contended + 严格内存屏障 + 并发压力测试
伪共享示例:
class Counter { 
  @volatile var a = 0L   // a 与 b 可能在同一缓存行
  @volatile var b = 0L   // 线程改 a、线程改 b → 互相失效
}

工程要点:「无锁」不等于「安全」——JDK 的并发集合已经是最优实现,99% 的场景直接用它。自己写无锁结构的门槛极高(ABA、重排、伪共享),属于「能不写就不写」的领域。

10. 速查表与一句话记忆

问题一句话答案
并发 bug 三根因可见性、原子性、有序性
不可变为何安全final 字段发布即安全(happens-before)
volatile 能做什么保证可见性,不保证原子性
原子更新用什么AtomicInteger/AtomicReference(CAS)
并发 map 选谁ConcurrentHashMap / TrieMap
读多写少遍历多CopyOnWriteArrayList
跨线程状态传递Actor(邮箱串行化)优先
多变量原子更新STM(事务组合)
无锁结构怎么写尽量别写,用 JDK 现成集合

一句话记忆:Scala 并发 = 不可变优先(final 安全发布)+ 原子类做单变量更新(CAS)+ 并发集合按访问模式选型(ConcurrentHashMap/CopyOnWrite/Blocking)+ Actor/Fiber 组织无共享并发 + STM 做多变量事务(冲突少才用)——「能不可变就不可变,能高层抽象就高层抽象,底层原语精确兜底」。

延伸阅读

  • /actor-model-detailed-explanation/ — Actor 消息与邮箱
  • /scala-functional-effects/ — Fiber 并发与 Effect
  • /scala-functional-programming/ — 不可变与纯函数
  • /scala-akka-cluster/ — 分布式集群并发
  • /scala-performance-jvm/ — JMM 与 JIT/GC 的相互影响
  • Erlang/Elixir 专题 — 原生 Actor 并发对照
  • 分布式系统专题 — 分布式一致性

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「scala」更多文章

  1. Scala Native 与 GraalVM:AOT 编译、互操作与部署
  2. Akka Streams 与响应式流:图 DSL、背压与流式实战
  3. 函数式架构:六边形设计、纯核心与副作用外壳