写点什么

深入理解 CAS:以 AtomicInteger 为例

发布于: 2020 年 07 月 09 日
深入理解CAS:以AtomicInteger为例

从本篇文章开始,我们将对JDK并发包java.util.concurrent中相关类的源码进行分析,通过分析源码,能让我们尽快地掌握并发包中提供的并发工具,能让我们更好地利用这些并发工具写出更加好的代码。本篇文章的主角是AtomicInteger,接下来,请跟随文章的节奏一起分析AtomicInteger吧!

一、问题场景引入



大家都清楚,在多线程环境下,i++会存在线程不安全问题,原因是因为i++不是一个原子操作,它可以被解析为i = i + 1,它在运行时是被划分为三个步骤,分别是从主存中读取i的值到线程的工作内存中,然后线程对i值进行+1操作,最后将计算后的i值写回到主存中。因为这三个步骤不是一个原子操作,那么就存在某个线程A在进行i++操作的过程中,线程B对主存中的i值进行了读取并完成修改,那么此时线程A的计算结果就不正确了,且会出现数据被覆盖的问题,这是线程不安全的根本原因。

解决这种线程之间不可见且非原子性的操作,通常可以使用synchronized关键字来保证数据的正确性,基本的代码如下所示:



public class AtomicExample {
private int size = 0;
public synchronized void increment() {
size++;
}
}



使用synchronized关键字后,某一个时间段只能有一个线程来进行size++的操作,其他线程如果需要进行同样的操作,那么必须等待当前线程结束并释放锁后才能操作,这样就保证了数据的安全性,避免了数据被覆盖的风险。



虽然synchronized关键字能保证线程的安全,但是synchronized关键字涉及到线程之间的资源竞争与锁的获取和释放,其性能略低,那么在JDK中是是否有替代方案呢?答案当然是有,AtomicInteger在这种场景下可以替代synchronized关键字,且性能优于synchronized关键字,基本的代码如下所示:



public class AtomicExample {
private final AtomicInteger size = new AtomicInteger(0);
public void increment() {
size.getAndIncrement();
}
}



那么问题来了,AtomicInteger是如何保证线程的安全的呢?这就需要进入到AtomicInteger源码中一探究竟了!

二、AtomicInteger源码解析



我们以上面的第二段代码为例,我们进入到AtomicInteger的源码(基于JDK8),部分源码粘贴如下所示:



public class AtomicInteger extends Number implements java.io.Serializable {
// unsafe对象的定义
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 省略部分代码
// 使用volatile修饰了一个变量用来存储数值
private volatile int value;
// 构造函数直接初始化value值
public AtomicInteger(int initialValue) {
value = initialValue;
}
// 无参构造,此时value默认为0
public AtomicInteger() {
}
// getAndIncrement方法内部调用了unsafe的getAndAddInt方法
public final int getAndIncrement() {
// unsafe定义如上静态变量所示,调用了Unsafe的getUnsafe()方法
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}



源码中使用volatile关键字修饰了vlaue属性,这样可以保证值的可见性。而getAndIncrement方法,内部调用的是Unsafe类对象的getAndAddInt方法,在正式介绍getAndAddInt方法之前,首先简单介绍一下Unsafe类。



Java语言本身是从C++语言衍生而来的,是比C++更加高级的一门语言。Java语言和C++语言有一个重要的区别就是前者无法直接操作某个内存区域,而C++语言是可以直接操作一块内存区域,可以实现自主内存区域申请和释放。虽然Java屏蔽了操作内存区域的接口,但是也提供了一个类似C++可以手动管理内存的Unsafe类,有了这个类,那么基本可以实现手动管理内存区域。



Unsafe类是包sun.misc下的一个类,这个类的大部分方法都是本地方法,比如getAndAddIntallocateMemoryfreeMemory等,这些方法都是使用了native进行修饰,底层调用的都是C++的代码,通常我们无法直接阅读本地方法的具体实现,需要进一步阅读OpenJDK的源码。这里先分析一下Unsafe类的构造方法和获取其对象的基本方法,代码如下:



public final class Unsafe {
// Unsafe的单例对象,由类加载的初始化阶段在静态代码块中初始化
private static final Unsafe theUnsafe;
static {
// 忽略部分代码
theUnsafe = new Unsafe();
}
private Unsafe() {
}
@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException("Unsafe");
} else {
// theUnsafe来源于单例变量
return theUnsafe;
}
}
}



