JVM原理及调优(1)——内存模型

系列文章规划:

  1. JVM原理及调优(1)——内存模型
  2. JVM原理及调优(2)——内存管理
  3. JVM原理及调优(3)——编译机制
  4. JVM原理及调优(4)——类加载机制
  5. JVM原理及调优(5)——垃圾回收和调优
  6. JVM原理及调优(6)——G1收集器及G1日志分析
  7. JVM原理及调优(7)——JDK常用内置工具

1. 计算机一致性问题

现代计算机一般都是通过并发执行多个任务来实现充分利用计算机CPU的性能。这个方案并不是那么容易实现,因为所有的运算任务都不可能只依靠处理器”计算“就能完成,至少要与内存交互,如读取运算数据、存储运算结果等。但计算机的CPU和内存、外存的速度差别太大,相差几个数量级,所以引入读写速度尽可能接近CPU运算速度的高速缓存,作为内存与CPU之间的缓冲:将运算需要使用到的数据复制到缓存中,让CPU进行运算,当运算计算后再从缓存同步到内存中,这样就无须等待缓慢的内存读写了。

引入高速缓存很好的解决了CPU与其它存储单元速度差异太大的问题,但同时也引入了新的问题:缓存一致性。在多CPU机器上,每个CPU都有自己的高速缓存,而它们又共享一个主内存,当多个CPU的运算任务都涉及内存的同一块区域时,就可能导致缓存不一致的情况,如果真是这样,那同步回内存的缓存以谁的数据为主呢?为了解决这个问题,需要各个CPU访问缓存时遵守一定的协议,比如MSI、MESI、MOSI等等。

2. 指令重排序

CPU执行代码,最简单的模型是顺序一致性模型,按照指令出现的顺序执行,规则如下:

1. 一个线程中的所有操作必须按照程序的顺序来执行。
2. 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

但人为指定的顺序并不能总是保证符合CPU处理的特性,因此现代计算机体系和处理器架构都不保证顺序一致性。为了尽量充分利用处理器内部的运算单元,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致。

JVM只需要线程内部维持顺序化语义,也就是说只要程序的最终结果等同于它在严格顺序化环境下执行的结果。因此,JVM会根据处理器的特性(CPU的多级缓存系统、多核处理器等)做指令重排序,从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

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

重排序需要遵守以下原则:

1. 数据依赖性。不会改变存在数据依赖关系的两个操作的执行顺序。(注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)
2. as-if-serial 语义。不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

3. 内存模型

Java内存模型(JMM,Java Memory Mode)是为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,是Java线程之间通信的控制机制。

JMM规定了所有的共享数据都存储在主内存中,每个线程还有自己的工作内存,线程要使用共享数据,需要从主内存拷贝副本到自己的工作内存,只能在自己的工作内存中对共享数据的副本进行所有操作。不同线程直接无法访问对方工作内存,线程间变量值的传递需要通过主内存来完成。

原子性

JMM定义了8种原子操作来完成主存和工作内存之间的交互协议。所谓原子性是指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。这8种原子操作分别是:

1. lock。将主存变量标识为单线程独占的状态。
2. unlock。将主存变量从锁定状态释放出来,释放锁后的主存变量可以被其他线程锁定。
3. read。将主存变量值,传输到线程的工作内存。
4. load。把主存传过来的变量值,赋值给工作内存的变量。
5. use。将工作内存变量值,传递给执行引擎。
6. assign。把一个从执行引擎接收到的值赋值给工作内存变量。
7. store。把工作内存变量值传递到主存。
8. write。把从工作内存获取的变量值写入到主存变量中。

要把一个变量从主存复制到工作内存,就必须按顺序执行read和load;要把一个变量从工作内存同步回主存,就必须按顺序执行store和write。同时,还规定了在执行上面8个基本操作时必须满足以下规则:

1. 不允许read和load、store和write操作之一单独出现,必须成对出现。即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起同步,主内存不接受
2. 不允许一个线程丢弃assign操作,即变量在工作内存中改变了以后必须同步回主内存
3. 不允许一个线程无原因(没有任何assign操作)就把数据从工作内存同步回主内存(因为是无用功嘛)
4. 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,即对一个变量实现use和store操作之前,必须执行过assign或者load操作
5. 一个变量在同一个时刻只允许一条线程对其lock,但lock可以被同一条线程重复执行多次,多次lock后也得有相同次数的unlock才能解锁
6. 对一个变量执行lock操作,将会清空工作内存中这个变量的值,在执行引擎使用这个变量前,需要重新执行load或assign初始化变量的值
7. unlock时必须保证是同一个线程在先前对这个变量执行过lock
8. 对一个变量执行unlock之前,必须把此变量同步回主内存

