体系化梳理JVM内存模型

1 cpu和内存模型

  cpu和内存模型  

 上图描述的是cpu和内存模型之间的交互关系,现代的cpu模式和过去有很大的不同,不仅存在多核,也有多cpu的场景,极大提高了线程的并发性。cpu的处理速度非常快,而内存的处理速度较cpu来说较慢。增加了cpu cache memory进行提高指令执行效率,而问题是:

cpu cache memory和主内存之间存在数据一致性问题。



1.1 硬件层面内存屏障

  cpu解决内存一致性问题的解决方案:什么是MESI

  内存屏障:

由于现代操作系统都是多处理器操作系统,每个处理器都会有自己的缓存,可能存再不同处理器缓存不一致的问题,而且由于操作系统可能存在重排序,导致读取到错误的数据,因此,操作系统提供了一些内存屏障以解决这种问题。
1.cpu操作的数据直接更新到主内存,保证内存可见性;
2.屏蔽cpu指令管道化优化,屏蔽指令排序,执行按照期望顺序执行。


 

 内存屏障使用方式:

1.硬件层面:
  - Store Memory Barrier(写屏障)
  - Load Memory Barrier(读屏障)
2.软件层面:屏蔽多种操作系统的差异,统一由jvm规范实施具体的内存屏障
  - volatile
  - synchronized内存屏障表现较弱,毕竟本身可以支持原子性、可见性和顺序性



2 JVM内存模型

  大家都说JVM内存模型和硬件层面cpu内存模型很像,其实他们的的确确存在某种关系。JVM内存模型和JAVA内存模型是两码事:JVM内存模型认知

JVM内存模型

 JAVA处理并发场景的底层内存模型包含:

线程
线程内工作内存(独享)
主内存(共享)


 JAVA为了提高线程执行效率,引入了线程内工作内存的概念,线程首先写入数据到工作内存,即工作内存和主内存存在数据一致性问题。而JVM内存模型操作的数据实际存储在硬件内存,线程执行实际运行在cpu上,JVM内存模型的并发问题实际是cpu内存模型问题。


3 JVM内存模型和硬件内存模型的关系

 JVM内存模型的设计思路和硬件内存模型较为类似。不过JVM内存模型的设计思路是用来屏蔽硬件层面的差异。

JVM内存模型和硬件内存模型的关系(别人输出的)

 阐述软件和硬件层面内存模型关系:

  • 线程之间的共享变量存储在主内存中。
  • 每个线程的工作内存是一个抽象概念,并不真实存在,它抽象的硬件包含:缓存、写缓冲区、寄存器和其他硬件。
  • 从硬件层面来看,主内存就是硬件内存,为了获得更高的执行效率,硬件层面优化优先执行寄存器和高速缓存中的数据。
  • JVM内存模型中的线程的工作内存是cpu的寄存器和高速缓存的抽象描述。而JVM内存模型中主内存就是从硬件内存中逻辑隔离一块内存。

 软件和硬件层面的内存模型共同面临的问题:

1.内存可见性
2.指令重排序
3.读写同步和原子性

Copy

 总结:软件和硬件层面的内存模型面临共同的问题,而硬件层面提供了解决方案,而JVM内存模型是直接利用了硬件层面的优化特性来完成JAVA中的多线程协作任务,所以JVM内存模型直接抽象了底层硬件的内存模型和硬件差异,为上层多线程模型提供了底层硬件的约束,成就了JAVA并发模型。


4 JVM内存模型的演进

 通过上面的分析,JVM内存模型提供了JAVA多线程并发的基础规范;但同时它又为JAVA内存模型提供了逻辑层面的支持,毕竟JVM内存模型是一个抽象的东西,是一个规范。

JVM内存模型提供的规范基础


5 JAVA并发模型

 JAVA多线程的底层基础就是线程内的工作内存和主内存。


5.1 并发的底层原理

 编译器将JAVA程序编译为字节码文件,字节码加载到JVM内存中,JVM执行字节码,最终转换为汇编命令交给cpu执行,JAVA中的所有的并发机制全部依赖于JVM的实现和cpu命令。

 和JAVA并发模型相关的并发关键字:

synchronized:保证并发、内存可见、重排序和原子性
volatile:保证可见性、指令重排序

Copy

 synchronized实现的基础是JAVA对象头,JAVA对象头包含数组类型(12字节)和对象类型(8字节),数组类型比对象类型多了32位的数组长度数值。

