第十章 Java多线程——乐观锁和悲观锁

10.1 乐观锁与悲观锁的概念

锁可以从不同的角都分类。其中乐观锁和悲观锁是一种分类方式。

悲观锁:

悲观锁就是我们常说到的锁。对于悲观锁来说,他总是认为每次访问共享资源时会发生冲突,所以必须每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

乐观锁:

乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突, 乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。

由于无锁操作中没有锁的存在,因此不肯能出现死锁的情况,也就是说乐观锁天生免疫死锁

乐观锁多用于“读多写少”的环境,避免频繁加锁影响性能;而悲观锁锁用于“写多读少”的环境。避免频繁失败和重试影响性能。

10.2 CAS的概念

CAS的全称:比较并交换(Compare And Swap)。在CAS中,有这样三个值:

  • V:要更新的新变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明有其他线程更新了V,则当前线程放弃更新,什么都不做。

所以这里的预期值E本质上指的是“旧值”。

一个简单的例子来解释这个过程:

  1. 如果有一个多线程共享的变量 i 原本等于5,我现在在线程A中,想把它设置新值为6;
  2. 我们使用CAS来做这个事情;
  3. 首先我们用 i 去与5对比,发现等于5,说明没有被其他线程改过,那我就把它设置为新值6,此次CAS成功, i 的值被设置成了6
  4. 如果不等于5,说明 i 被其他线程改过了(比如现在 i 的值为2),那么我就什么也不做,此次CAS失败,i的值仍然为2

在这个例子中, i 就是V,5就是E,5就是N。

那有没有可能我在判断了i为5后,正准备更新它的值的时候,被其他线程更改 i 的值呢?

不会的! 因为CAS是一个原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性。

当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并更新成功,其余均会失败。但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

10.3 Java实现CAS的原理 - Unsafe类

前面提到,CAS是一种原子操作。那么Java是怎样来使用CAS的呢?我们知道,在Java中,如果一个方法是native的,那Java就不负责具体实现它,而是交给底层的JVM使用者c++去实现。

在Java中,有一个Unsafe类,它在sun.misc包中。它里面是一些 native 方法,其中就有几个关于CAS的:

boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);

当然,他们都是 public native 的。

Unsafe中对CAS的实现是C++写的,它的具体实现和操作系统、CPU都没关系。

Linux的X86下主要通过 cmpxchgl 这个指令在CPU级完成CAS操作的,但在多处理器情况下必须使用lock指令加锁来完成。当然不同的操作系统和处理器的实现会有有所不同,大家可以自行了解。

当然Unsafe类里面还有其他方法用于不同的用途。比如支持线程挂起和恢复的park和unpark,LcokSupport类底层就是调用了这两个方法。还有支持反射操作的allocateInstance()方法。

10.4 原子操作-AtomicInteger类源码简析

上面介绍了Unsafe类的几个支持CAS的方法。那Java具体是如何使用这几个方法类实现原子操作的呢?

JDK提供了一些用于原子操作的类,在 java.util.concurrent,atomic 包下面。在JDK11中,有如下17个类:
第十章 Java多线程——乐观锁和悲观锁_第1张图片
从名字就可以看的出来这些类大概的用途:

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用
  • 原子更新字段(属性)

这里我们以 AtomicInteger 类的 getAndAdd(int delta) 方法为例,来看看Java 是如何实现原子操作的。

先看看这个方法的源码:

public final int getAndAdd(int delta) {
     return U.getAndAddInt(this, VALUE, delta);
}

这里的U其实就是一个Unsafe对象。

所以其实 AtomicInteger 类的 getAndAdd(int deltal) 方法是调用 Unsafe 类的方法来实现的:

@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
     int v;
     do {
         v = getIntVolatile(o, offset);
     } while (!weakCompareAndSetInt(o, offset, v, v + delta));
     return v;
}

注:这个方法是在JDK1.8才新增的。在JDK1.8之前,AtomicInteger 源码实现有所不同,是基于for死循环。

我们来⼀步步解析这段源码。⾸先,对象 o 是 this ,也就是⼀个 AtomicInteger 对象。然后 offset 是⼀个常量 VALUE 。这个常量是在 AtomicInteger 类中声明的。