happens-before法则

为了保证线程安全,JVM定义了一套规则,如果动作B要看到动作A的执行结果(无论A/B是否在同一个线程里面执行),那么A/B就需要满足happens-before法则

1. 程序顺序规则:线程中的每个操作happens before该线程中在程序顺序上后续的每个操作。
2. 监视器锁规则:对一个监视器的解锁操作happens-before随后对该监视器的加锁操作。
3. volatile变量规则:对一个volatile域的写操作happens-before任意后续对该volatile域的读操作。
4. 线程启动原则:线程上调用start()方法happens before这个线程启动后的任何操作。
5. 一个线程中所有的操作都happens before从这个线程join()方法成功返回的任何其他线程。(意思是其他线程等待一个线程的jion()方法完成,那么,这个线程中的所有操作happens before其他线程中的所有操作)
6. 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

注意:两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

在处理器层面上,内存模型定义了一个充要条件:

当前处理器可以看到其他处理器写入到内存的数据,并且其他处理器可以看到当前处理器写入到内存的数据。

有些处理器具有强内存模型(strong memory model),能够让所有的处理器在任意时间任意指定内存地址上看到完全相同的值。在强内存模型下,有时候编写程序可能会更容易,因为减少了对内存屏障的依赖。但是即使在一些最强的内存模型下,内存屏障仍然是必须的。设置内存屏障往往与我们的直觉并不一致。近来处理器设计的趋势更倾向于弱的内存模型,因为弱内存模型削弱了缓存一致性,所以在多处理器平台和更大容量的内存下可以实现更好的可伸缩性。

较弱内存模型(weaker memory model)中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作,实现可见性。这些内存屏障通常在lock和unlock操作的时候完成。内存屏障在高级语言中对程序员是不可见的。

4. 同步机制

正确同步的多线程程序的具有内存一致性:程序的执行将具有顺序一致性,程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

当程序未正确同步时,就会存在数据竞争

1. 在一个线程中写一个变量,
2. 在另一个线程读同一个变量,
3. 而且写和读没有通过同步来排序。

同步有几个方面的作用。最广为人知的就是互斥 ——一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。

但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。依据缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上面很容易见到。对编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作,我们使用了简述的方式来描述大量可能的影响。

lock

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

* 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
* 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
* 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

公平锁和非公平锁

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

非公平锁通过CAS实现的,CAS就是compare and swap。CAS实际上调用的JNI函数,也就是CAS依赖于本地实现。以Intel来说,对于CAS的JNI实现函数,它保证:(1)禁止该CAS之前和之后的读和写指令重排序。(2)把写缓冲区中的所有数据刷新到内存中。

volatile

volatile的内存语义:

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

从内存语义的角度来说,volatile 与锁有相同的效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。为了实现 volatile 内存语义,JMM 会分别限制两种重排序类型(编译器重排序和处理器重排序)。下面是 JMM 针对编译器制定的 volatile 重排序规则表:

是否能重排序(操作2)普通读/写(操作2)volatile读(操作2)volatile写
(操作1)普通读/写NO
(操作1)volatile读NONONO
(操作1)volatile写NONO

从上表我们可以看出:

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

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:

* 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
* 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
* 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
* 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

final

基本类型final域,编译器和处理器要遵守两个重排序规则:

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

引用类的final域,除上面两条之外,还有一条规则:

* final写:在“构造函数内对一个final引用的对象的成员域的写入”,与“随后在构造函数外把这个被构造对象的引用赋值给一个引用变量”,这两个操作之间不能重排序。

注意:写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。

JMM通过“内存屏障”实现final。在final域的写之后,构造函数return之前,插入一个StoreStore障屏。在读final域的操作前面插入一个LoadLoad屏障。

5. JMM总结

JMM保证:如果程序是正确同步的,程序的执行将具有顺序一致性 。

从JMM设计者的角度来说,在设计JMM时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型(程序尽可能的顺序执行)来编写代码。
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(对程序重排序,做尽可能多的并发)来提高性能。编译器和处理器希望实现一个弱内存模型。

JMM设计就需要在这两者之间作出协调。JMM对程序采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。

参考文献

  1. 细说Java多线程之内存可见性(视频)
  2. JSR 133: JavaTM Memory Model and Thread Specification Revision(JMM英文官方文档)
  3. Java内存模型FAQ
  4. 深入理解 Java 内存模型
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值