1. 有过GC调优的经历么?说一下常见的GC调优方法

1.1 GC调优调优的思路和调优方法可以叙述一下。

如果CPU使用率较高,GC频繁且GC时间长,可能就需要JVM调优了。
谈到调优,这一定是针对特定场景、特定目的的事情, 对于 GC 调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput),大多数情况下调优会侧重于其中一个或者两个方面的目标,很少有情况可以兼顾三个不同的角度。当然,除了上面通常的三个方面,也可能需要考虑其他。例如:OOM 也可能与不合理的 GC 相关参数有关;或者,应用启动速度方面的需求,GC 也会是个考虑的方面。
基本的调优思路可以总结为:

  1. 理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。
  2. 掌握 JVM 和 GC 的状态,定位具体的问题,确定真的有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启GC日志(-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:),或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。
  3. 选择的 GC 类型是否符合我们的应用特征,如果是,具体问题表现在哪里,是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。
  4. 通过分析确定具体调整的参数或者软硬件配置。
  5. 验证是否达到调优目标,如果没有达到目标重复上述操作。

目前,G1 已经成为新版 JDK (1.8以上)的默认选择

1.2 JVM内存调优方法及步骤

首先需要注意的是在对JVM内存调优的时候不能只看操作系统级别Java进程所占用的内存,这个数值不能准确的反应堆内存的真实占用情况,因为GC过后这个值是不会变化的,因此内存调优的时候要更多地使用JDK提供的内存查看工具,比如JConsole和Java VisualVM。

对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。特别要关注Full GC,因为它会对整个堆进行整理,导致Full GC一般由于以下几种情况:

  1. 旧生代(年老代)空间不足
    调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象
  2. 持久代Pemanet Generation空间不足
    增大Perm Gen空间,避免太多静态对象,控制好新生代和旧生代的比例
  3. 统计得到的GC后晋升到旧生代的平均大小大于旧生代剩余空间
    控制好新生代和旧生代的比例
  4. System.gc()被显示调用
    垃圾回收不要手动触发,尽量依靠JVM自身的机制

基本思路就是让每一次GC都回收尽可能多的对象,对于CMS来说,要合理设置年轻代和年老代的大小。
如何确定年轻代和年老代的大小?
这是一个迭代的过程,可以先采用JVM的默认值,然后通过压测分析GC日志。

  1. 如果看年轻代的内存使用率处在高位,导致频繁的Minor GC,而频繁GC的效率又不高,说明对象没那么快能被回收,这时年轻代可以适当调大一点。
  2. 如果看年老代的内存使用率处在高位,导致频繁的Full GC,这样分两种情况:如果每次Full GC后年老代的内存占用率没有下来,可以怀疑是内存泄漏;如果Full GC后年老代的内存占用率下来了,说明不是内存泄漏,要考虑调大年老代。
  3. 对于G1收集器来说,可以适当调大Java堆,因为G1收集器采用了局部区域收集策略,单次垃圾收集的时间可控,可以管理较大的Java堆。

调优手段主要是通过控制堆内存的各个部分的比例和GC策略来实现,下面来看看各部分比例不良设置会导致什么后果

1)新生代设置过小
一是新生代GC次数非常频繁,增大系统消耗;二是导致大对象直接进入旧生代,占据了旧生代剩余空间,诱发Full GC
2)新生代设置过大
一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC;
二是新生代GC耗时大幅度增加。一般说来新生代占整个堆1/3比较合适
3)Survivor设置过小
导致对象从eden直接到达旧生代,降低了在新生代的存活时间
4)Survivor设置过大
导致eden过小,增加了GC频率
另外,通过-XX:MaxTenuringThreshold=n来控制新生代存活时间,尽量让对象在新生代被回收。

由内存管理和垃圾回收可知新生代和旧生代都有多种GC策略和组合搭配,选择这些策略对于我们这些开发人员是个难题,JVM提供两种较为简单的GC策略的设置方式

1)吞吐量优先
JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置

2)暂停时间优先
JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值可由-XX:MaxGCPauseRatio=n来设置

调优步骤:

  1. 监控GC的状态