同样是调⽤的 Unsafe 的⽅法。从⽅法名字上来看,是得到了⼀个对象字段偏移量。

⽤于获取某个字段相对Java对象的“起始地址”的偏移量。

⼀个java对象可以看成是⼀段内存,各个字段都得按照⼀定的顺序放在这段内存⾥,同时考虑到对⻬要求,可能这些字段不是连续放置的,

⽤这个⽅法能准确地告诉你某个字段相对于对象的起始内存地址的字节偏移量,因为是相对偏移量,所以它其实跟某个具体对象⼜没什么太⼤关系,跟class的定义和虚拟机的内存模型的实现细节更相关。

继续CAS是“⽆锁”的基础,它允许更新失败。所以经常会与while循环搭配,在失败后不断去重试。

这⾥声明了⼀个v,也就是要返回的值。从 getAndAddInt 来看,它返回的应该是原来的值,⽽新的值的 v+delta 。

这⾥使⽤的是do-while循环。这种循环不多⻅,它的⽬的是保证循环体内的语句⾄少会被执⾏⼀遍。这样才能保证return 的值 v 是我们期望的值。

循环体的条件是⼀个CAS⽅法:

public final boolean weakCompareAndSetInt(Object o, long offset,int expected,int x) {
       return compareAndSetInt(o, offset, expected, x);
}

public final native boolean compareAndSetInt(Object o, long offset,int expected,int x);

可以看到,最终其实是调⽤的我们之前说到了CAS native ⽅法。那为什么要经过⼀层 weakCompareAndSetInt 呢?从JDK源码上看不出来什么。在JDK 8及之前的版本,这两个⽅法是⼀样的。

⽽在JDK 9开始,这两个⽅法上⾯增加了@HotSpotIntrinsicCandidate注解。这个注解允许HotSpot
VM⾃⼰来写汇编或IR编译器来实现该⽅法以提供性能。也就是说虽然外⾯看到的在JDK9中weakCompareAndSet和compareAndSet底层依旧是调⽤了⼀样的代码,但是不排除HotSpot
VM会⼿动来实现weakCompareAndSet真正含义的功能的可能性。

简单来说, weakCompareAndSet 操作仅保留了 volatile ⾃身变量的特性,⽽出去了happens-before规则带来的内存语义。也就是说, weakCompareAndSet **⽆法保证处理操作⽬标的volatile变量外的其他变量的执⾏顺序( 编译器和处理器为了优化程序性能⽽对指令序列进⾏重新排序 ),同时也⽆法保证这些变量的可⻅性。**这在⼀定程度上可以提⾼性能。

再回到循环条件上来,可以看到它是在不断尝试去⽤CAS更新。如果更新失败,就继续重试。那为什么要把获取“旧值”v的操作放到循环体内呢?其实这也很好理解。前⾯我们说了,CAS如果旧值V不等于预期值E,它就会更新失败。说明旧的值发⽣了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了do循环体内。

10.5 CAS实现原子操作的三大问题

10.5.1 ABA问题

所谓ABA问题,就是一个原来是A,变成了B,又变回了A。这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。

ABA问题的解决思路是在变量前面追加上版本号或者时间戳。从JDK 1.5开始,JDK的atomic包里提供了一个类AtomicStampedRefence类来解决ABA问题。

这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。

public boolean compareAndSet(V expectedReference,V newReference,int expectedStamp,int newStamp) {
     Pair<V> current = pair;
     return  expectedReference == current.reference && expectedStamp == current.stamp &&
             ((newReference == current.reference && newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
}

10.5.2 循环时间长开销大

CAS多与自旋锁结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。解决思路是让JVM支持处理器提供pause指令

pause指令能让⾃旋失败时cpu睡眠⼀⼩段时间再继续⾃旋,从⽽使得读操作的频率低很多,为解决内存顺序冲突⽽导致的CPU流⽔线重排的代价也会⼩很多。

10.5.3 只能保证一个共享变量的原子操作

这个问题有两种解决方案:

  1. 使用JDK 1.5开始提供的AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作。
  2. 使用锁。锁内的临界区代码可以保证只有当前线程能操作。

你可能感兴趣的:(java,java)