深入聊聊Java 垃圾回收机制【附原理图及调优方法】

目录

1、如何判断对象可以回收

1.1 引用计数法

1.2 可达性分析算法

1.3 四种引用:强、软、弱、虚引用

2、垃圾回收算法

2.1 标记清除

2.2 标记整理

2.3 复制

3、分代垃圾回收

3.1 相关JVM参数

4、垃圾回收器

4.1 串行

4.2 吞吐量优先

4.3 响应时间优先

4.4 G1 垃圾回收器 

5、垃圾回收调优

5.1 调优领域

5.2 确定目标

5.3 最快的GC是不生发GC

5.4 新生代调优

5.5 老年代调优

5.6 案例


1、如何判断对象可以回收

1.1 引用计数法

每个对象有一个引用计数器,当对象被引用一次则计数器加1,当对象引用失效一次则计数器减1,对于计数器为0的对象意味着是垃圾对象,可以被GC回收。

  • 引用计数法优点:实现逻辑简单
  • 引用计数法缺点:无法解决循环引用问题;目前没有在使用

1.2 可达性分析算法

从GC Roots作为起点开始搜索,那么整个连通图中的对象便都是活对象,对于GC Roots无法到达的对象便成了垃圾回收的对象,随时可被GC回收。

  • Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看是否能够沿着 GC Root 对象 为起点的引用链找到该对象,找不到表示可以回收

哪些对象可以作为GC Root ?

  1. 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表
  2. 方法区中的类静态属性引用的对象。
  3. 方法区中常量引用的对象
  4. 本地方法栈中N( Native方法)引用的对象

1.3 四种引用:强、软、弱、虚引用

1. 强引用

  • 只有所有GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收

2. 软引用(SoftReference)

  • 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
  • 可以配合引用队列来释放软引用自身

3. 弱引用(WeakReference)

  • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
  • 可以配合引用队列来释放弱引用自身

4. 虚引用(PhantomReference)

  • 必须配合引用队列使用,主要配合ByteBuffer使用,被引用对象回收时,会将虚引用入队,由Reference Handler线程调用虚引用相关方法释放直接内存

5. 终结器引用(FinalReference)

  • 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由Finalizer线程通过终结器引用找到被引用对象并调用它的 finalize方法,第二次GC 时才能回收被引用对象


2、垃圾回收算法

2.1 标记清除

定义:Mark Sweep

描述:分为标记清除两阶段:首先标记出所有需要回收的对象,然后统一回收所有被标记的对象。

  • 速度较快
  • 会造成内存碎片,导致在程序运行过程中需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

2.2 标记整理

定义:Mark Compact

描述:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

  • 速度慢
  • 没有内存碎片

2.3 复制

定义:Copy

描述:将可用内存容量划分为大小相等的两块,每次只用其中一块。当这块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 不会有内存碎片,但效率也不是很高
  • 需要占用双倍内存空间

优点:自带整理功能,这样不会产生大量不连续的内存空间,适合年轻代垃圾回收。


3、分代垃圾回收

当前商业虚拟机的垃圾收集都采用分代收集。此算法没啥新鲜的,就是将上述三种算法整合了一下。具体如下:

根据各个年代的特点采取最适当的收集算法:

  1. 在新生代中,每次垃圾收集时候都发现有大批对象死去,只有少量存活,那就选用复制算法。只需要付出少量存活对象的复制成本就可以完成收集。
  2. 老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须用标记-清除或者标记-整理。

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发minor gc,伊甸园 和 from存活的对象使用 copy 复制到 to中,存活的对象年龄加1 并且交换 from to
  • minor gc 会引发stop the word,暂停其它用户线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命15 (4bit)
  • 当老年代空间不足,会先尝试触发minor gc,如果之后空间仍不足,那么触发full gc,STW的时间更长

3.1 相关JVM参数

相关JVM参数
含义 参数 备注
堆初始大小 -Xms  
堆最大大小 -Xmx 或 -XX:MaxHeapSize=size  
新生代大小 -Xmn 或(-XX:NewSize=size + -XX:MaxNewSize=size)  
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy  
幸存区比例 -XX:SurvivorRatio=ratio  
晋升阈值 -XX:MaxTenuringThreshold=threshold  
晋升详情 -XX:+PrintTenuringDistribution  
GC详情 -XX:+PrintGCDetails -verbose:gc  
FullGC前 MinorGC -XX:+ScavengeBeforeFullGC  

4、垃圾回收器

4.1 串行

Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;JDK1.3.1前是HotSpot新生代收集的唯一选择;

  • 单线程
  • 针对新生代
  • 采用复制算法
  • 进行垃圾收集时,必须暂停所有工作线程,直到完成
  • 堆内存较小,适合个人电脑
