Java并发------并发基础之内存模型JMM

前言:

内存模型描述的是程序在 JVM 的执行过程中对数据的读写是否是按照程序的规则正确执行的。JMM 定义了一系列规则,这些规则定义了对共享内存的写操作对于读操作的可见性。简单地说,主要就是为了规范多线程程序中修改或者访问同一个值的时候的行为。

内存模型描述了程序执行时的可能的表现行为。只要执行的结果是满足 JMM 的所有规则,那么虚拟机对于具体的实现可以自由发挥。从侧面说,不管虚拟机的实现是怎么样的,多线程程序的执行结果都应该是可预测的。

一、并发三问题

1、重排序

Java 的编译器 compilers 是允许单线程内不产生数据依赖的语句之间进行重排序,这里说的很明确,是单线程内。如果扩展深一点的话,这种情况是不包括外部操作的,外部操作比较多的就是 native 方法。Java 可以得到 native 返回的值,但是不会感知到具体的执行过程,所以不会发生重排序。

试着运行一下下面的代码:

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            CountDownLatch latch = new CountDownLatch(1);
            Thread one = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                a = 1;
                x = b;
            });
            Thread other = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                }
                b = 1;
                y = a;
            });
            one.start();
            other.start();
            latch.countDown();
            one.join();
            other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

可能运行个几秒后就会发现,出现了 x == y == 0 的情况,这就是发生了指令重排。

重排序由以下几种机制引起:

  1. 编译器优化:对于没有数据依赖关系的操作,编译器在编译的过程中会进行一定程度的重排。

上面的代码 a = 1 和 x = b 没有相互依赖的关系,编译器就有可能将两条指令的执行顺序互换一下,这样就有可能会得到上面的情况。

  1. 指令重排序:CPU 优化行为,也是会对不存在数据依赖关系的指令进行一定程度的重排。

这个和编译器重排意思差不多。

  1. 内存系统重排序:内存系统没有重排序,但是由于有缓存的存在,使得程序整体上会表现出乱序的行为。

假设不发生编译器重排和指令重排,线程 1 修改了 a 的值,但是修改以后,a 的值可能还没有写回到主存中,那么线程 2 得到 a == 0
就是很自然的事了。同理,线程 2 对于 b 的赋值操作也可能没有及时刷新到主存中。

2. 内存可见性

线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。如果每个核心共享同一个缓存,那么也就不存在内存可见性问题了。

现代多核 CPU 中每个核心拥有自己的一级缓存或一级缓存加上二级缓存等,问题就发生在每个核心的独占缓存上。每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以会导致有些核心读取的值是一个过期的值。

Java 作为高级语言,屏蔽了这些底层细节,用 JMM 定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM 抽象了主内存和本地内存的概念。

所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换的,所以可见性问题依然是存在的。这里说的本地内存并不是真的是一块给每个线程分配的内存,而是 JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。

3. 原子性

说到原子性的时候,大家应该都能想到 long 和 double,它们的值需要占用 64 位的内存空间,Java 编程语言规范中提到,对于 64 位的值的写入,可以分为两个 32 位的操作进行写入。本来一个整体的赋值操作,被拆分为低 32 位赋值和高 32 位赋值两个操作,中间如果发生了其他线程对于这个值的读操作,必然就会读到一个奇怪的值。

这个时候我们要使用 volatile 关键字进行控制了,JMM 规定了对于 volatile long 和 volatile double,JVM 需要保证写入操作的原子性。

另外,对于引用的读写操作始终是原子的,不管是 32 位的机器还是 64 位的机器。

Java 编程语言规范同样提到,鼓励 JVM 的开发者能保证 64 位值操作的原子性,也鼓励使用者尽量使用 volatile 或使用正确的同步方式。

二、Java 对于并发的规范约束

1、Synchronization Order

每次程序的执行都有一个同步顺序,同步顺序是执行的所有同步动作的总顺序。对于每个线程中同步动作的同步顺序与线程中的程序执行顺序一致。Java 语言规范对于同步定义了一系列的规则:Synchronization Order,包括了如下同步关系:

  1. 监视器m上的解锁动作与m上的所有后续加锁动作同步。
  2. 对 volatile 变量 v 的写入与任何线程对 v 的所有后续读取同步。
  3. 启动线程的操作与启动的线程中的第一个操作同步。
  4. 向每个变量写入默认值(0,false或null)与每个线程中的第一个操作同步。

虽然在分配包含变量的对象之前将默认值写入变量似乎有点奇怪,但从概念上讲,每个对象都是在程序开始时使用其默认初始化值创建的。

  1. 线程 T1 中的最终操作与另一个检测到 T1 已终止的线程 T2 中的任何操作同步。

线程 T2 可以通过 T1.isAlive() 或 T1.join() 方法来判断 T1 是否已经终结。

  1. 如果线程 T1 中断线程 T2,则 T1 的中断与任何其他线程(包括 T2 )确定 T2 已被中断同步(通过抛出 InterruptedException 或通过调用 Thread.interrupted 或 Thread.isInterrupted )。

2、Happens-before Order

两个操作可以用 happens-before 来确定它们的执行顺序,如果一个操作 happens-before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的。

