欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Java内存模型之有序性问题

程序员文章站 2023-11-02 16:01:58
本博客系列是学习并发编程过程中的记录总结。由于文章比较多,写的时间也比较散,所以我整理了个目录贴(传送门),方便查阅。 "并发编程系列博客传送门" 前言 之前的文章中讲到,JMM是内存模型规范在Java语言中的体现。JMM保证了在多核CPU多线程编程环境下,对共享变量读写的原子性、可见性和有序性。 ......

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


前言

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

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

指令重排

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

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

重排序分为以下几种:

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

Java内存模型之有序性问题

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

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。

有序性

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

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

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

happens-before原则

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

下面是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机制

volatile机制

volatile的底层是使用内存屏障来保证有序性的。

内存屏障有两个能力:

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

简单总结

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

参考