专栏首页深入理解Java走进高并发(四)深入理解Atomic类

走进高并发(四)深入理解Atomic类

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

一、问题场景引入

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

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 {
	
	// 省略部分代码
	// 使用volatile修饰了一个变量用来存储数值
    private volatile int value;

   	// 构造函数直接初始化value值
    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    // 无参构造,此时value默认为0
    public AtomicInteger() {
    }

	// getAndIncrement方法内部调用了unsafe的getAndAddInt方法
    public final int getAndIncrement() {
        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 {

	private Unsafe() {
    }

    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
            throw new SecurityException("Unsafe");
        } else {
            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 {
        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方法,必然会抛出异常,我们打断点测试一下:

在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循环,直到写入主存成功为止。整个流程表现为自旋的形式,先将内存中的值读取后进行进行计算,计算完毕准备写回主存之前进行判断主存中的值是否发生改变,如果发生改变,则重新重复该流程,直到写入成功为止。基本的原理图如下所示:

三、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 Set,这是两个操作,但是这两个操作被合成了一个原子操作,从而保证了数据的线程安全。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的基本原理都是一样的,感兴趣的读者可以对比阅读器源码进行分析。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 深入理解类加载机制:拨开迷雾见真章

    我们平常写的Java代码是存储在.java文件中,这是一个文本文件,是不能直接执行的,但是这个文本文件可以被编译成为一个字节码文件(后缀为.class),这个字...

    itlemon
  • Spring Boot启用异步线程

    Spring中存在一个接口AsyncConfigurer接口,该接口就是用来配置异步线程池的接口,它有两个方法,getAsyncExecutor和getAsyn...

    itlemon
  • 走进高并发(二)Java并行程序基础

    上面的定义很完整,对进程进行了全方面的定义,但是貌似进程是看不见摸不着的一个东西,实际上,我们可以通过查看计算机的进程管理器来查看应用程序的进程。

    itlemon
  • 揭秘java中无数人伤透脑筋最为神秘的技术之一——ClassLoader

    ClassLoader 是 Java 届最为神秘的技术之一,无数人被它伤透了脑筋,摸不清门道究竟在哪里。网上的文章也是一篇又一篇,经过本人的亲自鉴定,很多都是在...

    java架构师
  • Java虚拟机--线程上下文类加载器

    贾博岩
  • Tomcat8类加载机制

    在了解类加载机制时,发现网上大部分文章还停留在tomcat6,甚至tomcat5。

    林老师带你学编程
  • JVM面试十问

    (1)标记-清除算法:首先标记出需要回收的对象,标记完成后统一清除。此算法缺点是标记-清楚效率不高,且容易出现大量不连续的碎片空间。

    用户1148394
  • 线程上下文类加载器ContextClassLoader内存泄漏隐患

    今天(2020-01-18)在编写Netty相关代码的时候,从Netty源码中的ThreadDeathWatcher和GlobalEventExecutor追溯...

    Throwable
  • 手无寸铁,如何强硬又体面地落地中间件

    内容来源:2017 年 12 月 03 日,找钢网资深架构师刘星辰在“IAS2017互联网架构峰会”进行《手无寸铁,如何强硬又体面地落地中间件》演讲分享。IT ...

    IT大咖说
  • Python-Excel-openpyxl-04-单元格背景色设置

    系统:Windows 7 语言版本:Anaconda3-4.3.0.1-Windows-x86_64 编辑器:pycharm-community-2016.3....

    zishendianxia

扫码关注云+社区

领取腾讯云代金券