摘抄自《深入理解Java虚拟机》
对象是否还活着
可达性分析算法
通过可达性分析
来判定对象是否存活。
这个算法的基本思路就是通过一系列的称为GC Roots
的对象作为起始点,从起始节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)
,当一个对象到GC Roots
没有任何引用链相连时,则证明此对象是不可用的。
object5、object6、object7虽然互相关联,但是它们到GC Roots
是不可达的,所以它们将会被判定为是可回收的对象。
在java语言中,可作为GC Roots
的对象包括下面几种:
- 虚拟机栈中引用的对象(栈帧中的本地常量表)
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方栈中(native方法)引用的对象
引用
判断对象是否存活都与引用
有关
引用
分为强引用(Strong Reference)
、软引用(Soft Reference)
、弱引用(Weak Reference)
、虚引用(Phantom Reference)
,这四种引用强度依次逐渐减弱
- 强引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
- 软引用,描述一些还有用但非必需的对象,在系统将要发生
内存溢出异常
之前,将会把这些对象列进回收范围之中进行第二次回收。 - 弱引用,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用,也称幽灵引用或者幻影引用,一个对象是否有虚引用的存在,完全不会影响其生存时间,无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个
系统通知
。
生存还是死亡
可达性分析
算法中不可达的对象,也并非是非死不可
的,这时候它们暂时处于缓刑阶段
要真正宣告一个对象死亡,至少要经历两次标记
过程:
如果对象在进行可达性分析
后发现没有与GC Roots
相连接的引用链
,那它将会被第一次标记
并且进行一次筛选
,筛选的条件是此对象是否有必要执行finalize()
方法。当对象没有覆盖finalze()
方法,或者finalze()
方法已经被虚拟机调用过,虚拟机将这两种情况都视为没必要执行
如果这个对象被判定为有必要执行finalize()
方法,那么这个对象将会放置在一个叫作F-Queue
的队列之中,并在稍后由一个由虚拟机自动建立
的、低优先级
的Finalizer线程
去执行它。这里的执行是指虚拟机会触发这个方法
finalize()
方法是对象逃逸死亡命运
的最后一次机会,稍后GC
将对F-Queue
队列中的对象进行第二次
小规模的标记
,如果对象要在finalize()
中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移除出即将回收
的集合;如果对象这时候还没有逃逸,那基本上他就真的被回收了
finalize()方法只会被系统自动调用一次
回收方法区
java虚拟机规范中说过可以不要求虚拟机在方法区实现垃圾收集,性价比
一般比较低
永久代
的垃圾收集主要回收两部分内容:废弃常量
和无用类
类需要满足下面3个条件才能算是无用类
- 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
- 加载该类的
ClassLoader
已经被回收 - 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过
反射
访问该类的方法
垃圾收集算法
标记-清除算法
标记-清除
算法是最基础
的收集算法,算法分为标记
和清除
两个阶段
首先标记
出所有需要回收的对象,在标记完成后统一回收
所有被标记的对象
它的主要不足有两个:
效率问题
,标记和清除效率都不高空间问题
,标记清除后会产生大量不连续
的内存碎片
,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
复制算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。
当这一块的内存用完了,就将还存活
着的对象复制
到另外一块上面,然后再把已使用过的内存空间一次清理掉,这样内存分配时不用考虑内存碎片等复杂情况,只要移动堆顶指针
,按顺序分配内存即可,实现简单,运行高效。
它的不足是:
- 把内存缩小了原来的一半,代价太高。
现在的商业虚拟机都采用这种收集算法来回收新生代
,IBM公司专门研究表明新生代中的对象98%是朝生夕死
,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden
空间和两块较小的Survivor
空间,每次使用使用Eden
和其中一块Survivor
.当回收时,将Eden
和Survivor
中还存活着的对象一次性地复制到另外一块Survivor
空间上,最后清理掉Eden
和刚才用过的Survivor
空间。HoSpot
虚拟机默认Eden
和Survivor
的大小比例是8:1
。我们没办法保证每次回收后存活的对象在Survivor
空间能放下,当Survivor
空间不够用时,需要依赖其他内存(这里指老年代
)进行分配担保
。
内存的分配担保
指另外一块Survivor
空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制
进入老年代
。
不足:
- 对象存活率较高时就要进行较多的复制操作,效率会变低。
- 需要额外的空间进行分配担保
标记-整理算法
标记-整理算法的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
分代收集算法
当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)算法
,这种算法并没有什么新的思想,只是根据对象存活周期
的不同将内存划分为几块。一般是把java堆分为新生代
和老年代
,这样就可以根据各个年代的特点采用最适当的收集算法。
- 在
新生代
中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法
,只需要付出少量存活对象的复制成本就可以完成收集。 - 在
老年代
中,因为对象存活率高、没有额外空间对它进行分配担保
,就必须使用标记-清理
或者标记-整理
算法来进行回收
HotSpot算法
枚举根节点
可达性分析
对执行时间的敏感体现在GC停顿
上,因为这项分析工作必须在一个能确保一致性
的快照中进行,这里一致性
的意思是指整个分析期间整个执行系统看起来就像被冻结在某个时间上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性
就无法得到保证
这点是导致GC
执行时必须停顿
所有java执行线程
(sun将这件事情称为stop the word)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS
收集器中,枚举根节
点时也是必须要停顿
的。
java虚拟机
使用的是准确式
(即虚拟机可以知道内存中某个位置的数据具体是什么类型)GC
,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文
和全局的引用位置
,虚拟机应当是有办法直接得知那些地方存放着对象引用
。在HotSpot
的实现中,是使用一组称为OopMap
的数据结构来达到这个目的的,在类加载完成的时候,HotSpot
就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译
(即时编译)过程中,也会在特定的位置记录下栈
和寄存器
中那些位置是引用。这样GC
在扫描时就可以直接得知这些信息了。
安全点
HotSpot
没有为每条指令都生成OopMap
,只是在特定的位置
记录了这些信息,这些位置称为安全点(Safepoint)
,即程序执行时并非在所有地方都能停顿下来开始GC
,只有在到达安全点
时才能暂停。安全点
的选定基本上是以程序是否具有让程序长时间执行的特征
为标准进行选定的,长时间执行
的最明显特征就是指令序列复用
,例如方法调用
、循环跳转
、异常跳转
等,所以具有这些功能的指令才会产生Safepoint
发生GC
时让所有线程(不包括执行JNI调用的线程)都跑
到最近的安全点
上再停顿下来有两种方案可供选择:
- 抢先式中断(Preemptive Suspension),
GC
时首先把所有线程全部中断,如果发现有线程中断的地方不在安全点
上,就恢复线程,让它
跑到安全点
上。现在几乎没有虚拟机采用这种方式 - 主动式中断(Voluntary Suspension),当
GC
需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志
,各个线程执行时去主动轮询
这个标志,发现中断标志
为真时就自己中断挂起。轮询
标志的地方和安全点
时重合的。
安全区域
Safepoint
机制保证了程序执行
时,在不太长的时间内就会遇到可进入GC
的Safepoint
,但是程序不执行
的时候呢?所谓的程序不执行
就是没有分配CPU
时间,典型的例子就是线程处于Sleep
状态或者Blocked
状态,这时候线程无法响应JVM
的中断请求,跑
到安全点
去中断挂起。
安全区域Safe Region
是指一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC
都是安全的。我们也可以把Safe Region
看做是被做扩展了的Safepoing
在线程执行到Safe Region
中的代码时,首先标识自己已经进入了Safe Region
,那样,当在这段时间里JVM
要发起GC
时,就不用管标识自己为Safe Region
状态的线程了。在线程要离开Safe Region
时,它要检查系统是否已经完成了根节点枚举
,如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region
的信号为止。
垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现
如果两个收集器之间存在连续,就说明它们可以搭配使用
Serial 收集器
Serial
(串行)垃圾收集器是最基本、发展历史最悠久的收集器
JDK1.3.1前是HotSpot
新生代收集的唯一选择
Serial/Serial Old收集器运行示意图
- 作用域:新生代
- 收集算法:新生代
Serial
采用复制算法
,老年代SerialOld
采用标记整理算法
- 线程数:单线程
- 特点:单线程收集器,在进行收集的时候,需要暂停其他所有工作线程(Stop the world),简单高效
- 应用场景:对于桌面应用场景(新生代使用内存不大)是很好的选择
- 缺点:在用户不可见的情况下将用户的工作线程都停掉,对其他应用造成影响
- 设置参数
强制指定使用Serial垃圾收集器
“-XX:+UseSerialGC”
ParNew 收集器
ParNew 收集器其实就是Serial收集器的多线程版本
ParNew/Serial Old收集器运行示意图
- 作用域:新生代
- 收集算法:新生代
ParNew
采用复制算法
,老年代SerialOld
采用标记整理算法
- 线程数:多线程
- 特点:多逻辑CPU时比
Serial
收集器有更好的效果 - 应用场景:Server模式下的虚拟机中首选的新生代收集器
- 缺点:在用户不可见的情况下将用户的工作线程都停掉,对其他应用造成影响
- 设置参数
强制指定使用ParNew垃圾收集器
-XX:+UseParNewGC
CMS收集老年代时,默认ParNew为新生代收集器
-XX:+UseConcMarkSweepGC
限制垃圾收集的线程数,默认与CPU的数量相同
-XX:ParallelGCThreads
Parallel Scavenge收集器
Parallel Scavenge
收集器也经常称为吞吐量优先收集器
。
目标是达到一个可控制的吞吐量(Throughput)
吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
- 作用域:新生代
- 收集算法:复制算法
- 线程数:多线程
- 特点:高效率地利用CPU时间,尽快完成程序的运算任务
- 应用场景:
高吞吐量为目标,即
减少垃圾收集时间
,让用户代码获得更长的运行时间
当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互
那些执行批量处理、订单处理、工资支付、科学计算的应用程序
- 缺点:不能与CMS配合使用
- 设置参数
-XX:MaxGCPauseMillis
控制最大垃圾收集停顿时间,大于0的毫秒数,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,收集的新生代变小,垃圾收集更频繁,停顿时间下降,吞吐量也降下来了
-XX:GCTimeRatio
设置垃圾收集时间占总时间的比例,大于0且小于100的整数,公式是1/(1+n) ,默认值为99,垃圾收集时间占比为1%
-XX:+UseAdaptiveSizePolicy
开启这个参数后,就不用手工指定一些细节参数,如:
-
新生代的大小
-Xmn
、Eden
与Survivor
区的比例-XX:SurvivorRation
、晋升老年代的对象年龄-XX:PretenureSizeThreshold
等; -
JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为
GC自适应的调节策略
(GC Ergonomics); -
这是一种值得推荐的方式:
1、只需设置好内存数据大小(如
-Xmx
设置最大堆);2、然后使用
-XX:MaxGCPauseMillis
或-XX:GCTimeRatio
给JVM设置一个优化目标;3、那些具体细节参数的调节就由JVM自适应完成;
这也是Parallel Scavenge
收集器与ParNew
收集器一个重要区别
;
Serial Old收集器
Serial
收集器的老年版本,同样是单线程收集器,使用多线程和标记-整理算法
- 作用域:老年代
- 收集算法:标记-整理
- 线程数:单线程
- 特点:单线程收集器,在进行收集的时候,需要暂停其他所有工作线程(
Stop the world
),简单高效 - 应用场景:
给Client模式下的虚拟机使用
Server模式有两大用途:- jdk1.5及以前与
Parallel Scavenge
收集器搭配使用 - 作为
CMS
收集器的后备预案,CMS
失败时使用
- jdk1.5及以前与
- 缺点:
- 设置参数
Parallel Old收集器
Parallel Scavenge
收集器的老年版本,使用多线程和标记-整理
算法
Parallel
- 作用域:老年代
- 收集算法:
标记整理算法
- 线程数:多线程
- 特点:与
Parallel Scavenge
组合称为名副其实的吞吐量优先
收集器 - 应用场景:
- JDK1.6及之后用来代替老年代的
Serial Old
收集器 - 与
Parallel Scavenge
组合使用
- JDK1.6及之后用来代替老年代的
- 缺点:
- 设置参数
指定使用Parallel Old收集器
-XX:+UseParallelOldGC
CMS收集器
CMS(Concurrent Mark Sweep)
收集器是一种以获取最短回收停顿时间
为目标的收集器。它非常适合在注重用户体验的应用上使用
CMS
也称之为并发低停顿
收集器(Concurrent Low Pause Collector)
目的是尽可能地缩短垃圾收集时用户线程的停顿时间
运作过程:
1. 初始标记(CMS initial mark),需要stop the world,速度很快
2. 并发标记(CMS concurrent mark)
3. 重新标记(CMS remark),需要stop the world,比初始标识时间长一些,远比并发标记短
4. 并发清除(CMS concurrent sweep)
- 作用域:老年代
- 收集算法:标记-清除
- 线程数:多线程
- 特点:并发收集、低停顿,需要更多内存
- 应用场景:与用户交互较多的场景;希望系统停顿时间最短,注重服务的响应速度
- 缺点:
CMS
收集器对CPU资源非常敏感,在并发阶段,因为占用了一部分线程而导致应用程序变慢,总吞吐量
降低
2.CMS
收集器无法处理浮动垃圾
(Floating Garbage),可能出现 Concurrent Mode Failure失败而导致另一次Full GC
的产生。这一部分垃圾出现在标记过程之后,CMS
无法在当次收集中处理掉它们,只好留待下一次GC
时再清理掉。这部分垃圾称为浮动垃圾
。垃圾收集时用户线程在运行,所以需要预留一部分空间提供并收集时的程序运作使用。
3.CMS
基于标记-清除
算法,收集结束会有大量空间碎片。无法找到足够大的连续空间来分配当前对象时就要触发一次Full GC
。
- 设置参数
-XX:+UseConcMarkSweepGC
使用CMS
收集器
-XX:CMSInitiatingOccupancyFraction
GC
触发时老年代
使用的百分比
,设置太高会导致预留内存无法满足程序需要,出现Concurrent Mode Failure失败,这时虚拟机就会临时启动Serial Old
收集器来重新进行老年代
的垃圾收集,停顿时间就很长了。
-XX:UseCMSCompactAtFullCollection
默认开启,用于在CMS
收集器顶不住要进行FullGC
时开启内存碎片的合并整理过程
-XX:CMSFullGCsBeforeCompaction
设置执行多少次不压缩Full GC
后,跟着来一次带压缩的,默认值为0,表示每次进入Full GC
都进行碎片整理
G1收集器
G1是一款面向服务端应用的垃圾收集器
G1的使命是在未来替换CMS,并且在JDK1.9已经成为默认的收集器
在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
-
线程数:多线程
-
特点:
- 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行
- 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念;能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配, 采用不同方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果
- 空间整理:与CMS的标记清理算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于复制算法实现的
- 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型。可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒。在低停顿的同时实现高吞吐量。
-
为什么G1可以实现可预测停顿
-
可以有计划地避免在Java堆的进行全区域的垃圾收集;
-
G1收集器将内存分大小相等的独立区域(Region),新生代和老年代概念保留,但是已经不再物理隔离。
-
G1跟踪各个Region获得其收集价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表;
每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);
-
-
一个对象被不同区域引用的问题
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题(而G1更突出):回收新生代也不得不同时扫描老年代?
这样的话会降低Minor GC的效率;
解决方法:
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:
每个Region都有一个对应的Remembered Set;
虚拟机发现程序在对Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
如果是,便通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;
就可以保证不进行全局扫描,也不会有遗漏。
-
应用场景
面向服务端应用,针对具有大内存、多处理器的机器;
最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;
(实践:对账系统中将CMS垃圾收集器修改为G1,降低对账时间20秒以上)
具体什么情况下应用G1垃圾收集器比CMS好,可以参考以下几点(但不是绝对):- 超过50%的Java堆被活动数据占用;
- 对象分配频率或年代的提升频率变化很大;
- GC停顿时间过长(长于0.5至1秒);
建议:
如果现在采用的收集器没有出现问题,不用急着去选择G1;
如果应用程序追求低停顿,可以尝试选择G1;
是否代替CMS只有需要实际场景测试才知道。(如果使用G1后发现性能还没有使用CMS好,那么还是选择CMS比较好)
不计算维护Remembered Set的操作,可以分为4个步骤(与CMS较为相似)。
- 初始标记(Initial Marking)
仅标记一下GC Roots能直接关联到的对象;
且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象;
需要"Stop The World",但速度很快;
- 并发标记(Concurrent Marking)
从GC Roots开始进行可达性分析,找出存活对象,耗时长,可与用户线程并发执行
并不能保证可以标记出所有的存活对象;(在分析过程中会产生新的存活对象)
- 最终标记(Final Marking)
修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录。
上一阶段对象的变化记录在线程的Remembered Set Log;
这里把Remembered Set Log合并到Remembered Set中;
需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;
G1采用多线程并行执行来提升效率;且采用了比CMS更快的初始快照算法:Snapshot-At-The-Beginning (SATB)。
- 筛选回收(Live Data Counting and Evacuation)
首先排序各个Region的回收价值和成本;
然后根据用户期望的GC停顿时间来制定回收计划;
最后按计划回收一些价值高的Region中垃圾对象;
回收时采用"复制"算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;
可以并发进行,降低停顿时间,并增加吞吐量;
- 设置参数
指定使用G1收集器:
"-XX:+UseG1GC"
当整个Java堆的占用率达到参数值时,开始并发标记阶段;默认为45:
"-XX:InitiatingHeapOccupancyPercent"
为G1设置暂停时间目标,默认值为200毫秒:
"-XX:MaxGCPauseMillis"
设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region:
"-XX:G1HeapRegionSize"
新生代最小值,默认值5%:
"-XX:G1NewSizePercent"
新生代最大值,默认值60%:
"-XX:G1MaxNewSizePercent"
设置STW期间,并行GC线程数:
"-XX:ParallelGCThreads"
设置并发标记阶段,并行执行的线程数:
"-XX:ConcGCThreads"