使用各种JVM工具,查看当前日志,分析当前JVM参数设置,并且分析当前堆内存快照和gc日志,根据实际的各区域内存划分和GC执行时间,觉得是否进行优化。
几种JVM工具如下:
除了jps、jstat、jinfo、jmap、jhat、jstack等小巧的工具,还有集成式的jvisualvm和jconsole。这些工具在 $JAVA_HOME/bin目录下

  1. //jps这个命令可以列出正在运行的虚拟机进程,并显示虚拟机执行主类名称,以及这些进程的本地虚拟机唯一ID
  2. jps -lv
  3. //jstat这个命令用于监视虚拟机各种运行状态信息。
  4. jstat -gc 2849 250 20 //需要每250毫秒查询一次进程2849 垃圾收集状况,一共查询20次
  5. //这个命令可以实时地查看和调整虚拟机各项参数。
  6. jinfo -flag MaxPermSize 2788 //查看2788进程号的MaxPerm大小可以用
  7. //jmap用于生成堆转存的快照,一般是heapdump或者dump文件。如果不适用jmap命令,
  8. 可以使用-XX:+HeapDumpOnOutOfMemoryError参数,当虚拟机发生内存溢出的时候可以产生快照。
  9. jmap -histo 577 //打印每个class的实例数目,内存占用,类全名信息
  10. jmap -dump:format=b,file=test.bin 577 //dump heap内容到文件
  11. //jhat这个工具是用来分析jmap dump出来的文件
  12. jhat test.bin //分析dump出来的test.bin,它会在本地启动一个web服务,端口是7000,
  13. 这样直接访问 127.0.0.1:7000就能看到分析结果了。
  14. //jstack这个命令用于查看虚拟机当前时刻的线程快照(一般是threaddump 或者 javacore文件)。
  15. 线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。
  16. jstack 11686 //查看进程2849 的堆栈信息
  17. //jconsole:一个java GUI监视工具,可以以图表化的形式显示各种数据。
  18. 命令行里打 jconsole,选则进程就可以了。
  19. //jvisualvm同jconsole都是一个基于图形化界面的、可以查看本地及远程的JAVA GUI监控工具
  20. 直接在命令行打入jvisualvm即可启动

举一个例子: 系统崩溃前的一些现象:

  • 每次垃圾回收的时间越来越长,由之前的10ms延长到50ms左右,FullGC的时间也有之前的0.5s延长到4、5s
  • FullGC的次数越来越多,最频繁时隔不到1分钟就进行一次FullGC
  • 年老代的内存越来越大并且每次FullGC后年老代没有内存被释放

之后系统会无法响应新的请求,逐渐到OutOfMemoryError的临界值,这个时候就需要分析JVM内存快照dump。

  1. 生成堆的dump文件

通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过Java的jmap命令来生成该文件。

  1. 分析dump文件
    几种工具打开该文件:
  • Visual VM
  • IBM HeapAnalyzer
  • JDK 自带的Hprof工具
  • Mat(Eclipse专门的静态内存分析工具)推荐使用
    备注:文件太大,建议使用Eclipse专门的静态内存分析工具Mat打开分析
  1. 分析结果,判断是否需要优化

如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化,如果GC时间超过1-3秒,或者频繁GC,则必须优化。

注:如果满足下面的指标,则一般不需要进行GC:

  • Minor GC执行时间不到50ms;
  • Minor GC执行不频繁,约10秒一次;
  • Full GC执行时间不到1s;
  • Full GC执行频率不算频繁,不低于10分钟1次;
  1. 调整GC类型和内存分配

如果内存分配过大或过小,或者采用的GC收集器比较慢,则应该优先调整这些参数,并且先找1台或几台机器进行beta,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择

  1. 不断的分析和调整

通过不断的试验和试错,分析并找到最合适的参数,如果找到了最合适的参数,则将这些参数应用到所有服务器。
面试准备四:JVM虚拟机二  GC - 图1
**

1.3. JVM调优参数参考

  1. 针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值;
  2. 年轻代和年老代将根据默认的比例(1:2)分配堆内存, 可以通过调整二者之间的比率NewRadio来调整二者之间的大小,也可以针对回收代。

    比如年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小。

  3. 年轻代和年老代设置比

    1)更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC 2)更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率

    如何选择应该依赖应用程序对象生命周期的分布情况: 如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。但很多应用都没有这样明显的特性。 **在抉择时应该根据以下两点:

    • 本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 。
    • 通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间。
  4. 在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: -XX:+UseParallelOldGC 。

  5. 线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。

    理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。

