Java内存模型(JMM)

Java内存模型(JMM)定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

在Java中,所有实例域、静态域和数组元素都存在堆内存中,堆内存在线程之间共享,这些变量就是共享变量

局部变量(Local Variables),方法定义参数(Formal Method Parameters)和异常处理参数(Exception Handler Parameters)不会在线程之间共享,它们不存在内存可见性问题。

JMM抽象结构

图参考自《Java并发编程的艺术》3-1

上图是抽象结构,一个包含共享变量的主内存(Main Memory),出于提高效率,每个线程的本地内存中都拥有共享变量的副本。Java内存模型(简称JMM)定义了线程和主内存之间的抽象关系,抽象意味着并不具体存在,还涵盖了其他具体的部分,如缓存、写缓存区、寄存器等。

此时线程A、B之间是如何进行通信的呢?

  • A把本地内存中的更新的共享变量刷新到主内存中。
  • B再从主内存中读取更新后的共享变量。

明确一点,JMM通过控制主内存与每个线程的本地内存之间的交互,确保内存的可见性

重排序

编译器和处理器为了优化程序性能会对指令序列进行重新排序,重排序可能会导致多线程出现内存可见性问题。

源码->最终指令序列

下图为《Java并发编程的艺术》3-3

编译器重排序

  • 编译器优化的重排序:编译器不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

JMM对于编译器重排序规则会禁止特定类型的编译器重排序。

处理器重排序

  • 指令级并行的重排序:现代处理器采用指令级并行技术(Instruction-Level-Parallelism,ILP)将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应及其指令的执行顺序。
  • 内存系统的重排序:处理器使用缓存和读/写缓冲区,使得加载和存储的操作看起来在乱序执行。

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

数据依赖性

如果两个操作访问同一变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

编译器和处理器会遵守数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。(针对单个处理器中执行的指令序列和单个线程中执行的操作)

考虑抽象内存模型,现代处理器处理线程之间数据的传递的过程:将数据写入写缓冲区,以批处理的方式刷新写缓冲区,合并写缓冲区对同一内存地址的多次写,减少内存总线的占用。但每个写缓冲区只对它所在的处理器可见,处理器对内存的读/写操作可能就会改变。

as-if-serial

不管怎么重排序,(单线程)程序的执行结果不能被改变,同样,不会对具有数据依赖性的操作进行重排序,相应的,如果不存在数据依赖,就会重排序。

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
  • C与A访问同一变量pi、C与B访问同一变量r,且存在写操作,具有依赖关系,它们之间不会进行重排序。
  • A与B之间不存在依赖关系,编译器和处理器可以重排序,可以变成B->A->C。

很明显,as-if-serial语义很好地保护了上述单线程,让我们以为程序就是按照A->B->C的顺序执行的。

happens-before

从JDK5开始,Java使用新的JSR-133内存模型,使用happens-before的概念阐述操作之间的内存可见性。

有个简单的例子理解所谓的可见性和happens-before“先行发生”的规则。

i = 1;  //在线程A中执行
j = i; //在线程B中执行

我们对线程B中这个j的值进行分析:

假如A happens-before B,那么A操作中i=1的结果对B可见,此时j=1,是确切的。但如果他们之间不存在happens-before的关系,那么j的值是不一定为1的。

在JMM中,如果一个操作执行的结果需要对另一个操作可见,两个操作可以在不同的线程中执行,那么这两个操作之间必须要存在happens-before。

happens-before的规则

以下源自《深入理解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):线程中的所有操作都先行发生于对此线程的终止检测。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):A在B之前发生,B在C之前发生,那么A在C之前发生。

happens-before关系的定义

  1. 如果A happens-before B,A的执行结果对B可见,且A的操作的执行顺序排在B之前,即时间上先发生不代表是happens-before。
  2. A happens-before B,A不一定在时间上先发生。如果两者重排序之后,结果和happens-before的执行结果一致,就ok。

举个例子:

private int value = 0;

public void setValue(int value){
this.value = value;
}
public int getValue(){
return value;
}

假设此时有两个线程,A线程首先调用setValue(5),然后B线程调用了同一个对象的getValue,考虑B返回的value值:

根据happens-before的多条规则一一排查:

  • 存在于多个线程,不满足程序次序的规则。
  • 没有方法使用锁,不满足监视器锁规则。
  • 变量没有用volatile关键字修饰,不满足volatile规则。
  • 后面很明显,都不满足。

