【Java知识点系列一】volatile 底层原理

目录

可见性

volatile案例

JMM内存模型

Java内存模型交互规则

MESI协议

MESI协议引发的问题

内存屏障

volatile底层原理

JMM对于volatile变量会有特殊的约束:

有序性

什么是指令重排?

as-if-serial

happens-before 原则

volatile不保证原子性


可见性

volatile案例

volatile主要用来保证可见性和有序性的,不保证原子性。

首先看一段代码

public class VolatiteTest {

    private static boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.err.println(Thread.currentThread().getName() + "开始运行");
                while (!initFlag) {

                }
                System.err.println(Thread.currentThread().getName() + "运行结束");
            }
        }, "线程1").start();

        Thread.sleep(2000);

        new Thread(new Runnable() {
            @Override
            public void run() {
                updateFlag();
            }
        },"线程2").start();
    }

    private static void updateFlag() {
        System.err.println(Thread.currentThread().getName() + "准备修改initFlag....");
        initFlag = true;
        System.err.println(Thread.currentThread().getName() + "修改initFlag完成....");
    }
}

未加volatile运行结果如下:

线程1开始运行
线程2准备修改initFlag....
线程2修改initFlag完成....

结果就是线程1会卡在这里

添加volatile运行结果如下

线程1开始运行
线程2准备修改initFlag....
线程2修改initFlag完成....
线程1运行结束

结果:线程1卡住,等待线程2修改完initFlag后,线程1重新读取到修改后的值,然后执行结束。这就是volatile 关键字 的可见性的

那到底是什么原因,又是如何实现可见性的呢?

这里得先说说JMM内存模型

JMM内存模型

        JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。

线程,工作内存,主内存工作交互图(基于JMM规范):

 主内存

        主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。

工作内存

        主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

根据JVM虚拟机规范主内存与工作内存的数据存储类型以及操作方式,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

        也就是说多线程在使用或者修改共享变量的时候,需要将共享变量读取到自己的工作内存中,然后进行修改,修改完成后在刷入到主存中。

        那么,一个共享变量是如何从主内存拷贝到工作内存、又是如何从工作内存同步到主内存中的呢?所以,Java内存模型定义了以下八种操作来完成。

Java内存模型交互规则

lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态

unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量

store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作

write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中

        如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

所以,上面案例中initFlag的加载过程如下:

由于线程1只是使用变量,所以八大操作只是画了一部分。

解释一下:线程1和线程2都将initFlag 通过 read load加载到自己的工作内存,然后在自己的工作内存中进行修改,修改完,通过store write写入主内存。        

到这里,还是会有一个问题,两个线程同时运行,都将变量读到自己的工作内存中了,线程2修改了变量的值,线程1怎么感知到呢?这就需要用到 MESI协议

MESI协议

MESI协议 基于总线嗅探机制的缓存一致性协议,该协议被应用在Intel奔腾系列的CPU中

在MESI协议中,每个缓存行有四种状态

M: 被修改(Modified)

        该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E: 独享的(Exclusive)

        该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。

S: 共享的(Shared)

        该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态)。

I: 无效的(Invalid)

        该缓存是无效的(可能有其它CPU修改了该缓存行)。

上面案例的MESI分析图如下:

 工作原理如下:

CPU1(线程1)使用共享数据 initFlag 的时候,会先将 initFlag 复制到 CPU1的缓存行中,并将其标记为 E;

此时CPU2(线程2)也是用了 initFlag 数据,也会将 initFlag 复制到自己的缓存行中,通过总线嗅探机制,CPU1嗅探到 CPU2也读取了 initFlag 数据,此时共享变量在 CPU1和CPU2中的状态修改为 S;

当CPU2修改了 initFlag 的值,需要对他自己的缓存行进行加锁,加锁成功才能修改,此时会将其状态切换为M;加锁的同时还会向BUS总线发送一个本地写Invalidate消息,告诉其他嗅探的CPU该变量已经被改变要写回主存,嗅探到消息的CPU会将共享变量的状态从S 变为 I

此时CPU1就需要重新从主存中读取变量的值

MESI协议引发的问题

Store Bufferes

缓存的消息一致性传递是需要时间的,修改变量,发送本地写Invalidate消息通知其他CPU并等待确认过程,会阻塞处理器,所以处理器会将修改后的值写入store buffers,然后继续处理其他事情,等到所有的失效确认都接收到时,才会将数据写入到主内存中

Store Bufferes的风险

第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二、保存什么时候会完成,这个并没有任何保证。

Invalidate Queue

执行失效也不是一个简单的操作,这就引入了失效队列。它们的约定如下:

1、所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送

2、Invalidate并不真正执行,而是被放在Invalidate Queue队列中,在方便的时候才会去执行。

3、处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。

这样还是无法确定,处理器什么时候去处理Invalidate Queue中的数据,所以干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。

内存屏障

写屏障 Store Memory Barrier 是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。

读屏障 Load Memory Barrier  是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