JVM常见配置

【堆设置】

-Xms:初始堆大小
-Xmx:最大堆大小
-XX:NewSize=n:设置年轻代大小
-XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
-XX:MaxPermSize=n:设置持久代大小

【收集器设置】
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器

【回收统计信息】
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:filename

【并行收集器设置】
-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)

【并发收集器设置】
-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

2. 说说几种GC算法类型及垃圾收集过程

常见的垃圾回收算法主要有四种

  1. 标记清除(用于老年代)
    分为标记和清除两个阶段,先标记出要回收的对象,然后统一回收这些对象

image.png

之所以说标记-清除算法是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改造而得到的。
主要有两点不足:1. 效率问题,标记和清除两个过程的效率都不高;2.空间问题,标记后会产生大量不连续的内存碎片 。

  1. 复制算法(新生代使用)
    复制算法为了解决效率问题 , 将可用内存按容量划分为大小相同的两块,每次使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上面。然后将已经使用过的内存空间一次清理掉。好处是内存分配时不用考虑内存碎片等复杂情况,运行高效。弊端是将内存缩小到原来的一半。由于新生代对象98%是朝生夕死的,故将内存空间氛围一块比较大的Eden空间和两块较小的Survivor空间。HotSpot虚拟机默认比例为8:1:1 当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。
  2. image.png
  3. 标记整理(用于老年代)
    标记部分与标记-清除一样 ;压缩:再次扫描,并往一端滑动存活对象,将空闲的内存区域连续
    整理的顺序:
    不同算法中,堆遍历的次数,整理的顺序,对象的迁移方式都有所不同。而整理顺序又会影响到程序的局部性。
    主要有以下3种顺序:
    • 任意顺序:对象的移动方式和它们初始的对象排列及引用关系无关
      任意顺序整理实现简单,且执行速度快,但任意顺序可能会将原本相邻的对象打乱到不同的高速缓存行或者是虚拟内存页中,会降低赋值器的局部性。任意顺序算法只能处理单一大小的对象,或者针对大小不同的对象需要分批处理;
    • 线性顺序:将具有关联关系的对象排列在一起
    • 滑动顺序:将对象“滑动”到堆的一端,从而“挤出”垃圾,可以保持对象在堆中原有的顺序
      所有现代的标记-整理回收器均使用滑动整理,它不会改变对象的相对顺序,也就不会影响赋值器的空间局部性。复制式回收器甚至可以通过改变对象布局的方式,将对象与其父节点或者兄弟节点排列的更近以提高赋值器的空间局部性。
      整理算法的限制,如整理过程需要2次或者3次遍历堆空间;对象头部可能需要一个额外的槽来保存迁移的信息。

垃圾收集
部分整理算法:

  1. 双指针回收算法:实现简单且速度快,但会打乱对象的原有布局
  2. Lisp2算法(滑动回收算法):需要在对象头用一个额外的槽来保存迁移完的地址
  3. 引线整理算法:可以在不引入额外空间开销的情况下实现滑动整理,但需要2次遍历堆,且遍历成本较高
  4. 单次遍历算法:滑动回收,实时计算出对象的转发地址而不需要额外的开销

image.png

  1. 分代收集算法
    当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法并无新的方法,只是根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

  2. 引用计数(弃用) 参考 8.2.1 使用引用计数法

常见的垃圾收集器有7种

  1. G1
  2. Serial收集器
  3. Serial Old收集器
  4. ParNew收集器
  5. Parallel Scavenge收集器
  6. Parallel Old收集器
  7. CMS收集器

3. JVM如何自动进行内存管理,Minor GC与Full GC的触发机制

