JVM 与动态内存管理
在 Java 体系中,提到并发就不得不提到 JMM,因为所有并发安全都是围绕内存来展开的,可以说不懂内存结构就不懂并发。
Java 运行时内存结构
- pc 寄存器
每个线程一个,标志该线程当前正要执行的指令。 - java 虚拟机栈
每个线程一个,每次方法调用压入一个栈帧,每个栈帧的组成实际上在编译期间就已经决定了,虚拟机只用按照自己对数据大小的定义来分配空间。栈帧的内容包括局部变量表(方法参数、局部变量)、操作数栈、动态链接、方法出口(返回地址)等。我们常说的“栈”一般指的是虚拟机栈。
主线程也拥有一个虚拟机栈,那么 main 方法的调用有必要也产生一个栈帧吗?是的,main 栈帧中保存的返回地址指向一个 HALT 指令。
如果 main 方法的调用不产生栈帧,那么 main 方法对应的指令块末尾必须有一个 HALT 指令替代 return 的语义。
那么线程又是怎么结束的?线程没有 main 方法,但是有 run 方法啊。 - 堆
所有线程共享的内存区,可以供类实例和数组对象分配空间。
堆是由 java 虚拟机分配的,而虚拟机可以由别的语言实现,对堆的分配并不要求是连续的,这一点和栈一样,其实我觉得堆和栈除了功能没有别的区别。那么堆可以使用(通过 C 语言)链表实现吗,我觉得是可以的,但是效率会很低,因为堆的主要功能还是查找,同样的栈也不方便使用链表实现,因为局部变量和临时变量都是不确定大小的。 - 方法区(method area)
存储类结构。方法区逻辑上属于堆,但简单的虚拟机实现可以选择在这个区域内不实现垃圾收集与压缩。类似于传统语言的代码区或操作系统的正文段(text segment)。
运行时常量池(runtime constant pool)
对应每个 class 文件中的每个类或接口的常量池表(constant_pool table)以及其他内部分配的对象,这意味着运行时常量池是分配于方法区的。
保存常量(比如 intern’d strings),比如在编译期可知的数值字面量或者在运行期才能获得的方法或字段引用,类似于符号表(symbol table)。 - 本地方法栈(native method stack)
用于支持 java 以外语言编写的方法的执行。
对本地方法栈的实现并没有强制规定,甚至于 Sun HotSpot 虚拟机将本地方法栈和虚拟机栈合而为一。 - 直接内存(Direct Memory)
jdk1.4 加入的 NIO 引入了一种基于通道(Channel)和缓冲区(Buffer)的 IO 方式,它
内存模型的概念
Java 内存模型 JMM(Java Memory Model)主要目标是定义程序中各个变量(非线程私有)的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。
JMM 定义了线程和主存之间的抽象关系,,下面将提到的多线程共享内存模型实际上就是指 JMM,具体体现为:
- JMM 决定了一个线程对共享变量的修改何时对另一个线程可见;
进程和线程在内存空间上的关系
在讨论什么是线程前有必要先说下什么是进程,因为线程是进程中的一个实体,线程本身是不会独立存在的。进程有很多种定义方式:进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是运行中的程序的抽象;线程则是进程的一个执行路径,一个进程至少有一个线程,所有线程共享进程的上下文,所以在分配线程的执行时间时不需要切换上下文。。
操作系统在分配资源时候是把资源分配给进程的,但是CPU 资源就比较特殊,它是分派到线程的,因为真正要占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位。
这里的资源包含 CPU 资源、内存资源、网络资源等。
Java 中当我们启动 main 函数时候其实就启动了一个 JVM 的进程,而 main 函数所在线程就是这个进程中的一个线程,也叫做主线程。
构成
如图一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器,栈区域。
- 堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时候分配的,堆里面主要存放使用 new 操作创建的对象实例。
- 方法区则是用来存放进程中的代码片段的,是线程共享的。
- 其中程序计数器是一块内存区域,用来记录线程当前要执行的指令地址,那么程序计数器为何要设计为线程私有的呢?前面说了线程是占用 CPU 执行的基本单位,而 CPU 一般是使用时间片轮转方式让线程轮询占用的,所以当前线程 CPU 时间片用完后,要让出 CPU,等下次轮到自己时候在执行,那么如何知道之前程序执行到哪里了?其实程序计数器就是为了记录该线程让出 CPU 时候的执行地址,待再次分配到时间片时候就可以从自己私有的计数器指定地址继续执行了。
- 另外每个线程有自己的栈资源,一般称为线程栈,用于存储该线程的局部变量,这些局部变量是该线程私有的,其它线程是访问不了的,另外栈还用来存放线程的调用栈帧。
栈帧被分配在 Java 虚拟机栈中,其内容包括本地变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用(因此该方法可以获取到它所属的 Class 对象)。
分辨当前栈帧、当前方法和当前类的概念。
栈帧和线程的关联,栈帧是线程本地似有的数据,一个栈帧不可以引用另一个线程中的栈帧。
共享资源
所谓共享资源是说该资源被多个线程共享,多个线程都可以去访问或者修改的资源。另外本文当讲到的共享对象就是共享资源。多个线程访问同一共享对象的代码可以称为临界区。
内存可见性问题
工作内存
首先,让我们看一下多线程场景下 JVM 是如何处理共享变量的。Java 内存模型规定了所有的变量都存放在主内存中,当线程使用变量时候都是把主内存里面的变量拷贝到了自己的工作空间或者叫做工作内存,如下图所示。
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),称为工作内存(working memory),工作内存中存储了该线程以读 / 写共享变量的副本,线程对变量的读写操作都必须在工作内存进行,无法直接读写主内存中的变量,且两个线程无法直接访问对方的工作内存。工作内存是 JMM 中的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化,每个线程的工作内存受 JMM 管理,当两个线程需要进行通信时,需要先由其中一个线程将共享变量刷新到主内存中,然后另一个线程再从主内存中读取更新过的变量。
Java 内存模型是个抽象的概念,那么在实际实现中什么是线程的工作内存呢?Java 是使用 C++线程库或操作系统相关 API 实现线程的(pthread_create),这里不谈其底层实现细节,但线程的执行无非就是将数据加载到寄存器中进行运算(内存和寄存器之间可能还存在高速缓存 cache),运行 ps 指向的内存中的指令,理解线程、主内存、工作内存之间的关系时,我们可以类比物理机中 CPU、高速缓存、内存之间关系,学过计算机组成原理,我们知道 CPU、高速缓存、内存之间的关系如下所示:
双核 CPU 系统架构中每核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算,并且有自己的一级缓存,并且有些架构里面双核还有个共享的二级缓存。
那么对应 Java 内存模型里面的工作内存,在实现上这里是指 L1 或者 L2 缓存或者 CPU 的寄存器。
会由主内存复制到工作内存的变量主要有两种:
- 显式定义了局部变量的,例如在方法内部
XXX foo = this.bar;
- 在多核或多 CPU 系统中,主内存中的数据被读入到了不同的高速缓存中。
线程间通信
线程间通信是并发编程的一个重要主题。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制主要有两种:共享内存和消息传递。
- 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。
- 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
线程间同步
线程同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥访问。
在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java 并发采用的是共享内存模型,通信隐式进行;同步显示指定。
内存可见性问题
- 单线程程序。JMM 不会出现内存可见性问题。
- 多线程且正确同步的程序。JMM 在临界区中可能会重排序,但执行结果与顺序一致性模型一致,因此不会出现内存可见性问题。
- 多线程且未正确/未同步的程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null, false)。
一个例子
假如线程 A 和 B 同时去处理一个共享变量,会出现什么情况呢?
使用上图 CPU 架构,假设线程 A 和 B 使用不同 CPU 去修改共享变量 X,假设 X 初始化为 0,并且当前两级 Cache 都为空的情况,具体看下面分析:
- 假设线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中,所以到主内存加载了 X=0,然后会把 X=0 的值缓存到两级缓存,假设线程 A 修改 X 的值为 1,然后写入到两级 Cache,并且刷新到主内存(注:如果没刷新回主内存也会存在内存不可见问题)。这时候线程 A 所在的 CPU 的两级 Cache 内和主内存里面 X 的值都是 1;
- 然后假设线程 B 这时候获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回 X=1;然后线程 B 修改 X 的值为 2;然后存放到线程 2 所在的一级 Cache 和共享二级 Cache,最后更新主内存值为 2;
- 然后假设线程 A 这次又需要修改 X 的值,获取时候一级缓存命中获取 X=1,到这里问题就出现了,明明线程 B 已经把 X 的值修改为了 2,为啥线程 A 获取的还是 1 呢?这就是共享变量的内存不可见问题,也就是线程 B 写入的值对线程 A 不可见。
解决内存可见性问题
一般解决可见性问题有两个方式。
- 通过缓存一致性协议;
在硬件层面上,CPU 提供了MESI 缓存一致性协议,当某个 CPU 修改了缓存中的数据,该数据会马上同步回主内存,其他 CPU 通过总线嗅探(snooping)机制可以感知到自己数据的变化从而将自己缓存里的数据失效。 - 通过在总线加锁。
CPU 在从主存读数据到 cache 前先对总线加锁,加锁后其他 CPU 无法同时对这个数据进行读写,直到这个 CPU 释放锁,显然这种方式的效率是比较低的。
既然 CPU 已经支持了MESI 缓存一致性协议,为什么还需要 volatile 呢?Java 中的volatile实际上是采用总线加锁的方式(实现上是利用了内存屏障指令)解决了内存可见性问题,是因为多核情况下,所有的 cpu 操作都会涉及缓存一致性的校验,只不过该协议是弱一致性的,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他 CPU 状态已经置为无效,但是当前 CPU 可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他 CPU 需要使用该变量,则又会从主存中读取到旧的值。而 volatile 则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作。如下图所示:
其中,在高速缓存和主存之间即为总线(MESI 缓存一致性协议),为了保证第一个 CPU 修改数据并同步回主存的操作的原子性,会在总线对这个数据进行加锁,等到该 CPU 同步完毕后其他 CPU 才能继续读写。
重排序
内存操作指令集
线程间若需要完成通信还需要提供一些操控主内存和工作内存内对象的指令,这些指令可以在反编译(javap -c)后的字节码中找到:
操作类型 | 作用域 | 含义 |
---|---|---|
lock | 主内存变量 | 标识变量为线程独占 |
unlock | 主内存变量 | 将锁定的变量释放 |
read | 主内存变量 | 将变量的值从主内存传输到工作内存,便于之后的 load |
load | 工作内存变量 | 将主内存传递的变量值放入工作内存变量副本中 |
use | 工作内存变量 | 将工作内存的变量值传递给执行引擎 |
assign | 工作内存 | 将执行引擎的值传递给工作内存的变量 |
store | 工作内存变量 | 将工作内存的变量值传递到主内存中,便于之后的 write |
write | 主内存变量 | 将工作内存传递来的值放入主内存变量中 |
从上图可知,变量从主内存放入工作内存变量副本中实际是分为两步的:
- 把主内存的值放在工作内存中,此时还没有放入变量副本中;
- 把已经放在工作内存的值放入变量副本中。
同理,变量副本从工作内存到主内存也是分为两步的,在此不再赘述。总之,两个内存空间的变量值的传递需要两个操作才能完成,这样做是为了提高 cpu 的效率,不等待主内存写入完成。
上述 8 个操作需要满足下面规则:
- 不允许 read、load 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受;store、write 同理,即不允许从工作内存写回主内存但主内存不接受。
read、load 操作必须按顺序执行,store、write 操作也是,但不一定是连续执行。
- 不允许丢弃最近的 assign 操作,即变量在工作内存更新了但是没有同步到主内存。
- 不允许线程无原因地把数据从工作内存同步回主内存。
- 顺序性:use、store 操作之前必须先执行 assign、load 操作,即使用前必须已经经过赋值操作、存储前必须已经经过载入操作。
- 互斥性:一个变量在同一时刻只允许一个线程对其进行 lock 操作,同一线程可以多次执行 lock。
- 如果对一个变量执行 lock 操作,线程会清空工作内存中此变量的值,执行引擎使用时,必须重新执行 load 或 assign 操作初始化变量的值。
- 如果变量事先没有被 lock 操作,就不能对其进行 unlock 操作。
- 对变量执行 unlock 操作之前,必须先把此变量同步回主内存中(store、write)。
重排序的类型
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,重排序会遵守数据的依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
重排序分为如下三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下(代码中不包含 synchronized 关键字),可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操 作看上去可能是在乱序执行。
编译器优化重排序属于编译器重排序,指令级并行重排序、内存系统重排序属于处理器重排序。
而我们编写的 Java 源程序中的语句顺序并不对应指令中的相应顺序,如(int a = 0; int b = 0;翻译成机器指令后并不能保证 a = 0 操作在 b = 0 操作之前)。因为编译器、处理器会对指令进行重排序,通常而言,Java 源程序变成最后的机器执行指令会经过如下的重排序。
- 这些重排序可能会导致多线程程序出现内存可见性问题,我们之后将讨论的volatile的主要功能就是解决内存可见性问题,其原理是通过内存屏障指令实现我们预定义的happens-before 原则;
- JMM 编译器重排序规则会禁止特定类型的编译器重排序;
- JMM 的处理器重排序会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序;
- 重排序必须遵循happens-before 原则和as-if-serial 语义。
内存屏障指令
为了保证内存可见性,Java 编译器在生成的指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。内存屏障指令分为如下四种:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的加载先于 Load2 及所有后续加载指令的加载 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),即 Store1 先于 Store2 及所有后续的存储指令的存储 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据的加载先于 Store2 及所有后续的存储指令刷新到内存 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据的存储先于 Load2 及所有后续的加载指令的加载 |
happens-before
JMM 中包含的 happens-before 主要目的是禁止可能会改变程序执行结果的重排序,但对不影响执行结果的重排序不作要求。
happens-before 原则是判断共享数据是否存在竞争、线程是否安全的主要依据。简单地说 happens-before 原则是解决内存可见性问题的充分条件,先行发生的操作产生的改变总能够被后面的操作看到(一条操作可能被编译成多条指令,指令可能还会发生重排序,但是必须满足可见性条件)。
JMM 对编译器和处理器的束缚已经尽可能的少,原则上,只要不改变程序的执行结果(指的是单线程 程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
happens-before 原则包括以下细则:
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作(控制流操作而不是程序代码顺序)。
- 监视器锁规则:对一个监视器的解锁,happens-before 于随后对这个监视器的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。
- 线程终止规则:线程中所有操作都 happens-before 于对此线程的终止检测。
- 线程中断规则:对线程 interrupt()方法的调用 happens-before 于被中断线程的代码检测到中断事件的发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)happens-before 于它的 finalize()方法的开始。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
依赖关系
在 JMM 分析中需要关注两种依赖关系:
数据依赖性:如果两个操作访问同一变量,且有一个是写操作,则这两个操作存在数据依赖性(仅针对单个 CPU 中执行的指令序列和单个线程中执行的操作)。
控制依赖性:前序操作是条件语句(if, while, for…),则后续操作和前序之间就产生了控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。
as-if-serial 语义
定义:不管怎么重排序(编译器和处理器为了提高并行度而重排序),(单线程)程序的执行结果不能被改变。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
可以采用猜测(Speculation) 执行来克服控制依赖性对并行度的影响。
例如,CPU 先执行后面的操作,并将其计算结果保存到**重排序缓存(Reorder Buffer: ROB)**的硬件缓存中,如果条件为真,直接使用缓存中的结果顺序执行。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因)。
在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
1 | class ReorderExample { |
上面代码希望使用线程 A 先执行 writer(),然后线程 B 后执行 reader()函数,则 A happens-before B,由于各线程内不存在数据依赖关系(操作 1 与操作 2,操作 3 与操作 4),因此它们各自的代码都是可重排序的,但是由于 if 语句的存在,会产生控制依赖。如下图所示,根据 A happens-before B 的语义,i 的结果本应该是 1,但是重排序后 i 的结果为 0。
如上图所示,加入猜测执行后,会使用一个 temp 缓存 i 指令的结果,这违反了 A happens-before B 的语义,因此为了在多线程中保证程序的正确性,必须进行适当的同步。
顺序一致性内存模型
顺序一致性内存模型是 JMM 的参考模型,它提供了很强的内存一致性与可见性,是一个被计算机科学家理想化了的理论参考模型,这对于程序员来说是一个极强的保证。
顺序一致性
如果程序是正确同步的(广义上的同步,包括对常用同步原语(synchronized, volatile, final)的正确使用),程序的执行将具有顺序一致性(sequentially consistent)。
具体的,必须满足以下要求:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见(JMM 中并不保证,除非额外使用一些同步方法)。
每一时刻只有一个线程可以访问内存,当多个线程并发执行时,必须有一个同步管理器(如监视器锁)进行协调。
JMM 和顺序一致性模型间的关系和区别
JMM 以顺序一致性模型作为参照,当然还有很多细节会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器内存模型和 JMM,那么很多的处理器和编译器优化都要被进制,这对执行性能将造成很大的影响。
下面这个例子用于区分 JMM 和顺序一致性模型:
1 | class SynchronizedExample { |
可见它们的区别主要是:
- JMM 在临界区内的操作可以重排序(除非含有数据依赖),顺序一致性模型中则必须按照操作顺序执行;
- JMM 必须在进入临界区时进行一些处理,使得线程在该时间点具有与顺序一致性模型相同的内存视图;
未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:
- 线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false);
- JMM 保证线程不会读取到除上面这种情况之外设置的值。
JVM 在堆上分配对象时,首先会清零内存空间,其实就是还原为默认值,然后才会在上面分配对象,JVM 内部会同步这两个操作)。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的(注意上边对顺序一致性模型的定义中,并没有规定多线程情况下线程间如何协作),其执行结果往往无法预知。保证未同步程序在这两个模型中的执行结果一致没什么意义。
未同步程序在 JMM 和顺序一致性模型中执行的区别如下:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(会进行重排序)。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
- JMM 不保证对 64 位的 long 型和 double 型变量的读/写操作具有原子性(JDK5 之后的读具有原子性,写不具有),而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
JMM 与顺序一致性内存模型程序执行结果对比
- 单线程程序。JMM 与顺序一致性模型执行结果一致。
- 多线程且正确同步的程序。JMM 与顺序一致性模型执行结果一致。
- 多线程且未正确/未同步的程序。JMM 与顺序一致性模型执行结果皆不可预知。
synchronized
synchronized 关键词提供了一种同步锁机制,是 Java 用于实现同步的一个工具
- 若加在代码块上,锁定一个对象,在此期间其他线程无法访问这个代码块,但可以访问其他未加锁的代码块
- 若加在静态方法上,则 JVM 会将它所在的类锁定,对这个类的所有对象有效
- 若加在非静态方法上,则将对应的对象锁定
虽说有这么多用法,但是本质上是相同的,只要两个线程同时申请一个锁,他们就会被同步,如果一个线程已经取得了锁,但是另一个线程不去申请这个锁,那么锁机制是没有意义的。
synchronized 块是 Java 提供的一种原子性内置锁,Java 中每个对象都可以当做一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫做监视器锁。
1 | public class SynchronizedTest { |
线程内部在遇到内部锁时需要获取资源,同样在退出时需要释放掉:
- 线程在进入 synchronized 代码块前会自动尝试获取内部锁,如果这时候内部锁没有被其他线程占有,则当前线程就获取到了内部锁,这时候其它企图访问该代码块的线程会被阻塞挂起。
- 拿到内部锁的线程会在正常退出同步代码块或者异常抛出后或者同步块内调用了该内置锁资源的 wait 系列方法时候释放该内置锁;内置锁是排它锁,也就是当一个线程获取这个锁后,其它线程必须等待该线程释放锁才能获取该锁。
锁在 Java 并发编程中,主要包含以下两个功能:
- 让临界区代码互斥执行;
- 让释放锁的线程向获取同一个锁的线程发送消息。
锁的数据结构
synchronized 的相关数据结构主要是对象头中的锁标志位和线程数据结构中的monitor:
- synchronized 将锁标志位保存到了对象头的Mark Word中,实际上它包含了 1 bit 的偏向锁标识和 2 bit 的锁标识;
- monitor 存放在一个全局的 monitor record 列表,同时该 monitor 中还会有一个 owner 字段记录拥有该锁的线程唯一标识,表示该锁被这个线程占用。
锁优化
自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁、重量级锁
- 自旋锁
优点:避免频繁地阻塞和唤醒线程;
缺点:占用大量 CPU 时间,特别是在高并发(竞争多)、线程占用锁时间长的场景中; - 自适应自旋锁
自旋的次数不再固定,而是由上一次获取该锁时的自旋时间及锁的持有者的状态来决定,比如:线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多;反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 - 锁消除
JVM 检测到不存在竞争,则对相应的同步锁进行锁消除。
我们平时的代码中很少会加这样没有意义的锁,但是在使用一些诸如 StringBuffer、Vector、Hashtable 这样的并发安全容器时,这些容器本身是会加锁的,JVM 检测到容器变量没有被多线程共享的可能后,就会将其中的加锁操作消除。锁消除的依据是逃逸分析的数据支持。
- 锁粗化
将多个连续的加锁、解锁操作(比如循环体内)合并为一个,避免频繁的连续加锁解锁操作造成性能损耗。
偏向锁、轻量级锁、重量级锁的解释,详见另一篇《并发和同步策略》。
锁获取和释放的内存语义
- 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
- 当线程释放锁时,JMM 会把该线程对应的工作内存中的共享变量刷新到主内存中,以确保之后的线程可以获取到最新的值。
锁释放与获取总结为如下三条
- 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出 了(线程 A 对共享变量所做修改的)消息。
- 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个 锁之前对共享变量所做修改的)消息。
- 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存 向线程 B 发送消息。
使用 synchronized 解决内存不可见问题
多线程并发修改共享变量时候会存在内存不可见问题,究其原因是因为 Java 内存模型中线程操作共享变量时候会从自己的工作内存中获取而不是从主内存获取或者线程写入到本地内存的变量没有被刷新回主内存。
下面讲解下 synchronized 的一个内存语义,这个内存语义就可以解决共享变量内存不可见的问题:
线程进入 synchronized 块的语义是会把在 synchronized 块内使用到的变量从线程的工作内存中清除,在 synchronized 块内使用该变量时候就不会从线程的工作内存中获取了,而是直接从主内存中获取;退出 synchronized 块的内存语义是会把 synchronized 块内对共享变量的修改刷新到主内存。
像上面那样,工作内存一般是采用 CPU 缓存(L1 或者 L2 缓存或者 CPU 的寄存器)实现,假如线程在 synchronized 块内获取变量 X 的值,那么线程首先会清空所在的 CPU 的缓存,然后从主内存获取变量 X 的值;当线程修改了变量的值后会把修改的值刷新回主内存。
其实这也是加锁和释放锁的语义,当获取锁后会清空本地内存中后面将会用到的共享变量,在使用这些共享变量的时候会从主内存进行加载;在释放锁时候会刷新本地内存中修改的共享变量到主内存。
除了可以解决共享变量内存可见性问题外,synchronized 经常被用来实现原子性操作。
缺点
synchronized 关键字会引起线程上下文切换和线程调度的开销。
锁是一种协议
锁其实是人为加上的限制,实际上变量还是变量,如果线程不去主动获取锁、遵循锁机制,变量还是可以随意访问修改的,实际上锁是多个线程访问内存的。
锁的 happens-before 规则例子
1 | class MonitorExample { |
volatile
上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太重,因为它会引起线程上下文的切换开销,对于解决内存可见性问题,Java 还提供了一种弱形式的同步,也就是使用了 volatile 关键字。
volatile 关键字是对锁机制的妥协,它可以防止对指令进行优化,每次用到一个变量都重新从内存读取而不是使用 寄存器值(网上资料说是一个线程内部数据区) ,从而防止产生脏数据
volatile 是 JVM 提供的最轻量级的同步机制,当一个变量定义为 volatile 时,它将具备两种特性,可见性与禁止指令重排序优化。
volatile 解决内存可见性问题
经 volatile 修饰后,当一个线程修改了该变量的值,其修改结果对于其他线程来说就是可以立即获得的,但是基于 volatile 变量的操作并不是安全的(如自增操作),下面是使用 volatile 的两个条件,如果不满足条件则需要考虑使用 synchrinized 等其他同步方法。
- 运算结果并不依赖变量的当前值(不会产生中间状态),或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束(即参与运算的其他变量也必须是线程安全的)。
volatile 是通过内存屏障(memory barrier,一项让 CPU 处理单元中的内存状态对其他处理单元可见的技术)指令实现的,内存屏障是一个 CPU 指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,会插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,相当于告诉编译器和 CPU:其他指令不能和这条内存屏障指令重排序。
内存屏障指令在硬件层面上可以分为两种:Load Barrier(读屏障)和Store Barrier(写屏障),主要有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
加入 volatile 关键字时,对变量的读写操作被翻译成字节码后会多一个 lock 前缀指令,lock 前缀的原子指令在多核处理器下会引发两件事:
- 保证指令重排序是不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。即在执行到内存屏障这句指令是,在他前面的操作已经全部完成。
- 将当前处理器缓存的数据写回到系统内存;
- 如果是写操作,则引起在其他 CPU 缓存了该地址的缓存行无效。
- 例 1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class Singleton {
private volatile static Singleton instance;
private static int a;
private static int b;
private static int c;
public static Singleton getInstance() {
if(null == instance) {
synchronized(Singleton.class) {
if(null == instance) {
a = 1; // 1
b = 2; // 2
instance = new Singleton(); // 3
c = a + b; // 4
}
}
}
return instance;
}
}
如果没有如果变量 instance 没有 volatile 修饰,则语句 1、2、3 可以任意重排序,如果用 volatile,则编译器会在语句 3 的前后各插入一个内存屏障。
- 例 2 - 观察内存屏障指令:
1
2
3
4
5
6
7
8
9
10
11
12public class VolatileTest {
private volatile static int counter;
public static void main(String[] args) {
counter++;
}
}
$ javac VolatileTest.java
# 执行下面命令前必须先安装hsdis
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly VolatileTest
volatile 的重排序规则
在讨论 happens-before 时我们涉及到过 volatile,这里重新强调一遍:
- 对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
Java 内存模型允许编译器和处理器对指令进行重排序以提高运行性能,并且重排序只会对不存在数据依赖性的指令进行重排序;在单线程下重排序可以保证最终执行的结果是与程序顺序执行的结果一致,但是在多线程下就会存在问题。
volatile 会禁止针对其修饰变量的重排序,具体的排序规则表如下所示:
第一个操作 \ 是否允许重排序 \ 第二个操作 | 普通读/写 | volatile 读 | volatile 写 |
---|---|---|---|
普通读/写 | YES | YES | NO |
volatile 读 | NO | NO | NO |
volatile 写 | YES | NO | NO |
如上表可见:
- 当第一个操作为 volatile 读时,无论第二个操作为何种操作,都不允许重排序;
- 当第二个操作为 volatile 写时,无论第一个操作为何种操作,都不允许重排序;
- 当第一个操作为 volatile 写时,第二个操作为 volatile 读时,不允许重排序。
例 1 - 重排序问题:
1 | int a = 1;//(1) |
如上代码变量 c 的值依赖 a 和 b 的值,所以重排序后能够保证(3)的操作在(2)(1)之后,但是(1)(2)谁先执行就不一定了,这在单线程下不会存在问题,因为并不影响最终结果。
例 2 - 多线程情况下的重排序问题:
1 | public class MultiThreadTest { |
首先这段代码里面的变量没有声明为 volatile 也没有使用任何同步措施,所以多线程下存在共享变量内存可见性问题,这里先不谈内存可见性问题,因为通过把变量声明为 volatile 本身就可以避免指令重排序问题。
这里先看看指令重排序会造成什么影响,如上代码不考虑内存可见性问题的情况下 程序一定会输出 4?答案是不一定,由于代码(1)(2)(3)(4)之间不存在依赖,所以写线程的代码(3)(4)可能被重排序为先执行(4)再执行(3),那么执行(4)后,读线程可能已经执行了(1)操作,并且在(3)执行前开始执行(2)操作,这时候打印结果为 0 而不是 4。
这就是重排序在多线程下导致程序执行结果不是我们想要的了,这里使用 volatile 修饰 ready 可以避免重排序和内存可见性问题。
volatile 读写内存语义
happens-before 规则只是 volatile 语义的一部分,而且比较抽象,volatile 的具体规则如下:
- 读内存语义。当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程之后将从主内存中读取共享变量。
- 写内存语义。当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。这样就保证了 volatile 的内存可见性。
volatile 读写内存语义总结为如下三条:
- 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了
其对共享变量所在修改的消息
。 - 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的
在写这个 volatile 变量之前对共享变量所做修改的消息
。 - 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(对 volatile 写、普通读写实现为不允许重排序,可能会影响性能)。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(普通读写、volatile 读实现为不允许重排序,可能会影响性能)。
下面通过一个示例展示 volatile 的内存语义。
1 | int a; |
volatile 的缺点
volatile 并不能保证并发安全,原因是 volatile 虽然提供了可见性保证,但是并没有保证操作的原子性。
下面这个例子希望证明 volatile 变量不是并发安全的:
1 | private volatile int x = 0; |
最后输出的值并非200000
,而且每一次都不一样,因为 x++操作并非原子,volatile 也并不能保证其原子性。
简而言之,volatile 能保证每次 i++读的都是内存中最新的值,但是如果两个线程同时做读+写的复合操作,仍会有并发问题,因为它们可能会同时读出最新的值,但是写入的顺序却是不一定的。
使用 volatile 的时机
- 当写入变量值时候不依赖变量的当前值,比如
i++
就不行。因为如果依赖当前值则是获取 -> 计算 -> 写入操作,而这三步操作不是原子性的,而 volatile 不保证原子性。 - 该变量没有包含在具有其他变量的不变式中;上面代码中,上下界初始化为 0 和 10,如果这时候线程 A 和 B 在某一时刻同时执行了 setLower(8)和 setUpper(5),且都通过了不变式的检查,可能会设置成一个无效的范围 (8, 5) ,所以在这种场景下,需要通过 synchronized 来保证方法 setLower 和 setUpper 的原子性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class NumberRange {
private volatile int lower = 0;
private volatile int upper = 10;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int v) {
if(v > upper) {
throw new IllegalArgumentException(...);
}
this.lower = v;
}
public void setUpper(int v) {
if(v < lower) {
throw new IllegalArgumentException(...);
}
this.upper = v;
}
} - 读写变量值时候没有进行加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36// volatile关键字是对锁机制的妥协
// 可以防止对指令进行优化,每次用到一个变量都重新从内存读取而不是使用寄存器值,从而防止产生脏数据
// 不能保证内存互斥性
volatile static boolean isStop;
public boolean isStop() {
return isStop;
}
public void setStop(boolean stop) {
isStop = stop;
}
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
setStop(true);
System.out.println("Over...");
}
public static void main(String[] args) {
VolatileTest v = new VolatileTest();
Thread t = new Thread(v);
t.start();
while(true) {
// 若不加volatile,则此处会成为死循环
// 因为v.isStop()会被优化为取内存中的值
if(v.isStop()) {
System.out.println("main...");
break;
}
//System.out.println("others...");
}
} - 状态标记量。使用一个状态变量来决定显示什么,这个变量可能被很多线程修改和访问,且不变式中没有别的变量,用 volatile 就很合适。
1
2
3
4
5
6
7
8
9
10
11
12
13public class Renderer {
private volatile isA;
public void run {
if(isA) {
// 输出A
} else {
// 输出B
}
}
public void setIsA(boolean isA) {
this.isA = isA;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35volatile static boolean isStop;
public boolean isStop() {
return isStop;
}
public void setStop(boolean stop) {
isStop = stop;
}
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
setStop(true);
System.out.println("Over...");
}
public static void main(String[] args) {
VolatileTest v = new VolatileTest();
Thread t = new Thread(v);
t.start();
while(true) {
// 若不加volatile,则此处会成为死循环
// 因为v.isStop()会被优化为取寄存器中的值
if(v.isStop()) {
System.out.println("main...");
break;
}
//System.out.println("others...");
}
} - double check。双检锁是单例模式的一种常见实现方式,但很多人会忽略 volatile 关键字,虽然没有 volatile 也能很好地运行,但是这是一个隐患。单例模式有很多实现方式,我认为静态内部类是更优雅的一种写法,且能保证懒加载的性质。枚举方式是《Effective Java》中的一种推荐方式,但,面试官其实不大喜欢看到这种写法。
1
2
3
4
5
6
7
8
9
10
11
12
13public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if(null == instance) {
synchronized(Singleton.class) {
if(null == instance) {
instance = new Singleton();
}
}
}
return instance;
}
}
final
final 的重排序规则
final 域包含两个重排序规则:
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
例子 1 - final 域的两个重排序规则
1 | public class FinalExample { |
如上代码所示,假设有一个线程 A 先执行 writer()方法,随后另一个线程 B 执行 reader()方法。操作 2 和 4 符合重排序规则 1,不能重排,操作 4 与操作 8 符合重排序规则 2,不能重排。
写 final 域重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面两个方面:
- JMM 禁止编译器把 final 域的写重排序到构造函数之外。
- 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
obj = new FinalExample();
其实包括两步,首先是在堆上分配一块内存空间建立 FinalExample 对象,然后将这个对象的地址赋值给 obj 引用。假设线程 B 读对象引用与读对象的成员域之间没有重排序,则可能的时序图如下:
可见:
- 写普通域的操作被编译器重排序到了构造函数外,读线程 B 错误的读取了普通变量 i 初始化之前的值。
- 写 final 域的操作,被写 final 域的重排序规则限定在了构造函数之内,因此读线程 B 正确地读取了 final 变量初始化之后的值。
写 final 域的重排序可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。
上例中,在读线程看到对象引用 obj 时,很可能 obj 对象还没有构造完毕、正在执行构造函数期间。
读 final 域重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器。
reader 方法包含三个操作:① 初次读引用变量 obj。② 初次读引用变量 obj 指向对象的普通域 i。③ 初次读引用变量 obj 指向对象的 final 域 j。假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:
如上图所示,reader 操作中 1、2 操作重排了,即读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。
final 域是引用类型
上面我们的例子中,final 域是基本数据类型,如果 final 与为引用类型的话情况会稍微不同。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束
- 在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。假设首先线程 A 执行 writerOne()方法,执行完后线程 B 执行 writerTwo()方法,执行完后线程 C 执行 reader ()方法。下面是一种可能的线程执行时序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public class FinalReferenceExample {
final int[] intArray; // final 是引用类型
static FinalReferenceExample obj;
public FinalReferenceExample () { // 构造函数
int Array = new int[1]; // 1
int Array[0] = 1; // 2
}
public static void writerOne () { // 写线程 A 执行
obj = new FinalReferenceExample (); // 3
}
public static void writerTwo () { // 写线程 B 执行
obj.intArray[0] = 2; // 4
}
public static void reader () { // 读线程 C 执行
if (obj != null) { //5
int temp1 = obj.intArray[0]; // 6
}
}
}
如上图所示,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能 和 3 重排序外,2 和 3 也不能重排序。JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步原语(lock 或 volatile)来确保内存可见性。
final 逸出
写 final 域的重排序规则可以确保:在引用变量为任意线程可见 之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其 实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的 引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。我们来看下面示例代码:
1 | public class FinalReferenceEscapeExample { |
假设一个线程 A 执行 writer()方法,另一个线程 B 执行 reader()方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且即使在程序中操作 2 排在操作 1 后面,执行 read()方法的线程仍然可能无 法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如下图所示:
在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。
伪共享
什么是伪共享
计算机系统中为了解决主内存与 CPU 运行速度的差距,在 CPU 与主内存之间添加了一级或者多级高速缓冲存储器(Cache),这个 Cache 一般是集成到 CPU 内部的,所以也叫 CPU Cache。
Cache 内部是按行存储的,其中每一行称为一个 Cache 行,Cache 行是 Cache 与主内存进行数据交换的单位,Cache 行的大小一般为 2 的幂次数字节。
当 CPU 访问某一个变量时候,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache 行大小的内存拷贝到 Cache(Cache 行是 Cache 与主内存进行数据交换的单位)。
由于存放到 Cache 行的是内存块而不是单个变量,所以可能会把多个变量存放到了一个 Cache 行。当多个线程同时修改一个缓存行里面的多个变量时候,由于同时只能有一个线程操作缓存行,所以相比每个变量放到一个缓存行性能会有所下降,这就是伪共享。
如上图变量 x,y 同时被放到了 CPU 的一级和二级缓存,当线程 1 使用 CPU 1 对变量 x 进行更新时候,首先会修改 CPU 1 的一级缓存变量 x 所在缓存行,这时候缓存一致性协议会导致 CPU 2 中变量 x 对应的缓存行失效。
那么线程 2 写入变量 x 的时候就只能去二级缓存去查找,这就破坏了一级缓存,而一级缓存比二级缓存更快,这里也说明了多个线程不可能同时去修改自己所使用的 CPU 中缓存行中相同缓存行里面的变量。更坏的情况下如果 CPU 只有一级缓存,那么会导致频繁的直接访问主内存。
伪共享出现原因
伪共享的产生是因为多个变量被放入了一个缓存行,并且多个线程同时去写入缓存行中不同变量。那么为何多个变量会被放入一个缓存行?其实是因为 Cache 与内存交换数据的单位就是 Cache 行,当 CPU 要访问的变量没有在 Cache 命中时候,根据程序运行的局部性原理会把该变量在内存中大小为 Cache 行的内存放入缓存行。
1 | long a; |
如上代码,声明了四个 long 变量,假设 Cache 行的大小为 32 个字节,那么当 CPU 访问变量 a 时发现该变量没有在 Cache 命中,那么就会去主内存把变量 a 以及内存地址附近的 b、c、d 放入缓存行。
也就是地址连续的多个变量才有可能会被放到一个缓存行中,当创建数组时候,数组里面的多个元素就会被放入到同一个缓存行。
单线程下多个变量或数组元素被放入缓存行对性能不会有影响,反而是有利的,因为数据都在缓存中,代码执行会更快,可以对比下面代码执行:
1 | public class ContentTest { |
我的电脑上执行 test1()耗时基本在 10ms 以下、test2()在 10ms 以上。
总的来说 test1()比 test2()执行的快,这是因为数组内数组元素之间内存地址是连续的,当访问数组第一个元素时候,会把第一个元素后续若干元素一块放入到 Cache 行,这样顺序访问数组元素时候会在 Cache 中直接命中,就不会去主内存读取,后续访问也是这样。
总结下也就是当顺序访问数组里面元素时候,如果当前元素在 Cache 没有命中,那么会从主内存一下子读取后续若干个元素到 Cache,也就是一次访问内存可以让后面多次直接在 Cache 命中。而代码 test2()是跳跃式访问数组元素的,而不是顺序的,这破坏了程序访问的局部性原理,并且 Cache 是有容量控制的,Cache 满了会根据一定淘汰算法替换 Cache 行,会导致从内存置换过来的 Cache 行的元素还没等到读取就被替换掉了。
所以单个线程下顺序修改一个 Cache 行中的多个变量,是充分利用了程序运行局部性原理,会加速程序的运行,而多线程下并发修改一个 Cache 行中的多个变量而就会进行竞争 Cache 行,降低程序运行性能。
伪共享避免
JDK 8 之前一般都是通过字节填充的方式来避免,也就是创建一个变量的时候使用填充字段填充该变量所在的缓存行,这样就避免了多个变量存在同一个缓存行。
例 1 - 字节填充解决伪共享问题:
1 | public final static class FilledLong { |
假如 Cache 行为 64 个字节,那么我们在 FilledLong 类里面填充了 6 个 long 类型变量,每个 long 类型占用 8 个字节,加上 value 变量的 8 个字节总共 56 个字节,另外这里 FilledLong 是一个类对象,而类对象的字节码的对象头占用了 8 个字节,所以当 new 一个 FilledLong 对象时候实际会占用 64 个字节的内存,这个正好可以放入 Cache 的一个行。
在 JDK 8 中提供了一个 sun.misc.Contended
注解,用来解决伪共享问题,上面代码可以修改为如下:
需要注意的是默认情况下 @Contended
注解只能用到 Java 核心类,比如 rt 包下的类,如果需要在用户 classpath 下的类中使用这个注解则需要添加 JVM 参数:-XX:-RestrictContended
,另外默认填充的宽度为 128,如果你想要自定义宽度可以设置 -XX:ContendedPaddingWidth
参数。
例 2 - 使用 Contended 注解解决伪共享问题:
1 | @sun.misc.Contended |
上面是修饰类的,当然也可以修饰变量,比如 Thread 类中就有几个字段使用了该注解:
1 | /** The current seed for a ThreadLocalRandom */ |
Thread 类里面这三个变量是在 ThreadLocalRandom 中为了实现高并发下高性能生成随机数时候使用的,这三个变量默认是初始化为 0。
对象的构成与分配
对象内存布局
对象包括以下三个部分:
- 对象头(Header),分两个部分:
- 第一个部分存储运行时数据(Mark Word),Mark Word 部分数据的长度在 32 位和 64 位虚拟机(未开启压缩指针)中分别为 32bit 和 64bit。然后对象需要存储的运行时数据其实已经超过了 32 位、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的外存储成本,Mark Word 一般被设计为非固定的数据结构,以便存储更多的数据信息和复用自己的存储空间;
- 另一个部分是类型指针,即指向它的类元数据的指针,用于判断对象属于哪个类的实例;
- 如果是数组,还需要记录数组的长度。
- 实例数据部分(Instance Data),实例数据存储的是真正有效数据,包括从父类继承来的和它自己定义的,如各种字段内容,各字段的分配策略为 longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),在存放数据时虚拟机会把相同宽度的数据放到一块,便于之后存取,在满足这个条件下,父类继承来的数据会在前面;
- 对齐填充(Padding),HotSpot 要求对象的起始地址必须为 8 字节的整数倍,对齐填充部分仅仅起到占位符的作用,并非必须;
对象的引用
虚拟机通过栈中的 reference 类型数据来操作堆上的对象,现在主流的访问方式有两种:
- 使用句柄访问对象。即 reference 中存储的是对象句柄的地址,而句柄中包含了对象示例数据与类型数据的具体地址信息,相当于二级指针。
Java 堆中维护一块句柄池,Java 栈中存储的 reference 指向对象的句柄地址,句柄保存了对象的实例数据(实例池)和类型数据(方法区)的具体地址信息;
这种方式更稳定,因为对象(实例数据)被移动后只需要修改句柄中的实例数据指针,而 reference 本身不需要修改。 - 直接指针访问对象。即 reference 中存储的就是对象地址,相当于一级指针。
reference 直接指向堆中的对象地址,对象除了保存实例数据外,还需要保存访问类型数据的指针;
这种方式更快(不需要间接引用,速度快一倍)。
HotSpot 使用这种方式。
两种方式有各自的优缺点。
- 当 GC 移动对象时,对于句柄方式而言,reference 中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址;而对于直接指针来说,则需要修改 reference 中存储的地址。
- 从访问效率上来看,直接指针要优于句柄,因为直接指针只需进行一次指针定位,节省了时间开销,这也是 HotSpot 采用的实现方式。
Java 对象在内存中的结构
如上图所示:
- 派生类的对象在内存中的存储结构与父类基本一致,除了多了一些属性,这可以保证指向父类对象的指针指向派生类对象时,在相同的位置同样能看到父类中的属性,操作也是安全的。
- 方法地址并不会存储在每个对象中,这样将浪费大量存储空间,方法地址会被存储在每个类对应的一个虚方法表中(
Vtable
)。
Java 数组在内存中的结构
如上图所示:
- 数组本身是一个类型,创建数组对象会在堆中分配一块内存(内存大小=数组长度*每个元素占用的内存空间大小),并返回这块内存的初始地址;
- 多维数组也是一种一维数组,只不过每个元素存储的都是一个一维数组的地址。
对象分配位置
- 在 常量池 中查找对象,如果没有则加载;
- 局部变量是基本类型的,在栈上分配,如果一个对象不会传出到方法之外,那么这个对象也可以直接在栈上分配,不过因为逃逸分析过程比较耗时、效果也不稳定,所以一般情况下并不建议开启这项优化;
- 在TLAB中分配空间。
- 在 堆 中分配内存空间
分配方式有 指针碰撞(Bump the Pointer) 、 空闲列表(Free List) 等,使用哪种方法取决于 堆是否存在空隙。
一些垃圾收集器(Serial、ParNew 等带 Compact 过程)带有压缩整理功能,使用的方法是指针碰撞;而另一些(CMS 等使用 Mark-sweep 过程)使用空闲列表。(C 语言的 malloc/free 函数使用指针碰撞)
JVM 首先会判断是否可以直接进入老年代(这一点我们在《垃圾收集器》部分会再分析),如果无需直接分配到老年代则先分配到新生代的 Eden 区。
栈上分配
Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。
如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)有所下降,所以在很长的一段时间里,即使是 Server Compiler,也默认不开启逃逸分析,甚至在某些版本(如 JDK 1.6 Update18)中还曾经短暂地完全禁止了这项优化。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用。逃逸主要有以下几种:
- 方法逃逸:例如作为调用参数传递到其他方法中;
- 线程逃逸:有可能被其他线程访问到,比如赋值给类变量。
对象分配的线程安全问题
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
保证对象分配过程线程安全的方式主要有两种:
- 对分配内存空间的动作进行同步处理,实际上虚拟机采用 CAS+失败重试的方式来保证更新操作的原子性;
- 将内存分配按线程划分在不同的空间中进行(本地线程分配缓冲 TLAB)。
TLAB 分配
哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB 参数来设定。通常默认的 TLAB 区域大小是 Eden 区域的 1%,当然也可以手工进行调整,对应的 JVM 参数是-XX:TLABWasteTargetPercent。内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
对象分配步骤
- 初始化为零值(类似填零操作,但不会修改对象头)
- 设置对象属性,包括对象是哪个类的实例、如何找到对象的元数据信息、对象的哈希码等,存放在对象头(Object Header)中
- 执行
方法,把对象按程序员的意愿进行初始化
对象的初始化
- 将分配给对象的存储空间初始化成二进制的 0
- 最顶层父类初始化块和成员变量赋值(按定义的先后)
- 构造器
- 一层一层父类执行…
- 当前类初始化块和成员变量赋值
- 构造器
类变量、成员变量、局部变量
- 类变量分配在方法区——因为一个类只需要一个,但是后来引入 MetaSpace 后,类变量就被一并转移到了堆中;
- 成员变量分配在堆中;
- 局部变量则分配在栈中,如果局部变量 new 了一个对象,那个对象分配在堆中,而引用变量实际上还是在栈中的。
前二者默认都会被初始化,局部变量不会(所以要小心),这个规则和 C 语言中的差不多。
1 | public class JavaTest { |
this 和 super
this 为对象本身的一个引用,super 为父类对象的一个引用,可以用于调用被子类重写的方法或成员变量。当使用 this 或 super 调用本类或父类中的构造函数时,调用语句必须在构造函数的第一行。
虚拟机在实例化一个类的同时会创建一个 this 和 super 的引用,在每次调用成员方法的时候会把这两个值作为隐藏参数传入。
构造方法
没有返回值的方法,它实际上是 static 的,只不过该 static 声明是隐式的。
OOP 的实现原理
封装
封装的关键是访问控制,这是由编译器保证的,比如说我访问了一个类中的 private 成员,编译器会报出错误。
继承
oo 语言可以通过继承来复用代码,但是注意:
- 子类会继承父类的方法和成员变量并初始化,但 private 是不可见的
- 静态成员的继承和非静态的一样,final 同理只是不能重写
- 子类不会继承父类的构造函数,只能通过 super 来调用父类的构造函数
Java 在创建一个子类对象时,会先给父类对象分配空间,然后子类依次跟在后面,如果用 super 调用成员变量就从父对象的基址开始计算偏移量,如果是 this 调用就从子对象的基址开始计算。
但是每个类的方法的地址都是基本不变的(在方法区),所以没有必要为每个对象都分配方法指针,而是另外创建一个方法表(虚函数表),保存对象所有可以调用的方法,包括从父类继承而来的(为什么不叫虚拟方法表?因为 Java 方法全都是 virtual 的,所以去掉了虚拟二字),而对象中只保存这个方法表的起始地址。
多态
当 JVM 执行 Java 字节码时,类型信息会存储在方法区中,为了优化对象的调用方法的速度,方法区的类型信息会增加一个指针,该指针指向一个记录该类方法的方法表,方法表中的每一个项都是对应方法的指针。
注意这里的关键是:每个类有且仅有一个方法表,继承时,子类的方法表会先从父类拷贝过来,然后再添上自己的方法,子类重写的方法会和父类对应的方法占用同一个表项。
由于这样的特性,使得方法表的偏移量总是固定的,例如,对于任何类来说,其方法表的 equals 方法的偏移量总是一个定值,所有继承父类的子类的方法表中,其父类所定义的方法的偏移量也总是一个定值(不是特别理解,能有源码吗?)。
最佳实践 - 写出 GC 友好代码
- 最基本的建议就是尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为 null.我们在使用这种方式时候,必须特别注意一些复杂的对象图,例如数组,队列,树,图等,这些对象之间有相互引用关系较为复杂。对于这类对象,GC 回收它们一般效率较低。如果程序允许,尽早将不用的引用对象赋为 null.这样可以加速 GC 的工作。
- 尽量少用 finalize 函数。finalize 函数是 Java 提供给程序员一个释放对象或资源的机会。但是,它会加大 GC 的工作量,因此尽量少采用 finalize 方式回收资源。
- 如果需要使用经常使用的图片,可以使用 soft 应用类型。它可以尽可能将图片保存在内存中,供程序调用,而不引起 OutOfMemory.
- 注意集合数据类型,包括数组,树,图,链表等数据结构,这些数据结构对 GC 来说,回收更为复杂。另外,注意一些全局的变量,以及一些静态变量。这些变量往往容易引起悬挂对象(dangling reference),造成内存浪费。
- 当程序有一定的等待时间,程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。使用增量式 GC 可以缩短 Java 程序的暂停时间。
QA
- 什么是共享变量的内存可见性问题。
- 什么是 Java 指令重排序?
- Java 中 synchronized 关键字的内存语义是什么?
- Java 中 volatile 关键字的内存语义是什么?
- volatile 与 happens-before、as-if-serial 的关系是什么?
happens-before 规定了一些重排序限制规则,其主要目的是禁止一些可能会导致程序运行结果出错的重排序,比如下面代码中,两个线程分别执行 writer 和 reader 方法,操作 2 和操作 3 可能发生重排序,如果操作 2 先执行,则 i 最终为 1, 如果操作 3 先执行,则 i 的值为 0。as-if-serial 是指不管如果重排序,程序的运行结果不变,也就是说 happens-before 可以保证 as-if-serial。1
2
3
4
5
6
7
8
9
10
11
12
13class ReorderExample {
int a = 0, i = 0;
boolean flag = false;
public void writer() {
a = 1; //操作1
flag = true; //操作2
}
public void reader() {
if (flag) { //操作3
i = a * a; //操作4
}
}
}
happens-before 是通过内存屏障执行来实现的,比如 volatile 规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。也就是说 volatile 包含了 happens-before 中的一条规则。 - 什么是伪共享,为何会出现,以及如何避免?