阅读 139

JVM 面试题【高级】

DCL 双判断单例为啥要加 vilatile 关键字

这个问题我从头写一遍,从单例进化的过程开始

1. 最早我们这样写单例

public class Max {

    public static Max instance;

    public static Max getInstance() {
        if (instance == null) {
        	instance = new Max();
        }
        return instance;
    }
}
复制代码

问题:

  • 没法处理多线程场景

2. 然后我们这样写单例

这次我们加上 sync 这个关键字

public class Max {

    public static Max instance;

    public synchronized static Max getInstance() {
        if (instance == null) {
        	instance = new Max();
        }
        return instance;
    }
}
复制代码

问题:

  • 锁粒度太粗,要是这个方法太耗时呢,要是这个方法太长呢,被锁的时间就会过长,锁优化要求我们仅仅在有必要的地方加上多线程锁

3. 然后我们进行锁细化优化

public class Max {

    public static Max instance;

    public static Max getInstance() {

        if (instance == null){
            synchronized (Max.class){
                instance = new Max();
            }
           
        }
        return instance;
    }
}
复制代码

问题:

  • 多线程一样有问题,别的线程被卡在 synchronized 这里,一旦线程获得了锁,机会重复创建 Max 单例,这样是不行的

4. 给锁优化代码加上非空双判断

public class Max {

    public static Max instance;

    public static Max getInstance() {

        if (instance == null) {
            synchronized (Max.class) {
                if (instance == null) {
                    instance = new Max();
                }
            }
        }
        return instance;
    }
}
复制代码

问题:

  • 一般场景下是没问题了,但是在高并发条件下一定会碰到 Max 半初始化的问题,这就是 JVM 乱序执行带来的

5. JVM 乱序执行带来的问题

我们先回顾下对象创建的过程:

  1. 判断对象对应的类是否被加载、链接、初始化了 也就是new指令会去方法区常量池中定位到类的符号引用,检查这个符号引用是否被加载了
  2. 给对象分配内存空间,计算对象需要占用多大的内存空间
    • 堆内存规整:指针碰撞
    • 堆内存不规整:JVM要维护一个空闲列表,记录哪些内存块可用的,哪块是用过的,碎片化的问题,对应的是标记清除算法,伊甸园区回收完垃圾之后不做规整整理就这样,比如早期的CMS垃圾回收器
  3. 并发处理 堆内存是共享区域,就会有并发问题
    • CAS 失败重试,区域加锁保证原子性
    • 堆内存给每个线程预先分配一块TLAB空间,也就是针对每个线程预先给一块专属的内存空间,以防止并发问题,问题是这块空间不大,所以还需要上面CAS的配合,可以通过-XX:UseTLAB来设置
  4. 初始化分配到的内存空间(默认初始化) 也就是给属性赋一个初始的默认值,即便该属性在代码里设置值了,在这一步也会先给一个默认值,在之后的步奏里再赋指定值
  5. 设置对象头 对象头保存有对象的hashcode,GC信息,锁信息,所属类(方法区元数据地址)
  6. 显示初始化,执行init方法并初始化 具体可以看下一个问题的解答,init就是类的构造器,包括属性的赋值操作

我们再来看看字节码

Max instance = new Max();
复制代码
0: new #2 // class java/lang/String
3: dup
4: invokespecial #3 // Method java/lang/String."<init>":()V
7: astore_1
复制代码

new 关键字先是在堆内存中开辟一块空间(会半初始化);然后把这块内存的地址放到操作数栈栈顶;然后进行初始化操作;最后把操作数栈栈顶也就是内存开辟的内存地址赋给局部变量表里面的对象指针

由于 JVM 的乱序执行,执行的顺序可能是这样的,0->3->7->4,这样就会出问题了

此时正好执行到7这里,CPU时间用完,下一个时间片没抢到,栈帧会把数据写回内存,此时内存中的 instance 对象的确是指向了内存中的一块区域,但是 instance 并没有执行初始化方法,使用起来肯定会出问题的

此时其他线程走到这一看 instance 不是空,那就继续下面的执行,因为 instance 没初始化就会出现一些列莫名其妙的问题,这个问题根据实际经验,百万次会出现一次,基本调试不出来,但是影响很恶劣,属于无解的难题的那种,怎么找都找不到原因的那种

6. 加上 volatile 就行了

public class Max {

    public static volatile Max instance;

    public static Max getInstance() {

        if (instance == null) {
            synchronized (Max.class) {
                if (instance == null) {
                    instance = new Max();
                }
            }

        }
        return instance;
    }
}
复制代码

加上了 volatile 关键字,编译器就不会对 instance = new Max() 这一行代码进行冲排序,该怎么执行就怎么执行

volatile 特性:

  • JVM 通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层 体系架构,将这些内存屏障替换成具体的 CPU 指令
  • 对于编译器而言,内存屏障将限制它所能做的重排序优化
  • 对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作前后各插入一些内存

volatile 指令冲排序的经典例子

可以接上面内存继续分析

public class ThreadTest {
    int a = 0;
    int b = 0;
    int x = 1;
    int y = 1;
 
    public boolean test() throws InterruptedException {
        a = b = 0;
        x = y = 1;
        CyclicBarrier cy = new CyclicBarrier(2);
        Thread t1 = new Thread(() -> {
             a = 1;
        	 x = b;
            
        });
        Thread t2 = new Thread(() -> {
             b = 1;
             y = a;
        });
 
        t1.start();
        t2.start();
 
        t1.join();
        t2.join();
 
        if (x == 0 && y == 0) {
            return false;
        } else {
            return true;
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        for(int i=0; i<=10; i++){
            ThreadTest tt = new ThreadTest();
            boolean b = tt.test();
            if(!b){
                System.out.println(i);
            }
        }
    }
}
复制代码

a = 1;x = b; 这2行代码之间是没有关系的,由于指令冲排序,x = b 有可能就在 a = 1 前面执行,就有可能 x=0,y=0,这种情况多跑机会绝对会出现,面试看到这道题不要慌,问的就是指令冲排序的事