Java性能优化JVM相关

雨雀照片引用实现参考Github hexo-yuque-picture,直接通过MarkDown语法链接引用即可(也可以通过标签)

JVM

内存布局(5个)

于线程共享(两个):堆 和 方法区

线程私有(三个):程序计数器,虚拟机栈,本地方法区栈

什么是GC?

介绍JVM-GC

GC:就是 garbage collection 垃圾回收

Stop The World

需要了解 GC,那么得先知道一个词 Stop The World,这个可以理解为程序的停顿时间。当 Stop The World 发生的时候,出来 GC 所需的线程外,所有的线程都进入等待状态直到 GC 完成。

它回收什么区域呢

方法区的基本数据和对象

如何判断对象可以被回收了

可以通过引用记数法,在引用的对象加一个计数器,有引用则+1

可达性分析法(根搜索算法):通过一条引用链,对调用的根节点一个一个的遍历,假如遇到不可达的节点,那么它的子节点也都不可达,就是通过链

当内存不足时

当创建一个新的对象或者基本数据类型的时候

分代

其中分为新生代和老年代,针对不同的年代使用不同的垃圾回收策略

分代回收机制

对于新生代,他绝大多数对象都是朝生夕死的,每次触发GC,这个区域里大部分对象都会被回收,非常适合使用可达性分析法,因为从根节点顺着引用链遍历下去,只有在这个引用链上的才是存活的,假设本次触发GC,这个区域里90%的对象都要被回收,但实际上只需要关注引用链上10%的对象就可以了,使用复制算法把这10%移动到一个幸存者区域,剩下的直接释放即可。

对于熬过很多次依然存活的对象,这种对象一般很难被回收了,这样的情况下,每次GC都对他们进行搜索标记,太浪费资源。把它们放到老年代区,这样JVM就能以较少的频率来回收这个区域,假如老年代的空间占比是60%,在不触发老年代回收的情况下,只需要对占比40%内存空间的新生代进行搜索和释放,效率提升还是很明显的

Minor GC:只回收新生代区域。
Major GC:只回收老年代区域。只有CMS实现了Major GC,所以在老年代里,触发GC,除了CMS和G1之外的其他收集器,大多数触发的其实是 Full GC
Full GC:回收整个堆区和方法区
Mixed GC:回收整个新生代和部分老年代。G1收集器实现了这个类型。

新生代

对于新生代,它大多数对象都是朝生夕死的,那么就非常适合使用标记-复制算法

新生代GC收集的执行顺序如下:

1、绝大多数新创建的对象会存放在伊甸园空间(Eden)。

2、在伊甸园空间执行第 1 次GC(Minor GC)之后,存活的对象被移动到其中一个幸存者空间(Survivor)。

3、此后每次 Minor GC,都会将 Eden 和 使用中的Survivor 区域中存活的对象,一次性复制到另一块空闲中的Survivor区,然后直接清理 Eden 和 使用过的那块Survivor 空间。

4、从以上空间分配我们知道,Survivor区内存占比很小,当空闲中的Survivor空间不够存放活下来的对象时,这些对象会通过分配担保机制直接进入老年代。

5、在以上步骤中重复N次(N = MaxTenuringThreshold(年龄阀值设定,默认15))依然存活的对象,就会被移动到老年代。

从上面的步骤可以发现,两个幸存者空间,必须有一个是保持空的

我们需要重点记住的是,新创建的对象,是保存在伊甸园空间的(Eden)。那些经历多次GC依然存活的对象会经由幸存者空间(Survivor)转存到老年代空间(Old generation)

也有例外出现,对于一些大的对象(指需要占用大量连续内存空间的对象)则直接进入到老年代。
Java提供了 -XX:PretenureSizeThreshold 来指定对象大于这个值,直接分配到老年代。

老年代(Old generation)

对象在新生代周期中存活了下来的,会被拷贝到这里。通常情况下这个区域分配的空间要比新生代多。正是由于对象经历的GC次数越多越难回收,加上相对大的空间,发生在老年代的GC次数要比新生代少得多。这个区域触发的垃圾回收称之为:Major GC 或者 Full GC

老年代空间的构成其实很简单,它不像新生代空间那样划分为几个区域,它只有一个区域,里面存储的对象并不像新生代空间里绝大部分都是朝闻道,夕死矣。这里的对象几乎都是从Survivor 空间中熬过来的,它们绝不会轻易狗带。因此,Major GC 或 Full GC 发生的次数不会有 Minor GC 那么频繁。

