[JVM] Java内存模型JMM

详解Java内存模型JMM

Posted by Hyuga on September 21, 2018

前言

本文主要讲解Java内存模型JMM协议,包括线程间通信、内存屏障等相关内容。

Java内存模型

JMM描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

Java内存模型(Java Memory Model),简称JMM,是一种解决JVM中缓存一致性和指令重排序问题的协议。

也可以这样理解,JMM是一种保证多线程通信中内存变量一致性的协议。

划重点:

  • 多线程:单线程中没有JMM的概念,不涉及到共享变量
  • 通讯中:不同线程操作同一内存变量[共享变量]
  • 内存变量:也就是共享变量
  • 一致性:这个就复杂点了,下面细说

也有另一种说法:JMM定义了多线程之间通信和同步规则。

  • 通信:不同线程对同一资源的并行访问
  • 同步:不同线程对同一资源的顺序访问

既然是保证一致性的协议,那我们就先了解下如果没有JMM,怎么就内存变量不一致了。

栗子

public class JmmDemo {
    static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Jmm1 target = new Jmm1();
        for (int j = 0; j < 2; j++) {
            new Thread(target).start();
        }
        TimeUnit.SECONDS.sleep(2);
        System.out.println("over:" + i);
    }

    static class Jmm1 implements Runnable {
        @Override
        public void run() {
            for (int k = 0; k < 10; k++) {
                System.out.println(Thread.currentThread().getName() + ":" + i++);
            }
        }
    }
}
输出:
Thread-0:0
Thread-1:0
Thread-0:1
Thread-0:3
Thread-0:4
Thread-1:2
Thread-0:5
Thread-1:6
Thread-0:7
Thread-1:8
Thread-0:9
Thread-1:10
Thread-0:11
Thread-1:12
Thread-0:13
Thread-1:14
Thread-0:15
Thread-1:16
Thread-1:17
Thread-1:18
Disconnected from the target VM, address: '127.0.0.1:54049', transport: 'socket'
over19

先说下,上面这个输出是尝试了很多遍才出来的结果,所以说并发有可能会造成数据不一致,但并不是每次必现的。

出现得更多的结果是正确答案:20

而上面这个答案是19,问题出在哪呢?没错,看前两行输出都是i=0,导致最终结果是19.这是怎么造成的呢?

先强行插播一条概念

JMM协议规定了线程和主内存之间的抽象关系:

  • 主内存:
    • 线程之间的共享变量存储在主内存中(Main Memory)
  • 本地内存:
    • 每个线程都有一个私有的本地工作内存(Local Memory)
    • 本地内存中存储了该线程以读/写共享变量的副本

回归正文,造成demo结果错误是因为demo里两条线程在启动的时候,同时从主内存中拷贝了i=0到本地线程内存,所以虽然是操作同一个对象,但这并不是我们想要的执行顺序。

而且细看下来,其实整个输出的顺序基本都是乱序的,即便大多数执行结果是20,但是执行顺序也是错的。

剖析

debugger模式下可以观察到,线程0和线程1是随机切换执行的(cpu时间片),这也就是我们看到输出结果为什么是乱序的。因为每条线程执行一个任务都是断断续续的,无锁情况下都是交叉运行的。

再来看看并发问题

Thread-0:0
Thread-1:0

上面已经说了是同时从主内存中拷贝了i=0到本地内存导致的,并且工作内存操作完后什么时候写回主内存是不确定的。

当本地内存和主内存数据不一致的时候,灾难就发生了。 |并发情况|线程0|线程1| |第一种|i=0|i=0| |第二种|i++;没执行完,只是值+1但并没有赋值回i,时间片耗完|拿到i=1;错误执行| |第三种|i++;执行完,但是还没写回主内存就没时间片了|拿到i=0;错误执行|

预期顺序:线程0和线程1对i的操作时原子性的,数据必须是一致的。

注:i++;编译后是三条指令

上面举例并不是说并发只有这三种情况,只是为了方便解释而已。

总结: Java多线程编程存在并发问题,一旦并发将无法保证执行顺序和数据一致性。

多线程并不一定会有并发问题,当没有涉及到主内存中共享变量的时候,只是操作线程的私有变量,不会产生并发问题。

所以多线程的并发问题,也可以说是多线之间程通信导致的问题,是主内存和本地内存间交互所导致的问题。

而JMM,就是用来解决这种并发问题的协议。具体怎么解决并发问题呢,下面继续。

并发终结者

虽说编译器甚至是JVM很聪明,会对我们写的代码编译后的字节码进行重排序(排序后单线程内结果一致),已达到更高效的执行数据和资源节约。

但是怎样才能保证字节码在多线程中执行也能一样不影响预期的输出值?

这里又要插播一个概念了:内存屏障

什么是内存屏障?

内存屏障,又称内存栅栏,是一个cpu指令。

内存屏障的作用?

保证特定操作的执行顺序,原子性,以及可见性。

内存屏障可以理解为一个锁,锁住了某一段字节码指令(同时也锁了这一段字节码的顺序)。某一个线程获得了锁的权限,进入执行,别的线程不允许进入执行,等锁被持有者释放后,其他线程才能进入加锁区域执行。

