8. JVM Memory Model and Visibility(JVM内存模型与可见性)

前言:JVM内存模型、Java内存区域、GC分代回收容易搞混。前面讲解了JVM内存区域,它是Java代码编译成.class字节码之后JVM运行时的一些实现。JVM内存区域实际上可以理解成JVM运行时数据区,即方法区、堆内存、虚拟机栈、本地方法栈、程序计数器等内容。而GC分代回收所描述的更多是垃圾回收器管理堆内存的方式,如堆内存被分为老年代、新生代(又分为Eden、From Survivor、To Survivor区),下面的部分将说明JVM内存模型(JMM)是什么。

8.1 Java内存模型引入

8.1.1 多线程引起的问题

public class VisibilityDemo {

    private boolean flag = true;

    public static void main(String args[]) throws InterruptedException {
        VisibilityDemo demo = new VisibilityDemo();
        Thread thread = new Thread(() -> {
            int i = 0;
            while(demo.flag){
                i++;
            }
            System.out.println(i);
        }
        );
        thread.start();

        TimeUnit.SECONDS.sleep(2);
        demo.flag = false;
        System.out.println("flag被设置为" + demo.flag);
    }
}

执行结果:

从结果可以看到,程序一直没有执行完,实际上flag已经被修改为false了,但是修改没有生效,心中无数只草泥马走过……

可能的原因:
首先前面的章节已经介绍过了,每个CPU核心有一级缓存、二级缓存、三级缓存(共享),还有主内存,这里有可能的是Main函数被核心1调用,而thread被核心2调用,程序刚开始加载的时候,会将flag的值加载到主内存中,核心1获取到了flag的值是true,核心1将flag的值保存在了它自己的缓存中,然后核心2将数据修改了并且刷新到了主内存,然后flag又被核心2放到它自己的缓存中并打印出来。

但是这种情况只会导致thread工作内存中的flag与主内存中的flag短暂的不一致,而不会导致while循环一直没法结束。

注意:CPU缓存间有最终一致的保证,并不会导致一直不一致。

我们执行程序的时候总会有一种假设:想象在程序中只存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行序列中最近一次写入该变量的值,这种乐观的模型就是“串行一致性”。但是事实上,没有任何一款现代多核处理器架构中会提供这种“串行一致性”。

Java内存模型(Java Memory Model,JMM)规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JVM。

JMM内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序中所有的操作定义了一个“Happen Before”规则。要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排(指令重排序,后面说明,请耐心往下阅读)。

8.1.2 Happens-Before先行发生原则

既然先提到了Happens-Before,先介绍一下有哪些原则:
程序顺序规则:如果程序中操作A在操作B之前,那么线程中A操作将在B操作之前执行
监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行
volatile变量规则:对volatile变量的吸入操作必须对该变量的读操作之前执行,
线程启动规则:在线程上对Thread.Start的调用必须在该线程中执行任何操作之前执行。
线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false
中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过跑出InterruptedException,或者调用isInterrupted和interrupted)
终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成。
传递性规则:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

当程序包含两个没有被Happens-Before关系排序的冲突访问时,就称存在数据争用。遵守这个原则,也就意味着有些代码不能进行重排序。

Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
这里所描述的工作内存只是一个概念,实际上只是一个逻辑上的理解,前面所学到的虚拟机栈、CPU高速缓存、程序计数器等等这些我们都可以将它们归结为线程的工作内存。这里的主内存也不仅仅是堆内存。
一个线程对共享数据的改变能够实现地反馈到另一个线程中去,我们就说这个共享数据是可见的。

8.2 volatile与可见性

8.2.1 指令重排序

前面说了,如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地重排。这里描述的就是指令重排序,Java语言的语意允许编译器和微处理器执行优化,而这些优化可以与不正确地同步代码交互,从而产生看似矛盾的行为。


指令重排只能保证单个线程执行结果不会出问题,其满足as-if-serial(前面已有介绍)的规则。但是多线程情况下,其无法保证最终结果是否会有问题。

回到前面多线程引入的问题中,为什么程序最后一直没有执行完呢,没错,就是因为指令的重排序造成最后结果运行不完的问题,下面给出解决方案,请继续往下阅读。

8.2.2 volatile关键字与可见性

8.2.2.1 volatile关键字的官方描述

下面我们查看官方的Java语言与虚拟机的规范:https://docs.oracle.com/javase/specs/jls/se8/html/index.html

在Java语言规范能看到volatile的说明,链接如下:https://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.3.1.4

