Jvm内存模型深度理解

之前是对jvm内存模型一知半解,本次打算抽时间认认真真的理解一遍jvm内存模型,在这个过程中遇到了好多问题,针对这些问题查询资料再加上自己的理解对jvm内存模型做一个理解分析,自己对jvm内存模型的理解还不够彻底,也还有思考不明白的问题,希望大家一起讨论学习,也能够让我搞明白一些困惑。

本篇文章有大量知名博客内容的应用,每一个段落中我会尽量标明文章内容出处,方便大家获取原文分享知识。

一、概述

1. jvm是如何工作的

对于这个问题,我每一次去深入思考就会有很多疑惑,很多问题不明白,总想追根问底。根据我的总结,想要把自己的这些疑惑搞清楚需要理解几门必要的知识点,计算机组成原理、cpu工作原理、虚拟化技术,需要对这些知识有自己的理解,然后再去理解jvm里面的知识就不难了。

那么jvm是如何工作的呢?要搞清楚这个问题,首先得搞清楚jvm是如何一个架构。 image[图1] 原图出处(The JVM Architecture Explained)

这就是jvm工作三大重要组成部分架构图,它包含了类加载子系统、运行时数据区域、执行引擎。一提及到jvm执行原理我们应该脑海中应该是这样的一张架构图,我们写的java文件首先通过javac编译成class文件,类加载子系统把class文件加载到内存中,并且存放到相应划分的区域,然后再由jvm执行引擎执行。

2. jvm内存模型是什么

在理解jvm内存模型的时候我突然在想jvm内存模型是什么?在自己的脑海里有没有一个明确的概念或者有没有一个形象的比方能够把它描述清楚,下面我画了一张图按照我的理解描述一下jvm内存模型是什么。

image[图2]

jvm内存模型就是java虚拟机对数据操作的一个抽象概念、是jvm内存数据区域架构的一种设计,这些数据真实存放地址要么在硬件的主存或者cpu的寄存器里面,他们通过一层映射关系保证了jvm只需要关注它本身的内存数据。

我们可以假设硬件内存RAM和cpu寄存器共同组成了一个大大实数据存储空间可以看成一个完整的‘数据库’。jvm内存模型定义了多个线程对这个数据库的操作规范,以及对数据区域的划分规范。

3. jvm内存模型的作用

计算机的世界里一切都是在操作数据,那么到底该如何理解‘jvm内存模型的作用’?也许没有人听过这么无聊的问题,简单的说jvm内存模型就是java虚拟机对数据操作设计的一套规范的架构。

4. jvm内存模型架构

[图3]

二、运行时数据区

并发编程模型的分类

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

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

Java内存模型的抽象

在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

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

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。 下面通过示意图来说明这两个步骤: image[图5]

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

jvm内存模型构成

1. the pc register

每个线程都有单独的PC寄存器,用于保存当前执行指令的地址,一旦指令执行,PC寄存器将更新到下一条指令。程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

1.1 为什么要有pc register,并且是私有的

当在执行多线程时候,CPU会不停的切换任务(对于多核来说是一个内核),本质上在一个确定的时间点,只会执行某一个线程的指令。那么这时候为了能够准确的记录各个线程正在执行的当前字节码指令的地址,最后的办法就是为每一个线程一人分配一个计数器,这样一来各个线程独立计算互不干扰,虽然理论上浪费了些空间,但问题则变得简单多了。

2. java virtual machine stacks

首先我要知道jvm虚拟机的实现大概有两种,一种是基于栈的架构(stack-based 不同),另一种是基于寄存器的架构(register-based)。 sun jvm是基于栈架构的实现。

在jvm的内存模型里面,栈、堆…各司其职,概括的来说栈内存用来存储局部变量和方法调用。栈中存放了多个栈帧。与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame①)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

2.1 栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。 image[图6]

2.1.1 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其它变量使用。局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。

2.1.2 操作数栈

操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图: image[图7]