为什么老年代的回收耗时,比新生代更长呢?

有两点原因:

1、老年代内存占比更大,所以理论上回收的时间也更长
2、老年代使用的是标记-整理算法,清理完成内存后,还得把存活的对象重新排序整理成连续的空间,成本更高

方法区(Method area)

这个区域主要回收废弃的常量和类型,例如常量池里不会再被使用的各种符号引用等等。类型信息的回收相对来说就比较严苛了,必须符合以下3个条件才会被回收:

1、所有实例被回收
2、加载该类的ClassLoader 被回收
3、Class 对象无法通过任何途径访问(包括反射)

可以使用 -Xnoclassgc 禁用方法区的回收。

回收算法

标记 - 清除算法

这个算法和它的名字一样,分两个步骤:标记 和 清除。首先标记出所有存活的对象,再扫描整个空间中未被标记的对象直接回收。

并没有规定标记阶段一定要标记“存活”的对象,也可以标记“可回收”的对象
标记“存活”的,还是标记“可回收”的,网上各种说法都有,我个人理解,是标记存活的。这样效率高些。
首先,Java使用的是可达性分析算法来判断对象是否存活,上面有详细说这个算法,这里就不重复了。
我们假设要标记“可回收”的对象,再进行清除,那么需要三个步骤:
1、先通过可达性分析法,通过根对象(GC Roots)顺着引用链先把这些存活对象都标出来
2、遍历这个区域所有对象,把没标记存活的对象,打上一个“可回收”的标记
3、遍历这个区域所有对象,把标记了“可回收”的对象,释放掉。
但标记的是“存活”的对象,再进行清除,只需要两个步骤即可:
1、先通过可达性分析法,通过根对象(GC Roots)顺着引用链先把这些存活对象都标出来
2、遍历这个区域所有对象,把没标记存活的对象,直接清理掉即可。
所以,标记“可回收”的对象,会多了一次完全没有必要的遍历。这也是我不认同标记“可回收”的原因。

标记 - 清除算法由于回收后没有进行整理的操作,所以会存在内存空间碎片化的问题,这个确实是缺点,但也是这个算法的特点,正因为它不进行整理,所以效率才高。

img

标记 - 复制算法

常规的复制算法,是把内存分成两块大小相同的空间(1 : 1),每次只使用其中一块,当使用中的这块内存用完了,就把存活的对象移动到另一块内存中,再把使用过的这块内存空间一次性清理掉。这个做法虽然效率极高,但也浪费了一半的内存空间。

标记-复制算法,在这个基础之上对其进行了优化,IBM曾有过一项针对新生代的研究,结论是绝大多数情况下,新生代区域里的对象有98%都熬不过第一次回收。

所以不需要按照 1 : 1 的比例来实现复制算法,而是可以按照 8 : 1 : 1 的比例来分配内存空间,也就是一个80%的Eden空间和两个10%的Survivor空间。

为什么要两块Survivor空间?
因为复制算法,必须要有一块空间是空闲的。想象一下,如果只有一块Eden空间 + 一块Survivor空间
当GC回收完成后,Eden中存活的对象会移动到Survivor空间。程序继续运行,新的对象又会进入Eden空间,此时就会出现 Eden 和 Survivor 空间里都有对象,复制算法也就进行不下去了。

每次分配内存,只使用Eden和其中一块Survivor空间,发生GC回收时,把Eden和其中一块Survivor空间中存活的对象,复制到另一块空闲的Survivor空间,然后直接把Eden和使用过的那块Survivor空间清理掉。

img

目前主流的使用分代回收机制的Java虚拟机,都是使用标记-复制算法来作为新生代的回收算法。它非常适合用在新生代这种回收率极高的场景,这样的场景下,复制算法浪费的空间几乎可以忽略不计。效率高,且内存不会有碎片化的问题。但对于老年代这种存活率很高的场景,就不适合了。

标记-复制算法还有一个非常重要的知识点,就是分配担保机制,虽然根据IBM的研究,每次GC新生代里98%的对象都会被回收,但这不是百分之百的几率,极端情况下可能会出现超过10%的对象存活。分配担保机制就是为了保证当出现这种情况时,有其他内存空间来进行兜底。通常这个“担保人”是老年代,当存活的对象超过Survivor空间大小时,这些存活的对象会忽略年龄,直接进入老年代里。

