1 CPU中的三级缓存及可见性问题
  1.1 简介
  1.2 缓存行Cacheline
  1.3 可见性问题-缓存一致性协议
2 JAVA中的有序性问题
  2.1 指令重排简介
  2.2 as-if-serial语义
    2.2.1 数据依赖
    2.2.2 控制依赖
  2.3 指令重排示例
    2.3.1 代码
    2.3.2 代码分析
    2.3.3 执行结果
3 this逸出问题
  3.1 一个对象的创建指令
  3.2 实例代码
4 DCL(Double Check Lock)
  4.1 单例模式Double Check Lock
  4.2 更安全的单例-静态内部类单例模式
5  JAVA怎么解决可见性和有序性的问题
  5.1 简介
  5.2 内存屏障(Memory Barrier)
  5.3 JVM屏障类型
  5.4Volatile修饰的变量
  5.5 屏障的底层实现

 

JAVA虚拟机22-原子性、可见性与有序性、先行发生原则:https://www.cnblogs.com/jthr/p/15780760.html

 

1 CPU中的三级缓存及可见性问题

1.1 简介

  ALU访问Registers里面的数据的速度是ALU访问内存数据的100倍

  为了提高访问效率,在CPU和内存之间有了多级缓存,不用每次都去访问内存里面的数据,缓存里面有直接访问缓存就可以了

  现在计算机大多是三级缓存,如下图

计算机上有两个双核CPU,L1,L2,L3就是三级缓存,L1和L2在核内,L3在核外

当核1需要使用内存里面的一个x,会先看L1有没有,没有就看L2,再没有就看L3,还是没有,去内存中取,存入到L3,L2,L1中。对x进行修改了,只需要修改离它最近的一个缓存,其他几个缓存中的值不修改

 

 

 

 

 

1.2 缓存行Cacheline

而且它去内存中拿数据不是一个一个的拿,是一块一块的拿放入缓存中(提高缓存命中率)。现在计算机中,会取一个Cacheline(缓存行)的数据,缓存行大小默认是64kb

 

1.3 可见性问题-缓存一致性协议

  如下图,现在有一个缓存行的数据,里面有x和y。左侧CPU需要用到x,右侧CPU需要用到y,它们都会把这个缓存行读取到自己的缓存里面。那么当左侧对x修改或者右侧对y修改时,是需要通知对方这个数据修改了的,因为它们使用的同一块缓存行数据数据。这个通知机制叫做缓存一致性协议,它是硬件级别的协议,软件控制不了的,所有的CPU都有这种机制

 

2 JAVA中的有序性问题

2.1 指令重排简介

  在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:

    1)编译器优化的重排序。编译器在不改变单线程程序语义(单线程执行结果一致)的前提下,可以重新安排语句的执行顺序。

    2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

    3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

  1属于编译器重排序,2和3属于处理器重排序。从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

 

2.2 as-if-serial语义

  不管指令怎么重排序,在单线程下执行结果不能被改变。不管是编译器级别还是处理器级别的重排序都必须遵循as-if-serial语义。

  为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。但是as-if-serial规则允许对有控制依赖关系的指令做重排序,因为在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果,但是多线程下确有可能会改变结果。

 

2.2.1 数据依赖

int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3

 上述代码,ab不存在依赖关系,所以1、2可以进行重排序;c依赖 ab ,所以3必须在1、2的后面执行

 

2.2.2 控制依赖

public void use(boolean flag, int a, int b) {
    if (flag) { // 1
        int i = a * b; // 2
    }
}

flagi存在控制依赖关系。当指令重排序后,2这一步会将结果值写入重排序缓冲(Reorder Buffer,ROB)的硬件缓存中,当判断为true时,再把结果值写入变量i中

 

2.3 指令重排示例
2.3.1 代码
public class LuanXuTest {

    public static int x,y,a,b;
    public static void main(String[] args) throws InterruptedException {

        for (long i = 0;i < Long.MAX_VALUE;i++){
            CountDownLatch latch = new CountDownLatch(2);
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(()->{
                a = 1;
                x = b;
                latch.countDown();
            });

            Thread t2 =new Thread(()->{
                b = 1;
                y = a;
                latch.countDown();
            });

            t1.start();
            t2.start();
            latch.await();
            if(x == 0 && y == 0){
                System.out.println("" + i + "次执行");
                break;
            }
        }
    }
}

 

2.3.2 代码分析

  如果指令不重排,按照顺序执行,那么只会有三种执行结果

1)x=0,y=1

2)x=1,y=2

3)x=1,y=0

 

 2.3.3 执行结果

第1603次执行

发现出现了x=0,y=0的情况