内存屏障(Memory Barrier)还有另外一个很NB的功能:强制刷新各种CPU Cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入cache的数据,以此达到多线程间的数据同步。


Java有哪些修饰词或者代码是有内存屏障功能的?

volatile

volatile关键字的原理是:如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。这意味着,如果写入一个volatile变量,就可以保证: 1.一个线程将共享变量a写入主内存中,Write-Barrier指令会强制刷新其他线程中的变量a副本为最新的值 2.一个线程在read变量a的时候,Read-Barrier指令会将本地副本a变量置为失效,重新从主内存中读取

锁是java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

锁的获取与释放,语义上和volatile的语义是一样的。

  • 锁获取:对应volatile的Read-Barrier指令,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。

  • 锁释放:对应对应volatile的Write-Barrier指令,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

下面对锁释放和锁获取的内存语义做个总结:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

java中锁有哪些?

  • synchronize
  • ReentrantLock
  • concurrent包

这里有点独特的是concurrent并发包的底层采用的就是CAS和volatile的内存语义,搭配出了java线程间通信的四种方法:

  • A线程写volatile变量,随后B线程读这个volatile变量。
  • A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  • A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  • A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

final域

什么是final域,举个栗子:

public class FinalExample {
    int i;                            //普通变量
    final int j;                      //final变量
    static FinalExample obj;

    public void FinalExample () {     //构造函数
        i = 1;                        //写普通域
        j = 2;                        //写final域
    }

    public static void writer () {    //写线程A执行
        obj = new FinalExample ();
    }

    public static void reader () {    //读线程B执行
        FinalExample object = obj;    //读对象引用
        int a = object.i;             //读普通域
        int b = object.j;             //读final域
    }
}

之前我也一脸蒙蔽,看了这个demo瞬间秒懂!!!

与前面介绍的锁和volatile相比较,对final域的读和写更像是普通的变量访问。对于final域,编译器和处理器要遵守两个重排序规则:

  • JMM禁止编译器把final域的写重排序到构造函数之外。
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

如果JMM不对final域进行处理的话,会出现的问题是,像上例中构造函数的i = i;有可能被重排序到构造方法外去执行,那么构建的对象就不是我们预期的。

通过为final域增加写和读重排序规则,可以为java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用),就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

这段话个人理解是:

  • final修饰的类变量,在构造函数中不会被重排序都构造函数外去执行,也就是说能被正常初始化,多线程中不会有问题
  • 没有final修饰的类变量,则需要使用锁或者volatile来保证任意线程都能看到这个final域在构造函数中被初始化之后的值

扩展阅读

缓存一致性问题

在现代计算机中,因为CPU的运算速度远大于内存的读写速度,因此为了不让CPU在计算的时候因为实时读取内存数据而影响运算速度,CPU会加入一层缓存, 在运算之前缓存内存的数据,CPU运算的时候操作的是缓存里的数据,运算完成后再同步回内存。 这样虽然能够加速程序的运行速度,但是却带来了一个问题:缓存一致性问题。

每个处理器都有自己的缓存,而它们又共享同一内存,当有多个处理器的操作涉及同一块内存区域的时候,他们的缓存可能会因为运算而导致不一致,在这种情况下,同步回内存的数据以谁的为准呢?

为了解决一致性问题,需要各个处理器访问缓存的时候都遵循一些协议,在读写时要根据协议来进行操作。

而Java中的线程在执行的时候,为了提高速度,也会把线程中使用到的公共变量缓存到线程本地备份,线程执行时实际操作的是线程本地备份,运算完成后再同步到公共变量。Java这种机制可以看成是硬件缓存之上的一种抽象,在Java实现于特定硬件的时候,就可以把公共变量保存到内存,把线程本地备份保存到CPU缓存从而提升运行速度。Java的这种缓存机制和硬件的缓存机制一样,存在缓存一致性问题。Java的缓存一致性问题怎么解决,参考处理器缓存一致性的解决方案,我们认为应该也需要某种协议。

没错,就是JMM.

什么是重排序问题?

编译器在编译的时候,允许重排序指令以优化运行速度。CPU在执行指令的时候,为了使处理器内部运算单元能被充分利用,也可以对指令进行乱序执行。

在编译器和CPU进行重排序的时候,要遵循“as-if-serial”原则,也就是要保证程序单线程执行的时候,重排序之后程序的运行结果必须和重排序前程序的运行结果一致。这里注意“as-if-serial”原则只保证单线程的执行结果不变,不保证多线程执行的结果不变。

那么如何保证多线程程序的正确运行?显然需要某种协议来限定多线程执行时要满足的规则。

说的就是你,JMM.


参考资料 链接:细说Java多线程之内存可见性-慕课网

链接:The Java Community Process(SM) Program - JSRs: Jav…

[链接:Java内存模型FAQ 并发编程网 – ifeve.com]3

链接:深入理解Java内存模型(一)——基础

链接:深入理解Java内存模型(二)——重排序

链接:深入理解Java内存模型(三)——顺序一致性

链接:深入理解Java内存模型(四)——volatile

链接:深入理解Java内存模型(五)——锁

链接:深入理解Java内存模型(六)——final

链接:深入理解Java内存模型(七)——总结

链接:Java 理论与实践: 修复 Java 内存模型,第 2 部分