前言
最近遇到一个很有意思的问题,如下代码在多线程运行下的问题与解决方案
1 | public class Counter { |
变量累加的过程
由 Java 的内存模型可知,变量的累加经历三个阶段:从主内存读取到工作内存 -> 工作内存中修改 -> 写回主内存
存在的问题
由上述三步可知,可能存在问题有三个:
- 读取的值是否为当前时刻的最新值
- 累加的前提值是否为当前时刻的最新值
- 写回主内存是否为当前时刻期望值
问题 1 ,一定是。因为变量存在于内存某一块区域,任何时刻的读取都是对该内存的读取。
问题 2,不一定是。经历了复制到工作内存的阶段,工作内存和主内存的值不能保证强一致。
问题 3,不一定是。因为累加的前提值不准确以及多线程同时写回的顺序问题,所以修改后的值与期望值可能不一致。
所以问题的关键是:如何保证工作内存读取的是最新值,如何保证写回主内存是顺序的。
解决
本地
本地分为两种:自旋尝试更新 & 顺序更新,分别对应 CAS 更新以及锁后顺序更新。
CAS 更新
CAS 更新分为两种,AtomicInteger 以及 LongAddr。
AtomicInteger 应该都很熟悉,利用 volatile 关键字保证变量的可见性,自旋使用 Unsafe 提供的原子指令保证更新的准确性。
LongAddr 则是使用 Cell 数组保存更新指令,后续通过 sum 方法提供当前的计算结果。
区别
AtomicInteger | LongAddr | |
---|---|---|
计算结果 | 实时 | 非实时 |
作用原理 | 时间换空间(针对单变量的自旋更新) | 空间换时间(针对 Cell 数组的 CAS 更新) |
冲突概率 | 高 | 低 |
重量级锁
重量级锁分为两种:synchronized 以及 ReentrantLock。
synchronized 通过 monitor enter & monitor exit 内存屏障实现锁的获取与释放。
ReentrantLock 通过 AQS 队列保证锁获取/释放的顺序。
区别
synchronized | ReentrantLock | |
---|---|---|
基本性质 | Java 关键字 | Java API |
实现方式 | 内存屏障 | AQS(底层 CAS 更新) |
公平性控制 | 只支持非公平锁 | 公平锁/非公平锁 |
多条件绑定 | 单个条件绑定 | 多 Condition 绑定 |
锁是否可升级 | 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 | 公平锁/非公平锁 |
使用复杂度 | 低,不用考虑锁获取/释放过程 | 高,需要手动获取/释放锁 |
使用灵活度 | 低 | 高,可支持超时获取锁、尝试获取锁,锁中断的操作 |
分布式
Redis increment 操作
针对单个 redis key 做递增的操作。因为 Redis 操作是单线程的,所以本质上利用了 Redis 顺序操作的特性。
ZooKeeper 顺序节点
针对每次操作新增一个临时节点。通过计算序号的大小得到最终的结果。
区别
Redis increment | Zookeeper | |
---|---|---|
计算结果 | 实时 | 非实时(事后计算结果) |
作用原理 | 顺序更新 | 对节点进行计数 |
性能 | 高 | 低,涉及节点新增与清除 |
为什么有意思
之前对于该问题,最常用就是 CAS/锁(本地),Redis(分布式)。优点是强一致,缺点是可能的自旋等待带来的性能损耗。(时间换空间,强一致)
如果只要最终一致但提升性能,只能暂存中间结果最后累加,但是自己实现又涉及操作升级、扩容等管理,而 Striped64 子类则实现了上述功能。(空间换时间,最终一致)
之前没有将最终一致的可行性与该问题联系到一起,而这也是我觉得有意思的地方。系统设计的 trade-off 体现得尤其明显。
本文首发于cartoon的博客
转载请注明出处:https://cartoonyu.github.io