只有在以下两种指令重排的情况下才会出现x=0,y=0。说明指令确实重排了

 

3 this逸出问题

  this逃逸是指在构造函数返回之前其他线程就持有该对象的引用。调用尚未构造完成的对象的方法可能引起奇怪的问题

 

3.1 一个对象的创建指令

public class T {

    public static void main(String[] args) {
        Object a = new Object();
    }

}

我们通过Jclasslib(直接在idea插件里面去搜索安装即可)插件来查看它的字指令

 

从指令来看,Object a = new Object()这一个操作不是原子性的,它主要分为三步(3dup指令这里不管)

1)new 分配内存创建对象

2)invokespecial 初始化(包含赋值操作)

3)astore_1关联,把User关联到a

由于这一个操作分为几步来走,经过指令重排,

 

如果发生指令重排,就可能发生下面情况

 

 

3.2 实例代码

  在LuanxuThisTest的构造方法中,启动了一个线程去获取当前对象的number,就可能存在获取到的number是默认值0而不是8。

  因为对象LuanxuThisTest的实例创建分为三步,第一步分配内存创建了对象,变量此时是默认值,而还没有完成初始化赋值,但是由于在构造方法中启动了一个线程,这个线程也在执行,它可能在创建LuanxuThisTest的第二步完成前就去获取到了LuanxuThisTes对象,而此时的LuanxuThisTest对象只是个半成品,它的number属性还是默认值0

  当然,下面代码执行很小几率才能获取到0

public class LuanxuThisTest {

    private int number = 8;

    public LuanxuThisTest(){
        new  Thread(()->{
            System.out.println(this.number);
        }).start();
    }

    public static void main(String[] args) throws IOException {
        while (true){
            new LuanxuThisTest();
        }

    }
}

 


4 DCL(Double Check Lock)

4.1 单例模式Double Check Lock

 下面单例模式使用就是Double Check Lock

public class Girlfiriend extends Friend{
 private int number = 10;
private static final Girlfiriend gf; private Girlfiriend (){ } public static Girlfiriend getGirlfriend(){ if(gf==null){ synchronized(Girlfriend.class){ if(gf==null){ gf= new Girlfriend(); } } } return gf;   } }
  在多线程情况下,当第一个线程在new Girlfriend()的过程中,还没有完成初始化赋值,但是已经完成关联了(发生了指令重排),另一个线程执行就第一个if(gf==null)的时候,由于已经完成关联,。所以这里if判断为false,会把没有构造完全的对象返回给第二个线程。所以在这里,可能导致返回不正确的结果
  补充一下:synchronized可以保证可见性和原子性,但是不能保证有序性。也就是说synchronized里面的也可能发生指令重排
  可以使用volatile修饰这个变量来保证有序性。
 
4.2 更安全的单例-静态内部类单例模式
public class Girlfiriend extends Friend{
 private int number = 10;
private Girlfiriend (){ } public static Girlfiriend getGirlfriend(){ return GirlfriendMother.gf; } private static class GirlfriendMother{ private static final Girlfiriend gf= new Girlfiriend (); } }
  第一次加载Girlfriend的时候,gf不会被初始化,(java类只有被调用的时候才会初始化)。这种方式不仅能够确保线程的安全,也能够保证单例对象的唯一性,同时也延迟了单例的实例化。
 
5  JAVA怎么解决可见性和有序性的问题
5.1 简介
  想要让两条指令不重排,只需要在它们中间加一个屏障。
 

5.2 内存屏障(Memory Barrier)

  内存屏障又称内存栅栏,是一个CPU指令,它的作用有两个:

  1)保证特定操作的顺序

  2)保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

  由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。

 

5.3 JVM屏障类型

 

LoadLoad:上面一个读操作,中间LoadLoad屏障,下面一个读操作,那么这两个操作不能换顺序

StoreStore:上面一个写操作,中间StoreStore屏障,下面一个写操作,那么这两个操作不能换顺序

LoadStore:上面一个读操作,中间LoadStore屏障,下面一个写操作,那么这两个操作不能换顺序

StoreLoad:上面一个写操作,中间StoreLoad屏障,下面一个读操作,那么这两个操作不能换顺序

 

5.4 Volatile修饰的变量

  Volatile保证了可见性和有序性

  可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。volatile变量做到了这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点

  在对Volatile修饰的变量的写操作前面会加一个StoreStore屏障,后面加一个StoreLoad屏障

  在对Volatile修饰的变量的读操作后面会加一个LoadLoad屏障和一个LoadStore屏障

  这样子保证了有序性

 

5.5 屏障的底层实现

  是通过lock指令实现的