0%

并发更新变量的问题

前言

最近遇到一个很有意思的问题,如下代码在多线程运行下的问题与解决方案

1
2
3
4
5
6
7
8
9
10
11
public class Counter {
private static int count = 0;

public static void increment() {
count++;
}

public static int getCount() {
return count;
}
}

变量累加的过程

由 Java 的内存模型可知,变量的累加经历三个阶段:从主内存读取到工作内存 -> 工作内存中修改 -> 写回主内存

存在的问题

由上述三步可知,可能存在问题有三个:

  1. 读取的值是否为当前时刻的最新值
  2. 累加的前提值是否为当前时刻的最新值
  3. 写回主内存是否为当前时刻期望值

问题 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