从上面的代码可知,Unsafe类的构造法方法是私有的,且类本身是要的是final修饰的,所以该类不可以在外部被实例化,且不能被继承。获取Unsafe类对象是通过getUnsafe方法来获取的,本质上是通过反射机制来创建对象。但是这里有个条件,调用getUnsafe方法的类必须是由启动类加载器加载才可以,否则将抛出SecurityException异常。何出此言呢?我们来对这段getUnsafe方法的代码细细地品:



public static Unsafe getUnsafe() {
// 第一行代码是获取调用当前方法的类的Class对象
Class var0 = Reflection.getCallerClass();
// if条件中判断Class对象的类加载器是否是启动类加载器,如果不是,则抛出SecurityException异常
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException("Unsafe");
} else {
// theUnsafe来源于单例变量
return theUnsafe;
}
}



上述的if条件中,如果VM.isSystemDomainLoader(var0.getClassLoader())返回false,那么将抛出异常,而这段代码就是判断当前调用getUnsafe方法的类的加载器是否是启动类加载器,为何这么说呢?我们进入到isSystemDomainLoader方法中看看:



public static boolean isSystemDomainLoader(ClassLoader var0) {
return var0 == null;
}



从这段代码很简单,就是判断传入的类加载器对象是否为null,如果为null,说明这个类加载器对象是启动类加载器对象。可能我说了这么多,读者不一定理解,为了帮助大家理解,我来简单描述一下类加载器机制。常见的类加载器有启动类加载器(Bootstrap ClassLoader)、拓展类加载器(Extension ClassLoader)、应用类加载器(Application ClassLoader)以及自定义类加载器(Custom ClassLoader)。

上图中不仅展示了常见的类加载器,还展示了『双亲委派机制』。双亲委派机制在JVM类加载系统中有着广泛的应用,它要求除了启动类加载器以外,其他所有的类加载器都应当有自己的父类加载器,也就是说,在现有的JVM实现中,启动类加载器没有自己的父类加载器,扩展类加载器和应用类加载器都有自己的父类加载器,其中启动类加载器是扩展类加载器的父类加载器,扩展类加载器是应用类加载器的父类加载器,而应用类加载器是自定义类加载器的父类加载器。这里需要注意一点,那就是这里的父类加载器并不是表明加载器之间存在继承关系,而是通过组合模式来实现的父类加载器的代码复用。



这里补充说明一下各个类加载器负责加载的内容:



  • 启动类加载器:主要的职责是加载JVM在运行是需要的核心类库,这个类加载器不同于其他类加载器,这个类加载器是由C++语言编写的,它是虚拟机本身的一个组成部分,负责将<JAVA_HOME>/lib路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,但是并不是所有放置在<JAVA_HOME>/lib路径下的jar都会被加载,从安全角度出发,启动类加载器只加载包名为java、javax、sun等开头的类。

  • 扩展类加载器:是指Sun公司实现的sun.misc.Launcher$ExtClassLoader类,这个类加载器是由Java语言实现的,是Launcher的静态内部类,它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。

  • 应用类加载器:是指 Sun公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath-D java.class.path指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器对象。



一般情况下,开发者自己写的类都是由应用类加载器加载的,比如如下获取Unsafe对象的代码:



public class UnsafeTest {
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
Unsafe unsafe = Unsafe.getUnsafe();
System.out.println(unsafe);
}
}



这里写的测试类UnsafeTest是由AppClassLoader加载的,所以在这个类下调用Unsafe的getUnsafe方法,必然会抛出异常,我们打断点测试一下:

只有启动类加载器加载的类,其Class对象中的类加载器才为null,因为启动类加载器是由C++运行时提供的加载能力,在JVM运行环境中无法获取它的对象信息。所以说想通过Unsafe的getUnsafe方法来获取Unsafe对象,是行不通的了,但是可以通过反射的方式来获取。



