JMM与happen-before规则

2021-01-13  本文已影响0人  得力小泡泡

1、CPU Cache模型

image.png

高速缓存区:CPU处理数据(读写)非常快,内存跟不上CPU的速度,所以提供了一层缓冲,CPU大部分精力主要从缓存区里面取数据(命中hit,即从L1取不到则从L2取,L2取不到则从L3取,L3取不到才从主内存中取,每个缓冲区的命中率是80%)

为了解决缓存不一致性问题,有两种解决方法
1、通过总线加锁的方式
该方式会阻塞其他CPU对其他组件的访问,从而使得只有一个CPU(抢到总线锁)能够访问这个变量的内存。这种方式效率低下,通常使用第二种方式
2、MESI缓存一致性协议
MESI缓存一致性协议保证了每一个缓存中使用的共享变量副本都是一直的,它大概的思想是,当CPU在操作Cache中的数据时,如果发现该变量是一个共享变量,也就是说在其他的CPU Cache中也存在一个副本,那么进行如下操作:
①:读取操作,不做任何处理,只是将Cache中的数据读取到寄存器
②:写入操作,发出信号通知其他CPU将该变量的Cache line置为无效状态,其他CPU在进行该变量读取的时候不得不到主存中再次获取
(当有一个CPU对该变量进行读写操作时,对该变量进行上锁lock,将该变量传入到主内存,并在主内存中对该变量进行写入(写入操作会对总线造成该变量的数据变化),写入完毕再进行解锁unlock,其他线程才能在主存中访问该变量。每一个CPU中都会通过CPU总线嗅探,实时嗅探总线,关注数据变化,CPU会将在工作内存的该变量对应的缓存行设置为无效状态, 当CPU对该变量进行读取时需要重新往主内存中再次读取,注意,需要等到写入的那个线程解锁后才能读取

2、Java内存模型(Java Momory Model,JMM)

Java内存模型的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存 和 从内存中取出变量这样的底层细节。注意一下,此处的变量并不包括局部变量与方法参数,因为它们是线程私有的,不会被共享,自然也不会存在竞争;此处的变量应该是实例字段、静态字段和构成数组对象的元素。

Java的内存模型决定了 一个线程对共享变量的写入,以及何时对其他线程可见,Java内存模型定义了线程和主内存之间的抽象关系,具体如下。
1、共享变量存储于主存之中,每个线程都可以访问。
2、每个线程都有私有的工作内存或者称为本地内存。
3、工作内存只存储该线程对共享变量的副本
4、线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存
5、工作内存和Java内存模型一样也是一个抽象的概念,它其实并不存在它覆盖了缓存,寄存器,编译器优化以及硬件等(可以粗略看成是缓存 + 寄存器

本质:
Java内存模型跟CPU的缓存模型非常相似,它是基于CPU缓存模型来建立的。从图中可以看出线程A和线程B是分别使用不同的CPU去处理的,对应的工作内存是CPU内存模型中 寄存器 和 缓存 的组合。Java内存模型只是将线程和主内存抽象化,实际上映射出来的还是CPU cache模型。对共享变量不加volatile时,相当于是CPU缓存模型中缓存不一致的情况;对共享变量加volatile时,则会映射成CPU缓存模型的MESI缓存一致性协议的情况

image.png

3、Java内存间的操作(了解)

主内存和工作内存之间具体的交互协议:即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的:(这8个操作是c++层面的,最终还是会映射到汇编底层的操作)

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

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

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

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

5、use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

6、assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

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

8、write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。

4、Java内存模型典型例子(可见性问题)

package com.concurrency2;

public class MyTest3 {

    private static volatile boolean status = false;

    public static void main(String[] args) {
        //启动线程A
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程A启动中,获取status状态值为:" + status);

/*                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }*/
                while(!status) {
                    //一直死循环
                }
            }
            }, "线程A").start();

        //启动线程B
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程B启动,获取数据,执行业务...");
                //修改值
                status = true;//写会主内存
                System.out.println("业务执行完毕");
            }
        }, "线程B").start();
    }
}
1、当status变量不使用volatile关键字修饰时

分析:线程A先运行,将主内存的status变量读入工作内存中,然后一直执行while(!status)死循环,然后线程B运行,将status变量读入到工作内存中,并将工作内存中status由原先的false改写成了true,然后将主内存的status变量进行刷新,这时候主内存的status已经修改成true了,可是线程A还是一直在死循环,原因是因为它的工作内存的status变量还是原来的false,并不是新的true

输出(程序未执行完)

线程A启动中,获取status状态值为:false
线程B启动,获取数据,执行业务...
业务执行完毕
2、当status变量使用volatile关键字修饰时

volatile可以实现变量的可见性,即当一个线程修改了被volatile修饰的共享变量的值,新的值总是可以被其他线程立即可知,具体原理如下

image.png
分析:

原因是:当线程B对status值进行修改时,如果是才用总线加锁,那么从主内存开始将status = false 读入到工作内存之前就开始就对主存中的status变量进行上锁,直到线程B操作完了,然后把新的status值写回到主内存时之后才释放锁,其他线程才可以从主内存中读取status数据导致多核程序串行执行,性能下降,锁粒度太大

