JAVA内存模型的秘密

 2019-12-10 15:59  阅读(712)
文章分类:Java Core

提到Java内存模型,相信都会想到一张简单的图

20191210001473\_1.png

堆和栈,栈是线程独有的,堆是线程共享的。这句话一出来就说明这样的设计跟并发脱不了关系。

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。 通信是指线程之间以何种机制来交换信息。 在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。Java 的并发采用的是共享内存模型,在共享内存并发模型里,同步是显式进行的,线程之间的通信总是隐式进行。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。

Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。 本地内存是 JMM 的一个抽象概念,并不真实存在。 它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

类似下面这样

20191210001473\_2.png

而CPU和内存之间的数据传递类似下面这样

20191210001473\_3.png

下面就着重分析下JMM是如何实现进程间的可靠通信的,也就是如何保证内存可见性的。

进程通信的过程就像下面这样图这样

20191210001473\_4.png

先给出一个前辈们总结的结论,在看看具体是怎么实现的。

JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  1. 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  2. 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  3. 未同步/未正确同步的多线程程序。 JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

对上面出现的几个让人听不懂的关键词做个解释:

顺序一致性模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。 顺序一致性内存模型有两大特性:

① 一个线程中的所有操作必须按照程序的顺序来执行。
② (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。 在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

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

从 Java 源代码到最终实际执行的指令序列,会分别经历编译器重排序处理器重排序

对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序。 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性)这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

这里必须要插进来讲的是一个happens-before 的概念,这是Java的设计人员提出来的,在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。

那么什么是 happens-before 关系,最重要的几个就是:

程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

Thread A{
        a = 1;//①
        b = 2;//②
    }
    这里①就发生在②之前(如果重排序不改变结果,可能就不一定了)

监视器锁规则:对一个监视器的解锁,happens- before 于随后对这个监视器的加锁。

Thread A{
        lock.lock();//①
        //do something
        lock.unlock();//②
    }

    Thread B{
        lock.lock();//③
        //do something
        lock.unlock();//④
    }

    这里②就肯定发生在③之前,④就发生在①之前。

volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。

volatile c;
    Thread A{
        c.write();//①
    }

    Thread B{
        c.read();//②
    }

    这里①发生在②之前

传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

happens- before相当于是设计者给程序员的保证,“在我这个JMM里,程序是这样执行的,你放一万个心吧!”

上面介绍了一些保证内存可见性的技术概念,那么在Java代码中是怎么保证内存可见性的呢?

volatile 关键字与JMM

volatile 变量自身具有下列特性:
可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性。

在JMM层面上的理解就是:
写:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
读:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

JMM是通过限制编译器重排序和处理器重排序来实现volatile 的:

JMM 针对编译器制定的 volatile 重排序规则表:

20191210001473\_5.png

此外,为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

volatile 写-读建立的 happens before 关系(对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读)

20191210001473\_6.png

锁与JMM

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。

锁的实现

就拿ReentrantLock来说,ReentrantLock 的实现依赖于 java 同步器框架 AbstractQueuedSynchronizer(AQS)。 AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态。

对于公平ReentrantLock,公平锁在释放锁的最后写 volatile 变量 state;在获取锁时首先读这个 volatile 变量。 根据 volatile 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变的对获取锁的线程可见。

非公平ReentrantLock 的释放和公平锁完全一样,在获取锁的时候使用CAS来原子更新state 状态。

总结下就是:
●公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
●公平锁获取时,首先会去读这个 volatile 变量。
●非公平锁获取时,首先会用 CAS 更新这个 volatile 变量,这个操作同时具有 volatile 读和 volatile 写的内存语义。

Java.util.concurrent 包的实现就是结合了volatile 的特性和CAS 操作的原子性,

Java 的 CAS 会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。 同时,volatile 变量的读/写和 CAS 可以实现线程之间的通信。 把这些特性整合在一起,就形成了整个 concurrent 包得以实现的基石。 如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:
① 首先,声明共享变量为 volatile;
② 然后,使用 CAS 的原子条件更新来实现线程之间的同步;
③ 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类,这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。

放一张熟悉的图,上面的内容就解释了这样图为啥这样画,哈哈

20191210001473\_7.png

final 关键字与JMM

对于 final 域,编译器和处理器要遵守两个重排序规则:

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

写 final 域的重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:
●JMM 禁止编译器把 final 域的写重排序到构造函数之外。
●编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。 这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

读 final 域的重排序规则
读 final 域的重排序规则如下:
●在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。 编译器会在读final 域操作的前面插入一个 LoadLoad 屏障。

如果final是引用类型,写final 域的重排序规则对编译器和处理器增加了如下约束:
●在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

最后对于未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(prezeroed memory)分配对象时,域的默认初始化已经完成了。

写完感觉有点乱,上下文没有表现出紧密的联系=。=

简单总结一下:

在JVM里面,每个线程工作时使用的内存可以抽象为工作内存和主内存,工作内存包括线程的栈、程序计数器和CPU工作的高速缓冲区,主内存是线程共享的,包括堆和方法区。

线程之间通过对共享变量的读写完成线程间通信,主内存上的内容才是所有线程可见了,为了保证通信的完成,引入volatile 关键字,来禁止重排序,加入内存屏障指令,让读写volatile 变量的结果与主内存中的一致。

锁是同步控制的重要内容,而锁的状态也要保证是线程之间可见的,所以所的状态需要是volatile ,加锁解锁的操作需要是原子的,所以采用了CAS操作。Lock是这么实现的,synchronized 就是JVM实现的了。

注:对JMM的学习中,看过不少文章,本文算是一个读书笔记,很多细节略去了。

推荐文章:

什么是volatile关键字?

深入理解java内存模型系列文章

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> JAVA内存模型的秘密

相关推荐