Java为何能将读与写封装为一个原子操作

标签: Java

保留所有版权,请引用而不是转载本文(原文地址 https://yeecode.top/blog/20/ )。

在编程中,我们知道如果对一个变量读取然后写入这并不是一个原子操作,例如我们常用的i++操作,其实可以简单划分为以下三步:

1 读入i变量
2 对i变量进行+1操作
3 将新的i值写入

【1-1】

以上三步操作时独立的,因此并不是原子化的。如果i变量在第1步和第3步之间被其他线程更改则会引发意向不到的结果。例如线程1对i=5进行i++操作时,线程2对变量i进行了写入操作,则还会发生混乱。如图所示:

线程1 读入i变量,i=5
线程2 对i写入新的值:i=3
线程1 对变量i进行+1操作,i=6
线程1 对i进行写入:i=6

【1-2】 此时我们看到,因为i++操作不是原子的,中间受到线程2的影响,使得i++的操作是错误的。如果是原子化操作,则i++操作要么发生在线程2的操作之前,最终得到i=3;要么发生在线程2操作之后,得到i=4。而不能出现i=6这样的结果。

其实i++就是一个最简单的汇聚了读取和写入的操作,因此读取并写入操作并不是原子化的。可是,Java中确实能够将读取并写入操作封装为一个原子化操作。那它是怎样实现的呢?

我们先介绍一种粗暴的实现:使用同步块实现原子化。

使用同步块实现原子化

为了能实现i++操作的原子化,则最容易想到的就是使用同步块:

synchronized int selfIncrement(int value){
    return value++;
}

但这显然大材小用,将整个操作放置在同步块中,虽然借助同步块实现了原子化,但因为同步块的操作要进行锁的分配等,使得程序的整体效率受到了影响。

而我们知道Java中AtomicInteger类可以直接将读和写封装在一个原子操作中实现原子化的i++等操作,那它们是怎么实现的呢?我们接下来进行探讨。

硬件CAS操作

Java中AtomicInteger类便可以将读和写封装在一个原子操作中。那它是怎么实现的呢?

它使用的是CAS操作。CAS是compare and swap的缩写,中文可以翻译成:比较并交换。CAS操作来源于底层硬件领域。因为CAS能够极大地提高并发效率,因此在硬件设计领域,CAS这种操作就是存在的。例如在intel的CPU中,使用cmpxchg指令就可以实现CAS操作。我们详细介绍下CAS操作的原理:

CAS操作一共包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。具体操作过程如下:

无论结果如何,该操作总能拿到V处的原值R,如果R等于A,则说明V处的值已经被更新为了B;否则说明更新失败,V处的值依旧是R。因此,经过该操作后,可以知道CAS操作是否成功。

当然,还有一种CAS实现是直接返回操作是否成功的结果,即:如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值,并返回操作成功;否则,返回操作失败。但是这与返回V位置原值的方式大同小异,而且返回原值R的实现方式不仅能获得操作是否成功的结果,还能顺便获得R值,因此更为常用。

Java中的CAS操作

在Java初期,java是无法直接利用这些硬件操作来提升并发性能的,直到后来Java本地方法(JNI)的出现,使得Java直接调用本地方法称为可能,在Doug Lea提供的cucurenct包得以实现。

在Java中如何实现呢?其实知道了CAS操作的原理,一下就变得简单起来。我们还是以i++操作为例,介绍CAS整个过程:

  1. 读取内存位置V的原值R
  2. 因为进行的是i++操作,因此CAS操作的预期原值A=R、新值B=R+1,进行CAS。
  3. 获取V的原值R
    • 如果R等于A,更改成功,操作结束
    • 如果R不等于A,则没有更改。但是获取当前时刻V的原值,将R重新设置为该值。则预期原值A=R、新值B=R+1,然后在继续进行CVS。

空口无凭,我们看一下AtomicInteger中i++操作的实现方法getAndIncrement():

public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

而getAndAddInt()方法如下:

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

我们继续追踪到compareAndSwapInt()方法,分析该方法是一个本地方法。

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

稍加需要注意的是,可能由于其他原因,这里的compareAndSet采用的是返回操作是否成功的实现方式,而不是返回当前内存位置原值R的方式。因此在每一次进行CAS操作时,都需要先调用getIntVolatile(var1, var2)方法获取一次当前的变量值作为预期原值A。

因此,getAndIncrement()的具体操作就是: 1. 获取当前值,在此基础上+1作为目标值 2. 进行CAS操作 3. 如果不成功,说明值不是最新的。重新获取值,不断再试。

如此一来,我们就知道了Java中是如何将读取和写入这两个操作封装成一个原子操作的。

总结

CAS的思想本质上就是乐观锁思想。即:假设情况都是乐观的,那么当前内存位置V的值是预期原值A,如果这样,请把内存位置V的值设置为新值B;否则,乐观的情况不成立,什么也别做。

乐观锁的思想减少了使用同步块等悲观锁的开销,但是本质是基于乐观锁思想的,因此肯定存在一些问题。

本文首发于个人知乎:易哥(https://www.zhihu.com/people/yeecode),欢迎关注。

作者书籍推荐