-XX:+UseSerialGC = Serial + SerialOld  // -XX:+UseSerialGC   添加该参数来显示的使用串行垃圾收集器

4.2 吞吐量优先

  • 多线程
  • 堆内存较大,多核CPU
  • 让单位时间内,STW的时间最短 0.2  0.2 = 0.4
-XX:+UseParallelGC    ~    -XX:+UseParallelOldGC // JDK1.8默认开启,只要开启UseParallelGC,就对应开启
-XX:+UseAdaptiveSizePolicy  // 自适应动态调整伊甸园和幸存区的内存比例
-XX:GCTimeRatio=ratio       // 目标1:1 / (1 + ratio)  一般设置ratio为19,20分钟垃圾回收不超过1分钟;会动态调整堆空间大小适应
-XX:MaxGCPauseMillis=ms     // 目标2:最大暂停用户线程时间,默认200ms
-XX:ParallelGCThreads=n     // 垃圾回收线程数
                            // 垃圾回收时,CPU会飚得很高 

4.3 响应时间优先

  • 多线程
  • 堆内存较大,多核CPU
  • 尽可能让单次STW的时间最短 0.1  0.1  0.1  0.1  0.1 = 0.5
-XX:+UseConcMarkSweepGC   ~    -XX:+UseParNewGC     ~     SerialOld
-XX:ParallelGCThreads=n   ~    -XX:ConcGCThread=threads // ParallelGCThreads为4,则ConcGCThread应该是ParallelGCThreads的1/4,对CPU占用没有Par那么高
-XX:CMSInitiatingOccupancyFraction=percent // 执行CMS执行占比,预留空间给浮动垃圾
-XX:+CMSScavengeBeforeRemark   // 在CMS垃圾标记前开启新生代垃圾回收,这样重新标记对象要少得多,Full GC时间从接近2秒,降低到300ms左右
//CMS致命问题:CMS会产生内存碎片,如果内存碎片过多,垃圾回收会退化到SerialOld单线程垃圾回收器

初始标记非常快,不影响用户工作工程;并发标记 

初始标记:仅仅单线程标记GC Roots的直接关联对象,并且STW,这个过程非常短暂,可以忽略不计;

并发标记:使用GC Roots Tracing算法,进行跟踪标记RC Roots间接相关的对象,不会STW;

重新标记:因为之前并发标记,其他用户线程不暂停,可能产生了新垃圾,所以需要重新标记;

清除垃圾:与用户线程并行执行垃圾回收,使用清除算法

CMS缺点:因为与用户工作程一起并发执行,所以会边清理,一边会产生新的垃圾

JAVA 堆垃圾回收示例:

// GC 分析 大对象OOM
public class T01_Gc_Demo01 {
    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;

    // -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
    public static void main(String[] args) throws InterruptedException {
//        ArrayList<byte[]> list = new ArrayList<>();
//        list.add(new byte[_8MB]);
//        list.add(new byte[_8MB]);

        // 一个线程OOM,不会导致整个进程挂掉
        new Thread(() -> {
            ArrayList<byte[]> list = new ArrayList<>();
            list.add(new byte[_8MB]);
            list.add(new byte[_8MB]);
        }, "Thread01").start();

        System.out.println("sleep...");
        TimeUnit.SECONDS.sleep(10);
    }
}

4.4 G1 垃圾回收器 

定义:Garbage First,优先回收最有价值的垃圾区域,达到暂停时间不短的目标

  • 2004 论文发布
  • 2009JDK 6u14体验
  • 2012 JDK 7u4官方支持
  • 2017 JDK 9默认,同时废弃了CMS垃圾回收

适用场景

  • 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是200ms
  • 超大堆内存,会将堆划分为多个大小相等的Region (区域)
  • 整体上是标记 + 整理算法,两个区域之间是复制算法

相关JVM参数

  • -XX:+UseG1GC
  • -XX:G1HeapRegionSize=size   // 设置Region区域大小
  • -XX:MaxGCPauseMillis=time   // 设置暂停目标,默认是200ms

总结:G1垃圾回收器,使用标记-整理算法,可以避免CMS标记-清除算法产生的内存碎片问题;在两个Region区域之间,则是使用复制算法。JDK8没有默认G1垃圾回收器,需要手动开启G1

1)G1垃圾回收阶段

  • Young Collection
  • Young Collection + Concurrent Mark
  • Mixed Collection

2) Young Collection 新生代回收

  • 会STW

如果伊甸园进行垃圾回收,则会将伊甸园区存活的对象使用复制算法到Survivor区

当Survivor进行垃圾回收时,对象年龄超过15次,放入老年代;年龄不足15次放入另一个Survivor区域