如果我们分别有操作 x 和操作 y,我们写成 hb(x, y) 来表示 x happens-before y。

以下几个规则也是来自于 Java 8 语言规范 Happens-before Order

  1. 如果 x 和 y 是同一个线程的动作,并且 x 在程序顺序中出现在y之前,那么hb(x,y)。
  2. 对象的构造函数最后一句指令 happens-before 于 finalize() 方法的第一行指令。
  3. 如果动作 x 与后续动作 y 构成同步,那么我们也有 hb(x, y)。
  4. hb(x, y) 和 hb(y, z),那么可以推断出 hb(x, z)。

这里需要强调一点 x happens-before y,并不是说 x 操作一定要在 y 操作之前被执行,而是说 x 的执行结果对于 y 是可见的,只要满足可见性,发生了重排序也是可以的。

JMM 其实就是在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

三、synchronized 关键字

一个线程在获取到监视器锁 monitor 以后才能进入 synchronized 控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,因此 synchronized 代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值。

退出代码块的时候的,会将该线程写缓冲区中的数据刷到主内存中,所以在 synchronized 代码块之前或 synchronized 代码块中对于共享变量的操作随着该线程退出 synchronized 块,会立即对其他线程可见(这句话的前提是其他读取共享变量的线程会从主内存读取最新值)。

总结一下就是:

线程 a 对于进入 synchronized 块之前或在 synchronized 中对于共享变量的操作,对于后续的持有同一个监视器锁的线程 b 可见。synchronized 会保证退出的时候能将本地内存的数据刷入到主内存。

四、单例模式中的双重检查

举一个单例模式:

public class Singleton {
    private static Singleton instance = null;
    private int v;
    private Singleton() {
        this.v = 3;
    }
    public static Singleton getInstance() {
        if (instance == null) { // 1. 第一次检查
            synchronized (Singleton.class) { // 2
                if (instance == null) { // 3. 第二次检查
                    instance = new Singleton(); // 4
                }
            }
        }
        return instance;
    }
}

这个写法为什么不对,原因就在于我们之前讲的指令重排。

Java 创建一个属性有初始值的对象一般会经过下面步骤:第一步申请一块内存的空间,基本属性值会使用默认 0 或者 null 来创建,这块空间有它自己唯一的地址值;第二步初始化它所设定的初始值,可能是个对象,也可能是在常量池中,它们也自己的地址值,再把这个对象的属性值引向这些地址值,也就是赋值;第三步再将这个这块内存空间的地址值引向这个对象 instance。

上面的第二步和第三步这两条指令如果发生重排的话,可能线程 b 进来时,发现 instance 已经不为 null,已经有地址值了,就直接返回了,但是它内部的属性值还没完成赋值操作。而线程 a 可能只将第三步操作从线程 a 的本地内存写入主内存中,还没来得及将第二步操作写入主存,而且在线程 a 本地内存中的赋值操作是对线程 b 不可见的。所以说 synchronized 只会保证从代码块出来后,一定会写到主内存中,也就是 instance 是个完整的实例;代码块内运行也会写入主存,但不保证会及时。

上面这个单例模式的问题怎么解决就需要用到下面讲的 volatile。

五、volatile

volatile 最关键的作用就是内存可见性和禁止指令重排。

1、内存可见性

synchronized 的语义是:进入 synchronized 时,使得本地缓存失效,synchronized 块中对共享变量的读取必须从主内存读取;退出 synchronized 时,会将进入 synchronized 块之前和 synchronized 块中的写操作刷入到主存中。

volatile 我们还是要建立在 Java 跨平台以后抽象出了内存模型的这个大环境下,通过用 JMM 的主内存和本地内存抽象来描述它的语义:读一个 volatile 变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个 volatile 属性会立即刷入到主内存。

2、禁止指令重排

为什么说上面单例模式的问题解决需要用到 volatile,就是因为它有禁止指令重排的功能。volatile 的禁止重排序并不局限于两个 volatile 的属性操作不能重排序,而且是 volatile 属性操作和它周围的普通属性的操作也不能重排序。

所以如果 instance 是 volatile 的,那么对于 instance 的赋值操作就不会和构造函数中的属性赋值发生重排序,能保证构造方法结束后,才将此对象引用赋值给 instance。

总结:

  1. volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值。在并发包的源码中,它使用得非常多。
  2. volatile 属性的读写操作都是无锁的,只能作用于属性,不能代替 synchronized。
  3. volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中读取。
  4. volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 所有其他线程后续对 v 的读操作。
  5. volatile 可以使得 long 和 double 的赋值是原子的。

六、final

最基本的含义就是:用 final 修饰的类不可以被继承,用 final 修饰的方法不可以被覆写,用 final 修饰的属性一旦初始化以后不可以被修改。

用 final 声明的属性正常情况下初始化一次后,就不会改变,编译器可以将final 属性的值直接缓存在寄存器中,而不需要像普通属性从内存中重新读取,这就是编译器的自由去除不必要的同步,所以我们使用时可以不需要使用同步就可以实现线程安全。

所以上面单例模式的例子,可以不使用 volatile,使用 final 也是可以的,因为对象在构造方法结束了才会被认为完全初始化,才会赋给该对象的引用,其他线程是看不到这个过程的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值