①:也需要加锁 (从工作内存准备将变量的值传送到主内存中的时候就上锁,直到主内存的值被修改完才释放锁)
②:CPU总线嗅探机制帮忙监听
(当有一个CPU对该变量进行读写操作时,对该变量进行上锁lock,将该变量传入到主内存,并在主内存中对该变量进行写入(写入操作会对总线造成该变量的数据变化),写入完毕再进行解锁unlock,其他线程才能在主存中访问该变量。每一个CPU中都会通过CPU总线嗅探,实时嗅探总线,关注数据变化,CPU会将在工作内存的该变量对应的缓存行设置为无效状态, 当CPU对该变量进行读取时需要重新往主内存中再次读取,注意,需要等到写入的那个线程解锁后才能读取

注意:上面的lock操作(c++代码)与nolock操作(c++代码)实际上相当于是一个内存屏障,即内存屏障的底层实现就是通过MESI缓存一致性协议实现的。内存屏障的具体内容volatile章节有

5、原子性问题

volatile无法保证原子性

典型例子
package com.concurrency2;

import java.util.Arrays;
import java.util.concurrent.CountDownLatch;

public class MyTest6 {
    private static volatile int count = 0;//加 volatile也没用
    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[100];
        CountDownLatch latch = new CountDownLatch(threads.length);//等待线程结束

        for(int i = 0;i < 100;i ++)
        {
            threads[i] = new Thread(() ->{
                for(int j = 0;j < 1000;j ++)
                    count ++;
                latch.countDown();
            });
        }

        Arrays.stream(threads).forEach((t) -> t.start());
        latch.await();
        System.out.println(count);
    }
}

输出

97512

分析

image.png

正常来说,100 * 1000 应该等于100000,可是输出的却不是100000,也就是说即使加不加上volatile也不能保证原子性问题,因为++count本身就不是一个原子操作,而volatile的作用的位置并不是这里,CAS的作用点却在这里。下面分析一下++count操作

4个步骤,以及对应图中的操作如下

2: getfield      #2                  // Field count:I
5: iconst_1
6: iadd
7: putfield      #2                  // Field count:I

步骤1:首先读取count的值,放入栈顶(读入到工作内存)
步骤2:将1放入栈顶
步骤3:将栈顶中的1和count进行相加,得到的结果放入栈顶(在传到CPU进行相加,然后结果返回工作内存)
步骤4:将栈顶元素赋值到count,并pop出(放回主内存)

下面分析一下流程:
每个线程都会进行++count操作,当count变量加上volatile时,只是保证count的可见性,即当线程1将count从0变成了1,结果在自己的本地内存,当它要往主内存中写回去的时候,由之前的理论可知,会对该变量count进行上锁lock,直到写完了再进行解锁,其他线程才可以访问该变量count,同时其他的CPU会通过CPU总线嗅探,嗅探到该变量count的数据变化,就会将工作内存的该变量count对应的缓存行设置为无效状态,注意,这里很关键,也就是说,可能会存在某些线程已经对该变量count的值从0变成1了,然后将count对应的缓存行设置为无效状态,也就是++count这个操作在这个线程中相当于没有操作过,并不会知道了该变量是无效状态的然后又回去重新累加过,要用CAS才有这样的效果(知道该变量无效又回去重新计算),因此可以得知,加了volatile无法保证原子性

延伸一个内容:CAS操作是作用在工作内存中的,当使用CAS操作时,当CPU计算了结果NewValue后准备往工作内存中写时,比较本来在工作内存的变量值E 和 现在在工作内存的变量值V,如果E == V,表示其他线程没有更改过该值,CPU并没有使之无效,则修改工作内存的变量V = NewValue;如果E != V,表示其他线程更改过该值,CPU并使之无效了,则回去重新计算 (CAS的原理在CAS篇章详细说明)

思考题

当该变量加了volatile时,我们可以知道,线程A准备往主内存写的时候,会进行该变量进行加锁,而此时线程B也想往主内存写结果,可这时已经被锁住了,线程B不会在那闲等,而是会继续执行其他的指令,这就是CPU会对指令进行重排序

6、volatile使用内存屏障防止指令重排序与实现变量的可见性

本质上还是根据这章来向上层扩展的

相关内容看volatile的章节https://www.jianshu.com/p/0ee1f17639db
和这一章紧密相关

7、happen-before规则(重排序一定要遵守的规则)

在Java的内存模型中,允许编译器和处理器对指令进行重排序,在单线程的情况下,重排序并不会引起什么问题,但是在多线程的情况下,重排序会影响到程序的正确运行,Java提供了三种保证有序性的方式,具体如下。
1、使用volatile关键字来保证有序性。
2、使用synchronized关键字来保证有序性。
3、使用显示锁Lock来保证有序性。
后两者采用了同步的机制,同步代码在执行的时候与在单线程情况下一样自然能够保证有序性(最终结果的顺序性)。
此外,Java的内存模型具备一些天生的有序规则不需要任何同步手段就能保证有序性,这个规则被称为Happen-before规则。如果两个操作的执行次序无法从happen-before原则推导出来,那么它们就无法保证有序性,也就是说虚拟机或者处理器可以随意对它们进行重排序处理。

上一篇 下一篇

猜你喜欢

热点阅读