3) Young Collection + CM(新生代回收+CM)

  • 在Young GC 时会 GC Root 的初始标记
  • 老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),由下面的JVM参数决定

-XX:InitiatingHeapOccupancyPercent=percent (默认45%)

4)Mixed Collection (混合回收)

会对E、S、O进行全面垃圾回收

  • 最终标记(Remark)会STW
  • 拷贝存活(Evacuation)会STW ,并不是所有老年代区域都会回收,而是回收最有价值

-XX:MaxGCPauseMillis=ms

5)Full GC

  • Serial GC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • Parallel GC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • CMS
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足,当回收速度高于垃圾产生的速度,后台不会有full gc字样
  • G1
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足,当回收速度高于垃圾产生的速度,后台不会有full gc字样

6)Young Collection 跨代引用

  • 新生代回收的跨代引用(老年代引用新生代)问题

如果遍历整个老年代根对象,显然效率会非常低;老年代设计对应一个卡表,每个卡512K,如果某个卡中的对象引用了对象,我们将此卡标记为脏卡,减少扫描范围,提升垃圾回收效率。

  • 卡表与Remembered Set
  • 在引用变更时通过 post-write barrier + dirty card queue
  • concurrent refinement threads 更新Remembered Set

7)Remark 重标记

  • pre - write barrier  + satb_mark_queue

在对象引用改变之前,采用写屏障,表示未处理完毕;同时将对象存入一个引用队列进行处理

8)JDK 8u20 字符串去重

  • 优点:节省大量内存
  • 缺点:略微多占用了cpu时间,新生代回收时间略微增加

-XX:+UseStringDeduplication  // 使用此功能,需要打开此配置,默认是打开

String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1并发检查是否字符串重复
  • 如果它们值一样,让它们引用同一个char[]
  • 注意,与String.intern() 不一样
    • String.intern() 关注的是字符串对象
    • 而字符串去重关注的是 char[] 
    • 在JVM 内部,使用了不同的字符串表

9)JDK 8u40 并发标记类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类

-XX:+ClassUnloadingWithConcurrentMark 默认启用

10)JDK 8u60回收巨型对象

  • 一个对象大于region的一半时,称之为巨型对象
  • G1 不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1 会跟踪老年代所有 incoming 引用,这样老年代incoming 引用为0的巨型对象就可以在新生代垃圾回收时处理掉

如下图,巨型对象在G1垃圾回收模型情况:

11)JDK 9 并发标记起始时间的调整

  • 并发标记必须在堆空间占满前完成,否则退化为Full GC (如果垃圾回收回收速度跟不上垃圾产生的速度,最终会Full GC)
  • JDK9 之前需要使用 -XX:InitiatingHeapOccupancyPercent  (默认45%)
  • JDK9 可以动态调整,更加合理;尽可能避免并发垃圾回收退化Full GC垃圾回收
    • -XX:InitiatingHeapOccupancyPercent 用来设置初始值
    • 进行数据采样并动态调整
    • 总会添加一个安全的空档空间

12)JDK9 更高效的回收


5、垃圾回收调优

预备知识

  • 掌握GC 相关的JVM参数,会基本的空间调整
  • 掌握相关工具
  • 明白一点:调优跟应用、环境有关、没有放之四海纳而皆准的法则

调优原则:让长时间存活对象尽快晋升,如果长时间存活对象大量停留在新生代,新生代采用复制算法,复制来复制去,性能较低而且是个负担

5.1 调优领域

  • 内存
  • 锁竞争
  • cpu占用
  • io

5.2 确定目标

  • 【低延迟】还是【高吞吐量】,选择合适的回收器
  • CMS,G1, ZGC
  • ParallelGC
  • Zing

科学运算,追求高吞吐量;互联网项目追求低延迟;高吞吐量垃圾回收,目前没有太多选择就一下ParallelGC;

低延迟垃圾回收,可以选CMS,G1, ZGC。目前互联公司还是很多在用CMS,JDK9 默认G1,不推荐CMS;因为CMS采用标记-清除算法会产生内存碎片,内存碎片多了之后会退化为serialOld,产生大幅度、长时间停顿,给用户的体验是不稳定

5.3 最快的GC是不生发GC

  • 查看Full GC 前后内存占用,考虑下面几个问题:自己的代码是否存在问题
    • 数据是不是太多?
      • resultSet = statement.executeQuery("select * from 大表") ,可以加限定条数 limit n
    • 数据表示是否太臃肿
      • 对象图
      • java对象最小也是16字节,Integer 16字节, int 4;所以我们在选则数据类型时尽量选用基本数据类型
    • 是否存在内存泄漏?
      • 比如定义了一个静态的Map,static Map map = ,然后不停地向里面添加数据
      • 在内存紧张时,可以使用软引用
      • 在内存不足时,可以使用弱引用
      • 缓存数据时,尽量使用第三方缓存实现,比如redis/memcache,减少对堆内存依赖