对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。特别要关注Full GC
导致Full GC一般由于以下几种情况:

  1. 旧生代(年老代)空间不足

    调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直
    接在旧生代创建对象

  2. 持久代Pemanet Generation空间不足

    增大Perm Gen空间,避免太多静态对象,控制好新生代和旧生代的比例,

  3. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存

    如果发现统计数据说之前Minor GC的平均晋升大小比目前old gen剩余的空间大,则不会触发Minor GC而是转 为触发full GC

  4. 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

  5. 方法区空间不足

    VM规范中运行时数据区域中的方法区,在HotSpot虚拟机中又被习惯称为永生代或者永生区,Permanet Generation中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方 法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果经
    过Full GC仍然回收不了,那么JVM会抛出如下错误信息:
    java.lang.OutOfMemoryError: PermGen space
    为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。

  6. System.gc()被显示调用

    垃圾回收不要手动触发,尽量依靠JVM自身的机制

    Minor GC触发条件:从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC;当Eden区满时,触发Minor GC。

    4. GC过程是怎么样的?

  • 哪些内存需要回收?——who
  • 什么时候回收?——when
  • 怎么回收?——how

哪些内存需要回收:
针对线程共享的堆和方法去进行回收
image.png

什么时间回收:

  1. 引用计数法
  2. 可达性分析

这两种方法进行判断哪些对象该回收
怎么回收:

  1. 标记——清除算法 遍历所有的GC Root,分别标记处可达的对象和不可达的对象,然后将不可达的对象回收,缺点是:效率低、回收得到的空间不连续
  2. 复制算法 将内存分为两块,每次只使用一块。当这一块内存满了,就将还存活的对象复制到另一块上,并且严格按照内存地址排列,然后把已使用的那块内存统一回收。缺点是:浪费了一半内存
  3. 分代算法 在java中,把内存中的对象按生命长短分为:
  • 新生代:活不了多久就回收了,比如局部变量 使用复制算法 发生YoungGC
  • 老年代:比如一些生命周期长的对象 使用标记-清除算法/CMS 发生一次 MajorGC至少伴随一次YoungGC。申请不到内存,会发生fullGC
  • 持久代:永远不死,比如加载的class信息

G1收集器
在G1中没有物理上的Yong(Eden/Survivor)/Old Generation,它们是逻辑的,堆被划分成一些非连续的区域(Region)组成的,每个区域大小相等。
新生代收集
G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。
经过Young GC后存活的对象被复制到一个或者多个区域空闲中,这些被填充的区域将是新的新生代;当新生代对象的年龄(逃逸过一次Young GC年龄增加1)已经达到某个阈值(ParNew默认15),被复制到老年代的区域中。
回收过程是停顿的(STW,Stop-The-Word);回收完成之后根据Young GC的统计信息调整Eden和Survivor的大小,有助于合理利用内存,提高回收效率。
回收的过程多个回收线程并发收集。
老年代收集
和CMS类似,G1收集器收集老年代对象会有短暂停顿。

  1. 标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。
  2. Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。
  3. Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
    Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。

5. CMS 内部使用什么算法做垃圾回收?过程怎么样?GC有什么问题?

使用的是并发清除算法
CMS过程如下几步:

  1. 初始标记(STW)
  2. 并发标记
  3. 并发预处理
  4. 重标记(STW)
  5. 并发清理
  6. 重置
  • 1.初始标记阶段需要STW
    该阶段进行可达性分析,标记GC ROOT能直接关联到的对象。
  • 2.并发标记阶段是和用户线程并发执行的过程
    该阶段进行GC ROOT TRACING,在第一个阶段被暂停的线程重新开始运行。
    由前阶段标记过的对象出发,所有可到达的对象都在本阶段中标记。
  • 3.并发预处理阶段做的工作还是标记
    与重标记功能相似,CMS是以获取最短停顿时间为目的的GC。重标记需要STW(Stop The World),因此重标记的工作尽可能多的在并发阶段完成来减少STW的时间。此阶段标记从新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。
    如何确定老年代的对象是活着的
    通过GC ROOT TRACING可到达的对象就是活着的
    老年代进行GC时如何确保Current Obj标记为活着的,答案是必须扫描新生代来确保。这也是为什么CMS虽然是老年代的gc,但仍要扫描新生代的原因。(注意初始标记也会扫描新生代)
  • 4.重标记(STW) 暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。
    有了前面的基础,这个阶段的工作量被大大减轻,停顿时间因此也会减少。
    注意这个阶段是多线程的。
  • 5.并发清理。用户线程被重新激活,同时清理那些无效的对象。
  • 6.重置。 CMS清除内部状态,为下次回收做准备。

