Skip to content

《深入理解JVM虚拟机》第二部分 自动内存管理

Updated: at 04:12 PM

使用教材:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》

1. JAVA内存区域

概括来说:

线程私有的有:程序计数器、虚拟机栈、本地方法区

线程共享的有:堆、方法区、堆外内存

1.1. 1. 运行时数据区域

1.1.1. 1.1 程序计数器

每个线程单独一个,记载当前线程执行到code中的哪个指令,为了线程切换能恢复现场。

如果是执行java方法,存放的是执行的code的指令地址,如果是本地方式,存放undefined

1.1.2. 1.2 java虚拟机栈

一个线程方法的执行就是一个栈帧

栈帧包括局部变量表(方法参数和局部变量)、操作数栈(计算中间结果和临时变量,返回值)、动态链接(指向当前栈帧在运行时常量池中的直接引用)、方法返回地址(正常返回即回到该地址的下一条地址,而异常返回要通过异常表得知,且不会有任何返回值)

1.1.3. 1.3 本地方法栈

调用本地方法,可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存。

并不是所有JVM都支持本地方法,HostSpot JVM中将本地方法栈和虚拟机栈合二为一。

1.1.4. 1.4 堆

为了优化GC性能,堆内存(逻辑上而不是物理上)分为新生代、老年代、元空间(JDK1.8之前叫永久代)

1.4.1 新生代

新生代存在新创建的对象和Minor GC中幸存下来的对象

新生代逻辑分为缅甸园(Eden Memory)、两个幸存者区(s0,s1)

新创建的对象先去缅甸园Eden分配,满了以后执行一次Minor GC,将Eden幸存的对象移动到某个幸存者区。执行Minor GC的时候还会检查幸存者区的对象,仍然幸存下来的且age不够长且该区未满就会被移动到另一个幸存者区(也就是说总有一个幸存者区是空的),多次仍停留在幸存者区的对象会被移动到老年代。

1.4.2 老年代

老年代存放的是多次Minor GC仍然存活的对象和大对象(为了防止大量拷贝),老年代满了会进行一次Major GC。

1.4.3 元空间

堆的一个逻辑部分,JDK1.8的元空间和JDK1.8之前的永久代本质上是方法区的实现

1.4.4 GC分类

这里说一下GC的分类(JVM 内存结构 - 知乎 (zhihu.com)

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)

1.4.5 TLAB

Eden空间的进一步划分,因为对象创建频繁且线程私有,为了防止频繁加锁,引入TLAB

如果TLAB创建对象,还会通过加锁的方式创建

1.4.6 逃逸分析

1.1.5. 1.5 方法区

存储类信息,常量池,静态变量,JIT编译后的代码等

方法区只是JVM概念,如何实现它由JVM虚拟机自己决定

在HostPost虚拟机中,JDK1.7使用的是永久代实现方法区,它显然是虚拟机堆的一部分。JDK1.8的时候,永久代被元空间代替,元空间是物理机内存的一部分。具体来说,永久代的类信息(这里我觉得也包括运行时常量池)转移到了本地内存,永久代的字符串常量池和静态变量转移到了JVM堆中(JDK1.7)。

img

上面图中可以看到运行时常量池和字符串常量池的具体关系

1.2. 2. HotSpot对象

1.2.1. 2.1 对象的内存布局

对象在内存中的存储布局可以划分为:对象头、实例数据、对齐填充

2.1.1 对象头

对象头分为两部分:MarkWord(32bit或者64bit)和类型指针

MarkWord存储对象自身运行的数据:HashCode,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等

在32bit虚拟机中,32bit的MarkWord中25bit用于存储对象的哈希码,4个bit用于存储对象的分代年龄,2个bit用于存储对象的锁标志位,1个bit固定为0。这里要注意前面的29bit是可以复用的。

锁标志位包括01(无锁状态,存储的是哈希码和分代年龄),00(轻量级锁,存储的是指向锁记录的指针),10(重量级锁,存储的是指向重量级锁的指针),11(GC标记,不需要存储),01(偏向锁,指向的是偏向线程ID,偏向时间戳,对象分代年龄)。

类型指针即指向对象所属类的元数据?

如果是数组对象,还要存储数组的长度

2.1.2 实例数据
2.1.3 对齐填充

1.2.2. 2.2 对象的访问定位

2.2.1 句柄池

间接访问,java栈中存放的是句柄池中引用,该引用包括指向对象实例数据的指针(指向堆)和指向对象元数据的指针(指向方法区)

好处是GC使得对象发生移动时java栈中的reference不需要改变指向

2.2.2 直接指针

java栈中reference指向的是堆中的对象,对象中的对象头中有指向方法区的对象类元数据的指针

好处是找对象只需要一次引用

2. 垃圾收集器与内存分配策略

2.1. 1. 对象回收

2.1.1. 1.1 判断对象可回收的算法

1.1.1 引用计数算法

很好理解,引用某个对象,就给他引用计数+1,引用计数为0的对象会被回收

缺点:互相引用

1.1.2 可达性分析算法

固定的GC Roots开始,不断向下找其引用的对象,只要可达就标记(引用链),经过一轮标记后,未被标记的就是不可达的,可以被回收

GC Roots包括的对象:

(1)虚拟机栈中引用的对象

(2)方法区中类的静态变量引用的对象

(3)方法区中常量引用的对象(还有字符串常量池)

(4)JNI引用的对象

(5)Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些异常对象和系统类加载器

(6)被同步锁持有的对象

2.1.2. 1.2. 引用类型

引用分为强引用,软引用,弱引用,虚引用

2.1 强引用

传统的引用类型,只要有强引用,对象就不能被GC回收

2.2 软引用

内存空间不足的时候,才会回收

