深入理解 CAS:以 AtomicInteger 为例
从本篇文章开始,我们将对JDK并发包
java.util.concurrent
中相关类的源码进行分析,通过分析源码,能让我们尽快地掌握并发包中提供的并发工具,能让我们更好地利用这些并发工具写出更加好的代码。本篇文章的主角是AtomicInteger,接下来,请跟随文章的节奏一起分析AtomicInteger吧!
一、问题场景引入
大家都清楚,在多线程环境下,i++
会存在线程不安全问题,原因是因为i++
不是一个原子操作,它可以被解析为i = i + 1
,它在运行时是被划分为三个步骤,分别是从主存中读取i
的值到线程的工作内存中,然后线程对i
值进行+1
操作,最后将计算后的i
值写回到主存中。因为这三个步骤不是一个原子操作,那么就存在某个线程A在进行i++
操作的过程中,线程B对主存中的i
值进行了读取并完成修改,那么此时线程A的计算结果就不正确了,且会出现数据被覆盖的问题,这是线程不安全的根本原因。
解决这种线程之间不可见且非原子性的操作,通常可以使用synchronized
关键字来保证数据的正确性,基本的代码如下所示:
使用synchronized
关键字后,某一个时间段只能有一个线程来进行size++
的操作,其他线程如果需要进行同样的操作,那么必须等待当前线程结束并释放锁后才能操作,这样就保证了数据的安全性,避免了数据被覆盖的风险。
虽然synchronized
关键字能保证线程的安全,但是synchronized
关键字涉及到线程之间的资源竞争与锁的获取和释放,其性能略低,那么在JDK中是是否有替代方案呢?答案当然是有,AtomicInteger
在这种场景下可以替代synchronized
关键字,且性能优于synchronized
关键字,基本的代码如下所示:
那么问题来了,AtomicInteger是如何保证线程的安全的呢?这就需要进入到AtomicInteger源码中一探究竟了!
二、AtomicInteger源码解析
我们以上面的第二段代码为例,我们进入到AtomicInteger的源码(基于JDK8
),部分源码粘贴如下所示:
源码中使用volatile
关键字修饰了vlaue
属性,这样可以保证值的可见性。而getAndIncrement方法,内部调用的是Unsafe类对象的getAndAddInt方法,在正式介绍getAndAddInt方法之前,首先简单介绍一下Unsafe类。
Java语言本身是从C++语言衍生而来的,是比C++更加高级的一门语言。Java语言和C++语言有一个重要的区别就是前者无法直接操作某个内存区域,而C++语言是可以直接操作一块内存区域,可以实现自主内存区域申请和释放。虽然Java屏蔽了操作内存区域的接口,但是也提供了一个类似C++可以手动管理内存的Unsafe类,有了这个类,那么基本可以实现手动管理内存区域。
Unsafe类是包sun.misc
下的一个类,这个类的大部分方法都是本地方法,比如getAndAddInt
、allocateMemory
、freeMemory
等,这些方法都是使用了native
进行修饰,底层调用的都是C++的代码,通常我们无法直接阅读本地方法的具体实现,需要进一步阅读OpenJDK的源码。这里先分析一下Unsafe类的构造方法和获取其对象的基本方法,代码如下:
从上面的代码可知,Unsafe类的构造法方法是私有的,且类本身是要的是final修饰的,所以该类不可以在外部被实例化,且不能被继承。获取Unsafe类对象是通过getUnsafe方法来获取的,本质上是通过反射机制来创建对象。但是这里有个条件,调用getUnsafe方法的类必须是由启动类加载器加载才可以,否则将抛出SecurityException异常。何出此言呢?我们来对这段getUnsafe方法的代码细细地品:
上述的if
条件中,如果VM.isSystemDomainLoader(var0.getClassLoader())
返回false,那么将抛出异常,而这段代码就是判断当前调用getUnsafe方法的类的加载器是否是启动类加载器,为何这么说呢?我们进入到isSystemDomainLoader
方法中看看:
从这段代码很简单,就是判断传入的类加载器对象是否为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对象的代码:
这里写的测试类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的源码如下所示:
while条件中调用了unsafe的compareAndSwapInt方法,也就是常说的CAS(Compare And Swap,比较和替换)。这个方法是一个本地方法,无法看到其具体实现,但是可以通过查看OpenJDK的源码来看到compareAndSwapInt方法的C++实现。在查看C++代码之前,我们一起来分析一下compareAndSwapInt方法的四个参数。
第一个参数:指向调用getAndAddInt方法的对象,本文中是指AtomicInteger对象。
第二个参数:指向调用getAndAddInt方法的对象的属性在内存中的偏移量,这里指的是AtomicInteger对象的value属性的偏移量。
第三个参数:这个值是从unsafe的第二个参数指定内存地址中读取出来的值。
第四个参数:即将设置到主存中的值。
有了以上对参数的理解,接下来我们重点来理解CAS原理,这里我们一起来读compareAndSwapInt的源码,查看OpenJDK的源码,compareAndSwapInt的C++实现代码如下所示:
上述代码中在写回新值到主存中之前进行了一个判断,判断从内存中读取到的值是否和计算新值前的老值是一致的,如果不是一致,将不会把计算后的值写回主存,并写返回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
方法如下所示:
这个方法有四个参数,前面两个参数分别是需要比较和修改的值,后面两个参数版本号的对比值和新值。比较的原理也很简单,就是判断当expectedReference
是否与当前的reference
是否一致,如果不一致就说明该数据必然被其他线程修改过,如果是一致的,那么就在比较版本号是否一致,如果不一致,说明当前reference
中途被修改过,但是最后还是修改回来。
数据是被存储在Pair
内,Pair
是AtomicStampedReference
的一个内部类,如下所示:
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)
版权声明: 本文为 InfoQ 作者【独钓寒江雪】的原创文章。
原文链接:【http://xie.infoq.cn/article/79fd68d510b0a52324d6ca7e1】。文章转载请联系作者。
评论 (2 条评论)