并发编程:java内存模型

1. 关于CPU和内存

  • CPU、内存、I/O 设备都在不断迭代,有一个核心矛盾是这三者的速度差异。CPU 和内存的速度差异可以简单地认为:CPU快于内存快于 I/O 设备,程序里大部分语句都要访问内存,有些还要访问 I/O,所以程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:CPU 增加了缓存,以均衡与内存的速度差异;操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

2. 并发编程会遇到的问题

2.1 原子性

  • 即一个操作或者多个操作,要么全部执行成功,要么全部执行失败,并且执行过程中不会被打断。
  • 比如经典的转账问题。

2.2 可见性

  • 单核pc,所有的线程都是在一个CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的,一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性。
  • 多核cpu,每个 core 都有自己的缓存,这时 core 的缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 core 上执行时,这些线程操作的是不同的 core缓存。比如,线程 A 修改的是core1 上缓存中的变量x,而线程 B 读取的是 core2上缓存中的变量x,这个时候线程 A 对变量 x 的操作如果没有及时写到内存中,那么对于线程 B 而言就不具备可见性了。

2.3 有序性

  • 程序执行的顺序按照代码的先后顺序执行
  • 为了提高代码的执行效率,会发生编译优化,分别为处理器的重排序、编译器的重排序,内存系统的重排序
  • 比如下面这个代码

x =3; load x set x store x
y=4; load y sety store y
x=x+6; load x set x store x

  • 编译优化后可能是下面这个样子

x =3; load x set x
x=x+6; set x store x
y=4; load y sety store y

  • 少了2步操作(store x和load x),提高了执行速度,这种优化不会影响程序的执行结果,下面的情况的就可能会影响执行结果。

示例一

package org.example;

public class Test1 {

    public static int a = 0, b = 0;
    public static int x = 0, y = 0;

    public static void main(String[] args) throws Exception {
        int count = 0;
        while (true) {
            count++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("第" + count + "次输出结果:i = " + x + " , j = " + y);
            if (x == 0 && y == 0) {
                break;
            }
        }
    }

}
  • 如果不发生指令重排的三种情况

x=0;y=1
x=1;y=0
x=1;y=1

  • 下面x=0; y=0一定是发生了指令重排导致的

在这里插入图片描述

  • 猜测重排的结果应该如下
Thread t1 = new Thread(() -> {
    x = b;
    a = 1;
});
Thread t2 = new Thread(() -> {
	y = a;
    b = 1;
});
  • 如果要防止指令重排,可以给变量添加volatile
public static volatile int a = 0, b = 0;
public static volatile int x = 0, y = 0;

示例二

public class Test5 {

    private static Test5 INSTANCE;

    private Test5() {
    }

    public static Test5 getInstance() {
        if (INSTANCE == null) {
            synchronized (Test5.class) {
                INSTANCE = new Test5();
            }
        }
        return INSTANCE;
    }

}
  • 这种单例模式存在并发安全问题(在高并发,而且发生指令重排的情况下)
//1.分配内存
//2.初始化
//3.地址赋值给INSTANCE 
INSTANCE = new Test5();
  • 这行代码其实包含上面3步操作,如果是顺序执行,不会又并发安全问题,但如果发生指令重排,3.地址赋值给INSTANCE 在2.初始化之前执行,那么当下一个线程执行到getInstance()方法时,会判断出INSTANCE != null,直接返回INSTANCE 对象,但是INSTANCE 还没有初始化,也就是对象内的属性都是空的,还没有赋值,比如这时你要去拿到INSTANCE 内某个属性的值,就是null。
  • 如果要防止指令重排,可以给变量添加volatile
private static volatile Test5 INSTANCE;

3. java内存模型

  • java内存模型是为了屏蔽各个硬件平台和操作系统的内存访问差异,让Java程序在各种平台下都能达到一致的内存访问效果。主要是针对可见性和有序性,也就是缓存一致性问题和指令重排序的问题。
  • 解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。合理的方案应该是按需禁用缓存以及编译优化。
  • 对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
  • Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
  • 这些方法包括 volatile、synchronized 和 final 三个关键字,以及八项 Happens-Before 规则
  1. synchronized :锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
  2. volatile:volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。
  3. final:final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。

Happens-Before:

  1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构;但是这个规则是对结果负责
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序
  4. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作(t1写入的变量,如果t1调用t2.start(),则这些变量对t2可见)
  5. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生(t1写入的变量,如果t2调用t1.interrupt(),则这些变量对t2可见)
  6. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行(t1写入的变量,如果t2调用t1.join()结束,或t1.isAlive()得到返回值,那么这些变量对t2可见)
  7. 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始
  8. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则操作A先行发生于操作C

4. volatile如何禁止指令重排

  • MESI(缓存一致性协议)
    因为CPU访问内存的速度比较慢,所以在CPU和内存之间加了个缓存以提高访问速度。既然每个核都有缓存,那么假设两个核或者多个核同时访问同一个变量时这些缓存是如何进行同步的,使用了store buffer保存变量,并异步通知其他cache更新变量。
  • 例如下图的情况,当cpu1对a变量进行了修改,这时候cpu2可能很忙,没办法接收cpu1的store buffer的通知,如果使用同步通知,可以解决可见性问题,但是cpu1需要一直等待cpu2空闲下来才能通知成功,效率很低,所以采用异步通知,这也是为什么有了MESI协议,还存在可见性问题的原因。

在这里插入图片描述

  • JMM规定了volatile关键字解决可见性问题需要使用storeload() 方法添加内存屏障,具体实现方法自定义。下面看下HotSpot的实现方式。
    http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp#l1
  • 实现storeload,就相当于实现了上面三种方法loadload,storestore,loadstore
  • 这里的volatile 是c++的关键字,表示防止gcc优化无用的代码,这里的代码意思是0+0,相当于空操作没有实际意义,使用lock指令锁总线;
  • 锁了总线,cpu就不能工作,因为cpu工作一定要和内存交互,过总线,这时候cpu空闲下来,就可以接受其他cpu的异步通知a=2,当前线程把cache中的值a=2写到主存时,其他线程也不会对主存进行操作,并行变为串行。

在这里插入图片描述

5. 编译优化案例

public class Test{  
    private static boolean flag;  
  
    public static void main(String[] args) throws Exception {  
        Thread t = new Thread(new Runnable() {  
            public void run() {  
                int i = 0;  
                while (!flag)  
                    i++;  
           }  
        });   
        t.start();  
        TimeUnit.SECONDS.sleep(1);   
        flag = true;  
    }  
}
while (!stopRequested)   
   i++;  
  • 上面的代码会被优化为
if (!flag) {  
    while (true)  
      i++;  
}  
  • 即使flag 后面被设置为 true,循环也不会结束
  • 解决方法:1、flag前加上volatile,禁止指令重排,循环就会结束;2、如果i++换成 System.out.println(i++),一样不会发生指令重排,循环也能结束(println方法内部使用了synchronized关键字)
  • 这个问题和可见性没有任何关系
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值