综上所述,最然在时间线上A操作在B操作之前发生,但是它们不满足happens-before规则,是无法确定线程B获得的结果是啥,因此,上面的操作不是线程安全的。

如何去修改呢?我们要想办法,让两个操作满足happens-before规则。比如:

  • 利用监视器锁规则,用synchronized关键字给setValue()getValue()两个方法上一把锁。
  • 利用volatile变量规则,用volatile关键字给value修饰,这样写操作在读之前,就不会修改value值了。

重排序对多线程的影响

考虑重排序对多线程的影响:

如果存在两个线程,A先执行writer()方法,B再执行reader()方法。

class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
……
}
}
}

在没有学习重排序相关内容前,我会毫不犹豫地觉得,运行到操作4的时候,已经读取了修改之后的a=1,i也相应的为1。但是,由于重排序的存在,结果也许会出人意料。

操作1和2,操作3和4都不存在数据依赖,编译器和处理器可以对他们重排序,将会导致多线程的原先语义出现偏差。

顺序一致性

数据竞争与顺序的一致性

上面示例就存在典型的数据竞争

  • 在一个线程中写一个变量。
  • 在另一个线程中读这个变量。
  • 写和读没有进行同步。

我们应该保证多线程程序的正确同步,保证程序没有数据竞争。

顺序一致性内存模型

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • 所有线程都只能看到一个单一的操作执行顺序。
  • 每个操作都必须原子执行且立刻对所有线程可见

这些机制实际上可以把所有线程的所有内存读写操作串行化

顺序一致性内存模型和JMM对于正确同步的程序,结果是相同的。但对未同步程序,在程序顺序执行顺序上会有不同。

JMM处理同步程序

对于正确同步的程序(例如给方法加上synchronized关键字修饰),JMM在不改变程序执行结果的前提下,会在在临界区之内对代码进行重排序,未编译器和处理器的优化提供便利。

JMM处理非同步程序

对于未同步或未正确同步的多线程程序,JMM提供最小安全性。

一、什么是最小安全性?

JMM保证线程读取到的值要么是之前某个线程写入的值,要么是默认值(0,false,Null)。

二、如何实现最小安全性?

JMM在堆上分配对象时,首先会对内存空间进行清零,然后才在上面分配对象。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成(0,false,Null)

三、JMM处理非同步程序的特性?

  1. 不保证单线程内的操作会按程序的顺序执行。
  2. 不保证所有线程看到一致的操作执行顺序。
  3. 不保证64位的long型和double型的变量的写操作具有原子性。(与处理器总线的工作机制密切相关)
  • 对于32位处理器,如果强行要求它对64位数据的写操作具有原子性,会有很大的开销。
  • 如果两个写操作被分配到不同的总线事务中,此时64位写操作就不具有原子性。

总结

JMM遵循的基本原则:

对于单线程程序和正确同步的多线程程序,只要不改变程序的执行结果,编译器和处理器无论怎么优化都OK,优化提高效率,何乐而不为。

as-if-serial与happens-before的异同

异:as-if-serial 保证单线程内程序的结果不被改变,happens-before 保证正确同步的多线程程序的执行结果不被改变。

同:两者都是为了在不改变程序执行结果的前提下,尽可能的提高程序执行的并行度


参考资料:

《Java并发编程的艺术》方腾飞

《深入理解Java虚拟机》周志明