5.4 新生代调优

  • 新生代的特点
    • 所有的new 操作的内存分配非常廉价
      • TLAB  thread-local allocation buffer,线程局部缓冲区,线程使用自己私有区域分配对象内存
    • 死亡对象的回收代价是零;因为采用复制算法,存活的对象使用复制算法到Survivor区域,剩下都是需要被回收的
    • 大部分对象用过即死,只有少数对象存活
    • Minor GC 的时间远远低于Full GC
    • 新生代优化空间更大一些

如何给新生代调优呢?是不是将新生代内存调得越大越好?下面是Oracle官方文档说明截图

网页链接:https://docs.oracle.com/en/java/javase/11/tools/java.html#GUID-3B1CE181-CD30-4178-9602-230B800D4FAE

上述大致中文翻译:设置年轻代的堆的初始大小和最大大小(以字节为单位)。 字母k或K表示千字节,m或M表示兆字节,g或G表示千兆字节。 堆的年轻代区域用于新对象。 与其他区域相比,在该区域执行GC的频率更高。 如果年轻代设置太小,则会执行大量 minor gc垃圾回收。 如果设置太大,则仅执行full gc垃圾回收才有效,这可能需要很长时间才能完成。 Oracle官方建议设置年轻代的大小保持大于堆总大小的25%,并且小于堆总大小的50%。 


总结:新生代,还是需要调大一些,因为新生代采用复制算法,需要移动对象,复制算法性能效率较低。

公式:新生代能容纳所有【并发量 * (请求 - 响应)】的数据

  • 幸存区大到能保留【当前活跃对象 + 需要晋升对象】,原则就是让真正需要进入老年代的对象才进入老年代。
  • 晋升阈值配置得当,让长时间存活对象尽快晋升
    -XX:MaxTenuringThreshold=threshold     //  设置年龄阈值,大值为15。并行(吞吐量)收集器的默认值为15,而CMS收集器的默认值为6。
    -XX:+PrintTenuringDistribution                  //  启用打印保有权年龄信息,这个参数对于设置-XX:MaxTenuringThreshold有很大帮助,阀值需要长时间观察对象分布,设置合理即可。

5.5 老年代调优

以CMS 为例

  • CMS的老年代内存越大越好
  • 先尝试不做调优,如果没有Full GC 那么老年代已经足够大了;如果有Full GC 则先尝试调优新生代
  • 观察发生Full GC 时老年代内存占用,将老年代内存预设调大 1/4  ~  1/3
    • -XX:CMSInitiatingOccupancyFraction=percent    // 控制老年代占用空间大小占总空间大小比例,进行CMS垃圾回收;值越小就越早进行垃圾回收,推特工程师有一个演讲建议将此值设置为0,即一有垃圾就回收;一般我们将此值设置75%~80%之间,预留25%-20%给浮动垃圾

5.6 案例

  • 案例1:Full GC 和Minor GC频繁(一分钟上百次),意味着堆内存空间紧张,可能是新生代空间过小,导致不需要晋升到老年代的对象进入老年代,然后老年代空间存在大量这种对象,空间也紧张就是频繁gc;
  • 案例2:请求高峰期发生Full GC,单次暂停时间特别长(CMS);可以重新标记前开启垃圾回收,这样重新标记对象数没有那多,性能有一定提高;
  • 案例3:老年代充裕情况下,发生Full GC (1.7) ;可能是JDK1.7永久代空间不足导致内存不足;JDK1.8元空间使用系统内存不易内存溢出

文章最后,给大家推荐一些受欢迎的技术博客链接

  1. JAVA相关的深度技术博客链接
  2. Flinak 相关技术博客链接
  3. Spark 核心技术链接
  4. 设计模式 —— 深度技术博客链接
  5. 机器学习 —— 深度技术博客链接
  6. Hadoop相关技术博客链接
  7. 超全干货--Flink思维导图,花了3周左右编写、校对
  8. 深入JAVA 的JVM核心原理解决线上各种故障【附案例】
  9. 请谈谈你对volatile的理解?--最近小李子与面试官的一场“硬核较量”
  10. 聊聊RPC通信,经常被问到的一道面试题。源码+笔记,包懂
  11. 深入聊聊Java 垃圾回收机制【附原理图及调优方法】

欢迎扫描下方的二维码或 搜索 公众号“大数据高级架构师”,我们会有更多、且及时的资料推送给您,欢迎多多交流!

                                           

       

猜你喜欢

转载自blog.csdn.net/weixin_32265569/article/details/107830848