|--------------------------------------------------------------|
|                     Object Header (64 bits)                  |
|------------------------------------|-------------------------|
|        Mark Word (32 bits)         |    Class Word (32 bits) |
|------------------------------------|-------------------------|


|---------------------------------------------------------------------------------|
|                                 Object Header (96 bits)                         |
|--------------------------------|-----------------------|------------------------|
|        Mark Word(32bits)       |    Class Word(32bits) |  Array Length(32bits)  |
|--------------------------------|-----------------------|------------------------|

Copy

 Mark Word(32bits)默认存储了对象的Hashcode、分代年龄和锁标记位。

 Class Word(32bits)存储对象描述数据的指针。

 Array Length记录数组对象的数组长度。

 对象头状态变化:

|---|-------------------------------------------------------|--------------------|
|   |                  Mark Word (32 bits)                  |       State        |
|---|-------------------------------------------------------|--------------------|
| 1 | identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 |       Normal       |
|---|-------------------------------------------------------|--------------------|
| 2 |  thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 |       Biased       |
|---|-------------------------------------------------------|--------------------|
| 3 |               ptr_to_lock_record:30          | lock:2 | Lightweight Locked |
|---|-------------------------------------------------------|--------------------|
| 4 |               ptr_to_heavyweight_monitor:30  | lock:2 | Heavyweight Locked |
|---|-------------------------------------------------------|--------------------|
| 5 |                                              | lock:2 |    Marked for GC   |
|---|-------------------------------------------------------|--------------------|

Copy

 对象头状态变化:

  • 1:无锁状态,Mark Word包含Hashcode(25bits),分代年龄(4bits),是否偏向锁标记(1bits),锁状态(2bits)
  • 2:偏向锁状态,Mark Word包含thread(23bits 持有偏向锁的线程id),epoch(2bits 偏向时间戳),分代年龄(4bits),偏向锁状态(2bits)
  • 3:轻量锁状态,ptr_to_lock_record(30bits 指向栈中锁记录的指针),轻量锁状态(2bits)
  • 4:重量锁状态,ptr_to_heavyweight_monitor(30bits 指向管程Monitor的指针),重量锁状态(2bits)

 其中,偏向锁、轻量级锁和重量级锁的膨胀过程也是知识点java偏向锁,轻量级锁与重量级锁为什么会相互膨胀。 

 从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

  当然,juc并发包不是使用JVM内存模型实现的并发控制机制,不在本文阐述范围内。


5.2 对齐填充

 由于HotSpot规定对象的大小必须是8的整数倍,对象头刚好是整数倍,如果实例数据不是的话,就需要占位符对齐填充。


5.3 对象访问定位

 JAVA程序通过栈上的reference对对象进行定位和访问,JAVA虚拟机规范reference只是表示对象的访问,但并没有指定具体的何时访问、如何访问等细节。


5.3.1 直接访问

 栈中的reference直接访问堆中的对象,对象持有指向数据类型的指针。直接指针访问更直接更快,省略了中间访问转达的步骤,目前JVM使用这种方式。

直接访问示意图


5.3.2 句柄访问

 在JAVA堆中隔离一块区域专门用于维护对象访问的句柄池,栈中的reference直接访问句柄池,句柄池持有对象的类型、数据和地址。句柄池对reference透明了底层实现,句柄池的好处在于堆内对象移动或垃圾收集器处理对象堆reference没任何影响。

句柄池的示意图


6 JAVA内存模型

  由上可知,JVM内存模型提供了JAVA内存模型和JAVA并发模型的基础,已经对JAVA并发模型作了讲解,接下来讲解JAVA内存模型。


6.1 JAVA内存区域

前提:本文讲的基本都是以Sun HotSpot虚拟机为基础的,Oracle收购了Sun后目前得到了两个【Sun的HotSpot和JRockit(以后可能合并这两个),还有一个是IBM的IBMJVM】。

Copy

 JAVA内存区域实际上是线程的数据和代码的集合,JVM内存模型中的工作内存和主内存也分布在JAVA内存区域。上面讲了从cpu内存模型和JAVA虚拟机规范(抽象),在这里引申出JAVA内存模型,是一个真真正正的实体,它为JAVA并发模型提供了运行基础(主内存、工作内存、对象头等),同时也为后续的JAVA内存管理提供了依据。