缺点:

  1. GC过程中会出现STW(Stop-The-World),若Old区对象太多,STW耗费大量时间。
  2. CMS收集器对CPU资源很敏感。
  3. CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
  4. CMS导致内存碎片问题。

    6. 怎么避免产生浮动垃圾?

  5. CMS的解决方案是使用UseCMSCompactAtFullCollection参数(默认开启),在顶不住要进行Full GC时开启内存碎片整理。这个过程需要STW,碎片问题解决了,但停顿时间又变长了。

  6. 虚拟机还提供了另外一个参数CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认为0,每次进入Full GC时都进行碎片整理)。
  7. 使用G1

    7. 强制young gc会有什么问题?

STW停顿时间变长

8. 知道G1么

8.1.G1 GC 的内部结构和主要机制。

从内存区域的角度,G1 同样存在着年代的概念,但其内部是类似棋盘状的一个个 region 组成,请参考下面的示意图。
image.png

region 的大小是一致的,数值是在 1M 到 32M 字节之间的一个 2 的幂值数,JVM 会尽量划分 2048 个左右、同等大小的 region,这点可以从源码heapRegionBounds.hpp中看到。当然这个数字既可以手动调整,G1 也会根据堆大小自动进行调整。

在 G1 实现中,年代是个逻辑概念,具体体现在,一部分 region 是作为 Eden,一部分作为 Survivor,除了意料之中的 Old region,G1 会将超过 region 50% 大小的对象(在应用中,通常是 byte 或 char 数组)归类为 Humongous 对象,并放置在相应的 region 中。逻辑上,Humongous region 算是老年代的一部分,因为复制这样的大对象是很昂贵的操作,并不适合新生代 GC 的复制算法。

region 大小和大对象很难保证一致,这会导致空间的浪费。示意图中有的区域是 Humongous 颜色,但没有用名称标记,这是为了表示,特别大的对象是可能占用超过一个 region 的。并且,region 太小不合适,会令你在分配大对象时更难找到连续空间,这是一个长久存在的情况,请参考OpenJDK 社区的讨论。这本质也可以看作是 JVM 的 bug,尽管解决办法也非常简单,直接设置较大的 region 大小,参数如下:

  1. -XX:G1HeapRegionSize=<N, 例如 16>M

从 GC 算法的角度,G1 选择的是复合算法,这是因为:

  1. 在新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
  2. 在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。

人们喜欢把新生代 GC(Young GC)叫作 Minor GC,老年代 GC 叫作 Major GC,区别于整体性的 Full GC。但是现代 GC 中,这种概念已经不再准确,对于 G1 来说:

  1. Minor GC 仍然存在,虽然具体过程会有区别,会涉及 Remembered Set 等相关处理。
  2. 老年代回收,则是依靠 Mixed GC。并发标记结束后,JVM 就有足够的信息进行垃圾收集,Mixed GC 不仅同时会清理 Eden、Survivor 区域,而且还会清理部分 Old 区域。可以通过设置下面的参数,指定触发阈值,并且设定最多被包含在一次 Mixed GC 中的 region 比例。
  1. XX:G1MixedGCLiveThresholdPercent
  2. XX:G1OldCSetRegionThresholdPercent

从 G1 内部运行的角度,下面的示意图描述了 G1 正常运行时的状态流转变化,当然,在发生逃逸失败等情况下,就会触发 Full GC。
image.png

G1中最重要的就是Remembered Set 用于记录和维护 region 之间对象的引用关系。为什么需要这么做呢?试想,新生代 GC 是复制算法,也就是说,类似对象从 Eden 或者 Survivor 到 to 区域的“移动”,其实是“复制”,本质上是一个新的对象。在这个过程中,需要必须保证老年代到新生代的跨区引用仍然有效。下面的示意图说明了相关设计。
image.png

G1 的很多开销都是源自 Remembered Set,例如,它通常约占用 Heap 大小的 20% 或更高,这可是非常可观的比例。并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。

描述 G1 内部的资料推荐Charlie Hunt 等撰写的《Java Performance Companion》

10. G1中的Remember Set底层是怎么实现的?

