先声明一下,以下总结的问题都是在针对HotSpot虚拟机展开讨论,这些问题其实都可以在下面这本秘籍中找到答案,《深入理解Java虚拟机》几乎是每一个Java开发者必备宝典。
思考这些问题的时候,建议先阅读JVM内存结构与垃圾回收总结打打基础。
为什么要分新生代与老年代
这是基于两个共识:1、绝大多数对象都是朝生夕死的,2、熬过越多次垃圾收集过程的对象就越难以消亡。
对象的生命周期有长有短,分代回收,能针对这个特点做很好的优化。分代后,GC时进行可达性分析的范围大大降低,新生代的规模比老年代小很多,回收频率也要高,显然新生代gc的时候不能去遍历老年代。只要把非收集部分(老年代)指向收集部分(新生代)的引用保存下来,加入gc roots,就可以避免在新生代GC时去对老年代进行可达性分析(老年代对象大量存活),能节省大量时间。
如果不分代,所有对象全部在一个区域,总的GC频率应该和分代后的Young GC差不多,每次GC都需要从gc roots对全堆进行扫描,大大增加了开销。
为什么新生代会采用复制算法
先从理论上复习一下什么是复制算法。
复制算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用区域的可回收的对象进行回收。这样可以使得每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等复杂情况,只要移动堆顶指针按顺序分配内存即可,实现简单运行高效。只是这种算法将内存缩小为原来的一半,代价有点儿高。
新生代的对象大多朝生夕死,大约98%的新建对象可以被很快回收,复制算法成本低,同时还能保证空间没有碎片。
虽然标记整理算法也可以保证没有碎片,但是由于新生代要清理的对象数量很大,将存活的对象整理到与待清理对象区分开,需要大量的移动操作,时间复杂度比复制算法高。老年代的空间比较大,不可能采用复制算法,因为特别占用内存空间。
为什么需要Survivor区
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间通常要大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。
Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
为什么新生代需要两个Survivor区
设置两个Survivor区最大的好处就是提高堆内存使用效率,减少碎片化。原始的复制算法是把一块内存一分为二,gc时把存活的对象从一块空间(From space)复制到另外一块空间(To space),再把原先的那块内存(From space)清理干净,最后调换From space和To space的逻辑角色(这样下一次gc 的时候还可以按这样的方式进行)。
也就是说如果选择了复制算法。
- 必然至少要有两块内存,From区和To区
- 一开始,对象只在From区分配,To区是空闲的。GC将存活的对象从From区域复制到To,清空掉From区,这个时候,交换指针,交换之后,From区里面还是有存活的对象,To区空闲。
从吞吐量、内存的分配效率和内存碎片化这几个方面来看都要优化标记-清除/整理算法。但是堆的使用效率就相对而言比较低。
为什么要有两个Survivor区域:From和To,这个问题不应该是这样的,而是HotSpot虚拟机为什么要增加Eden区域。在复制算法中,有一个很大的缺点就在于堆的使用效率问题,如果是按照五五分成,总有一半是不能用的。那为什么不干脆八二分?即如果内存有100MB,From区占用80MB,To区占用20MB,那么问题来了,某次触发YGC,需要把From中的存活对象复制到To空间(假设占用了To区的10MB),这个时候,交换指针之后,From空间就只剩下10MB能用了,相信很快就会再次触发YGC,STW发生频次提高。
矛盾点就在于From区与To区五五分成,堆利用率实在不高。不五五分成,由于复制算法的特性,YGC很容易发生,YGC频次高则STW的频次也很高。为了解决内存分配空间的capacity不恒定的问题,HotSpot引入了Eden区作为对复制算法的优化,其实eden区域可以看作是From和To区域的缓冲和共享区域。
使用两块Survivor可以保持新对象始终在Eden区创建,并且Eden区的capacity恒定,存活对象在Survivor之间转移即可,空间消耗是8:1:1,明显后者的空间利用率更高。内存碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存就会出现问题。
HotSpot新生代的实际可用空间是多少
新生代虽然使用了复制算法,但并不是完全按理论指导,将内存空间平均划分为两半,每次只使用其中一半的。而是划分为一个Eden区,两个Survivor区。
YGC后,总有一块Survivor区是空闲的,因此新生代的可用内存空间是90%。在YGC的log中或者通过 jmap -heap pid 命令查看新生代的空间时,如果发现capacity只有90%,不要觉得奇怪。
哪些对象的引用可以作为GC Roots
下面所列举出来的对象,指向它们的引用,就可以作为GC Roots
1)在虚拟机栈中引用的对象
如下代码所示,a是栈帧中的本地变量,指向了name= “Jack” 这个User对象。当a = null时,由于此时a充当了GC Root的作用,a与原来指向的User对象断开了连接,所以这个User对象会被回收。
public class Test {
public static void main(String[] args) {
User a = new User("Jack");
a = null;
}
}
2)在本地方法栈中JNI引用的对象(Java Native Interface,Java本地接口,即通常所说的Native方法)
3)类的静态变量引用的对象(JDK 1.7开始静态变量的存储从方法区移动到堆中)
如下代码所示,a是栈帧中的本地变量,指向了name= “Jack” 这个User对象。当a = null时,由于此时a充当了GC Root的作用,a与原来指向的User对象断开了连接,所以这个User对象会被回收。
而由于我们给s赋值了变量的引用,s在此时是类静态属性引用,充当了 GC Root的作用,它指向的name = "Tom"的User对象依然存活。
public class Test {
public static User s;
public static void main(String[] args) {
User a = new User("Jack");
s = new User("Tom");
a = null;
}
}
4)常量引用的对象(运行时常量池属于方法区的一部分,另外,其中的字符串常量池从JDK 1.7开始由方法区移动到堆中)
如下代码所示,常量s指向的对象并不会因为a指向的对象被回收而回收
public class Test {
public static final User s = new User("Tom");
public static void main(String[] args) {
User a = new User("Jack");
a = null;
}
}
5)JVM内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。这个很好理解,如果这些核心的系统类对象被回收了,程序就没办法运行了。
6)所有被同步锁(synchronized关键字)持有的对象
Major GC和Full GC的区别是什么
JVM发展这么多年,对各种名词的解释已经完全混乱了。针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种。
Partial GC:并不收集整个GC堆的模式
- Young GC:只收集新生代的GC
- Old GC:只收集老年代的GC,只有CMS的concurrent collection是这个模式
- Mixed GC:收集整个新生代以及部分老年代的GC,只有G1有这个模式
Full GC:收集整个堆,包括新生代young gen、老年代old gen、永久代perm gen(如果存在的话)等所有部分的模式。
Major GC这个词目前已经有了歧义,有时被人理解为是Old GC,但在很多时候也被人们理解为跟Full GC等价。
Minor GC和Full GC的触发条件
最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是。
- Young GC也叫Minor GC:当新生代中的Eden区分配满的时候触发。注意Young GC中有部分存活对象会晋升到老年代,所以Young GC后老年代的空间使用率通常会有所升高。
- Full GC:当准备要触发一次young GC时,如果发现新生代中要晋升到老年代的对象大小大于老年代剩余空间,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代,所以不需要事先触发一次单独的young GC);或者,如果有perm gen的话,想在perm gen分配空间但发现空间不足时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。
HotSpot VM里其它非并发GC的触发条件复杂一些,不过大致的原理与上面说的其实一样。并发GC的触发条件就不太一样。以CMS GC为例,它主要是定时去检查old gen的使用量,当使用量超过了触发比例就会启动一次CMS GC,对old gen做并发收集。
jdk8为什么去掉了永久代
在 Java 8之前有个永久代的概念,实际上指的是HotSpot虚拟机上的永久代,它用永久代实现了JVM规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受GC的管理。每当一个类初次被加载的时候,它的元数据都会放到永久代中。永久代是有XX:MaxPermSize的大小上限的,因此如果加载的类或动态生成类太多,很有可能导致永久代内存溢出,即万恶的java.lang.OutOfMemoryError: PermGen,为此我们不得不对虚拟机做调优。有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。由于PermGen内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此JVM的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的OOM。
JDK 8把方法区的实现移到了本地堆内存中的元空间中,这一块区域就叫Metaspace,中文名叫元空间。使用本地内存有什么好处呢?最直接的好处就是方法区就不受JVM的控制了,它属于堆外内存,也就不会进行GC,也因此提升了性能,也就不存在大小限制了,不会有OOM异常了。理论上Metaspace就可以有多大(容量取决于32位或64位操作系统的可用虚拟内存大小),这解决了空间不足的问题。不过,让Metaspace 变得无限大显然是不现实的,因此我们也要限制 Metaspace的大小:使用-XX:MaxMetaspaceSize参数来指定Metaspace区域的大小。JVM默认在运行时根据需要动态地设置MaxMetaspaceSize的大小。
堆外内存不受GC控制,无法通过GC释放内存,MetaSpace区域的内存回收目标主要是针对常量池的回收和对类型的卸载。那该以什么样的形式释放呢,可以参见这篇文章:堆外内存的回收机制分析。
GC时为什么要Stop-The-World
JVM进行垃圾回收时使用可达性分析,从GC Root向下判断对象是否有引用,如果不把所有线程进入safe points并阻塞起来就会出现对象上一秒没有引用被判断为可以删除,后一秒又出现引用,导致判断错误。如果在垃圾回收的过程中,同时还允许系统继续不停的在Eden里持续创建新的对象,是非常不合适的一个事情。Stop-The-World对系统性能存在影响,因此垃圾回收的一个原则是尽量减少Stop-The-World的时间。
STW会直接停止我们写的Java系统的所有工作线程,让我们写的代码不再运行!然后让垃圾回收线程可以专心致志的进行垃圾回收的工作。这样的话,就可以让我们的系统暂停运行,然后不再创建新的对象,同时让垃圾回收线程尽快完成垃圾回收的工作。
Serial、Parallel和CMS收集器均存在不同程度的Stop-The-Word情况;而即便是最新的G1收集器也不例外。
-
Serial收集器:复制算法新生代垃圾收集器,Minor GC全过程STW
-
Serial Old收集器:标记-整理算法老年代垃圾收集器,全过程STW,可以与Serial收集器/ParNew收集器/Parallel Scavenge收集器搭配使用
-
ParNew收集器:复制算法新生代垃圾收集器,Minor GC全过程STW
-
CMS收集器:标记-清除算法的老年代垃圾收集器,Old GC只有初始标记与重新标记两个小阶段STW。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。并发清理阶段会清理在重复标记中被标记为可回收的对象,这一阶段与用户线程并行,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理时被回收。如果在清理过程中预留给用户线程的内存不足就会出现“Concurrent Mode Failure”,一旦出现此错误便会切换到SerialOld收集方式。
-
G1(Garbage First) 收集器:关注最小时延,内存按照2048份均分,每个Region被标记为E、S、O和H,这些区域在逻辑上被映射为Eden、Survivor和老年代。并发标记周期大致可划分为4个阶段:初始标记(需要STW)、并发标记(无需STW)、最终标记(需要STW)和筛选回收(需要STW)。筛选回收阶段会更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集CSet,然后把决定回收的那一部分Region的存活对象复制到空的Region中,清理掉整个旧Region的全部空间(复制算法),这里的操作涉及存活对象的移动,是必须暂停用户线程的,而且是由多条收集器线程并行完成的。GC模式有Young GC与Mixed GC两种,Young GC是把所有的年轻代的Region放在CSet中,而Mixed GC的CSet放的是所有年轻代的Region和并发标记选出的收益高的老年代Region。
JVM本身的迭代演进,就是不断的在优化垃圾回收器的机制和算法,尽可能的降低垃圾回收的过程对我们的系统运行的影响。而我们作为一个合格的Java工程师,我们的责任就是尽可能搞懂这些垃圾回收器的运行机制和算法。然后合理的对线程系统优化内存分配和垃圾回收,尽可能减少垃圾回收的频率,降低垃圾回收的时间,减少垃圾回收对系统运行的影响。
总结CMS的优缺点
分析下CMS的优缺点
- 优点:CMS最主要的优点在名字上已经体现出来——并发收集、低停顿
- 缺点:CMS同样有三个明显的缺点,Mark Sweep算法会导致内存碎片比较多,当碎片较多导致无法提供整块连续的空间给新对象/晋升为老年代对象时又会触发FullGC;CMS的并发能力比较依赖于CPU资源,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降。并发清除阶段,用户线程依然在运行,会产生所谓的理“浮动垃圾”(Floating Garbage),本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。
适用于生产环境的JVM垃圾收集器组合
以JDK1.8为例,其实默认垃圾收集器设置就已经是经过实践验证过的表现相当好的组合了。那么JDK1.8默认用的什么垃圾收集器呢,可以用以下命名来查看。
java -XX:+PrintCommandLineFlags -version
可以看到输出有这么一行
-XX:+UseParallelGC
UseParallelGC = Parallel Scavenge + Parallel Old,表示的是新生代用的Parallel Scavenge收集器,老年代用的是Parallel Old收集器。这是JDK1.8的默认设置,可以修改成其他组合。
ParallelGC的特点是什么呢?高吞吐!大量的系统是业务复杂型,并发并不是非常高,所以要尽可能多地利用处理器资源,出于提高吞吐量的考虑采用Parallel Scavenge + Parallel Old的组合。
CMS收集器是一种以获取最短回收停顿时间为目标的收集器,互联网应用场景并发量很高,对服务响应速度有很高的要求,希望系统停顿时间最短,以给用户带来较好的体验。所以运行在互联网站或者B/S系统上的Java应用通常采用Parallel New + CMS的组合,老年代用CMS可以降低停顿时间。
从程序角度,有哪些原因会导致Full GC
1、大对象:系统一次性加载了过多数据到内存中(比如SQL查询未做分页),导致大对象进入了老年代
2、内存泄漏:频繁创建了大量对象,但是无法被回收(比如IO对象使用后未调用close释放资源),先引发FGC,最后导致OOM
3、程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发FGC
4、程序BUG导致动态生成或加载了很多新类,使得 Metaspace 不断被占用,先引发FGC,最后导致OOM.
5、代码中显式调用了gc方法,包括自己的代码甚至框架中的代码。
6、JVM参数设置问题:包括总内存大小、新生代和老年代的大小、Eden区和S区的大小、元空间大小、垃圾回收算法等等。