Java并发读书笔记:JMM与重排序的更多相关文章

  1. Java并发读书笔记:线程安全与互斥同步

    目录 导致线程不安全的原因 什么是线程安全 不可变 绝对线程安全 相对线程安全 线程兼容 线程对立 互斥同步实现线程安全 synchronized内置锁 锁即对象 是否要释放锁 实现原理 啥是重进入? ...

  2. Java并发(三):重排序

    在执行程序时为了提高性能,提高并行度,编译器和处理器常常会对指令做重排序.重排序分三种类型: 编译器优化的重排序.编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序. 指令级并行的重排序 ...

  3. java并发学习--第九章 指令重排序

    一.happns-before happns-before是学习指令重排序前的一个必须了解的知识点,他的作用主要是就是用来判断代码的执行顺序. 1.定义 happens-before是用来指定两个操作 ...

  4. Java并发读书笔记:Lock与ReentrantLock

    Lock位于java.util.concurrent.locks包下,是一种线程同步机制,就像synchronized块一样.但是,Lock比synchronized块更灵活.更复杂. 话不多说,我们 ...

  5. Java并发编程的艺术(二)——重排序

    当我们写一个单线程程序时,总以为计算机会一行行地运行代码,然而事实并非如此. 什么是重排序? 重排序指的是编译器.处理器在不改变程序执行结果的前提下,重新排列指令的执行顺序,以达到最佳的运行效率. 重 ...

  6. Java并发读书笔记:如何实现线程间正确通信

    目录 一.synchronized 与 volatile 二.等待/通知机制 等待 通知 面试常问的几个问题 sleep方法和wait方法的区别 关于放弃对象监视器 三.等待通知典型 生产者消费者模型 ...

  7. Java并发读书笔记:线程通信之等待通知机制

    目录 synchronized 与 volatile 等待/通知机制 等待 通知 面试常问的几个问题 sleep方法和wait方法的区别 关于放弃对象监视器 在并发编程中,保证线程同步,从而实现线程之 ...

  8. 《深入了解java虚拟机》高效并发读书笔记——Java内存模型,线程,线程安全 与锁优化

    <深入了解java虚拟机>高效并发读书笔记--Java内存模型,线程,线程安全 与锁优化 本文主要参考<深入了解java虚拟机>高效并发章节 关于锁升级,偏向锁,轻量级锁参考& ...

  9. java并发编程笔记(三)——线程安全性

    java并发编程笔记(三)--线程安全性 线程安全性: ​ 当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现 ...

随机推荐

  1. Spring 资源注入

    Spring开发中经常需要调用各种资源,包含普通文件.网址.配置文件.系统环境变量等,我们可以使用Spring表达式语言(Spring-EL)实现资源的注入. Spring主要使用@Value注解实现 ...

  2. 浅谈Linux下/etc/passwd文件

    浅谈Linux 下/etc/passwd文件 看过了很多渗透测试的文章,发现在很多文章中都会有/etc/passwd这个文件,那么,这个文件中到底有些什么内容呢?下面我们来详细的介绍一下. 在Linu ...

  3. 无聊读论文:视觉注意力模型RARE2012

    Riche, N., Mancas, M., Duvinage, M., Mibulumukini, M., Gosselin, B., & Dutoit, T. (2013). RARE20 ...

  4. 敏捷开发流程之Scrum:3个角色、5个会议、12原则

    本文主要从Scrum的定义和目的.敏捷宣言.Scrum中的人员角色.Scrum开发流程.敏捷的12原则等几方面帮助大家理解Scrum敏捷开发的全过程. 一.Scrum的定义和目的 Scrum是一个用于 ...

  5. C语言关键字const作用及其应用

    只要学过C语言的,都有知道const这个关键字,知道是用来定义常量的,如果一个变量被const修饰,那么它的值就不能再被改变,那么还有什么其他作用呢? 一.const常用作用 1.修饰局部变量 con ...

  6. 变量内容的删除、取代与替换(optional)

    这部分内容非常繁琐且不易记忆且枯燥,用到来查询即可. 1.变量内容的删除 这一部分比较细枝末节,就不再一一手打了,贴范例图片 #:符合取代文字的最短的一个 ##:符合取代文字的最长的一个 2.变量内容 ...

  7. Fabric1.4:Go 链码开发与编写

    1 链码结构 1.1 链码接口 链码启动必须通过调用 shim 包中的 Start 函数,传递一个类型为 Chaincode 的参数,该参数是一个接口类型,有两个重要的函数 Init 与 Invoke ...

  8. 2019年全网最热门的123个Java并发面试题总结

    前言 并发编程几乎是所有互联网公司面试必问的问题,并发编程是Java程序员最重要的技能之一,也是最难掌握的一种技能.它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰.思维缜密, ...

  9. hexo博客零基础搭建系列(一)

    文章目录 其他搭建 1.简介 2.安装Node和Git 3.安装Hexo 4.Hexo的目录结构 5.我的版本 其他搭建 不好意思,下面的链接都是CSDN的链接,如果要在博客园看,请点我的分类查看.因 ...

  10. 迭代器使用过程中为什么抛出ConcurrentModificationException

    出现的场景:在迭代器对集合进行遍历的同时,集合本身进行变更操作(add(), remove(), set()). 当正常调用时: import java.util.ArrayList; import ...