浅谈 Java 并发下的乐观锁


引子

各位少侠你们好!今天咱们来聊聊 Java 并发下的乐观锁。html

在聊乐观锁以前,先给你们复习一个概念:原子操做:java

什么是原子操做呢?web

咱们知道,原子(atom)指化学反应不可再分的基本微粒。在 Java 多线程编程中,所谓原子操做,就是即便命令涉及多个操做,这些操做依次执行,不会被别的线程插队打断。算法

原子操做

聊完原子操做了,咱们进入正题。编程

你们都知道,通常而言,因为多线程并发会致使安全问题,针对变量的操做,都会采用锁的机制。锁通常会分为乐观锁悲观锁两种。api

悲观锁

对于悲观锁,开发者认为数据发送时发生并发冲突的几率很大,因此每次进行读操做前都会上锁。安全

乐观锁

对于乐观锁,开发者认为数据发送时发生并发冲突的几率不大,因此读操做前不上锁。微信

到了写操做时才会进行判断,数据在此期间是否被其余线程修改。若是发生修改,那就返回写入失败;若是没有被修改,那就执行修改操做,返回修改为功。网络

乐观锁通常都采用 Compare And Swap(CAS)算法进行实现。顾名思义,该算法涉及到了两个操做,比较(Compare)和交换(Swap)。多线程

CAS 算法流程

CAS 算法的思路以下:

  1. 该算法认为不一样线程对变量的操做时产生竞争的状况比较少。
  2. 该算法的核心是对当前读取变量值 E 和内存中的变量旧值 V 进行比较。
  3. 若是相等,就表明其余线程没有对该变量进行修改,就将变量值更新为新值 N。
  4. 若是不等,就认为在读取值 E 到比较阶段,有其余线程对变量进行过修改,不进行任何操做。

当线程运行 CAS 算法时,该运行过程是原子操做,也就是说,Compare And Swap 这个过程虽然涉及逻辑比较繁冗,但具体操做一鼓作气。

Java中 CAS 的底层实现

Java 中的 Unsafe 类

我先问你们一个问题:

什么是指针?

针对学过 C、C++ 语言的同窗想必都不陌生。说白了,指针就是内存地址,指针变量也就是用来存放内存地址的变量。

但对于指针这个东西的使用,有利有弊。有利的地方在于若是咱们有了内存的偏移量,换句话说有了数据在内存中的存储位置坐标,就能够直接针对内存的变量操做;

弊端就在于指针是语言中功能强大的组件,若是一个新手在编程时,没有考虑指针的安全性,错误的操做指针把某块不应修改的内存值修改,容易致使整个程序崩溃。

错误使用指针

对于 Java 语言,没有直接的指针组件,通常也不能使用偏移量对某块内存进行操做。这些操做相对来说是安全(safe)的。

但其实 Java 有个类叫 Unsafe 类,这个类类使 Java 拥有了像 C 语言的指针同样操做内存空间的能力,同时也带来了指针的问题。这个类能够说是 Java 并发开发的基础。

Unsafe 类中的 CAS

通常而言,你们接触到的 CAS 函数都是 Unsafe 类提供的封装。下面就是一些 CAS 函数。

public final native boolean compareAndSwapObject(
    Object paramObject1, 
    long paramLong, 
    Object paramObject2, 
    Object paramObject3)
;

public final native boolean compareAndSwapInt(
    Object paramObject, 
    long paramLong, 
    int paramInt1, 
    int paramInt2)
;

public final native boolean compareAndSwapLong(
    Object paramObject, 
    long paramLong1, 
    long paramLong2, 
    long paramLong3)
;

这就是 Unsafe 包下提供的 CAS 更新对象、CAS 更新 int 型变量、CAS 更新 long 型变量三个函数。

咱们以最好理解的 compareAndSwapInt 为例,来看一下吧:

public final native boolean compareAndSwapInt(
    Object paramObject, 
    long paramLong, 
    int paramInt1, 
    int paramInt2)
;

能够看到,该函数有四个参数:

  • 第一个是目标对象
  • 第二个参数用来表示咱们上文讲的指针,这里是一个 long 类型的数值,表示该成员变量在其对应对象属性的偏移量。换句话说,函数就能够利用这个参数,找到变量在内存的具体位置,从而进行 CAS 操做
  • 第三个参数就是预期的旧值,也就是示例中的 V。
  • 第四个参数就是修改出的新值,也就是示例中的 N。

有同窗会问了,Java 中只有整型的 CAS 函数吗?有没有针对 double 型和 boolean 型的 CAS 函数?

