Loading...
墨滴

Bayanbulake

2021/12/23  阅读:34  主题:自定义主题1

深入理解volatile关键字

volatile

volatile是JVM提供的一个轻量级的同步机制,除了能够“避免JVM对long/double的误操作”外,还有以下两个作用:

  • volatile修饰的变量可以对所有线程立即可见

不同的线程如果要访问同一个变量,就必须借助主内存进行传递。但是如果给变量加了votatile关键字,则该变量的值就可以被所有线程立即感知。

  • volatile可以禁止指令“重排序”优化

在理解重排序之前,有必要了解一下“原子性”,因为重排序的对象必须是原子性的语句。但是在Java中,并不是所有语句都是原子性的。例如,如果已经存在变量num

,那么对num = 10是一个原子性操作;但是如果不存在age,声明并赋值age的语句int age = 23就不是原子操作。该语句会被分为以下两条语句执行:

int age;
age = 23;

重排序是指JVM为了提高执行效率,会对编写的代码进行一些额外的优化。例如,会对已经写完的代码指令,重新进行排序。重排序的的优化不会影响单线程执行的结果。

int height = 10;
int width;
width = 20;
int area = height * width;

因为重排序不会影响单线程程序的执行结果。因此以上代码的实际执行顺序可以是1、2、3、4或者2、3、1、4或者2、1、3、4的结果都是相同的。

懒汉式单例模式

了解了原子性和重排序之后,我们看看双重检查方式的懒汉式单例模式。

public class Singleton{
    private static Singleton instance = null// 多线程共享的instance
    private Singleton(){}
    public static Singleton getInstance(){
        if(instance == null){
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
            return instance;
        }
    }
}

上面的代码的第8行也不是一个原子性操作,JVM会在执行时将这条语句大致拆分为3步:

  • 分配内存地址、内存空间
  • 使用构造方法实例化对象
  • 将分配好的内存地址赋值给instance

由于重排序的存在,第8行的内部执行顺序可能是1、2、3,也可能是1、3、2。如果是后者,当一个线程正在执行第8行,具体情况是执行完3,但是还没执行2(即Instance虽然已被赋值,但是还没实例化),另一个线程Y正好抢占了CPU并且执行到第5行,判断Instance不为null,因此线程Y会直接返回instance对象,但是Instance是还没有实例化的对象,所以后续使用Instance就会出错。

为了避免这种JVM重排序而造成的问题,我们就可以给Instance加上volatile关键字,如下:

private volatile static Singleton instance = null;

经过这样的修改,就算真正意义上实现了单例模式。

实际上,volatile关键字是通过“内存屏障”来防止指令重排序的,具体的实现步骤如下:

  • volatile写操作前,插入一个StoreStore屏障;
  • volatile写操作后,插入一个StoreLoad屏障;
  • volatile读操作前,插入一个LoadLoad屏障;
  • volatile读操作后,插入一个LoadStrore屏障;

此外,要特别注意,虽然volatile修饰的变量具有原子可见性,但是并不具备原子性,因此volatile不是线程安全的。要理解这一点,就得明确区分原子性重排序的概念。

  • 原子性是指某一条语句JVM不可再拆分执行;

  • 重排序是指某一条语句内部的多个指令在不影响结果的前提下,可以进行的重新排序。

下面的代码可以说明volatile的非线程安全的问题。

/**
 * @Author: Wangb
 * @EMail: 1149984363@qq.com
 * @Date: 23/12/2021 上午9:16
 * @Description
 */

public class TestVolatile {
    public static volatile int num = 0;

    @SuppressWarnings("AlibabaAvoidManuallyCreateThread")
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {

            new Thread(() -> {
                for (int j = 0; j < 20000; j++) {
                    num++;//num++不是一个原子性操作
                }
            }).start();
        }
        //休眠3秒,确保创建的100个线程都已执行完毕
        Thread.sleep(3000);
        System.out.println(num);
    }
}

当开始num=0时,创建了100个线程,并且每个线程都会执行20000次num++。因此如果volatile线程安全的,打印的结果应该是2000000,但是实际结果并非如此,如下图,由于多线程的资源抢占,每次执行的结果都不一样:

