1.随线程而生的内存
程序计数器、虚拟机栈、本地方法栈
栈中的栈帧随着方法的进入和退出可以进行自动分配栈帧内存与回收
2.堆区内存回收
2.1 可达性分析
2.1.1 GC Roots
- 虚拟机栈本地变量表中的引用对象,包括传递的参数、局部变量、临时变量等
- 类中静态属性和常量引用的对象(方法区中)
- 本地方法栈中引用的对象
- 虚拟机内部引用
- synchronize 锁定的对象
2.1.2 引用类型与垃圾回收
- 强引用:
- 软引用:内存不够时进行第一次垃圾回收,先保留,看此次收集完之后够不够分配,如果仍然不够再回收软引用指向的对象
- 弱引用:内存不够触发垃圾回收,不管内存够用与否,直接回收弱引用指向的对象
- 虚引用:指向直接内存,暂时没研究清楚
2.1.3 finalize()
public class Test {
public static Test HOOK;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("function finalize() has been called!");
HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
HOOK = new Test();
HOOK = null;
System.gc(); // 垃圾回收
Thread.sleep(1000); // 等待一秒钟,finalize方法执行
if (HOOK != null){
HOOK.isAlive(); // 第一次回收 触发finalize执行,使 this 重新赋值给 HOOK, 也就是自己赋值个自己,
// 使得上一次置空失效
}else
isDied();
HOOK = null; // 再次置空,再次垃圾回收,使得对象被回收,因为 finalize 只会被触发一次
System.gc();
Thread.sleep(1000);
if (HOOK != null){
HOOK.isAlive();
}else
isDied();
}
public void isAlive(){
System.out.println("I'm Fucking Alive!");
}
public static void isDied(){
System.out.println("I have been Fucked!");
}
}
2.2 方法区回收
方法区主要有两种东西:常量 + 类
2.2.1 常量的回收
拿字符串来说:如果已经没有任何字符串对象引用常量池中的"java"
字符串,那么这个字符串就会被移除常量池,
2.2.2 类型回收
- 该类所有实例都已经被回收
- 加载该类的类加载器已经被回收
- 该类型对应的 class 对象没有在任何地方被引用
3.垃圾回收算法
3.1 标记清除算法
主要缺点有两个:
- 第一个是执行效率不稳定,如果Java堆中包含大量对象,必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
- 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.2 标记复制算法
“半区复制”的垃圾收集算法
- 将内存按容量划分为大小相等的两块,每次只使用其中的一块。
- 一块用完就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象
- 每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有 空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
- 缺陷:
- 将可用内存缩小为了原来的一半,空间浪费。
Java具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间(默认Eden和Survivor的大小比例是8∶1),每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
3.2.1 内存分配担保
如果另外一块 Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代。
3.3 标记整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。极端情况老年代可能100%存活。
移动对象的弊端:
- 如果移动存活对象,在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
不移动的缺点:
- 存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
3.3.1 “和稀泥式”解决方案:
虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经 大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标 记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
4 HotSpot
垃圾收集的实现细节
4.1 保守式GC的缺点
- 会有部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过GC的收集,因为GC不知道一块内存中的值到底是不是指针。即:这块4B内存的值有可能仅仅是一个int值,但是这个int的二进制字面量正好是堆中的某个位置,但是此时这个“堆中的某个位置的对象”已经不再根可达,完全可以回收。但是因为这个int值的存在,导致这个已死对象没有被回收。
- 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;
- 如果JVM采用保守式GC,那么反射机制也将不可用。因为反射功能,本来就需要让对象能了解自身的结构
4.2 半保守GC
JVM可以选择在栈上不记录类型信息,而在对象上记录类型信息。这样的话,扫描栈的时候仍然会跟上面说的过程一样,但扫描到GC堆内的对象时因为对象带有足够类型信息了,JVM就能够判断出在该对象内什么位置的数据是引用类型了。也称为“根上保守
4.3 准确式GC-OopMap
要实现这样的GC,JVM就要能够判断出所有位置上的数据是不是指向GC堆里的引用,包括活动记录(栈+寄存器)里的数据。
这就需要从外部记录下类型信息,存成映射表。HotSpot把这样的数据结构叫做OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。这些数据是在类加载过程中计算得到的。
4.3.1 为什么需要这个OopMap
代码都是在线程栈中执行,Java代码经过JIT编译之后的代码就只有变量在栈上的位置了。GC怎么知道现在栈中的变量表中的东西到底是不是指向堆中的引用?信息都记录在这个OopMap中,就像是一个附加的信息,告诉你栈上哪个位置本来是个什么东西。如果没有这个map的话,那不还就又成了半保守GC了吗。
4.3.2 为什么要JIT才能生成OopMap
因为只有JIT知道你的Java字节码是什么,所以只有JIT能完成这个工作
4.3.2 安全点
既然是个表,那就必定需要内存空间存储,如果生成过多,那么造成内存严重浪费,如果生成过少,那么GC还要保证自己是准确式的,就需要等到有OopMap的指令时才能开始STW,进行根节点枚举
所以Oop既不能太多,也不能太少。
而且只能在有OopMap的位置才能STW进行垃圾收集,所以这个位置就是安全点!
4.4 HotSpot的根节点枚举
每个线程的都停在安全点,然后GC挨个遍历OopMap即可
4.4.1 如何中断一个线程
- 抢先式中断:
- 不需要线程的执行代码 主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
- 主动式中断:
- 虚拟机设置一 个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最 近的安全点上主动中断挂起。
- HotSpot生成的轮询指令如下:当需要暂停用户线程时,虚拟机把某个内存页设置为不可读,线程执行到安全点之后的第一条指令会让线程去访问这个内存页,读一个不可读的内存就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。
- 虚拟机设置一 个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最 近的安全点上主动中断挂起。
4.4.2 处理正在休眠的线程
将要休眠的线程会标识自己进入安全区域,这段时间内不会导致任何引用关系的变更。
我个人认为会在这些休眠的线程中进行半保守式垃圾收集(猜测,需要阅读HotSpot源码才能确定是否正确)。
当线程结束休眠的时候,先检查虚拟机是否完成了GC Roots枚举。完成了才能离开安全区域。
4.5 分代收集的跨代引用问题
记忆集 + 卡表
新生代上的全局数据结构,这个数据结构把老年代划分成若干的小块,标识出老年代那一个内存存在跨代引用。只有包含跨代引用的小块内存对象才会被加入到 GC Roots 中进行枚举。省去了遍历整个老年代的开销
一般记忆集的精度只是精确到某一块内存区域,该区域内有对象含有跨代指针。这就是卡页精度的“卡表”方式去实现记忆集。
只要卡页内有一个对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
4.5.1 写屏障更新卡表
“写屏障”的含义就是对一个对象引用进行写操作(即引用赋值)之前或之后附加执行的逻辑
此处的写屏障与volatile的读写屏障是两种东西!!!包括本文之后的读屏障也是在读操作之前所做的一个操作或者执行的一串代码逻辑
写屏障就是在将引用赋值写入内存之前,先做一步mark card——即将出现跨代引用的内存块对应的卡页置为dirty
4.5.2 伪共享问题
CPU缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
参数-XX:+UseCondCardMark
,就是开启有条件的写屏障:在将卡页置为dirty之前,先检查它是否已经为dirty状态,如果已经是了,就不必再执行mark card动作,以避免伪共享。
5 三色标记
白色:表示对象尚未被垃圾收集器访问过。
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
上图第一个红框:正在扫描灰色对象的时候,突然白色掉线,去跟黑色的建立联系。
上图第二个红框:已经被标记为白色的对象或者还没被扫描到的对象,突然跟黑色扫描过的对象建立了联系
增量更新:
-
当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。也就是说黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
-
弊端:
导致黑色对象变回白色对象,使得其他没有变更的引用还得被扫描一次,浪费性能
-
原始快照:
- 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
6. JAVA
垃圾收集器
新生代收集器.
6.1 Serial收集器
6.2 ParNew收集器
支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处
可以与CMS配合使用,CMS作为老年代收集器的时候,只能使用 ParNew 作为新生代收集器.
6.3 Parallel Scavenge收集器
一款新生代收集器,基于标记-复制算法实现,能够并行收集的多线程收集器,目标则是达到一个可控制的吞吐量
老年代收集器.
6.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本
6.5 Parallel Old收集器
老年代多线程收集+ 标记整理 + STW
6.6 CMS收集器
6.6.1 工作的四个阶段
(1)初始标记
“Stop The World” + 仅仅只是标记GC Roots能直接关联到的对象,速度很快
(2)并发标记
GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
(3)重新标记
“Stop The World” + 增量更新处理
(4)并发清除
清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
等到老年代的碎片已经无法完成内存分配担保的时候,就需要标记整理一下了
6.6.2 缺点
-
并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计 算能力)而导致应用程序变慢,降低总吞吐量。
-
无法处理“浮动垃圾”
并发标记和并发清理阶 段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分 垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集 时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运 行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待 到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。
-
当预留空间不够,就会出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生
-
CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生,给大对象分配不友好,会出现老年代还有很多剩余空间,但无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况,开启内存碎片的合并整理过程。
整理过程无法并发,又会导致 Stop The World
6.7 G1 收集器
不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立Region,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。衡量标准不再是它属于哪个分代,而 是哪块内存中存放的垃圾数量最多,回收收益最大。
6.7.1 待解决问题
(1)跨代引用问题
仍然记忆集避免全堆作为GC Roots扫描。G1的记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
G1的记忆集在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。自己的卡表记录着自己指向别的Regin,再配上一个额外的哈希表,存储谁指向我
此处纯为猜测:
没看过源码,只能对着周志明老师的书来猜着理解了😪, 但是讲道理我个人觉得这得有一张卡表 + 每个Region的卡表数量个 HashMap 才行吧,而且HashMap中存储的也得是HashMap,套娃中的HashMap中才存储某个Region的卡表号,为了存储这些卡表号码还得是一个 list
个人感觉只有这样才能存储完整引用关系
(2)并发标记
G1
使用原始快照,并且每个Region都有自己的TAMS(Top at Mark Start)的指针,用于分配并发过程中的创建的对象
放弃了增量更新
为什么G1用原始快照,CMS用增量更新
增量更新:黑色对象新增一条指向白色对象的引用,那么要进行深入扫描白色对象及它的引用对象。
原始快照:灰色对象删除了一条指向白色对象的引用,就产生了浮动垃圾,好处是不需要像 CMS 那样 remark,再走一遍 root trace 这种相当耗时的流程。
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,而且还有上边猜想中的那么复杂的数据结构记录引用关系,如果遍历下来,代价会更大。CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
(3)停顿预测
G1收集器会记 录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得 出平均值、标准偏差、置信度等统计信息。
6.7.2 工作流程
-
初始标记:STW,仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。
-
并发标记:从GC Root开始对堆中对象进行可达性分析,处理SATB记录下的在并发时有引用变动的对象。
-
最终标记:STW,对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录。
-
筛选回收:STW,负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据期望的停顿时间自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。