《五》单例模式双重检查

目录

      • new T的过程
      • 单例模式双重检查
        • CPU的乱序执行
        • 单例
        • DCL单例需不需要加volatile?
        • JMM Java内存模型
        • 多线程一致性的硬件支持
        • MESI 协议
        • 缓存行伪共享


执行上述代码显示以下五条指令

  • new #2 : 与C++ 里面new对象没有任何区别,就是申请一块内存地址,存储new 出来的class对象,对象里面有成员变量m,m的值刚new出来值为0
  • dup : dup指令为复制操作数栈顶值,并将其压入栈顶,也就是说此时操作数栈上有连续相同的两个对象地址
  • invokespecial #3 : 特殊调用 T的初始化方法(默认的构造方法,这个时候m的值变为8)在一个对象初始化的过程当中,中间有一个状态(半初始化状态)成员变量赋默认值
  • astore_1 : 这是我们new出来的对象与小t建立关联关系
  • return :最后由return指令结束方法

new T的过程

  1. 一开始在初始化阶段会new T为NULL,new出来一个内存,里面的成员变量还没有赋值,这是个m为0
  2. 申请完内存赋值完默认值之后,下一步才开始进行调用构造方法,调构造方法才开始赋值为初始值这个初始值为8

总结:

  • load -> 默认值 -> 初始值
  • new -> 申请内存 ->默认值 -> 申请值

单例模式双重检查

当volatile开始修饰一个变量的时候,代表

  • 这个变量线程间可见
  • 禁止重排序
CPU的乱序执行


CPU速度特别快,他比内存快100个数量级,比硬盘快100万。

指令1与指令2两者没有依赖关系,CPU为了提高效率,可能原先执行指令1然后在执行指令2变为先执行指令2在执行指令1,这个就是执行的重排序
volatile禁止指令重排序

单例
  • new一个对象,并且他的构造方法是私有的,他有个public方法,外部调用不能new这个对象,只能调用getInstance方法,返回我们new的对象,这时候调用方法返回的对象都是同一个,同一个引用,下面就是饿汉示单例。
    《五》单例模式双重检查_第1张图片

  • 程序改造,能不能让他在使用的时候在开始创建对象,不用提前创建好,防止浪费资源,先判断INSTANCE是否为空,不为空直接拿来用。这个方法不能保证线程安全,在多线程的情况会出现上一个线程还在睡眠状态,下一个线程又开始进行执行创建对象,就会导致所以的对象不是同一个对象。
    《五》单例模式双重检查_第2张图片

  • 解决办法,线程同步机制,上锁,线程一致性,如果在getInstance里面还有业务逻辑时候锁的粒度太粗了,所以要把锁的粒度变细
    《五》单例模式双重检查_第3张图片

  • 解决办法:把锁的粒度变细,这种方法还是有问题的,第一个线程拿到锁然后线程睡眠了,这时候第二个线程再次判断为null,然后往下面执行创建对象,这时候第二个线程拿到锁进行执行创建对象,会导致创建的对象不是同一个。
    《五》单例模式双重检查_第4张图片

  • 最终诞生了DCL Double Check Loading
    先判断是否为空,为空进行上锁,再次判断是否为空,依然为空就说明没有一个线程改过,没有线程改动过就进行创建对象
    类似于CAS(Compare And Set “比较并交换”)现在有个数据为0一堆线程对这条数据进行递增,如何保证数据的一致性。

    1. 一种方法是递增的上锁。
    2. 第二种方法先把0读到自己的线程内存里面来读完之后,改为1。然后再写回去把0写成1
      • (写法是 先判断你是不是依然为0,如果依然为0就说明再改线程进行中没有其他线程改动过,就可以直接改为1。如果当判断这个数已经被线程改为8了,这时候就把8拿过来判断是否为8,如果是就改为9,这就是自旋锁)
        《五》单例模式双重检查_第5张图片
      • 这种自旋锁还存在ABA的问题
        你把0改为1的写法中判断依然是0,这时候你以为没有被其他线程改动过这个0,其实还有一种情况就是再你把0改为1的过程中另外一个线程已经把0改为3,然后再有第三个线程把这个3改回为0。再第一个线程看来就是没有其他线程改动过。这种使用版本号进行解决,没修改一次加一个版本号。
DCL单例需不需要加volatile?
  1. 先检查INSTANCE是否为空,如果为空说明还没有任何一个线程给他初始化
  2. 如果为空,上锁,我来对他进行初始化
  3. 再一次进行检查,(因为在加锁的期间有可能被别的线程进行占用)
  4. 进行第二次检查,确保刚才没有线程进行初始化
  5. 我们对其进行初始化

Mgr06需不需要加volatile?
在这里插入图片描述
《五》单例模式双重检查_第6张图片
结论是要的。
不加上volatile会发生一下情况:
《五》单例模式双重检查_第7张图片

  • 检查(先判断t是否为空),当第一个线程加上锁
  • 上锁完,new这个对象
  • 但是new这个对象刚new了一半的时候,m的值为0赋默认值
  • 正好再这个时间发生了指令重排
    《五》单例模式双重检查_第8张图片
  • 这时候就先建立与class的关联关系(astore_1是把这个引用值赋值到小t)后进行调用构造方法(invokespecial 是调用构造方法 把内存中的m原来为0现在变为8),先建立关联关系的时候t不为空,它指向new出来一半的初始化对象(半初始化状态)
  • 当初始化到一半的时候另外一个线程来了
  • 但是现在的INSTANCE已经是个半初始化的状态,不为空
  • 那第二个现在就直接用初始值了,不用默认值
  • 就导致数据不一致问题

