Java笔记_4
垃圾回收算法(四种)
- 垃圾回收需要找到内存中存活的对象
- 还需要释放不再存活的对象, 使程序可以再次利用这部分空间
- 标记-清除算法
- 复制算法
- 标记-整理算法
- 分代GC
- 垃圾回收会有单独的
GC
线程来完成, 但是不管哪一种GC
算法, 都会有部分阶段需要停止所有用户线程, 称之为Stop The World
, 简称STW
, 如果STW
的时间过长, 则会影响用户使用 - 为什么一定需要
STW
?- 分析工作必须在能够确保一致性的快照中进行
- 一致性指整个分析期间系统被冻结在某个时间点上
- 如果分析过程中对象的引用关系还在不断地变化, 那么分析的准确性就没有办法保证
- 如果不暂停业务线程的话, 在垃圾回收期间新创建的对象会被错误的回收, 这是因为业务线程和垃圾回收线程都是并行执行的
- 评价标准:
- 吞吐量:
CPU
用于执行用户代码的时间与CPU
总执行时间的比值, 吞吐量=执行用户代码的时间/(执行用户代码的时间+GC
时间), 吞吐量越高表示垃圾回收效率越高 - 最大暂停时间: 垃圾回收过程中
STW
时间的最大值, 越小越好 - 堆使用效率: 不同垃圾回收算法, 堆内存使用效率不同. 比如标记清除算法, 可以完整的使用堆内存, 复制算法会将堆内存一分为二, 每次只能使用一半内存. 从堆使用效率上来说, 标记清除算法要优于复制算法
- 吞吐量:
- 三者不可兼得, 堆内存越大, 最大暂停时间就越大, 减少最大暂停时间, 就会降低吞吐量
标记-清除算法
两个阶段
- 标记阶段: 将所有存活对象标记, 使用可达性算法, 从
GC Root
开始通过引用链遍历出所有的存活对象- 清除阶段: 从内存中删除没有被标记, 也就是非存活对象
- 优点: 实现简单, 只需要在第一阶段维护每个对象的标志位, 第二阶段删除即可
- 缺点:
- 碎片化问题: 内存是连续的, 所以对象被删除之后, 内存中可能会出现很多细小的可用内存单元. 如果我们需要一个比较大的空间, 这些内存单元会无法进行分配
- 分配速度比较慢:因为内存碎片存在, 需要维护一个空闲链表, 有可能发生每次遍历链表的最后才能获得合适的内存空间
复制算法
核心思想
- 准备两块空间
From
和To
空间, 每次在对象分配阶段, 只能使用其中一块空间(From
空间)GC
阶段将From
中存活的对象复制到To
空间中- 将两块空间的名字互换, 也就是
From
和To
互换名字, 因为只有From
空间存储对象
- 完整复制算法
- 将堆内存分隔成两块
From
空间和To
空间, 对象分配阶段, 创建对象 GC
阶段开始, 将GC Root
搬运到To
空间- 将
GC Root
关联的对象, 搬运到To
空间,From
空间剩下的是没有被GC Root
关联的对象了 - 清理
From
空间, 并把名称互换
- 将堆内存分隔成两块
- 优点:
- 吞吐量高, 只需要遍历一次存活对象并复制到
To
空间即可, 比标记-整理算法少了一次遍历过程. 但是性能比标记-清除算法低, 因为标记-清除算法不需要进行对象的移动 - 不会出现碎片化的问题, 复制算法复制后会有序地排列对象, 所以不会出现内存碎片
- 吞吐量高, 只需要遍历一次存活对象并复制到
- 缺点: 每次只能用一半的堆内存, 另一半的空间来创建对象使用
标记-整理算法(标记-压缩算法)
对标记-清理算法中容易产生内存碎片的问题的一种解决方案
两个阶段
- 标记阶段: 将所有的存活对象标记, 使用可达性分析算法, 从
GC Root
开始通过引用链遍历出所有存活对象- 整理阶段: 将存活对象移动到堆的一端, 清理掉非存活对象的内存空间
- 优点:
- 内存使用率高, 整个堆内存都可以使用, 复制算法只能用半个堆内存
- 不会出现碎片化的问题, 整理阶段可以将对象往内存的一侧进行移动, 剩下的空间都是可以分配对象的有效空间
- 缺点: 整理阶段的效率不高, 整理算法有很多, 比如
Lisp2
整理算法需要对整个堆中的对象搜索三次, 整体性能不佳. 可以通过Two-Finger
, 表格算法,ImmixGC
等高效的整理算法优化这个阶段的性能
分代GC
(Generational GC
)
分代垃圾回收将整个内存区域划分为年轻代(
Young
区, 新生代)和老年代(Old
区)年轻代存放存活时间比较短的对象, 老年代存放存活时间比较长的对象
年轻代中有
Eden
区,Survivor
区(S0, S1
)
JDK 8
中, 添加-XX:+UserSerialGC
参数使用分代回收的垃圾回收器, 运行程序, 使用Arthas
中的memory
命令可以查看三个区域的内存情况, 分别是eden_space, survivio_space, tenured_gen
, 就是伊甸园区, 幸存者区, 老年代
-Xmn
: 设置新生代的大小, 也就是伊甸园区和幸存者区的大小-XX:SurvivorRatio
: 伊甸园区和幸存区的比例, 默认为8. 如果新生代1G
内存, 伊甸园区就是800MB
,S0
和S1
各100MB
-XX:+PrintGCDetails
orverbose:gc
打印GC
日志, 前者打印信息详细一些,后者简单一些。- 老年代大小就是堆大小与新生代大小的差
- 使用分代回收时, 创建出来的对象首先放入
Eden
伊甸园区, 如果对象在Eden
区越来越多, 直到Eden
区满, 新创建的对象无法放入, 就会触发年轻代GC
, 称为Minor GC
或者Young GC
.Minor GC
会把需要Eden
中和From
需要回收的对象回收, 把没有回收的对象放在To
区域中- 所以
Minor GC
就是一个复制算法, 初始S0
是From
,S1
是To
, 接下来S0
变成了To
,S1
变成了From
- 当
Eden
再次满时, 此时会回收Eden
区和S1 From
中的对象, 并把Eden, From
中的剩余的对象放在S0
中
- 所以
- 每次
Minor GC
后都会为对象记录他的年龄, 初始值为0, 每次GC
完都要加1 - 年龄到达15以后(15是最大值), 默认值和垃圾回收器有关, 对象就会晋升到老年代
- 老年代空间不足, 无法放入新的对象时, 先会尝试
Minor GC
(为了尽量避免对象放入老年代, 比如新生代中年龄都是2或者3, 只是被占用满了, 需要将对象放在老年代中, 如果新生代空间没有满, 也可以不用放入老年代, 所以先进行Minor GC
, 尽量避免对象放入老年代). 如果还是不足, 就会触发Full GC
, 对整个堆进行垃圾回收- 如果
Full GC
没有释放足够的老年代的空间, 就会产生OutOfMemory
错误
- 如果
- 为什么分代
GC
要把堆分成年轻代和老年代?
系统中的大部分对象, 都是创建出来以后不再使用可以被回收, 比如用户获取订单数据, 订单数据返回给用户之后就可以释放了
老年代中会存放长期存活的对象, 比如
Spring
中的大部分bean
对象, 在程序启动之后就不会被回收了在虚拟机的默认设置中, 新生代的大小要远小于老年代的大小
- 可以通过调整新生代和老年代的比例来适应不同类型的应用程序, 提高内存的利用率和性能.
- 比如一个用户很多的网站, 有很多人需要访问订单数据, 那如果新生代较小, 则会有很多年龄较小的对象进入老年代, 导致
GC
时间延长, 此时可以将新生代的内存区调大一些, 直接可以通过Minor GC
回收
- 比如一个用户很多的网站, 有很多人需要访问订单数据, 那如果新生代较小, 则会有很多年龄较小的对象进入老年代, 导致
- 新生代和老年代可以使用不同的垃圾回收算法, 新生代一般使用复制算法, 减少内存碎片; 老年代可以使用标记-清除或者标记-整理算法, 由程序员自己调整(因为老年代空间比较大, 使用了复制算法就只能使用一半空间了, 就不合理了), 标记-清除算法效率较高, 但是会有内存碎片, 而标记-整理算法没有内存碎片, 但是
STW
较长 - 分代设计中允许只回收新生代
Minor GC
, 如果能满足对象分配的要求就不需要堆整个堆进行回收了,STW
时间减少(最核心), 尽可能做Minor GC
, 减少Full GC
垃圾回收器
- 垃圾回收器是垃圾回收算法的具体实现, 由于垃圾回收器分为年轻代和老年代, 所以除了
G1
之外的其他垃圾回收器必须成对使用
年轻代的
Serial
以及老年代的Serial Old
年轻代的
ParNew
以及老年代的CMS
年轻代的
Parallel Scavenge
以及老年代的Parallel Old
G1
可以同时控制年轻代和老年代(JDK 9
之后主流推荐)
JDK 9
废弃了年轻代的Serial
以及老年代的CMS
; 年轻代的ParNew
以及老年代的Serial Old
JDK 14
中废弃了Parallel Scavenge
以及老年代的Serial Old
Serial
垃圾回收器-年轻代
单线程串行回收年轻代的垃圾回收器
- 回收年轻代, 使用复制算法
- 优点: 单
CPU
下吞吐量出色 - 缺点: 多
CPU
下吞吐量不如其他垃圾回收器, 堆偏大会让用户线程处于长时间等待(因为回收的时候只有单线程进行GC
) - 适合
Java
编写的客户端程序, 或者硬件配置有限的场景
Serial Old
垃圾回收器-老年代
单线程串行回收, 是
Serial
的老年代版本
-XX:+UseSerialGC
新生代、老年代都使用串行回收器- 回收老年代, 使用标记-整理算法
- 优缺点跟
Serial
算法相同 - 一般配合
Serial
使用, 或者在特殊情况下同CMS
使用, 实际使用不多, 一般在CPU
资源比较匮乏的时候使用
ParNew
垃圾回收器-年轻代
本质上就是对
Serial
在多CPU
下的优化, 使用多线程进行垃圾回收
-XX:+UseParNewGC
新生代使用ParNew
回收器,老年代使用串行回收器
- 回收年轻代, 使用复制算法
- 优点: 多
CPU
下停顿时间较短 - 缺点: 吞吐量和停顿时间不如
G1
, 所以在JDK 9
之后不建议使用 - 适合
JDK 8
以及之前的版本中, 与CMS
老年代垃圾回收器配合使用
CMS(Concurrent Mark Sweep)
垃圾回收器-老年代
关注的是系统的暂停时间, 允许用户线程和垃圾回收线程在某些步骤中同时执行, 减少用户线程的等待
-XX:+UseConcMarkSweepGC
- 使用标记-清除算法回收老年代
- 优点: 系统由于垃圾回收停顿时间较短, 所以用户体验好
- 缺点: 内存碎片问题, 退化问题(会退化为
Serial Old
单线程), 浮动垃圾问题 - 适合大型互联网系统中, 用户请求数据量大, 频率高的场景, 比如订单接口, 商品接口等
CMS
执行步骤
- 初始标记: 用极短的时间标记出
GC Root
能够直接关联到的对象 - 并发标记: 标记所有的对象, 用户线程此时不需要暂停
- 重新标记: 由于并发标记阶段没有
STW
, 因此有些对象会发生变化, 存在错标和漏标的情况, 需要重新标记(这也是STW
的原因) - 并发清理: 清理死亡对象, 用户线程不需要暂停(因此会有浮动垃圾, 不能完全垃圾回收)
只有初始标记和重新标记两个阶段用户线程需要暂停, 但是这两个线程执行时间较短, 是并发执行的, 所以
STW
较短
CMS
缺点:- 使用标记-清除算法, 会有大量内存碎片, 在
Full GC
时进行碎片整理, 导致用户线程暂停, 可以使用-XX:CMSFullGCsBeforeCompation=N
(默认为0)调整N
次Full GC
之后再整理 - 无法处理在并发清理过程中产生的浮动垃圾, 不能做到完全的垃圾回收, 也就是在并发清理阶段, 如果用户产生了对象, 并且很快就失效了, 则不能在并发清理阶段被回收
- 如果老年代内存不足无法分配对象,
CMS
就会退化为Serial Old
单线程回收老年代
- 使用标记-清除算法, 会有大量内存碎片, 在
Parallel Scavenge
垃圾回收器-年轻代
- 是
JDK 8
默认的垃圾回收器, 多线程并行回收, 关注系统的吞吐量, 具备自动调整堆内存大小的特点 - 年轻代的复制算法
- 优点: 吞吐量高, 手动可控, 为了提高吞吐量, 虚拟机会动态调整堆的参数(不需要关注最大堆内存, 年轻代的大小了)
- 缺点: 不能保证单次的停顿时间(可以设置最大单次暂停时间)
- 适合后台任务, 不需要与用户进行交互, 并且容易产生大量的对象的任务, 比如大数据的处理, 大文件的导出
允许手动设置最大暂停时间和吞吐量, 官方建议不要设置堆内存的最大值, 垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小
最大暂停时间
-XX:MaxGCPauseMillis=n
设置每次垃圾回收时的最大停顿毫秒数吞吐量
-XX:GCTimeRatio=n
设置吞吐量为n
, 也就是用户线程执行时间=n/(n+1)自动调整内存大小
-XX:+UseAdaptiveSizePolicy
设置可以让垃圾回收器根据吞吐量和最大停顿毫秒数自动调整内存大小实际上, 当我们把最大暂停时间减小的时候, 垃圾回收器会主动减少堆内存, 从而减少最大暂停时间
Parallel Old
垃圾回收器-老年代
- 是
PS
收集器的老年代版本, 利用多线程并发收集 -XX:+UseParallelGC
或者-XX:+UseParallelOldGC
可以使用PS + PO
这种组合- 回收老年代的标记-整理算法, (
Arthas
上面显示MarkSweep
也就是标记清除, 这是因为老年代垃圾回收器不会单独使用标记清除算法, 官方没有将整理放上来, 所以显示MarkSweep
, 包括CMS
用的也不是单纯的标记清除算法) - 优点: 并发收集, 在多
CPU
下效率较高 - 缺点: 暂停时间较长
- 适合与
PS
一起使用
G1
垃圾回收器
JDK 9
之后默认使用G1
垃圾回收器,PS
关注吞吐量, 允许用户设置最大暂停时间, 但是会减少年轻代的可用空间大小
CMS
关注暂停时间, 但是吞吐量方面会有下降
G1
设计目标就是将上述两种垃圾回收器的优点融合:
- 支持巨大的堆空间回收, 具有较高的吞吐量
- 支持多
CPU
并行垃圾回收- 允许用户设置最大暂停时间
所以强烈建议使用G1
垃圾回收器
内存结构
G1
之前的垃圾回收器, 一般内存结构是连续的
G1
将整个堆划分为多个大小相等的区域, 称为区Region
, 区域不要求连续, 分为eden, Survivor, Old
区.Region
的大小通过堆空间大小/2048计算得到, 也可以通过-XX:G1HeapRegionSize=32m
指定(region
大小为32M
),Region size
必须是2的指数幂,取值范围从1M
到32M
垃圾回收方式
- 年轻代回收(
Young GC
) - 混合回收(
Mixed GC
)(回收年轻代加上老年代)
年轻代回收(Young GC
)
回收
Eden
区和Survivor
区中不再使用的对象, 会导致STW
,G1
会尽可能的保证暂停时间, 可以通过-XX:MaxGCPauseMillis=n
(默认为200)设置最大暂停时间的毫秒数
- 新创建的对象放在
Eden
区, 如果Eden + Survivor
超过年轻代区的60%
, 就会判断年轻代空间不足, 无法分配对象的时候会执行Young GC
- 标记
Eden
和Survivor
区域中的存活对象 - 根据最大暂停时间选择某些区域将存活对象复制到一个新的
Survivor
区域, 并且年龄+1, 并清空这些区域(因为使用了复制方法, 不会产生内存碎片)
进行
Young GC
的时候会记录每次垃圾回收的Eden
区域和Survivor
区域的平均耗时, 从而作为下次回收时的参考依据, 这样就可以根据配置的最大暂停时间计算出本次回收最多能回收多少个Region
区域了比如
-XX:MaxGCPauseMillis=n
(默认为200),每个Region
回收耗时40ms
, 所以这次最多只能回收4个Region
- 后面如果再发生
Young GC
, 步骤相同, 只是Survivor
会搬运到另一个Survivor
区
如果一个对象年龄达到阈值(默认是15), 就会被放入老年代
- 如果部分对象大小超过
Region
的一半, 那么就会直接放到老年代中, 称为Humongous
区(巨大的)
比如堆内存
4G
, 每个Region``2M
, 只要一个对象超过1M
, 就会被放入Humongous
区中, 如果对象过大, 就会横跨多个Region
- 多次回收之后, 会有很多老年代区, 如果达到阈值(
-XX:InitiatingHeapOccupancyPercent
)占用总堆空间的默认的45%
, 就会触发Mixed GC
, 回收所有年轻代和部分老年代对象以及大对象区, 采用复制算法完成
混合回收(Mixed GC
)
分为初始标记
Initial Mark
, 并发标记Concurrent Mark
, 最终标记Remark
或者Finalize Marking
, 并发清理(Cleanup
)
G1
对老年代的清理会选择存活度最低的区域来进行回收, 可以保证回收效率最高, 也就是G1
的由来(判断哪个区域存活对象最少, 就优先清除哪个区域)
- 初始标记: 标记
GC Root
引用的对象为存活, 并行执行, 暂停用户线程, 速度较快 - 并发标记: 将第一步中标记对象的引用对象标记为存活, 和用户线程一起执行(可能会出现错标漏标)
- 最终标记: 标记一些引用改变漏标的对象, 不管新创建、不再关联的对象(但是上一步结束后可能有些对象不再使用了,
G1
不再处理) - 并发复制清理: 使用复制算法, 将存活对象复制到别的
Region
中, 此操作不会产生内存碎片
- 如果清理过程中没有足够的空
Region
存放转移的对象, 就会出现Full GC
, 单线程执行标记-整理算法, 此时会导致用户线程暂停. 所以尽量保证堆中有一定的空间
-XX:+UseG1GC
, 打开G1
的开关,JDK 9
之后默认打开
-XX:MaxGCPauseMillis=n
设置最大暂停时间
- 使用复制算法回收年轻代+老年代
- 优点:
- 对较大的堆如果超过
6G
对回收时, 延迟也可控 - 不会产生内存碎片
- 并发标记的
SATB
算法效率高
- 对较大的堆如果超过
- 缺点:
JDK 8
之前还不够成熟
总结
JDK 8
以及之前:ParNew + CMS
(关注暂停时间);PS + PO
(关注吞吐量);G1
(JDK 8
之前不建议使用, 较大堆并且关注暂停时间)JDK 9
之后, 使用G1