JAVA多线程(五) volatile原理分析

volatile: 能够保证线程可见性,当一个线程修改主内存共享变量能够保证对外一个线程可见性,但是他不能保证共享变量的原子性问题。

1. volatite特性

1.1 可见性

能够保证线程可见性,当一个线程修改共享变量时,能够保证对另外一个线程可见性,

1.2 顺序性

程序执行程序按照代码的先后顺序执行。

1.3 防止指令重排序

 通过插入内存屏障在cpu层面防止乱序执行

2. volatile可见性

public class VolatileTest extends Thread {

    /**
     * volatile关键字底层通过 汇编 lock指令前缀 强制修改值,并立即刷新到主内存中,另外一个线程可以马上看到刷新的主内存数据
     */
    private static volatile boolean FLAG = true;

    @Override
    public void run() {
        while (FLAG){
            try {
                TimeUnit.MILLISECONDS.sleep(300);
                System.out.println("==== test volatile ====");
            } catch (InterruptedException ignore) { }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new VolatileTest().start();
        TimeUnit.SECONDS.sleep(1);
        FLAG = false;
    }
}

3. CPU多核硬件架构剖析

CPU的运行速度非常快,而对磁盘的读写IO速度却很慢,为了解决这个问题,有了内存的诞生;而CPU的速度与内存的读写速度之比仍然有着100 : 1的差距,为了解决这个问题,CPU又在内存与CPU之间建立了多级别缓存:寄存器、L1、L2、L3三级缓存。

4.产生可见性的原因

因为我们CPU读取主内存共享变量的数据时候,效率是非常低,所以对每个CPU设置对应的高速缓存 L1、L2、L3  缓存我们共享变量主内存中的副本。

相当于每个CPU对应共享变量的副本,副本与副本之间可能会存在一个数据不一致性的问题。比如线程线程B修改的某个副本值,线程A的副本可能不可见,导致可见性问题。

CPU的摩尔定律

https://baike.baidu.com/item/%E6%91%A9%E5%B0%94%E5%AE%9A%E5%BE%8B/350634?fr=aladdin

基本每隔18个月,可能CPU的性能会提高一倍。

5.JMM内存模型

Java内存模型定义的是一种抽象的概念,定义屏蔽java程序对不同的操作系统的内存访问差异。

主内存:存放我们共享变量的数据

工作内存:每个CPU对共享变量(主内存)的副本。堆+方法区

6.JMM八大同步规范

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

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

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

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

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

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

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

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

7. volatile汇编lock指令前缀

  1. 将当前处理器缓存行数据立刻写入主内存中。
  2. 写的操作会触发总线嗅探机制,同步更新主内存的值。

通过Idea工具查看java汇编指令

1. jdk安装包\jre\bin\server 放入 hsdis-amd64.dll

2. idea 配置 VM options, 最后一个参数 *xxxxx.* 就是一个我们的需要查看汇编的class类

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*

3.  查看结果 ,会发现在 volatile 关键字 修饰的变量,在写操作时,对应的汇编指令,都有一个lock指令前缀

 

8. volatile的底层实现原理

通过汇编lock前缀指令触发底层锁的机制,锁的机制两种:总线锁/MESI缓存一致性协议,主要帮助我们解决多个不同cpu之间缓存之间数据同步的问题

8.1 总线锁

当一个cpu(线程)访问到我们主内存中的数据时候,往总线总发出一个Lock锁的信号,其他的线程不能够对该主内存做任何操作,变为阻塞状态。该模式,存在非常大的缺陷,就是将并行的程序,变为串行,没有真正发挥出cpu多核的好处。

8.2 MESI协议

1.M 修改 (Modified) 这行数据有效,数据被修改了,和主内存中的数据不一致,数据只存在于本Cache中。

2.E 独享、互斥 (Exclusive) 这行数据有效,数据和主内存中的数据一致,数据只存在于本Cache中。

3.S 共享 (Shared) 这行数据有效,数据和主内存中的数据一致,数据存在于很多Cache中。

4.I 无效 (Invalid) 这行数据无效。

 

E: 独享:当只有一个cpu线程的情况下,cpu副本数据与主内存数据如果,保持一致的情况下,则该cpu状态为E状态 独享。

S: 共享:在多个cpu线程的情况了下,每个cpu副本之间数据如果保持一致的情况下,则当前cpu状态为S

M: 如果当前cpu副本数据如果与主内存中的数据不一致的情况下,则当前cpu状态为M

I: 总线嗅探机制发现 状态为m的情况下,则会将该cpu改为i状态 无效

 

如果状态是M的情况下,则使用嗅探机制通知其他的CPU工作内存副本状态为I无效状态,则刷新主内存数据到本地中,从而多核cpu数据的一致性。

该cpu缓存主动获取主内存的数据同步更新。

 

总线:维护解决cpu高速缓存副本数据之间一致性问题。

9.volatile不能保证原子性原因

public class VolatileTest extends Thread {

    private static volatile int count = 0;

    public static void add() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        ArrayList<Thread> threads = new ArrayList<>();
       for (int i= 0;i<100;i++){
         Thread test =  new Thread(() -> {
             for (int k=0;k<1000;k++){
                 add();
             }
           });
           threads.add(test);
           test.start();
       }
        threads.forEach(v -> {
           try {
               v.join();
           } catch (InterruptedException ignore) { }
       });
        System.out.println("<><><><> count: "+ count);
    }
}