从运行结果就可以发现,volatile并不能将修饰的num设置为原子性操作,这会造成num++被多个线程同时执行,最终导致出现漏加的线程不安全的情况,即结果远小于20000000.

如果要将上面的程序修改为线程安全的,我们可以使用java.util.concurreent.atomic包中提供的原子类型。

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @Author: Wangb
 * @EMail: 1149984363@qq.com
 * @Date: 23/12/2021 上午9:51
 * @Description
 */

public class TestVolatile_2 {
    public static volatile AtomicInteger num = new AtomicInteger();

    @SuppressWarnings("AlibabaAvoidManuallyCreateThread")
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            new Thread(() ->{
                for (int j = 0; j < 20000; j++) {
                    //num自增
                    num.getAndIncrement();
                }
            }).start();
                    }
        Thread.sleep(3000);
        System.out.println(num);
    }
}

AtomicInteger实现原子性的操作的关键是compareAndget(),因为它实现了CAS算法,该算法 能够保证变量的原子性操作。

CAS算法

CAS全称为Compare And Swap即比较并交换,其实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg指令,也就是说CAS是靠硬件实现的,从而在硬件层面提升效率。其算法公式如下:

函数公式:CAS(V,E,N) V:表示要更新的变量 E:表示预期值 N:表示新值

CAS原理

如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。

ABA问题: ABA问题是CAS中的一个漏洞。CAS的定义,当且仅当内存值V等于就得预期值A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。那么如果先将预期值A给成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,这时其他线程的CAS操作仍然能够成功,但是很明显是个漏洞,因为预期值A的值变化过了。如何解决这个异常现象?java并发包为了解决这个漏洞,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,即在变量前面添加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

了解CAS算法就得先了解,无锁的概念:

无锁分为以下两大派系:

**乐观派系:**它们认为事情总会往好的方向去发展,总是认为坏的情况发生概率特别小,可以无所顾忌的做任何事情.

**悲观派系:**它们总会认为发展事态如果不及时控制,以后就无法挽回,即时此种局面不会发生的情况下。上述两大派系映射到并发编程中就如同加锁与无锁策略,即加锁是一种悲观策略,无锁是一种乐观策略,因为对于加锁的并发程序来说,它们总是认为每次访问共享资源时总会发生冲突,因此必须对每一次数据操作实施加锁策略。而无锁则总是假设对共享资源的访问没有冲突,线程可以不停执行,无需加锁,无需等待,一旦发现冲突,无锁策略则采用一种称为CAS的技术来保证线程执行的安全性,这项CAS技术就是无锁策略实现的关键。

实现思想 在线程开启的时候,会从主存中给每个线程拷贝一个变量副本到线程各自的运行环境中,CAS算法中包含三个参数(V,E,N),V表示要更新的变量(也就是从主存中拷贝过来的值)、E表示预期的值、N表示新值。

实现过程 假如现在有两个线程t1,t2,,他们各自的运行环境中都有共享变量的副本V1、V2,预期值E1、E2,预期主存中的值还没有被改变,假设现在在并发环境,并且t1先拿到了执行权限,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次发起尝试,然后t1比较预期值E1和主存中的V,发现E1=V,说明预期值是正确的,执行N1=V1+1,并将N1的值传入主存。这时候贮存中的V=21,然后t2又紧接着拿到了执行权,比较E2和主存V的值,由于V已经被t1改为21,所以E2!=V,t2线程将主存中已经改变的值更新到自己的副本中,再发起重试;直到预期值等于主存中的值,说明没有别的线程对旧值进行修改,继续执行代码,退出;

CAS的优点: 这个算法相对synchronized是比较“乐观的”,它不会像synchronized一样,当一个线程访问共享数据的时候,别的线程都在阻塞。synchronized不管是否有线程冲突都会进行加锁。由于CAS是非阻塞的,它死锁问题天生免疫,并且线程间的相互影响也非常小,更重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,所以它要比锁的方式拥有更优越的性能。

CAS的缺点:

CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。

  • 循环时间长、开销很大。

当某一方法比如:getAndAddInt执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。

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

当操作1个共享变量时,我们可以使用循环CAS的方式来保证原子操作,但是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。

  • 存在ABA问题

Bayanbulake

2021/12/23  阅读:34  主题:自定义主题1

作者介绍

Bayanbulake