很惋惜的是, Java 中 CAS 操做和 UnSafe 类没有提供对于 double 型和 boolean 型数据的操做方法。但咱们能够利用现有方法进行包装,自制 double 型和 boolean 型数据的操做方法。

  • 对于 boolean 类型,咱们能够在入参的时候将 boolean 类型转为 int 类型,在返回值的时候,将 int 类型转为 boolean 类型。
  • 对于 double 类型,则依赖 long 类型了, double 类型提供了一种 double 类型和 long 类型互转的函数。
public static native double longBitsToDouble(
    long bits)
;

public static native long doubleToRawLongBits(
    double value)
;

你们都知道,基础数据类型在底层的存储方式都是bit类型。所以不管是long类型仍是double类型在计算机底层存储方式都是比特。因此就很好理解这两个函数了:

  • longBitsToDouble 函数将 long 类型底层的实际二进制存储数据,用 double 类型强行翻译出来
  • doubleToRawLongBits 函数将 double 类型底层的实际二进制存储数据,用 long 类型强行翻译出来

CAS 在 Java 中的使用

一个比较常见的操做,使用变量 i 来为程序计数,能够对 i 自增来实现。

int i=0;
i++; 

但稍有经验的同窗都知道这种写法是线程不安全的。

若是 500 个线程同时执行一次 i++,获得 i 的结果不必定为 500,可能会比 500 小。

这是由于 i++ 其实并不仅是一行命令,它涉及如下几个操做:(如下代码为 Java 代码编译后的字节码)

getfield  #从内存中获取变量 i 的值
iadd      #将 count 加 1
putfield  #将加 1 后的结果赋值给 i 变量

能够看到,简简单单一个自增操做涉及这三个命令,并且这些命令并非一鼓作气的,在多线程状况下很容易被别的线程打断。

自增操做

虽然两个线程都进行了 i++ 的操做,i 的值本应是 2,可是按上图的流程来讲,i 的值就变为 1 了

若是须要执行咱们想要的操做,代码能够这样改写。

int i=0;
synchronized{
    i++;
}

咱们知道,经过 synchronized 关键字修饰时代价很大,Java 提供了一个 atomic 类,若是变量 i 被声明为 atomic 类,并执行对应操做,就不会有以前所说的问题了,并且相较 synchronized 代价较小。

AtomicInteger i= new AtomicInteger(0);
i.getAndIncrement();

Java 的 Atomic 基础数据类型类还提供

  • AtomicInteger 针对 int 类型的原子操做
  • AtomicLong 针对 long 类型的原子操做
  • AtomicBoolean 针对 boolean 类型的原子操做

Atomic基础数据类型支持的方法以下图所示:

Atomic基础数据类型
  • getCurrentValue :获取该基础数据类型的当前值。
  • setValue :设置当前基础数据类型的值为目标值。
  • getAndSet :获取该基础数据类型的当前值并设置当前基础数据类型的值为目标值。
  • getAndIncrement :获取该基础数据类型的当前值并自增 1,相似于 i++。
  • getAndDecrement :获取该基础数据类型的当前值并自减 1,相似于 i--。
  • getAndAdd :获取该基础数据类型的当前值并自增给定参数的值。
  • IncrementAndGet :自增 1 并获取增长后的该基础数据类型的值,相似于 ++i。
  • decrementAndGet :自减 1 并获取增长后的该基础数据类型的值,相似于 --i。
  • AddAndGet :自增给定参数的值并获取该基础数据类型自增后的值。

这些基本数据类型的函数底层实现都有 CAS 的身影。

咱们来拿最简单的 AtomicIntegergetAndIncrement 函数举例吧:(源码来源 JDK 7 )

volatile int value;
···
public final int getAndIncrement(){
    for(;;){
        int current = get();
        int next= current + 1;
        if(compareAndSet(current, next))
            return current;
    }
}

这就相似以前的 i++ 自增操做,这里的 compareAndSet 其实就是封装了 Unsafe 类的一个 native 函数:

public final compareAndSet(int expect, undate){
    return unsafe.compareAndSwapInt
    (this, valueOffset, expect, update);
}

也就回到了咱们刚刚讲述的 unsafe 包下的 compareAndSwapInt 函数了。

自旋

除了 CAS 以外,Atomic 类还采用了一种方式优化拿到锁的过程。

咱们知道,当一个线程拿不到对应的锁的时候,能够有两种策略:

策略 1:放弃得到 CPU ,将线程置于阻塞状态,等待后续被操做系统唤醒和调度。

固然这么作的弊端很明显,这种状态的切换涉及到了用户态到内核态的切换,开销通常比较大,若是线程很快就把占用的锁释放了,这么作显然是不合算的。