下面给出示例代码:

// 强引用
String strongReference = new String("abc");
// 软引用
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

软引用可以配合引用队列使用:

ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<>(str, referenceQueue);

str = null;
// Notify GC
System.gc();

System.out.println(softReference.get()); // abc

Reference<? extends String> reference = referenceQueue.poll();
System.out.println(reference); //null

当软引用被回收的时候,就被加入到该引用队列。

软引用的本质是:将自己的引用设为null,再通知gc回收

2.3 弱引用

只要垃圾回收器线程扫描到弱引用,即回收

String str = new String("abc");
WeakReference<String> weakReference = new WeakReference<>(str);
str = null;

弱引用也可以配合引用队列使用

2.4 虚引用

虚引用不会决定对象生命周期,如同没有引用

虚引用必须和引用队列(ReferenceQueue)联合使用(要不然还能有啥用?)。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。我们可以从队列中取出虚引用进行一些处理。

String str = new String("abc");
ReferenceQueue queue = new ReferenceQueue();
// 创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);

2.1.3. 1.3 finalize()拯救

2.1.4. 1.4 方法区回收

方法区的垃圾回收主要分为两部分:废弃的常量和不再使用的类型

回收废弃常量类似于java堆中的对象回收:不会再被使用,虚拟机中没有其他地方引用这个字面量

判断一个类型是否属于“不再被使用的类型”:

(1)该类的所有实例(包括派生子类)都已经被回收了

(2)加载该类的类加载器已经被回收了

(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

上面条件只是说允许回收,Hotspot要不要回收可以通过参数-Xnoclassgc控制

2.2. 2. 分代收集理论

2.2.1. 2.1 跨代引用假说

假说内容:跨代引用相对于同代引用来说仅占极少数

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

2.3. 3. 垃圾收集算法

2.3.1. 3.1 标记-清除算法

先标记(存活or未存活),再清除

缺点:(1)内存碎片问题(2)随着对象增多而效率降低

2.3.2. 3.2 标记-复制算法

空间平均分为两半,只能由一半上存放对象,每次清除后把存活对象复制到另一半上(当然一开始还是要标记)

解决内存碎片问题,但是效率低,且牺牲一半空间

2.3.3. 3.3 标记-整理算法

标记过程相同,将不被回收的对象忘内存一段移动,边界外的全部清理

总的来说,清除但不移动会让停顿时间变短,移动会让吞吐量更大。

2.4. 4. Hotspot算法实现细节

2.4.1. 4.1 OopMap

每次垃圾回收得到GC Roots的时候,都必须停顿(stop the world),查找GC Roots时间成本太大

解决办法:空间换时间,引入新的数据结构OopMap,提前记录类(类加载的时候)和方法的引用信息(方法栈帧中即时编译得到)

2.4.2. 4.2 安全点

OopMap避免大量内存扫描,选择只有在安全点(指令流的一个特定的位置)才生成OopMap

安全点的比喻:就像公交车一样,每个乘客到达的地点是不同的,但公交车不会为每一个人去停车,必须等到提前设定的站台才会停下,这个时候乘客才可以下车。

两种方式中断线程:

(1)抢占式中断:垃圾回收时,中断所有线程,没有到安全点的线程让它执行到安全点再中断

(2)主动式中断:线程不断轮询标志位,有则到安全点挂起等待中断

适合插入安全点的地方:

(1)方法栈帧结束

(2)非计数循环末尾

(3)每条java编译后的字节码边界

2.4.3. 4.3 安全区

上面安全点针对执行的线程有效,那针对锁住的和睡眠的呢?

安全区是不会对引用造成影响的线程的指令区域,线程进入安全区后打上标记,垃圾回收器发现某个线程进入安全区域,则会跳过该线程,不会等待该线程进入安全点。

如果一个线程到达安全区域边界,也会停下来检查gc是否在工作,如果gc在工作就要主动停下来等待完成。

2.4.4. 4.4 卡表

卡表是前面提到的记忆集的实现方式,Hotspot用字节数组完成对卡表的记录,老年代每个内存块由卡表一个数组槽位记录。

2.4.5. 4.5 写屏障

类似于spring的AOP,写前之后执行的操作

写屏障实现某个老年代存在跨代引用赋值后卡表的记录操作

2.4.6. 4.6 JVM的并发可达性分析

所有对象分为白,黑,灰,分别记为:1.未被垃圾回收器访问过,2.已经被垃圾回收器访问过,并且这个对象上的所有引用都被扫描过了,3.全部访问过了

GC Roots的可达性分析就是一个结点从白到黑的过程,直到最后有些节点变成黑色,可保留,有些结点变成白色,要被清理。每个节点都是从白色变成灰色最后变成黑色的,且灰色节点是白色和黑色结点的桥梁。

为了解决标记过程中对象引用改变的情况,主要是黑色结点突然引用指向改变,有两种情况(赋值器插入了一条或多条从黑色对象到白色对象的新引用和赋值器删除了全部从灰色对象到该白色对象的直接或间接引用):

1.增量更新:当黑色结点插入新的指向白色结点的引用关系的时候,记录这些新插入的引用记录,等并发扫描完之后再让这些记录为根重新扫描一遍。

2.原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。

2.5. 5. 垃圾收集器

JVM基础(五)垃圾收集器 - 知乎 (zhihu.com)

5.1 Serial(标记复制)+Serial Old(标记整理)

单线程

5.2 Parallel Scavenge(标记复制) + Parallel Old(标记整理)

多线程

5.3 ParNew (标记复制)+ CMS

ParNew多线程,类似Parallel Scavenge

CMS:

(1)初始标记(2)并发标记(3)重新标记(4)并发清理

5.4 G1收集器