JAVA内存区域


6.1.1 程序计数器

  程序计数器占据内存中很小的一块空间,它可以看作是当前线程所执行的字节码的行号指示器。

  cpu按照时间片执行线程,切换线程前需把线程数据保存好,为方便切换原线程后可以继续工作,需要程序计数器记录字节码执行的行数。每个线程的数据是独立的。

此区域在JVM中唯一没有OutOfMemory的区域。

Copy

 


6.1.2 栈

 与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。

 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

 栈帧的详细的解释:栈的讲解

栈示意图

 栈顶帧表示的是当前正在执行的方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。

虚拟机栈规定了两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

Copy


6.1.3 本地方法栈

 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

Copy


6.1.4 堆

 堆是占内存最大,管理最为复杂的物理区域。堆面向线程共享的,所以线程间通信和线程安全都是在堆内发生的。

 注意将堆、对象头、线程并发关联在一起。

 java虚拟机规范对这块的描述是:所有对象实例及数组都要在堆上分配内存,但随着JIT编译器的发展和逃逸分析技术的成熟,这个说法也不是那么绝对,但是大多数情况都是这样的。

即时编译器:可以把把Java的字节码,包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序)

逃逸分析:通过逃逸分析来决定某些实例或者变量是否要在堆中进行分配,如果开启了逃逸分析,即可将这些变量直接在栈上进行分配,而非堆上进行分配。这些变量的指针可以被全局所引用,或者其其它线程所引用。

Copy

逃逸技术理论分析

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

Copy

 常用堆内存分配参数:

-Xms 设置java应用程序启动时的初始堆大小。 
-Xmx 设置java应用程序能获得的最大堆大小。 
-Xss 设置线程栈的大小。 
-XX:PermSize 设置永久区的初始值 
-XX:MaxPermSize 设置最大的永久区大小 
-XX:MinHeapFreeRatio 设置堆空间最小空闲比例。 
-XX:MaxHeapFreeRatio 设置对空间最大空闲比例。 
-XX:SurvivorRatio 新生代中eden区与survivor区的比例。

Copy


6.1.5 方法区

 方法区同堆一样,是所有线程共享的内存区域,为了区分堆,又被称为非堆。

 java虚拟机对方法区比较宽松,除了跟堆一样可以不存在连续的内存空间,定义空间和可扩展空间,还可以选择不实现垃圾收集。

 JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。

 不过要谨防,永久代或元空间不够用引起的fullgc现象,一般默认是可变大小的,不够用会申请更大的空间,且伴随fullgc。

 运行时常量池

 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。 一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。 运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

 直接内存

 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

 在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

  显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

Java线程与内存 -《码出高效》

内存区域划分框架图


7 GC

 上面我们讲了JVM内存模型引出出的JAVA并发模型和JAVA内存模型,关于JAVA内存模型我们进一步完善性的讲解“内存”的管理。


7.1 为什么需要GC

JVM虚拟机具有平台无关性和随处运行的特性,且不需要手动的去管理内存分配和清除。一切都交由虚拟机管理内存,防止内存泄漏和内存溢出。

 从三个方面来看如何进行内存管理:

哪些内存要回收
什么时候回收
怎么回收

Copy


 7.1.1 哪些内存要回收

java内存模型中分为五大区域已经有所了解。我们知道程序计数器、虚拟机栈、本地方法栈,由线程而生,随线程而灭,其中栈中的栈帧随着方法的进入顺序的执行的入栈和出栈的操作,一个栈帧需要分配多少内存取决于具体的虚拟机实现并且在编译期间即确定下来【忽略JIT编译器做的优化,基本当成编译期间可知】,当方法或线程执行完毕后,内存就随着回收,因此无需关心。

 而Java堆、方法区则不一样。方法区存放着类加载信息,但是一个接口中多个实现类需要的内存可能不太一样,一个方法中多个分支需要的内存也可能不一样【只有在运行期间才可知道这个方法创建了哪些对象没需要多少内存】,这部分内存的分配和回收都是动态的,gc关注的也正是这部分的内存。


7.2 对象存活

 对象被确定回收前要确定对象是否还存活:引用计数法与可达性分析算法。


