Loading

Java内存模型之有序性问题


本博客系列是学习并发编程过程中的记录总结。由于文章比较多,写的时间也比较散,所以我整理了个目录贴(传送门),方便查阅。

并发编程系列博客传送门


前言

之前的文章中讲到,JMM是内存模型规范在Java语言中的体现。JMM保证了在多核CPU多线程编程环境下,对共享变量读写的原子性、可见性和有序性。

本文就具体来讲讲JMM是如何保证共享变量访问的有序性的。

指令重排

在说有序性之前,我们必须先来聊下指令重排,因为如果没有指令重拍的话,也就不存在有序性问题了。

指令重排是指编译器和处理器在不影响代码单线程执行结果的前提下,对源代码的指令进行重新排序执行。这种重排序执行是一种优化手段,目的是为了处理器内部的运算单元能尽量被充分利用,提升程序的整体运行效率。

重排序分为以下几种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

通过指令重排的定义可以看出:指令重拍只能保证单线程执行下的正确性,在多线程环境下,指令重排会带来一定的问题(一个硬币具有两面性,指令重排带来性能提升的同时也增加了编程的复杂性)。下面我们就来展示一个列子,看看指令重排是怎么影响程序执行结果的。

public class Demo {

    int value = 1;
    private boolean started = false;

    public void startSystem(){
        System.out.println(Thread.currentThread().getName()+" begin to start system, time:"+System.currentTimeMillis());
        value = 2;
        started = true;
        System.out.println(Thread.currentThread().getName()+" success to start system, time:"+System.currentTimeMillis());
    }

    public void checkStartes(){
        if (started){
            //关注点
            int var = value+1;  
            System.out.println("system is running, time:"+System.currentTimeMillis());
        }else {
            System.out.println("system is not running, time:"+System.currentTimeMillis());
        }
    }
}

对于上面的代码,假如我们开启一个线程调用startSystem,再开启一个线程不断调用checkStartes方法,我们并不能保证代码执行到“关注点”处,var变量的值一定是3。因为在startSystem方法中的两个赋值语句并不存在依赖关系,所以在编译器进行代码编译时可能进行指令重排。所以真实的执行顺序可能是下面这样的。

started = true;
value = 2;

也就是先执行started = true;执行完这个语句后,线程立马执行checkStartes方法,此时value值还是1,那么最后在关注点处的var值就是2,而不是我们想象中的3。

重排序的原则

处理器为了提升程序的性能,可以对程序进行重排序。但是必须满足重排序之后的代码在单线程环境下执行的结果不能改变。这个原则也就是我们常说的as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上面的代码中,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。因此这段代码可能存在下面两种执行顺序

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

但是像上面的例子中说的一样,在多线程环境下as-if-serial语义并不能保证程序的正确性。在多线程环境下,如果我们想要消除指令重排序给程序带来的影响,我们就要采取相应的同步措施了。

有序性

有序性定义:即程序执行的顺序按照代码的先后顺序执行

在JMM中,提供了以下三种方式来保证有序性:

  • happens-before原则
  • synchronized机制
  • volatile机制

happens-before原则

happens-before原则是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。“影响”包括修改了内存中共享变量的值、 发送了消息、 调用了方法等。

《并发编程的艺术》中的定义如下

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens- before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the f irst is visible toand ordered before the second)

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。 如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序:

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。 准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、 循环等结构。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、 Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

这边举个列子来帮助理解happens-before原则:

private int value=0;
pubilc void setValue(int value){
    this.value=value;
}
public int getValue(){
    return value;
}

假设两个线程A和B,线程A先(在时间上先)调用了这个对象的setValue(1),接着线程B调用getValue方法,那么B的返回值是多少?

对照着hp原则,上面的操作不满下面的任何条件:

  • 不是同一个线程,所以不涉及:程序次序规则;
  • 不涉及同步,所以不涉及:管程锁定规则;
  • 没有volatile关键字,所以不涉及:volatile变量规则
  • 没有线程的启动,中断,终止,所以不涉及:线程启动规则,线程终止规则,线程中断规则
  • 没有对象的创建于终结,所以不涉及:对象终结规则
  • 更没有涉及到传递性

所以一条规则都不满足,尽管线程A在时间上与线程B具有先后顺序,但是,却并不满足hp原则,也就是有序性并不会保障,所以线程B的数据获取是不安全的!!

时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。只有真正满足了happens-before原则,才能保障安全。
如果不能满足happens-before原则,就需要使用下面的synchronized机制和volatile机制机制来保证有序性。

synchronized机制

synchronized保证有序性的原理很好理解。因为synchronized可以保证同一时间只有线程能访问代码块,而在单线程环境下,JMM能天然保证代码的串行语义。

虽然说使用synchronized的代码块,还是可能发生指令重排,但是因为synchronized可以保证只有一个线程执行,所以最后的执行结果还是正确的。

volatile机制

volatile的底层是使用内存屏障来保证有序性的。写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

内存屏障有两个能力:

  • 就像一套栅栏分割前后的代码,阻止栅栏前后的没有数据依赖性的代码进行指令重排序,保证程序在一定程度上的有序性
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性

简单总结

特性 volatile关键字 synchronized关键字 Lock接口 Atomic变量
原子性 无法保障 可以保障 可以保障 可以保障
可见性 可以保障 可以保障 可以保障 可以保障
有序性 一定程度保障 可以保障 可以保障 无法保障

参考

posted @ 2019-12-30 15:48  程序员自由之路  阅读(2977)  评论(1编辑  收藏  举报