JAVA乐观锁小结

说到Java锁机制,我们可以暂时将Java的锁分为乐观锁和悲观锁。它们是整个并发编程中很重要的基石,也是面试当中经常会被问到的知识点。今天我们就来总结一下乐观锁的那些事。

什么是乐观锁

既然说到了乐观锁,那么就需要先解释一个问题,即什么是乐观锁?

顾名思义,乐观锁就是一种理想化的状态,非常乐观。每次去取数据的时候都认为其他线程不会对该数据进行修改,因此不会考虑上锁(毕竟一般上锁都是为了主动从防止一些“坏人”,而我们的乐观锁天真的认为没什么坏人)。

基于乐观锁的思想有很多种实现方式,而谈到这里就不得不说一下今天的主角,就是CAS("Compare and Swap)

CAS技术

CAS是乐观锁的一种经典实现方式,当多个线程尝试更新一个变量的时候,只有一个线程能够更新成功,其它线程都会被告知更新失败,但是失败的线程并不会挂起而是会自旋等待并重试。再整个CAS实现当中,有几个参数需要给大家解释一下:V(内存的新值),A(期待值),B(要写入的值)。而CAS的工作过程即:先去读取一次内存当中的旧值作为期待值,再准备向数据B之前,再次取内存当中读取该值是否有被修改。如果被修改(A != V),则放弃被次操作并自旋重试。如果没有被修改,则写入该值。由于其简单的轻量级的实现方式,JUC当中也有大量的工具类选择基于CAS进行实现:Atomic相关类,ReentrantLock中的FairSync类等。

接下来我们通过源码的方式,具体看一下CAS在AtomicInteger类中的应用。

AtomicInteger

对于AtomicInteger,我们可以通过其getAndIncrement()方法来一窥CAS的工作原理和CAS的一些特性:

整个getAndIncrement的方法调用链:

graph LR
AtomicInteger.getAndIncrement-->unsafe.getAndAddInt
unsafe.getAndAddInt -->this.compareAndSwapInt

而compareAndSwapInt就是整个getAndIncrement实现的关键。通过源码我们可以知道compareAndSwapInt是一个native修饰符修饰的本地方法。查找资料后,我对这个方法有了进一步的了解。即compareAndSwapInt在Unsafe类中会调用Atomic::cmpxchg(该方法再winodws_x86 和 linux_x86中均有实现)。具体的:

1.会先通过os:is_mp()方法判断是否为多处理系统。(如果为单处理器系统则没必要加上lock指令,减少性能开销)

2.Lock_If_MP(mp)会根据mp的值来决定是否为cmpxchg添加lock前缀。(【注】此处的cmpxchg为汇编指令,其作用是比较与交换)

并且Intel手册对lock指令有如下说明:

  1. lock指令能够确保对内存的读-改-写操作的原子性
  2. lock指令能够禁止该指令前后的读写指令重排序
  3. lock指令会立即将缓存中的数据写回主存

小结

通过上面的描述我们可以看到,虽然在代码层面CAS并没有加锁,但最终依然只能有一个线程可以对目标变量进行操作。除此之外CAS可以保证单个共享变量操作的原子操作并且具有volatile的读写语义。

CAS可能会出现什么问题?

上面我们了解了CAS的原理和应用,那么我们需要思考一个问题。在我们使用CAS进行并发操作的时候,可能会出现什么问题?这里,我们可以主要总结三点问题:

  • ABA问题
  • 自旋所带来的CPU开销
  • 只能保证单个共享变量的原子操作

ABA问题

ABA问题是使用CAS时最常出现的一个问题。它的整个过程如下图所示:

sequenceDiagram
A(线程2修改一次值,线程3改回原值)->>线程1: 读
A(线程2修改一次值,线程3改回原值)->>线程2: 读
线程2->>A(线程2修改一次值,线程3改回原值):写
A(线程2修改一次值,线程3改回原值)->>线程3: 读
线程3->>A(线程2修改一次值,线程3改回原值):写
线程1->>A(线程2修改一次值,线程3改回原值):写

通过上图我们可以看到虽然线程1仍然可以CAS写成功,但是它并没有感受到该A值在整个过程当中发生的问题。有可能它的值没有发生变化,而其含义却已经发生了变化。其实这种场景也非常常见,比如在我们的业务当中,我们需要在修改记录数据之前验证数据是否发生过变化,如果没有发生变化则进行写入,如果发生变化则放弃。这样可以在一定程度上提高并发度。

那么如何解决ABA问题?  

对于ABA问题的常见解决思路即生成一个唯一可表示记录信息的标记值。例如我们可以新增一个自增字段,每次操作这个字段后该值加1,写写入数据之前比较该值是否与进入该方法时读取到的值相同。初次之外还可以记录版本号和时间戳(思路大同小异)。

对于自旋带来的CPU资源浪费问题

根据上面的分析我们知道,在CAS写入的过程当中,如果写入失败并不会挂起线程,而是会自旋并继续重试。在某些极端场景下,这可能会死循环或者造成CPU资源的白白浪费。在我们平时的编码过程当中,我们其实也可以考虑jdk1.6之后对synchronized进行锁升级的思路。自旋到一定次数还无法或者资源时,我们可以考虑放弃该任务返回null值或主动升级成重量级锁。

以上,便是我在学习轻量级锁时的一些总结和认识,希望能够帮助到有需要的人。也希望各位大家能够不吝指出错误,留言进一步的讨论。

你可能感兴趣的:(Java基础)