解决问题的关键加volatile 指令重排
因为volatile禁止指令重排序

总结:
单例模式双重检测为什么要加volatile?
因为指令可能重排,在创建对象的时候应该先调用构造方法在进行引用赋值astore_1,但是由于指令有可能会重排,这两个先后顺序会不一样。

JMM Java内存模型

硬件层的并发优化基础知识
《五》单例模式双重检查_第9张图片
CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。
CPU速度特别快,他比内存快100个数量级,比硬盘快100万。

  1. 假如有个数据要被读入到CPU里面去执行了,他首先被loading到内存里面
  2. 而CPU去查找,首先去高速缓存里面去查找,找到就拿来用
  3. 找不到就往下一层去找,如果有就往上一级load,下次寻找速度就快了

这里会产生一个问题 :数据不一致问题
假如有个数据在我们的main memory L4主存当中,这个数他会被load到L3这个缓存当中,L2与L1这个两级缓存在一个CPU的内部,就会产生一种情况:主存里面的数据会被我们load到不同的CPU内部
例如CPU1把X变为1,CPU2把x变为2就会产生数据不一致的问题

多线程一致性的硬件支持

不同的线程或者CPU都是通过总线去访问主存里面的数据,在总线加把锁,当其中一个CPU去访问主存里面的数的时候,其他CPU不允许访问,这样效率偏低,所以总线锁是老的CPU在用
新的CPU用各种各样一致性协议

  1. MSI
  2. MESI 英特尔使用的
  3. MOSI
  4. Synapse
  5. Firefly
  6. Dragn
MESI 协议

简要介绍:
它给每一个缓存里面做了一个标记 四种状态标记:

  • Modified 与主存做对比,如果更改过就标记
  • Exclusive 主存里面的这个内容为我所独享就加标记
  • Shared 如果一个内容我读的时候别人也在读的时候就标记为分享
  • Invalid 如果一个内容在我读的时候被别的CPU改过,就说明我读的数无效了

通过这个协议来让各个CPU之间的缓存保持一致,还是无法解决总线锁,总线锁效率比较低,MESI缓存锁效率比较高,但是有一些无法被缓存的数据、数据特别大、跨越多个缓存的数据还是需要总线锁进行支持,现在CPU的底层的一致性,是通过缓存一致性协议,缓存锁(MESI),总线锁一起执行的

缓存行伪共享

缓存行指的是当我们要把内存里面的数据放到CPU自己的内部缓存里面去,他不会单独把值放到CPU缓存里面
例如 一个值12,它会把12这个值以及后面值一起放到缓存里面,读一个内容把一块的内容全读进去,这个一块内存称为缓存行,多数为64个字节。

  • 这样会产生一个问题:如果X,Y在同一个缓存行里面,第一个CPU只用X,第二个CPU只用Y,现在CPU都会把XY加入到缓存里面,现在如果X改了需要通知其他CPU整个缓存行被改过了,这个缓存行已经是Invalid状态,需要重新读一遍缓存行,现在第二个CPU已经改完后,它改动了Y,第一个CPU不需要去读Y,但是第一个CPU还需要去读一下缓存行,两个互相无关的值变来变去的时候,内部会产生缓存行互相影响问题
  • 伪共享:位于统一缓存行的两个不同的数据,被两个不同的CPU锁定,产生相互的影响伪共享问题
public class FalseShareTest implements Runnable {
    // 并发线程数:4
    public static int NUM_THREADS = 4;
    // 迭代次数:100万次
    public final static long ITERATIONS = 1_000_000L;
    // 数组索引数
    private final int arrayIndex;
    // VolatileLong对象数组
    private static VolatileLong[] longs;
    // 花费总时长
    public static long SUM_TIME = 0l;
 
    public FalseShareTest(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }
 
    private static void runTest() throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseShareTest(i));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }
    
    // 对对象数组进行修改
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
    }
 
    public final static class VolatileLong {
        // 加volatile让变量的修改对所有线程可见
        public volatile long value = 0L;
    }
 
    public static void main(final String[] args) throws Exception {
        // 执行10次
        for (int j = 0; j < 10; j++) {
            // 构建实验对象数组
            longs = new VolatileLong[NUM_THREADS];
            for (int i = 0; i < longs.length; i++) {
                longs[i] = new VolatileLong();
            }
            // 开始时间戳
            final long start = System.currentTimeMillis();
            // 运行测试程序
            runTest();
            // 结束时间戳
            final long end = System.currentTimeMillis();
            SUM_TIME += end - start;
        }
        System.out.println("总耗时:" + SUM_TIME);
    }
}

解决办法:
想办法让这个两个对象不为与一个缓存行,位于两个不同的行 (缓存行对齐与填充)

  • 第一种办法,由于一个缓存行可以存储64个字节,也就是8个long型变量,那我就前后各安插7各long型变量,让字段value,把缓存行填充满。这也是高性能队列Disruptoer的解决方式。
public final static class VolatileLong {
 
    // 填充
    public long p1, p2, p3, p4, p5;
 
    // 加volatile让变量的修改对所有线程可见
    public volatile long value = 0L;
 
    // 填充
    public long p6, p7, p8, p9, p10;
}
  • 使用JDK8新增的@Contented,使用@Contented注解后会增加128字节的padding,需要启动时增加-XX:-RestrictContented选项才能生效。

你可能感兴趣的:(#,jvm,jvm,java,开发语言)