标记 - 整理算法

标记-清除算法会产生内存碎片,不适合哪些需要大量连续内存空间的场景,而标记-整理算法,就是在其基础之上,增加了整理这个操作,去解决这些内存空间碎片化的问题,如下图所示:

img

和标记-清除算法一样,先标记,但清除之前,会先进行整理,把所有存活的对象往内存空间的左边移动,然后清理掉存活对象边界以外的内存,即完成了清除的操作。标记-整理 算法是在 标记-清除 算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。

老年代里的对象存活率很高,不适合使用标记-复制的算法。而且老年代存储大对象的概率要比新生代大很多,这些大对象需要连续的内存空间来存储,标记-清除这个算法也不适合。所以大多数的老年代都采用标记-整理来作为这个区域的回收算法。

Q & A

为什么老年代不使用和新生代一样的标记-复制算法呢?

新生代里绝大多数对象都是朝生夕死的,使用的标记-复制算法,空间占比可以8 : 1 : 1,但是老年代里对象存活率很高,这个占比明显不合适。如果占比得设置得大 (例如50%),又会浪费很多内存空间,而且由于对象很多都是存活的,复制移动也是一笔开销。所以标记-复制这个算法,不适合老年代这种对象存活率很高的区域。

垃圾收集器

就目前来说,JVM 的垃圾收集器主要分为两大类:分代收集器和分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC,下面我们来看看这两大类的垃圾收集器。

img

分代收集器

CMS

以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW,JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除,详情可见 JEP 363open in new window

CMS(Concurrent Mark Sweep)垃圾收集器是第一个关注 GC 停顿时间(STW 的时间)的垃圾收集器。之前的垃圾收集器,要么是串行的垃圾回收方式,要么只关注系统吞吐量。

CMS 垃圾收集器之所以能够实现对 GC 停顿时间的控制,其本质来源于对「可达性分析算法」的改进,即三色标记算法。在 CMS 出现之前,无论是 Serious 垃圾收集器,还是 ParNew 垃圾收集器,以及 Parallel Scavenge 垃圾收集器,它们在进行垃圾回收的时候都需要 Stop the World,无法实现垃圾回收线程与用户线程的并发执行。

CMS 垃圾收集器通过三色标记算法,实现了垃圾回收线程与用户线程的并发执行,从而极大地降低了系统响应时间,提高了强交互应用程序的体验。它的运行过程分为 4 个步骤,包括:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

初始标记,指的是寻找所有被 GCRoots 引用的对象,该阶段需要「Stop the World」。这个步骤仅仅只是标记一下 GC Roots 能直接关联到的对象,并不需要做整个引用的扫描,因此速度很快。

并发标记,指的是对「初始标记阶段」标记的对象进行整个引用链的扫描,该阶段不需要「Stop the World」。 对整个引用链做扫描需要花费非常多的时间,因此通过垃圾回收线程与用户线程并发执行,可以降低垃圾回收的时间。

这也是 CMS 能极大降低 GC 停顿时间的核心原因,但这也带来了一些问题,即:并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该回收的垃圾没有被回收)和多标(本不应该回收的垃圾被回收)了。

重新标记,指的是对「并发标记」阶段出现的问题进行校正,该阶段需要「Stop the World」。正如并发标记阶段说到的,由于垃圾回收算法和用户线程并发执行,虽然能降低响应时间,但是会发生漏标和多标的问题。所以对于 CMS 来说,它需要在这个阶段做一些校验,解决并发标记阶段发生的问题。

并发清除,指的是将标记为垃圾的对象进行清除,该阶段不需要「Stop the World」。 在这个阶段,垃圾回收线程与用户线程可以并发执行,因此并不影响用户的响应时间。

img

CMS 的优点是:并发收集、低停顿。但缺点也很明显:

①、对 CPU 资源非常敏感,因此在 CPU 资源紧张的情况下,CMS 的性能会大打折扣。

默认情况下,CMS 启用的垃圾回收线程数是(CPU数量 + 3)/4,当 CPU 数量很大时,启用的垃圾回收线程数占比就越小。但如果 CPU 数量很小,例如只有 2 个 CPU,垃圾回收线程占用就达到了 50%,这极大地降低系统的吞吐量,无法接受。