在AtomicInteger类中,是可以通过Unsafe的getUnsafe方法来获取Unsafe对象的,这是因为AtomicInteger是由启动类加载器加载的。在AtomicInteger的getAndIncrement方法中,调用了Unsafe的getAndAddInt方法,这个方法在Unsafe类中有具体实现,在看代码之前,我们首先需要了解一下getAndAddInt方法的三个参数值的含义:



  • 第一个参数:指向当前AtomicInteger对象。

  • 第二个参数:指向当前AtomicInteger对象的value属性在内存中的偏移量。这里特别解释一下这个value属性在内存中偏移量的含义,其实就是当前AtomicInteger对象的value属性存储在内存中某个位置的long类型数值表示,后期通过unsafe来操作这个value属性的时候都是直接去指定的offset处去读取值。我们在一开始就说过,Unsafe具有像C++一样操作内存的能力,所以这里可以理解为unsafe获取value的值是直接从内存地址中读取的。

  • 第三个参数:就是value属性值需要加的值,这里就是常量1。



getAndAddInt的源码如下所示:



public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 从内存中直接获取指定对象var1的偏移量为var2的属性的值
var5 = this.getIntVolatile(var1, var2);
// 利用CAS原理来写入新值var5 + var4
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}



while条件中调用了unsafe的compareAndSwapInt方法,也就是常说的CAS(Compare And Swap,比较和替换)。这个方法是一个本地方法,无法看到其具体实现,但是可以通过查看OpenJDK的源码来看到compareAndSwapInt方法的C++实现。在查看C++代码之前,我们一起来分析一下compareAndSwapInt方法的四个参数。



  • 第一个参数:指向调用getAndAddInt方法的对象,本文中是指AtomicInteger对象。

  • 第二个参数:指向调用getAndAddInt方法的对象的属性在内存中的偏移量,这里指的是AtomicInteger对象的value属性的偏移量。

  • 第三个参数:这个值是从unsafe的第二个参数指定内存地址中读取出来的值。

  • 第四个参数:即将设置到主存中的值。



有了以上对参数的理解,接下来我们重点来理解CAS原理,这里我们一起来读compareAndSwapInt的源码,查看OpenJDK的源码,compareAndSwapInt的C++实现代码如下所示:



jboolean
sun::misc::Unsafe::compareAndSwapInt (jobject obj, jlong offset,
jint expect, jint update)
{
// 计算出value属性在对象所存储的值
jint *addr = (jint *)((char *)obj + offset);
return compareAndSwap (addr, expect, update);
}
static inline bool
compareAndSwap (volatile jint *addr, jint old, jint new_val)
{
jboolean result = false;
spinlock lock;
if ((result = (*addr == old)))
*addr = new_val;
return result;
}



上述代码中在写回新值到主存中之前进行了一个判断,判断从内存中读取到的值是否和计算新值前的老值是一致的,如果不是一致,将不会把计算后的值写回主存,并写返回false表示写入失败。这样就使得while条件为true,那么将进行下一次的do...while循环,直到写入主存成功为止。整个流程表现为自旋的形式,先将内存中的值读取后进行进行计算,计算完毕准备写回主存之前进行判断主存中的值是否发生改变,如果发生改变,则重新重复该流程,直到写入成功为止。基本的原理图如下所示:

CAS自旋方式是很好理解的,但是存在一个ABA的问题,所谓ABA问题,就是存在于当前线程在计算结果值V的过程中,其他线程已经完成了多次主存中值的读取、计算、写回,比如将值从最初A修改为B,后又计算改为了A,虽然最后的当前线程读取的还是A,但是中间状态是没有获知的,这就是ABA问题。ABA问题广泛存在于AtomicInteger、AtomicBoolean、AtomicLong等以Atomic开头的原子类中(当然也有例外,比如AtomicStampedReference用来解决ABA问题),这是因为它们的底层都是基于Unsafe的,Unsafe本身就存在ABA问题。解决ABA问题其实并不难,那就是额外加一个版本号,每次修改了主存中的值,都需要对版本号进行标记修改,其他线程写主内存的值的时候,都需要比较比较值和版本号,两者都一致,那么才说明主存中的值没有被其他线程修改,这样可以放心去更新主存中的值。AtomicStampedReference类就是使用版本号的方式来解决ABA问题的,有兴趣的同学可以跟我一起来阅读其源码。

三、ABA问题的解决办法



解决ABA问题已经有了成熟的方案,那就是通过添加版本号来进行解决的,JDK中的AtomicStampedReference就帮助我们来做了这个事情,其对应的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)));
}