7.2.1引用计数法

 引用计数法的逻辑是:在堆中存储对象时,在对象头处维护一个counter计数器,如果一个对象增加了一个引用与之相连,则将counter++。如果一个引用关系失效则counter–。如果一个对象的counter变为0,则说明该对象已经被废弃,不处于存活状态。

 这种方式统计对象是否存活存在着问题:JDK从1.2开始增加了多种引用方式:软引用、弱引用、虚引用,且在不同引用情况下程序应进行不同的操作。如果我们只采用一个引用计数法来计数无法准确的区分这么多种引用的情况。

 引用计数法无法解决多种类型引用的问题。但这并不是致命的,因为我们可以通过增加逻辑区分四种引用情况,虽然麻烦一些但还算是引用计数法的变体,真正让引用计数法彻底报废的下面的情况。

 如果一个对象A持有对象B,而对象B也持有一个对象A,那发生了类似操作系统中死锁的循环持有,这种情况下A与B的counter恒大于1,会使得GC永远无法回收这两个对象。


7.2.2可达性分析

 在主流的商用程序语言中(Java和C#),都是使用可达性分析算法判断对象是否存活的。这个算法的基本思路就是通过一系列名为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,下图对象object5, object6, object7虽然有互相判断,但它们到GC Roots是不可达的,所以它们将会判定为是可回收对象。

 那么那些点可以作为GC Roots呢?

虚拟机栈(栈桢中的本地变量表)中的引用的对象
方法区中的类静态属性引用的对象
方法区中的常量引用的对象
本地方法栈中JNI(Native方法)的引用的对象


可达性分析算法实现

 即使可达性算法中不可达的对象,也不是一定要马上被回收,还有可能被抢救一下。网上例子很多,基本上和深入理解JVM一书讲的一样对象的生存还是死亡


7.3 垃圾的收集算法

 可达性分析算法帮我们解决了哪些对象可以回收的问题,垃圾收集算法则关心怎么回收。


7.3.1 标记-清除算法

 分为标记和清除两个阶段:

首先,标记处所有需要回收的对象,一般扫描GC Roots,找得到的对象在对象头中记录是否被引用
最后,标记完成后统一回收所有未被标记的对象

Copy

 标记-清除算法是最基础的收集算法,其它的收集算法都是基于这种思路并对其不足进行改进而得到的。

 标记-清除算法存在一些核心问题:

效率问题:标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是非常庞大的,这无疑很耗费时间,而且GC时需要停止应用程序,这会导致非常差的用户体验 
空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存二不得不提前触发另一次垃圾收集动作

Copy


7.3.2 复制算法

 为了解决标记-清除效率问题,复制收集算法出现了,他将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

 复制算法存在的一些核心问题:

实现成本:复制算法的代价是将内存缩小为原来的一半,代价太高 
效率问题:如果对象的存活率很高,极端一点的情况假设对象存活率为100%,那么我们需要将所有存活的对象复制一遍,耗费的时间代价也是不可忽视的。

Copy

 用法:存活区采用这种算法,是因为新生代中的对象98%是“朝生夕死”,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。不能保证每次回收都只有不多于10%的对象存活,当Survivor空间不够时,需要依赖老年代进行分配担保(Handle Promotion)。


7.3.3 标记-整理算法

 复制算法在对象存活率较高时要进行较多的复制操作,效率会变得很低,更关键的是,如果不想浪费50%的内存空间,就需要有额外的内存空间进行分配担保,以应对内存中对象100%存活的极端情况,因此,在老年代中由于对象的存活率非常高,复制算法就不合适了。根据老年代的特点,高人们提出了另一种算法:标记/整理算法。从名字上看,这种算法与标记/清除算法很像,事实上,标记/整理算法的标记过程任然与标记/清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。

 上述三种回收算法对比:

效率:复制算法 > 标记/整理算法 > 标记/清除算法(标记/清除算法有内存碎片问题,给大对象分配内存时可能会触发新一轮垃圾回收) 
内存整齐率:复制算法 = 标记/整理算法 > 标记/清除算法 
内存利用率:标记/整理算法 = 标记/清除算法 > 复制算法

Copy


7.3.4 分代算法

 当前商业虚拟机都采用分代收集算法,它结合了前几种算法的优点,将算法组合使用进行垃圾回收,与其说它是一种新的算法,不如说它是对前几种算法的实际应用。分代收集算法的思想是按对象的存活周期不同将内存划分为几块,一般是把Java堆分为新生代和老年代(还有一个永久代,是HotSpot特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法。

新生代:朝生夕灭,存活时间很短
老年代:经过多次Minor GC而存活下来,存活周期长 

Copy

 在新生代中每次垃圾回收都发现有大量的对象死去,只有少量存活,因此采用复制算法回收新生代,只需要付出少量对象的复制成本就可以完成收集;而老年代中对象的存活率高,不适合采用复制算法,而且如果老年代采用复制算法,它是没有额外的空间进行分配担保的,因此必须使用标记/清理算法或者标记/整理算法来进行回收。 总结一下就是,分代收集算法的原理是采用复制算法来收集新生代,采用标记/清理算法或者标记/整理算法收集老年代。 以上内容介绍了几种收集算法的原理、优缺点以及使用场景,它们的共同点是:当GC线程启动时(即进行垃圾收集),应用程序都要暂停(Stop The World)。理解了这些知识,为我们研究垃圾收集器的运行原理打下了基础。


7.3.5 对象移动

 无论是复制算法、标记-整理算法都会对对象的内存地址进行改动,我们从上面了解到对象的访问形式有:句柄池和直接访问;而我们用到的就是直接访问,所以对象的移动就要考虑到移动后的对象如何被找到的问题。

 对象头Mark Word用于存储对象的信息,。 在 GC 时,如果一个对象被拷贝(或移动)了,那么该对象(被拷贝或被移动的对象)头中 mark word 的 forwarding pointer 就会指向拷贝后的对象的地址。怎么理解这句话:比如你知道你朋友的地址的A,你按照地址A去找你朋友,到了A之后发现一个纸条指向了地址B,于是你到了地址B找到了你的朋友。


7.3.5.1 YGC对象拷贝

 首先从 GC Roots 和 Old -> Young 的 Card Table(即存储了老年代对象与新生代对象之间的引用关系)出发,扫描追踪整个新生代的对象关系图。注意,在扫描过程中如果碰到指向老年代对象的引用,则停止这一路径的扫描。同时每扫描到一个对象,如果它是第一次被标记的话,我们就会将其拷贝到 survivor 区,或者晋升到老年代,并且在原对象位置的 mark word 域填上它的新地址 forwarding pointer。这样,如果原对象同时被两个以上的 reference 指向,那么在追踪过程中,别的 reference 还是有机会碰到此对象的原位置,然后发现它已经被标记过了,所以只需要通过 mark word 域的 forwarding pointer 更新 reference 值即可。

 使用这类算法的有 Serial Young GC(即DefNew)、Parallel Young GC、ParNew,以及 G1 GC 的 Young GC & Mixed GC。 只需要一次遍历就可以完成对对象的拷贝和 reference 的更新。


7.3.5.1 FULLGC对象拷贝

标记:

直接从 GC Roots 出发,扫面一遍整个堆(有时可以加上 metaspace),找到所有活的对象。

计算新地址:

既然已知所有活的对象,那么就能够准确计算出它们在 compaction 后的新地址,然后将计算好的新地址保存到 mark word 域中。

更新 reference:

更新所有活对像中指向其他对象的 reference 的值,让它们指向步骤 2 中计算好的新地址(从 mark word中读取)。

复制对象到新地址:

将对象复制到步骤 2 计算的新地址。

使用这类算法的有 Serial Old GC、PS MarkSweep GC、Parallel Old GC、Full GC for CMS 和 Full GC for G1 GC。


8 垃圾收集器

 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。接下来讨论的收集器基于JDK1.7 Update 14 之后的HotSpot虚拟机(在此版本中正式提供了商用的G1收集器,之前G1仍处于实验状态),该虚拟机包含的所有收集器如下图所示:

img

垃圾收集器搭配示意图

 学习垃圾收集器前,需要提前准备一些小知识点。

 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个CPU上。

 吞吐量(Throughput):吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 :

吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)

Copy

 假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

 老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。

 Major GC的速度一般会比Minor GC慢10倍以上。


8.1 新生代收集器


8.1.1 Serial收集器

 Serial(串行)收集器是最基本、发展历史最悠久的收集器,它是采用复制算法的新生代收集器,曾经(JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说是难以接收的。

 下图展示了Serial 收集器(老年代采用Serial Old收集器)的运行过程:

img

Serial Old运行图

 为了消除或减少工作线程因内存回收而导致的停顿,HotSpot虚拟机开发团队在JDK 1.3之后的Java发展历程中研发出了各种其他的优秀收集器,这些将在稍后介绍。但是这些收集器的诞生并不意味着Serial收集器已经“老而无用”,实际上到现在为止,它依然是HotSpot虚拟机运行在Client模式下的默认的新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率

 在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。


8.1.2 ParNew 收集器

 ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。

ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):

img

ParNew运行图

 ParNew收集器除了使用多线程收集外,其他与Serial收集器相比并无太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作,CMS收集器是JDK 1.5推出的一个具有划时代意义的收集器,具体内容将在稍后进行介绍。

 ParNew 收集器在单CPU的环境中绝对不会有比Serial收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越。在多CPU环境下,随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用**-XX:ParallerGCThreads**参数设置。


8.1.3 Parallel Scavenge 收集器

 Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。

 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

 Parallel Scavenge收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数**-XX:+UseAdaptiveSizePolicy**,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

 另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。


8.2 老年代收集器


8.2.1 Serial Old收集器

 Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用**“标记-整理”(Mark-Compact)**算法。

 此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:

  • 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

 它的工作流程与Serial收集器相同,这里再次给出Serial/Serial Old配合使用的工作流程图:

img

Serial Old示意图


8.2.2 Parallel Old收集器

 Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和**“标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感**的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作流程与Parallel Scavenge相同。

 这里给出Parallel Scavenge/Parallel Old收集器配合使用的流程图:

img

Parrael Old示意图


8.2.3 CMS收集器

 CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于**“标记-清除”**算法实现的。

 CMS收集器工作的整个流程分为以下4个步骤:

  • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
  • 并发清除(CMS concurrent sweep)

 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:

img

Cms示意图

优点

  •  并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。

缺点

  • 对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。



8.3 示例

 示例本地环境各不一样,适当调整。

-Xms41m -Xmx41m -Xmn10m -XX:+UseParallelGC -XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

Copy

/**
 * 写一段程序,让其运行时的表现为触发5次ygc,然后3次fgc,然后3次ygc,然后1次fgc,请给出代码以及启动参数。
 * VM设置:-Xms40m -Xmx40m -Xmn10m -XX:+UseParallelGC -XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
 * -Xms40m         堆最小值
 * -Xmx40m         堆最大值
 * -Xmn10m         新生代大小大小(推荐 3/8)
 * -XX:+UseParallelGC   使用并行收集器
 *
 * @author chenhailong
 *         <p>
 *         初始化时:835k(堆内存)
 *         第一次add:3907k
 *         第二次add:6979k
 *         第三次add: eden + survivor1 = 9216k < 6979k + 3072k,区空间不够,开始 YGC
 *         YGC  6979k -> 416k(9216k) 表示年轻代 GC前为6979,GC后426k.年轻代总大小9216k
 *         Created by xxxx.xxxx
 */
public class FiveYgcThreeFgc {

    private static final int ONE_MB_UNIT = 1024 * 1024;

    public static void main(String[] args) {
        System.out.println("初始化eden " + "10m");
        System.out.println("初始化suvvior " + "2m");
        System.out.println("初始化old " + "30m");

        // 执行完年轻代3M,老年代 30M
        List<byte[]> byteList = new ArrayList<byte[]>();
        for (int i = 0; i < 11; i++) {
            if(i == 10) {
                System.out.println("第1次FullGC");
            }
            byteList.add(new byte[3 * ONE_MB_UNIT]);
        }

        // 老年代腾出6M空间,执行第二次FullGC
        // 执行完之后,老年代满了,年轻代6M
        for(int k = 0; k < 1; k++) {
            byteList.remove(0);
        }
        System.out.println("第2次FullGC");
        byteList.add(new byte[6 * ONE_MB_UNIT]);

        // 老年代再腾出6M,执行第三次FullGC
        // 执行完之后,老年代12M,年轻代3M
        for(int k = 0; k < 8; k++) {
            byteList.remove(0);
        }
        System.out.println("第3次FullGC");
        byteList.add(new byte[3 * ONE_MB_UNIT]);

        System.out.println("第二轮");
        for (int i = 0; i < 6; i++) {
            System.out.println("oooo = [" + i + "]");
            byteList.add(new byte[3 * ONE_MB_UNIT]);
        }
    }
}


评论区
Rick ©2018