2.1.3 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

2.1.4 方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等.

2.1.5 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

参考 深入理解Java虚拟机笔记—运行时栈帧结构

3. heap

对于大多数应用来说,Java 堆(java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配①,但是随着JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换②优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap,幸好国内没翻译成“垃圾堆”)。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden 空间、From Survivor空间、To Survivor 空间等。如果从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。在本章中,我们仅仅针对内存区域的作用进行讨论,Java堆中的上述各个区域的分配和回收等细节将会是下一章的主题。根据Java 虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小 的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

4. method area

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。对于习惯在HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。即使是HotSpot虚拟机本身,根据官方发布的路线图信息,现在也有放弃永久代并“搬家”至Native Memory来实现方法区的规划了。

Java 虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。在Sun公司的BUG 列表中,曾出现过的若干个严重的BUG就是由于低版本的HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

5. run-time constant pool

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

Java 虚拟机对Class文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只能在编译期产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

三、并发编程

基础

指令重排

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

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

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序: image 上述的 1 属于编译器重排序,2 和 3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排 序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类 型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁 止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上, 通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见 性保证。

内存屏障

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令 流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延 迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地 址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。说了这么多,那么内存屏障到底是什么呢?简单的说就是硬件或者编译器提供的一个指令,告诉执行器不要做指令优化顺序执行即可。

happens-before

从 JDK5 开始,java 使用新的 JSR -133 内存模型(本文除非特别说明,针对的都 是 JSR- 133 内存模型)。JSR-133 使用 happens-before 的概念来阐述操作之间 的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那 么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以 是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器的解锁,happens- before 于随后对这个监视器 的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对 这个 volatile 域的读。
  • 传递性:如果 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)。happens- before 的定义很微妙,后文会具 体说明 happens-before 为什么要这么定义。happens-before 与 JMM 的关系如下图所示: image 如上图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序 规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免 java 程序 员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则 的具体实现。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。只要重排序两个操作的执行顺序,程序的执行结果将会被改变。前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度), (单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。

int a = 1;
int b = 2;
int c = a + b;

顺序一致性

数据竞争与顺序一致性保证

当程序未正确同步时,就可能会存在数据竞争。java 内存模型规范对数据竞争的定 义如下:

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

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM 对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)–即程序的执行结果与该程序在顺序一致性内存模型中的执行结 果相同。马上我们将会看到,这对于程序员来说是一个极强的保证。这里的同 步是指广义上的同步,包括对常用同步原语(synchronized,volatile 和 final) 的正确使用。

顺序一致性内存模型

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

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

CAS

volatile

volatile语义如下:

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

那么volatile是如何实现的呢?,概括的来说,volatile关键字修饰的变量,JMM通过添加内存屏障,会分别限制编译器和处理器的重排序,从而保证内存的可见性。

锁的获取和释放

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

synchronized

作用域

  1. 对于普通同步方法,锁是当前实力对象。
  2. 对于静态同步方法,锁是当前类的class对象。
  3. 对于同步方法块,锁是synchronized括号里配置的对象。

实现原理

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的monitorenter和monitorexit指令,即同步代码块)还是隐式同步都是如此。在Java语言中,同步用的最多的地方可能是被 synchronized修饰的同步方法。同步方法并不是由monitorenter和monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED 标志来隐式实现的。

Java虚拟机对synchronized的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。

参考 Thread Synchronization 深入理解Java并发之synchronized实现原理

Lock接口

ReentrantLock

ReentrantLock 基于底层CAS

final

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

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

参考

这里需要说明的是文章中大部分内容都不是我创造的,为了让自己能够理解知识阅读他人文章分析和总结再分享给大家。一下就是这篇总结文章中参考的文章和书籍(去掉各个章节中已经表明‘参考’的文章或书籍)。

  1. Java并发编程的艺术
  2. 深入Java内存模型
  3. 指令重排序
  4. 内存屏障与JVM并发