这个方法有四个参数,前面两个参数分别是需要比较和修改的值,后面两个参数版本号的对比值和新值。比较的原理也很简单,就是判断当expectedReference是否与当前的reference是否一致,如果不一致就说明该数据必然被其他线程修改过,如果是一致的,那么就在比较版本号是否一致,如果不一致,说明当前reference中途被修改过,但是最后还是修改回来。



数据是被存储在Pair内,PairAtomicStampedReference的一个内部类,如下所示:



private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}



ABA问题解决办法比较简单好理解,感兴趣的同学可以再更加深入的理解一下AtomicStampedReference的源码。

四、CAS思想引发的思考

4.1 乐观锁和悲观锁



CAS的实现其实就是典型的乐观锁的基本思想的实现。乐观锁认为,共享数据被其他线程修改的概率比较小,所以在读取数据之前不会对数据进行加锁,读取完数据之后再进行计算,但是在写入之前,会再去读取一次共享数据,判断该数据在此期间是否被其他线程修改,如果被其他线程修改了,那么将重新读取并重复之前的操作,如果没有被修改,那么就直接将数据写回到主存中。CAS就是Compare And Swap,这是两个操作,但是这两个操作被合成了一个原子操作,从而保证了数据的线程安全。AtomicInteger就是典型的乐观锁的实现,这种思想广泛存在于各种中间件中,比如MySQL、Redis等。



相对于乐观锁,必然存在悲观锁,悲观锁认为,共享数据总可能被其他线程并发修改,所以在读取修改数据之前都会对数据进行加锁处理,保证在此时间段内只能被一个线程访问,等当前访问线程访问结束并释放锁后,那么其他的线程才有机会访问共享数据。悲观锁思想是通过上锁的方式保证了数据的安全性,但是损失了数据访问的性能,悲观锁思想应用也很广泛,比如synchronized、ReentrantLock、MySQL等都有悲观锁的实现。

4.2 阻塞和自旋



阻塞和自旋其实是线程两种等待操作共享资源的方式,这两种方式也是比较常用的方式,它们主要区别在于:



  • 阻塞:线程进入阻塞状态,其表现为放弃CPU时间片,等待后期被操作系统线程调度器唤醒,然后在继续执行线程中的逻辑。

  • 自旋:线程进入自旋状态,其表现为不放弃CPU时间片,利用CPU来进行“旋转”,也就是不断地进行重试。



这两种方式都有各自的应用场景,在单核CPU中,自旋不太适合,因为如果一旦自旋持续进行很久,那么其他线程都将无法被执行,在这种场景下,更加适合阻塞。在多核CPU下,自旋就很适合,以为其他CPU核心可以持续工作,当前CPU核心的线程中的任务在自旋,可以减少线程切换的次数,提高性能。



Atomic类是基于Unsafe来实现的,底层的基本原理都是采用的自旋方式,在现代多核CPU中应用效果还是十分可观的。当然阻塞和自旋并不是一对互斥的关系,它们可以很好地结合起来应用,比如自适应自旋锁的应用,其基本原理是优先自旋,达到一定次数之后仍然没有得到资源,那么就进入到阻塞状态。在JDK1.6之后,自旋锁是默认开启的,适用于锁被占用时间很多的情况,反之自旋的线程只会白白消耗处理器资源,反而带来了性能上的浪费。所以自旋等待的时间必须有一定的限度,超过了限定的次数仍然没有成功获取锁,就应当使用传统的方式挂起线程了。自旋次数的默认值是10,用户可以通过-XX:PreBlockSpin来更改。



本文以AtomicInteger为例分析了CAS的基本实现原理,其他的比如AtomicBoolean、AtomicLong的基本原理都是一样的,感兴趣的读者可以对比阅读器源码进行分析。



了解更多干货,欢迎关注我的微信公众号:爪哇论剑(微信号:itlemon)



发布于: 2020 年 07 月 09 日阅读数: 1162
用户头像

还未添加个人签名 2018.09.30 加入

Java码农一枚。

评论 (2 条评论)

发布
用户头像
优秀~
2020 年 07 月 09 日 10:57
回复
谢谢!
2020 年 07 月 09 日 11:04
回复
没有更多了
深入理解CAS:以AtomicInteger为例