关于Remembered Set概念:
G1收集器中,Region之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用是使用Remembered Set来避免扫描全堆。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序对Reference类型数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之间(在分代中例子中就是检查是否老年代中的对象引用了新生代的对象),如果是便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当内存回收时,在GC根节点的枚举范围加入Remembered Set即可保证不对全局堆扫描也不会有遗漏。
G1虽然保留了CMS关于代的概念,但是代已经不是物理上连续区域,而是一个逻辑的概念。在标记过程中,每个区域的对象活性都被计算,在回收时候,就可以根据用户设置的停顿时间,选择活性较低的区域收集,这样既能保证垃圾回收,又能保证停顿时间,而且也不会降低太多的吞吐量。Remark阶段新算法的运用,以及收集过程中的压缩,都弥补了CMS不足。
记录集Remembered Sets简称RSet,用于记录对象在不同分区之间的引用关系,目的是为了加速垃圾回收的速度,主要是加速标记阶段。

通常的,有两种记录引用关系的方式,PointOut和PointIn。
如果obj1.field1=obj2,如果是PointOut方式,则在obj1所在region的RSet记录obj2的位置;如果是PointIn方式,则在obj2所在region记录obj1的位置。G1采用的是PointIn方式。
一共有五种分区间的引用关系:

  1. 分区内引用
  2. 新生代分区Y1引用新生代分区Y2
  3. 新生代分区Y1引用老年代分区O1
  4. 老年代分区O1引用新生代分区Y1
  5. 老年代分区O1引用老年代分区O2

YGC时,GC root主要是两类:栈空间和老年代分区到新生代分区的引用关系。
Mixed GC时,由于仅回收部分老年代分区,老年代分区之间的引用关系也将被使用。
因此,我们仅需要记录两种引用关系:老年代分区引用新生代分区,老年代分区之间的引用。

RSet的结构
由于PointIn模式的缺点,一个对象可能被引用的次数不固定,为了节约空间,G1采用了三级数据结构来存储:

  • 稀疏表:通过哈希表来存储,key是region index,value是card数组
  • 细粒度PerRegionTable:当稀疏表指定region的card数量超过阈值时,则在细粒度PRT中创建一个对应的PerRegionTable对象,其包含一个C heap位图,每一位对应一个card
  • 粗粒度位图:当细粒度PRT size超过阈值时,则退化为分区位图,每一位表示对应分区有引用到当前分区

每个HeapRegion都包含了一个HeapRegionRemSet,每个HeapRegionRemSet都包含了一个OtherRegionsTable,引用数据就保存在这个OtherRegionsTable中。
我们通过添加引用来了解RSet的结构。
添加引用的入口在heapRegionRemSet.cpp中

  • 大对象可能跨region,因此需要找到该对象的头部region
  • 判断region index是否在粗粒度位图中,如是,则直接返回
  • 在细粒度PRT中查找region index对应的记录,如有,则返回
  • 在稀疏表中添加该region index和card,如果返回成功或found,则返回
  • 如果_sparse_table.add_card返回overflow,则表示稀疏表对应region记录已超过阈值,则在细粒度PRT中添加region index和card

稀疏表主要逻辑在sparsePRT.hpp中

  • 往稀疏表中添加引用的逻辑主要在两个add_card方法中:
  1. 根据region index从哈希表中找到SparsePRTEntry
  2. 在_cards中遍历,如果card已经记录,则返回found;
  3. 如果小于阈值,则添加card到_cards数组
  4. 如果大于等于阈值,则返回overflow

细粒度PRT主要逻辑在heapRegionRemSet.cpp的PerRegionTable类中

  • _bm是个C heap位图,每一位对应region中的一个card
  • 添加引用时,先根据card在_bm中找到对应bit位置,然后将该bit置为1
  • _occupied引用数量加1,如果是并行操作则使用原子指令加1

粗粒度位图的逻辑简单很多,主要是在OtherRegionsTable中定义了一个CHeapBitMap

class OtherRegionsTable { CHeapBitMap _coarse_map; }

总结:
在串行和并行GC中,GC通过整堆扫描,来确定对象是否处于可达路径中。
然而G1为了避免整堆扫描,为每个分区记录了一个RSet,记录引用分区内对象的card索引。这样标记时,仅仅需要扫描对应分区的对应card中的对象是否可达即可,极大的提升了GC效率。

