java并发编程-volatile

java / 2022-08-22
0 1,420

volatilejava虚拟机提供的一种轻量级的同步机制,它有三个重要的特性:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

要理解这三个特性,就需要对JMM(JAVA内存模型)有一定的了解才行。

主要解决的问题:
JVM中,每个线程都会存在本地内存,本地内存是公共内存的副本,各个线程的本地内存相互隔离,就会存在一个线程对共享变量做了修改,其他线程没有感知到的情况,从而导致数据不一致

一、JMM(JAVA内存模型)

JMMJava 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMMJVM 中定义的一种并发编程的底层模型机制。JMM定义了线程和主内存(可以理解为买电脑时8/16G内存)之间的抽象关系:不同线程之间的共享变量存在主内存中,而每个线程中存在一个私有的本地内存,对共享变量的操作需要将主内存中的共享变量拷贝一份到本地内存中。也就是说,在每个线程的本地内存中存在的是共享变量的副本。

JMM关于同步的规定:

  • 1、线程解锁前,必须把共享变量的值刷新会主内存
  • 2、线程加锁前,必须读取主内存中的最新共享变量的值到本地内存
  • 3、加解锁是同一把锁

每个线程在创建时JVM都会为其分配工作内存(也叫栈空间),工作内存是每个线程的私有区域。而java内存模型规定所有变量都必须存在主内存中,主内存是共享区域,所有线程都可以访问。但是线程对变量的操作必须在工作内存中进行,大概流程就是,线程将变量的值从主内存拷贝到本地内存中,进行操作,然后在将其写回主内存。由于不同线程之间的工作内存互不可见,所有线程中的通信必须通过主内存来进行。具体过程如下:

java内存模型

由于JMM这样的机制,就导致了可见性的问题。

JMM三大特性

  • 可见性
  • 原子性
  • 有序性

二、可见性

内存可见性指当一个线程修改了某个变量的值后,其他线程总能知道这个值的变化。

这里用例子来说明一下:

package com.fzkj.juc;

import java.util.concurrent.TimeUnit;

/**
 * @DESCRIPTION  volatile关键字测试类
 */
public class VolatileTest {

    public static void main(String[] args) {
        Number number = new Number();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            number.numTo(20);
            System.out.println(Thread.currentThread().getName() + ":\t number的值是: " + number.num);
        }, "A线程").start();

        while(number.num == 0){}

        System.out.println(Thread.currentThread().getName() + ":\t number的值是: " + number.num);
    }


}

class Number{
    int num = 0;

    public void numTo(int target){
        this.num = target;
    }

    public void add(){
        this.num++ ;
    }
}

运行上面的例子就会发现,程序会陷入死循环,永远不会输出最后一句话。就是因为A线程中对变量num的修改对main线程不可见,导致while循环一直进行。

可见性问题常见的解决方案包括:

  • 加锁
  • volatile关键字

volatile

对上面代码进行改造

class Number{
    volatile int num = 0;

    public void numTo(int target){
        this.num = target;
    }

    public void add(){
        this.num++ ;
    }
}

这样在运行上面例子。就不会在陷入死循环了。

volatile是如何保证可见性的?

其他线程又是如何知道共享变量被修改了呢?
为了解决缓存一致性问题,需要遵循一些协议,叫做缓存一致性协议,如:MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。

嗅探

通过嗅探机制来保证及时的知道自己的缓存过期了。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,
当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里

由于嗅探机制会不断的监听总线,打量使用volatile可能会引起总线风暴

三、原子性

在来看另一种情况。

package com.fzkj.juc;

import java.util.concurrent.TimeUnit;

/**
 * @DESCRIPTION  volatile关键字测试类
 */
public class VolatileTest {

    public static void main(String[] args) {
        atomicity();
    }

    // 原子性
    public static void atomicity(){
        Number num = new Number();
        for (int i = 0; i < 10; i++) { // 启动10个线程
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) { // 每个线程对num的值操作1000次
                    num.add();
                }
            }).start();
        }
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(num.num);
    }
}

class Number{
     int num = 0;

    public void numTo(int target){
        this.num = target;
    }

    public void add(){
        this.num++ ;
    }
}

在上面的例子中,我们启动了10个线程,每个线程调用了1000次add方法,对num的值进行1000累加,那么我们期待的最终结果就是num的值是10000。但是实际上运行程序就会发现,每次的结果都会比10000少。

这个问题的成因,其实跟jvm有关系,我们都知道,程序员写的代码只是给程序员自己看的,还需要将代码编译才是机器执行的。一个++操作被编译成字节码文件之后,可以简化成三个步骤。第一步取值;第二步加一;第三步赋值。所以在高并发的场景下,就会出现值被覆盖的情况。

原子性的定义:指在一组操作中,要么全部操作都成功,要么全部操作都失败。

原子性是JMM的特性之一,但是volatile却并不支持原子性。要想在多线程的环境下保证原子性,可以使用锁机制,或者使用原子类(AtomicInteger)

四、有序性

禁止指令重排就叫做有序性。

什么是指令重排?

为了提高性能,在遵守as-if-serial语义的情况下,编译器和处理器往往会对指令做重排序。在多线程的情况下,指令重排可能会导致一些意想不到的情况。

volatile是怎么禁止指令的重排序的呢?这里又引出一个新的概念:内存屏障

内存屏障

内存屏障的作用是禁止指令重排序和解决内存可见性的问题。

先了解两个指令:

  • store:将缓存中的数据刷新到内存中
  • load:将内存存储的数据拷贝到缓存中

JMM主要将内存屏障分为四类

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2确保Load1数据的装载先于Load2
StoreStoreStore1;StoreStore;Store2确保Store1立刻刷新数据到内存的操作先于Store2
LoadStoreLoad1;LoadStore;Store2确保Load1数据装载先于Store2数据刷新
StoreLoadStore1StoreLoad;Load2确保Store1数据刷新先于Load2数据装载

StoreLoad被称为全能屏障,因其同时具备其他三个屏障的效果,但是相对于其他屏障,消耗会多。

了解了这些,下面就来看看volatile是如何插入内存屏障的。

volatile内存屏障

可以看到,

  • volatile在读操作后面加了LoadLoad和LoadStore屏障
  • 在写操作前后分别加了StoreStore和StoreLoad屏障

这就是说,编译器不会对volatile读和读后面的操作重排序;不会对写和写前面的操纵重排序。这样就保证了volatile本身的有序性。