官方文档中有这么个描述:
The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.
A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable
翻译:Java语言提供第二种机制,volatile域,这种方式在某种目的上比用锁更方便。一个域如果声明为volatile,则Java内存模型就能保证所有线程看到这个变量都是一致的。

8.2.3.2 可见性

可见性:让-个线程对共享变量的修改,能够及时的被其他线程看到,就说明此变量是满足可见性的。

根据JMM中规定的happen before和同步原则:
1)对某个volatile字段的写操作happens-before每个后续对该volatile字段的读操作。
2)对volatile变量v的写入,与所有其他线程后续对v的读同步

➢要满足这些条件,所以volatile关键字就有这些功能:
1)禁止缓存:volatile变量的访问控制符会加个ACC_VOLATILE
官方说明: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.5

2)对volatile变量相关的指令不做重排序

8.2.3 JIT即时编译器

JIT(Just-In-Time Compiler)有三个优化级别,它能缓存编译后的热点汇编代码,以及运行一段时间之后可能会改变代码逻辑,即时编译器有类似的指令重排的优化。所以很可能是因为JIT优化后导致结果的不一致问题。我们尝试关闭JIT优化,发现确实如此。

执行效果:发现程序能够正常停止

但是JIT优化关闭,很可能会导致程序执行效率大幅度下降,所以最好还是使用volatile来解决可见性和指令重排的问题。

8.3 JMM的扩展内容

8.3.1 JMM的八种操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了种操作来完成,并且每种操作都是原子的、不可再分的。
lock:作用于主内存的变量,把一个变量标识为一条线程独占的状态
unlock:作用于主内存的变量,把一个处于锁定状态的变量释放出来。
read:把一个变量的值从主内存传输到工作内存中,以便随后的load使用。
load:把read操作从主内存中得到的变量值放入到工作内存的变量副本中。
use:把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign:把一个从执行引擎中接收到的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store:把工作内存中的一个变量的值传递到主内存,以便随后的write使用。
write:把store操作从工作内存中得到的变量值放入到主内存的变量中。

8.3.2 同步的规则定义

1)对于监视器m的解锁与所有后续操作对于m的加锁同步
2)对volatile变量v的写入,与所有其他线程后续对v的读同步
3)启动线程的操作与线程中的第一个操作同步(线程能够看到操作的数据变化)
4)对于每个属性写入默认值(0,false,null)与每个线程对其进行的操作同步
5)线程T1的最后操作于线程T2发现线程T1已经结束同步(isAlive,join可以判断线程是否终止)
6)如果线程T1中断了T2,那么线程T1的中断操作与其他所有线程发生T2被中断了的同步,通过抛出InterruptedException异常,或者调用Thread.interrupted或Thread.isInterrupted。
前面两条是指导我们写代码使用volatile和synchronized关键字的一些场景,后面四条是JDK帮我们保证的,我们只要知道就行了。

8.3.3 final在JMM中的处理(大概了解)

1)final在该对象的构造函数中设置对象的字段,当线程看到该对象时,将始终看到该对象的final字段的正确构造版本。
如:f = new finalDemo(),则读取到的f.x一定是最新的(x为final字段)
2)如果在构造函数中设置字段后发生读取,则会看到该final字段分配的值,否则它将看到默认值。
如:public finalDemo(){x=1;y=x;},y会等于1
3)读取该共享对象的final成员变量之前,先要读取共享对象
如:r = new ReferenceObj();k=r.f;这两个操作不能重排序
4)通常static final是不可以修改的字段,然而System.in,System.out和System.err是static final字段,遗留原因,必须允许通过set方法改变,我们将这些字段成为写保护,以区别于普通final字段。

class FinalFieldDemo {
    final int x;
    int y;

    public FinalFieldDemo(){
        x = 1;
        y = 2;
    }
}

解释第一条,意思是多线程下,使用了final字段,去读取的时候一定能保证x能构造成功,读取到的值肯定是1,但是y这种普通字段就不能保证,有些线程可能会读取到默认值0。

8.3.4 字撕裂-double和long的特殊处理(大概了解)

JVM将64位(long和double变量)的读取和写入当做两个分离的32位操作来执行,就产生了一个读取和写入操作中间发生上下文切换,从而导致不同的任务可以看到不正确结果的可能性,这种情况叫做字撕裂(word tearing),但是当你定义long、double变量时,使用volatile关键字就会获得(简单的赋值和返回操作的)原子性(JDK5之前volatile一直没能正确解决问题),不同的JVM可以任意地提供更强的保证,但是你不应该依赖于平台相关的特性。

你可能感兴趣的:(8. JVM Memory Model and Visibility(JVM内存模型与可见性))