11. JVM垃圾回收的时候如何确定垃圾?

11.1 什么是垃圾

简单的说就是内存中已经不在被使用到的空间就是垃圾

11.2 要进行垃圾回收,如何判断一个对象是否可以被回收

11.2.1 使用引用计数法

(应用:微软的COM/ActionScrip3/Python)

具体做法: 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
缺点: 很难解决对象之间的相互循环引用的问题
代码示例:

  1. public class ReferenceCountingGC{
  2. public Object instance=null
  3. private static final int_1MB=1024*1024
  4. /**
  5. *这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
  6. 对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0
  7. */
  8. private byte[]bigSize=new byte[2*_1MB];
  9. public static void testGC(){
  10. ReferenceCountingGC objA=new ReferenceCountingGC();
  11. ReferenceCountingGC objB=new ReferenceCountingGC();
  12. objA.instance=objB
  13. objB.instance=objA
  14. objA=null
  15. objB=null
  16. //假设在这行发生GC,objA和objB是否能被回收?
  17. System.gc();
  18. }
  19. }

11.2.2 使用枚举根节点做可达性分析(根搜索路径)

image.png

执行时从一系列GCRoots的对象为起点,从这些节点向下开始进行搜索所有的引用链,当一个对象到GCRoots 没有任何引用链时,则证明此对象不可用了。

可达性分析需要考虑下面两个点:

  • 如果方法区大小就有数百兆,如果逐一检查引用,则肯定消耗性能,所以不可能这么做,在执行可达性分析时,必须要保证这个过程期间对象的引用关系不能再变化,否则不能保证分析结果正确性
  • 必须要停止所有线程去执行枚举根节点,被称为Stop the World

【上面两个点反映出来的性能问题,解决方法】:

  1. OopMap数据结构: 保存GC Roots 节点,避免全局扫描去一一查找。(目前主流java虚拟机都是准确式GC)
  2. 安全点: 精简指令,为特定位置(安全点: Safepoint)上的指令生成对应的OopMap,暂停进行GC的位置也是在安全点;
  3. 安全区域 :在一段代码中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。处理没有被分配CPU时间的线程。

    OopMap会在类加载时进行计算,并在JIT也会进行记录。

    安全点设置原则:

  4. 不能太少,太少会导致GC等待时间过长

  5. 不能太过于频繁,以致于过分增加运行时的负荷

安全点的选定基本都是以“是否具有让程序长时间执行的特征”为标准进行选定–因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行” 的最明显特征就是指令程序复用,例如方法调用,循环调转 ,异常跳转等。所以具有这些功能的指令才会产生SafePoint

GC发生时,让所有线程(不包括执行JNI调用的线程) 都“跑”到最近的安全点上再停顿
  1. 抢先式中断(Preemptive Suspension) : GC发生时,中断全部线程,如果发现线程不在安全点,则恢复让其”跑” 到安全点
  2. 主动式中断(Voluntary Suspension ): 设置一个标志,然后采用轮询触发。
    安全区域(Safe Region)
    主要针对没有分配CPU时间的线程,如线程处于Sleep状态或者Blocked状态。这个时候线程无法响应JVM的中断请求。所以需要安全区域来解决。

12. 是否知道什么是GC Roots

GC Roots 就是一组必须活跃的引用
基本思路就是通过一系列名为GC Roots的对象作为起始点,从这个被称为GC Roots的set集合对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,说明此对象不可用。能被遍历到的对象被判定为存活,没有被遍历到的被判定为死亡。
java 中可以作为GC Roots的对象包括以下几种

  • 虚拟机栈(栈帧中的局部变量区,也叫局部变量表)
  • 方法区中的静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中的JNI引用的对象
  1. private byte[] bytes = new byte[100 * 1024 * 1024];
  2. private static GCRootsTest1 test;
  3. private static final GCRootsTest2 test3 = new GCRootsTest2(8);
  4. public static void m1(){
  5. GCRootsTest t1= new GCRootsTest();
  6. System.gc();
  7. System.out.println("第一次GC完成");
  8. }
  9. public static void main(String[] args) {
  10. m1();
  11. }