所以volatile关键字可以添加内存屏障,从而保证MESI协议中修改数据的可见性

volatile底层原理

Lock前缀指令 + MESI缓存一致性协议

该Lock前缀指令有三个功能:

1、将当前CPU缓存行的数据立即写回系统内存

2、lock前缀指令会引起在其他CPU中缓存了该内存地址的数据无效

写回操作时要经过总线传播数据,而每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改时,就会将当前CPU的缓存行设置为无效状态,当CPU要对这个值进行修改的时候,会强制重新从系统内存中把数据读到CPU缓存。

3、lock前缀指令禁止指令重排

lock前缀指令的最后一个作用是作为内存屏障(Memory Barrier)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。

JMM对于volatile变量会有特殊的约束:

(1)使用volatile修饰的变量其read、load、use都是连续出现的,所以每次使用变量的时候都要从主存读取最新的变量值,替换私有内存的变量副本值(如果不同的话)。

(2)其对同一变量的assign、store、write操作都是连续出现的,所以每次对变量的改变都会立马同步到主存中

        虽然volatile修饰的变量可以强制刷新内存,但是其并不具备原子性。虽然其要求对变量的(read、load、use)、(assign、store、write)必须是连续出现,但是在不同CPU内核上并发执行的线程还是有可能出现读取脏数据的时候。

总结:

所以volatile 如何保证可见性

        volatile修饰的变量,执行写操作的时候,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写入主内存,同时因为有MESI协议,所以各个CPU都会对总线进行嗅探自己本地缓存中的数据是否被人修改过,如果发现别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取这个变量的时候,就会从主内存中重新加载最新的数据了。

说完了可见性,接下来就看看volatile 如何保证有序性的

有序性

案例:一个非常经典的单例的双重检测的代码

public class DoubleCheckLock {
    private static DoubleCheckLock instance;
    private DoubleCheckLock(){}
    public static DoubleCheckLock getInstance(){
        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new  DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题,

因为instance = new DoubleCheckLock();可以分为以下3步完成

memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null

所以在多线程下,第二步和第三步 可能重排序,会导致一个线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。如何避免重排序,就需要将instance实例使用volatile进行修饰。

先来看看指令重排

什么是指令重排?

        指令重排是指的指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排发生在执行器编译阶段和cpu运行时。

指令重排需遵循 as-if-serial 和 happens-before 原则 

as-if-serial

        不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

        为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

happens-before 原则

指令重排需要遵循以下原则:

1. 程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

2. 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

3. volatile规则: volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

4. 线程启动规则: 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

5. 传递性: A先于B ,B先于C 那么A必然先于C

6. 线程终止规则: 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

7. 线程中断规则: 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

8. 对象终结规则: 对象的构造函数执行,结束先于finalize()方法

遵循上述规则后出现的重排序,此时可以引入 volatile 关键字来禁止指令重排

        前面说过volatile 关键字底层是 Lock前缀指令+MESI协议,Lock前缀指令的一个作用就是添加内存屏障,禁止指令重排。

对于volatile修改变量的读写操作,都会加入内存屏障

每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和下面的voaltile写重排序;

每个volatile写操作后面,加StoreLoad屏障,禁止上面的voaltile写跟下面的volatile读/写重排序

每个volatile读操作前面,加LoadLoad屏障,禁止下面的普通读和上面的voaltile读重排序;

每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和上面的volatile读重排序

所以,voaltile关键字 通过内存屏障 禁止指令重排序,从而实现了有序性

volatile不保证原子性

前面说过 volatile 是不保证原子性的。具体是什么原因呢?

案例:

public class TestVolatile {

    private static volatile int a = 0;

    public static void main(String[] args) throws InterruptedException {

        for (int i = 0; i < 1000; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 100; j++) {
                       a++;
                    }
                }
            });
            thread.start();

        }

        Thread.sleep(2000);
        System.err.println(a);

    }

}

        上面的变量a使用 volatile 进行修饰的,执行程序,运行后的结果有时候并不是我们想要的结果100000,这是为什么呢?

简单描述一下,前面说到JMM对volatile修饰的变量有特殊的规定:

对同一变量的assign、store、write操作都是连续出现的,所以每次对变量的改变都会立马同步到主存中

这里只用两个线程举例:

        假设线程1和线程2分别在不同的处理器上运行,现在两个线程同时将变量a=0读到自己的工作内存,线程1将a进行++后变成1了,在完成了assign、store的操作,假设在执行write指令之前,线程1的CPU时间片用完,此时线程1的write操作没有到达主存。

        由于线程1的store指令触发了写的信号,线程2中的缓存过期,重新从主存读取到a值,此时线程2读到的a值还是0。线程2执行完成所有的操作之后,将a变成1写入主存,此时线程1的时间片重新拿到,重新执行write操作,将过期了的1写入主存。此时就出现了数据不对的情况。

        所以volatile变量无法保障其原子性,如果要保证复合操作的原子性,就需要使用锁。

以上是个人理解,如有不对的地方,欢迎指正。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值