②、CMS 采用的是「标记-清除」算法,会产生大量的内存碎片,导致空间不连续,当出现大对象无法找到连续的内存空间时,就会触发一次 Full GC,这会导致系统的停顿时间变长。

③、CMS 无法处理浮动垃圾,当 CMS 在进行垃圾回收的时候,应用程序还在不断地产生垃圾,这些垃圾会在 CMS 垃圾回收结束之后产生,这些垃圾就是浮动垃圾,CMS 无法处理这些浮动垃圾,只能在下一次 GC 时清理掉。

分区收集器

G1

G1(Garbage-First Garbage Collector)在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。G1 有五个属性:分代、增量、并行、标记整理、STW。

①、分代:相信大家还记得我们上一讲中的年轻代和老年代open in new window,G1 也是基于这个思想进行设计的。它将堆内存分为多个大小相等的区域(Region),每个区域都可以是 Eden 区、Survivor 区或者 Old 区。

img

可以通过 -XX:G1HeapRegionSize=n 来设置 Region 的大小,可以设定为 1M、2M、4M、8M、16M、32M(不能超过)。

G1 有专门分配大对象的 Region 叫 Humongous 区,而不是让大对象直接进入老年代的 Region 中。在 G1 中,大对象的判定规则就是一个大对象超过了一个 Region 大小的 50%,比如每个 Region 是 2M,只要一个对象超过了 1M,就会被放入 Humongous 中,而且一个大对象如果太大,可能会横跨多个 Region 来存放。

G1 会根据各个区域的垃圾回收情况来决定下一次垃圾回收的区域,这样就避免了对整个堆内存进行垃圾回收,从而降低了垃圾回收的时间。

②、增量:G1 可以以增量方式执行垃圾回收,这意味着它不需要一次性回收整个堆空间,而是可以逐步、增量地清理。有助于控制停顿时间,尤其是在处理大型堆时。

③、并行:G1 垃圾回收器可以并行回收垃圾,这意味着它可以利用多个 CPU 来加速垃圾回收的速度,这一特性在年轻代的垃圾回收(Minor GC)中特别明显,因为年轻代的回收通常涉及较多的对象和较高的回收速率。

④、标记整理:在进行老年代的垃圾回收时,G1 使用标记-整理算法。这个过程分为两个阶段:标记存活的对象和整理(压缩)堆空间。通过整理,G1 能够避免内存碎片化,提高内存利用率。

年轻代的垃圾回收(Minor GC)使用复制算法,因为年轻代的对象通常是朝生夕死的。

img

⑤、STW:G1 也是基于「标记-清除」算法,因此在进行垃圾回收的时候,仍然需要「Stop the World」。不过,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

img

G1 中存在三种 GC 模式,分别是 Young GC、Mixed GC 和 Full GC。

img

当 Eden 区的内存空间无法支持新对象的内存分配时,G1 会触发 Young GC。

当需要分配对象到 Humongous 区域或者堆内存的空间占比超过 -XX:G1HeapWastePercent 设置的 InitiatingHeapOccupancyPercent 值时,G1 会触发一次 concurrent marking,它的作用就是计算老年代中有多少空间需要被回收,当发现垃圾的占比达到 -XX:G1HeapWastePercent 中所设置的 G1HeapWastePercent 比例时,在下次 Young GC 后会触发一次 Mixed GC。

Mixed GC 是指回收年轻代的 Region 以及一部分老年代中的 Region。Mixed GC 和 Young GC 一样,采用的也是复制算法。

在 Mixed GC 过程中,如果发现老年代空间还是不足,此时如果 G1HeapWastePercent 设定过低,可能引发 Full GC。-XX:G1HeapWastePercent 默认是 5,意味着只有 5% 的堆是“浪费”的。如果浪费的堆的百分比大于 G1HeapWastePercent,则运行 Full GC。

在以 Region 为最小管理单元以及所采用的 GC 模式的基础上,G1 建立了停顿预测模型,即 Pause Prediction Model 。这也是 G1 非常被人所称道的特性。

我们可以借助 -XX:MaxGCPauseMillis 来设置期望的停顿时间(默认 200ms),G1 会根据这个值来计算出一个合理的 Young GC 的回收时间,然后根据这个时间来制定 Young GC 的回收计划。