volatile为了能够保证数据的可见性,但是不能够保证原子性,及时的将工作内存的数据刷新主内存中,导致其他的工作内存的数据变为无效状态,其他工作内存做的count++操作等于就是无效丢失了,这是为什么我们加上Volatile count结果在小于100000以内。

10. volatile存在的伪共享的问题

CPU会以缓存行的形式读取主内存中数据,缓存行的大小为2的幂次数字节,一般的情况下是为64个字节。如果该变量共享到同一个缓存行,就会影响到整理性能。

例如:线程1修改了long类型变量A,long类型定义变量占用8个字节,在由于缓存一致性协议,线程2的变量A副本会失效,线程2在读取主内存中的数据的时候,以缓存行的形式读取,无意间将主内存中的共享变量B也读取到内存中,而化主内存中的变量B没有发生变化。

 

解决缓存行解为共享问题 ,使用缓存行填充方案避免为共享

@sun.misc.Contended

可以直接在类上加上该注解@sun.misc.Contended,启动的时候需要加上该参数-XX:-RestrictContended,该方案在JDK8有效,JDK12中被优化掉了

例如 ConcurrentHashMap中的CounterCell,就是使用了缓存行填充方案避免为共享

11. JMM中的重排序及内存屏障

public class ReorderThread {
    private static int a,b,x,y;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x == 0 & y == 0) {
                break;
            }
        }
    }
}

当我们的CPU写入缓存的时候发现缓存区正在被其他cpu站有的情况下,为了能够提高CPU处理的性能可能将后面的读缓存命令优先执行。注意:不是随便重排序,需要遵循as-ifserial语义。

as-ifserial:不管怎么重排序(编译器和处理器为了提高并行的效率)单线程程序执行结果不会发生改变的。也就是我们编译器与处理器不会对存在数据依赖的关系操作做重排序。

CPU指令重排序优化的过程存在问题

as-ifserial 单线程程序执行结果不会发生改变的,但是在多核多线程的情况下,指令逻辑无法分辨因果关系,可能会存在一个乱序中心问题,导致程序执行结果错误。

如同上面图,所示会出现会有机会两个线程中,A线程执行顺序1逻辑,而B线程执行顺序2逻辑。

11.1 内存屏障解决重排序

处理器提供了两个内存屏蔽指令,解决以上存在的问题

1.写内存屏障:在指令后插入Stroe Barrier ,能够让写入缓存中的最新数据更新写入主内存中,让其他线程可见。这种强制写入主内存,这种现实调用CPU就不会因为性能的考虑对指令重排序。

2.读内存屏障:在指令前插入load Barrier ,可以让告诉缓存中的数据失效,强制从新主内存加载数据强制读取主内存,让CPU缓存与主内存保持一致,避免缓存导致的一致性问题。

11.2 手动插入内存屏障 

public class ReorderThread {
    private static int a,b,x,y;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    // 添加写屏障
                    ReorderThread.getUnsafe().storeFence();
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    // 添加写屏障
                    ReorderThread.getUnsafe().storeFence();
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次(" + x + "," + y + ")");
            if (x == 0 & y == 0) {
                break;
            }
        }
    }

    /**
     * 通过Unsafe 插入内存屏障
     * @return
     */
    public static Unsafe getUnsafe(){
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            return (Unsafe)theUnsafe.get(null);
        } catch (Exception e) {
            return null;
        }

    }
}

12. 双重检验锁为什么需要加上volatile

public class Singleton双重检验锁3 {
    private volatile static Singleton双重检验锁3 singleton双重检验锁3;


    //构造函数私有化
    private Singleton双重检验锁3() {
    }


    public  static Singleton双重检验锁3 getSingleton双重检验锁3() {

        if(singleton双重检验锁3 == null){
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (Singleton双重检验锁3.class){
                if(singleton双重检验锁3 == null){
                    singleton双重检验锁3 = new Singleton双重检验锁3();
                }
            }
        }
        return singleton双重检验锁3;
    }

    public static void main(String[] args) {
 
        for (int i = 0; i < 20; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Singleton双重检验锁3 双重检验锁 = Singleton双重检验锁3.getSingleton双重检验锁3();

                    System.out.println(Thread.currentThread().getName() + 双重检验锁);
                }
            }).start();

        }
    }

}

注意:在声明 private volatile static Singleton双重检验锁3 singleton双重检验锁3 中 ,如果去掉volatile关键字,我们在new操作存在重排序的问题。

getSingleton双重检验锁3() 获取对象过程精简为3步如下

1. 分配对象的内存空间
2. 调用构造函数初始化
3. 将对象复制给变量

如果没有volatile关键字修饰  singleton双重检验锁3  变量,则第2步和第3步流程存在重排序问题,有可能先执行将对象复制给变量,再执行调用构造函数初始化,导致另外一个线程获取到该对象不为空,但是该改造函数没有初始化的半初始化对象,会导致报错 。就是另外一个线程拿到的是一个不完整的对象。

13. synchronized 与volatile存在的区别

1. volatile保证线程可见性,当工作内存中副本数据无效之后,主动读取主内存中数据

2. volatile可以禁止重排序的问题,底层是内存屏障实现。

3.volatile不会导致线程阻塞,不能够保证线程安全问题,synchronized 会导致线程阻塞能够保证线程安全问题。

 

参考来源:

  CPU缓存一致性协议MESI https://www.cnblogs.com/yanlong300/p/8986041.html

  CPU多级缓存与指令重排  https://blog.csdn.net/weixin_44129784/article/details/107135733?fps=1&locationNum=2

posted @ 2020-07-22 21:26  Brian_Huang  阅读(793)  评论(0编辑  收藏  举报