策略 2:不放弃 CPU ,不停的重试,这种操做也称为自旋。

固然这么作也有弊端,若是某个线程持有锁的时间过长,就会致使其它等待获取锁的线程一直在毫无心义的消耗 CPU 资源。使用不当会形成 CPU 使用率极高。在这种状况下,策略 1 更合理一些。

咱们前文中所说的 AtomicIntegerAtomicLong 在执行相关操做的时候就采起策略 2。通常这种策略也被称为自旋锁。

能够看到在 AtomicIntegergetAndIncrement 函数中,函数外包了一个

for(;;)

其实就是一个不断重试的死循环,也就是这里说的自旋。

但如今大多采起的策略是开发者设置一个门限值,在门限值内进行不断地自旋。

若是自旋失败次数超过门限值了,那就采起进入阻塞状态。

自旋

ABA 问题与 AtomicMarkable

CAS 算法自己有一个很大的缺陷,那就是 ABA 问题。

咱们能够看到, CAS 算法是基于值来作比较的,若是当前有两个线程,一个线程将变量值从 A 改成 B ,再由 B 改回为 A ,当前线程开始执行 CAS 算法时,就很容易认为值没有变化,误认为读取数据到执行 CAS 算法的期间,没有线程修改过数据。

ABA 问题

咋一看好像这个缺陷不会引起什么问题,实则否则,给你们举个例子吧。

假设小艾银行卡有 100 块钱余额,且假定银行转帐操做就是一个单纯的 CAS 命令,对比余额旧值是否与当前值相同,若是相同则发生扣减/增长,咱们将这个指令用 CAS(origin,expect) 表示。因而,咱们看看接下来发生了什么:

银行转帐
  1. 小明欠小艾100块钱,小艾欠小牛100块钱,

  2. 小艾在 ATM 1号机上打算 转帐 100 块钱给小牛;假设银行转帐底层是用CAS算法实现的。因为ATM 1号机忽然卡了,这时候小艾跑到旁边的 ATM 2号机再次操做转帐;

  3. ATM 2号机执行了 CAS(100,0),顺顺利利地完成了转帐,此时小艾的帐户余额为 0;

  4. 小明这时候又给小艾帐上转了 100,此时小艾帐上余额为 100;

  5. 这时候 ATM 1 网络恢复,继续执行 CAS(100,0),竟然执行成功了,小艾帐户上余额又变为了 0;

可怜的小艾,因为 CAS 算法的缺陷,让他损失了100块钱。

解决 ABA 问题的方法也不复杂,对于这种 CAS 函数,不只要比较变量值,还须要比较版本号。

public boolean compareAndSet(V expectedReference,
                             V newReference, 
                             int expectedStamp,
                             int newStamp)

以前的 CAS 只有两个参数,带上版本号比较的 CAS 就有四个参数了,其中 expectedReference 指的是变量预期的旧值, newReference 指的是变量须要更改为的新值, expectedStamp 指的是版本号的旧值, newStamp 指的是版本号新值。

修改后的 CAS 算法执行流程以下图:

改正 CAS 算法

AtomicStampedReference

那如何能在 Java 中顺畅的使用带版本号比较的 CAS 函数呢?

Java 开发人员都帮咱们想好了,他们提供了一个类叫作 Java 的 AtomicStampedReference ,该类封装了带版本号比较的 CAS 函数,一块儿来看看吧。

AtomicStampedReference 定义在 java.util.concurrent.atomic 包下。

下图描述了该类对应的几个经常使用方法:

AtomicStampedReference
  • attemptStamp :若是 expectReference 和目前值一致,设置当前对象的版本号戳为 newStamp
  • compareAndSet :该方法就是前文所述的带版本号的 CAS 方法。
  • get :该方法返回当前对象值和当前对象的版本号戳
  • getReference :该方法返回当前对象值
  • getStamp :该方法返回当前对象的版本号戳
  • set :直接设置当前对象值和对象的版本号戳

参考:

  1. Java并发实现原理:JDK源码剖析
  2. https://mp.weixin.qq.com/s/Ad6ufmGSEiQpL38YrvO4mw
  3. https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/atomic/AtomicStampedReference.html
  4. https://zhang0peter.blog.csdn.net/article/details/84020496?utm_medium=distribute.pc_relevant_t0.none-task-blog-searchFromBaidu-1.control&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-searchFromBaidu-1.control
  5. https://mp.weixin.qq.com/s/kvuPxn-vc8dke093XSE5IQ

感谢各位少侠阅读,咱们将会为你们带来更多精彩文章


本文分享自微信公众号 - 程序IT圈(DeveloperIT)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索