对于一个java小白来说,聊到并发问题,为了保证数据一致性,我首先会想到加锁—— java 中的 Synchronized 关键字。但是这个想法被大神严格否定了,因为 Synchronized 是一种悲观锁,存在一定的性能问题。
锁机制
java中的锁机制是为了实现数据一致的一种方式,在并发的情况下,共享资源可能同时被多个线程使用。假设 A 线程和 B 线程同时获取到变量 i,并对其加 1 ,那么最终放回内存的值,很可能是 i+1 而不是正确的 i+2。所以,我们需要对这部分操作加锁,同一时间仅允许一个线程对资源进行操作。
悲观锁
从字面上意思来看,悲观地认为一定会与其他线程产生冲突,那么首先将资源加锁,只能自己进行操作,操作完成之后释放锁。悲观锁会带来一定的性能问题,由于cpu中不停地进行线程调度,当一个线程获取不到资源,会被挂起,让出cpu,等到资源可用时才被唤醒,这个过程存在很大的开销。
乐观锁
乐观地认为不会与其他线程产生冲突,首先获取资源,但是在执行修改时,会先判断有没有被其余线程更改过。如果发生冲突,则不对资源进行修改,同时不让出cpu,不断进行重试,直到修改成功。
CAS简述
CAS即比较并交换(Compare And Swap),是一种乐观锁的实现。CAS概念中有3个参数:
- 内存地址 V
- 最后一次获取到的内存值 A (old value)
- 待写入内存的值 B (new value)
CAS认为,地址 V 中的值应当与 A 一致,如果确认一致,那么写入在 V 中写入 B ,反之,不执行写入操作,并认为操作失败。
Unsafe类
java 中的 CAS 操作使用 jdk 提供的 Unsafe 类,在 rt.jar 中。java 不能直接访问底层操作系统,需要通过 native 方法来访问。Unsafe 类包含了许多硬件级别的原子操作,包括对内存的读写等。这些操作主要用于 java 的核心类库使用,而不是标准用户。Unsafe 提供了三个方法实现 CAS 操作,分别用于三种不同的数据类型:
1 | public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5); |
我们可以使用这些方法,实现一个基于 CAS 的计数器。
1 | class CASCounter { |
代码中,首先使用反射获取到 Unsafe 对象(直接使用 Unsafe.getUnsafe() 会抛出 SecurityException 异常)。在CASCounter 的构造方法中,传入 Unsafe 对象与 counter 字段的内存地址。执行增加操作时,首先将 counter 赋予 before,再通过 compareAndSwapLong 方法,比较 offset 地址的值是否与我们所获取的一致,不一致则返回 false,并更新 before 值。
1 |
|
测试代码中,启动 5 个线程,每个线程对计数器执行 5 次操作。使用 Future 等待线程执行完毕。断言计数器执行的次数与我们预期的次数相等。
JAVA 源码中的应用
java.concurrent 包中的许多方法,都是基于 Unsafe 类的 compareAndSwap
比如 ConcurrentHashMap 中的 addCount 方法等(只展示了部分源码)
1 | private final void addCount(long x, int check) { |
由于 CAS 的操作能够避免线程阻塞,在编码中的应用十分常见。同时,对于这种思维的学习,也有利于理解更多核心代码。
CAS 缺点
学习的过程中,了解到一些 CAS 的缺点,不过可能在真正应用的时候才能有深刻理解吧。
- ABA 问题。即一个值从 A 变为 B 再变为 A,而 CAS 操作不能发现这个值发生了变化。解决思路可以引入版本号概念,每一次修改都设置版本号。
- 只能操作一个共享变量。根据提供的本地方法可以看到,使用 CAS 一次只能操作一个共享变量。
- 循环时间所带来的开销。由于 CAS 会不断重试直到操作成功,过程中无限循环,如果线程数众多,并且长时间执行不成功,会给 CPU 带来大量的执行开销。