概述
得益于Java虚拟机优秀的垃圾回收器,使得Java开发者不在需要考虑内存管理。那为什么还要了解垃圾回收呢?很明显:当需要排查各种内存溢出,内存泄漏,以及垃圾回收成为系统并发的瓶颈时,我们就需要对这些“自动化”技术实施必要的监控和调节。
对象已死?
1.引用计数法
- 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器都为0的对象就是不再被使用的,垃圾收集器将回收该对象使用的内存。
- 引用计数算法实现简单,效率很高,但是引用计数算法对于对象之间相互循环引用问题难以解决,因此java并没有使用引用计数算法。
2.可达性分析法
- 通过一系列的名为“GC Root”的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Root没有任何引用链相连时,则该对象不可达,该对象是不可使用的,垃圾收集器将回收其所占的内存。
- 在java语言中,可作为GC Root的对象包括以下几种对象:
- java虚拟机栈(栈帧中的本地变量表)中的引用的对象。
- 方法区中的类静态属性引用的对象。
- 方法区中的常量引用的对象。
- 本地方法栈中JNI本地方法的引用对象。
- Java虚拟机内部的引用。
- 所有被同步锁(synchronize关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等。
引用类型
强引用
代码中普遍存在的引用。如果一个对象具有强引用,无论何种情况下,只要强引用还存在,垃圾收集器就永远不会回收它。
软引用(SoftReference)
用来描述一种还有用但是不是必须的对象。如果内存空间足够,垃圾回收器就不会回收它,如果将要发生内存溢出,会将这些对象列入回收范围进行第二次回收。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
弱引用(WeakReference)
用来描述一些非必须对象,比软引用更弱一些。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
虚引用
又称为幽灵引用或幻影引用,虚引用既不会影响对象的生命周期,也无法通过虚引用来获取对象实例,而且它的优先级低,仅用于在发生GC时接收一个系统通知,不知道什么时候调用,因此尽量避免使用finalize方法,可以使用try/catch/finallyh语句块代替。
生存还是死亡?
即使在可达性分析法中判断为不可达的对象,也不是“非死不可”,要宣告一个对象死亡,至少要经历两次标记过程:如果进过分析后对象没有发现GC Root相连接的引用,那么将进行第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()
方法。
任何一个对象的finalize方法只会被系统自动调用一次,如果面临下一次回收,它的finalize方法不会被执行,因此尽量避免使用finalize方法。
回收方法区
方法区的垃圾回收主要回收两部分:废弃的常量和不再使用的类型
判断废弃常量的方法:
- 如果常量池中的某个常量没有被任何引用所引用,则该常量是废弃常量。
判断无用的类:
- 该类的所有实例都已经被回收,即java堆中不存在该类的实例对象。
- 加载该类的类加载器已经被回收。
- 该类所对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射机制访问该类的方法。
分代回收理论
两个分代假说
收集器应该将堆划分出不同区域,然后将回收对象依据年龄分配到不同区域中存储。
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
跨代引用
对象不是孤立的,对象之间会存在跨带引用
跨带引用假说:跨代引用相对于同代引用来说占极少数。
依据这条假说,就不必为了少量跨代引用而扫描整个老年代,也不必浪费空间记录每一个对象是否存在跨带引用。只需要在新生代上建立一个全局数据结构(记忆集),这个结构把老年代分成若干小块,标识出老年代的哪一块存在跨带引用。
常用的垃圾收集算法
1.标记-清除算法
定义:
分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收掉所有被标记的对象。
缺点
效率问题,标记和清除效率都不高。
标记清除之后会产生大量的不连续的内存碎片,空间碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
图解
2.标记-复制算法
定义:
将可用内存按容量分成大小相等的两块,每次只使用其中一块,当这块内存使用完了,就将还存活的对象复制到另一块内存上去,然后把使用过的内存空间一次清理掉。这样使得每次都是对其中一块内存进行回收,内存分配时不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点
可使用的内存缩减到原来的一半
当对象存活率高时,就要执行较多的复制操作,效率将会变低
图解
3.标记-整理算法
定义
改进了标记-清除算法,标记阶段是相同的标记出所有需要回收的对象,在标记完成之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在移动过程中清理掉可回收的对象,这个过程叫做整理。
特点
内存被整理以后不会产生大量不连续内存碎片问题。
当对象存活率高时,使用标记-整理算法效率会大大提高。
图解
4.分代收集算法
定义
根据内存中对象的存活周期不同,将内存划分为几块,java的虚拟机中一般把内存划分为新生代和年老代,当新创建对象时一般在新生代中分配内存空间,当新生代垃圾收集器回收几次之后仍然存活的对象会被移动到年老代内存中,当大对象在新生代中无法找到足够的连续内存时也直接在年老代中创建。
分类
堆内存被分成新生代和年老代两个部分,整个堆内存使用分代复制垃圾收集算法。
新生代:
新生代使用复制垃圾回收算法,新生代分为Eden区,Survivor from和Survivor to三部分,其占新生代内存容量默认比例分别为8:1:1,其中Survivor from和Survivor to总有一个区域是空白,当新生代内存空间不足需要进行垃圾回收时,仍然存活的对象被复制到空白的Survivor内存区域中,Eden和非空白的Survivor进行标记-清理回收,两个Survivor区域是轮换的。
对于创建大对象时,如果新生代中无足够的连续内存时,也直接在年老代中分配内存空间。
Java虚拟机对新生代的垃圾回收称为Minor GC,次数比较频繁,每次回收时间也比较短。
使用java虚拟机-Xmn参数可以指定新生代内存大小。
老年代:
老年代中的对象一般都是长生命周期对象,对象的存活率比较高,因此在老年代中使用标记-清除垃圾回收算法。
Java虚拟机对老年代的垃圾回收称为MajorGC/Full GC,次数相对比较少,每次回收的时间也比较长。
当新生代中无足够空间为对象创建分配内存,年老代中内存回收也无法回收到足够的内存空间,并且新生代和年老代空间无法在扩展时,堆就会产生OutOfMemoryError异常。
java虚拟机-Xms参数可以指定最小内存大小,-Xmx参数可以指定最大内存大小,这两个参数分别减去Xmn参数指定的新生代内存大小,可以计算出年老代最小和最大内存容量。
永久代(JDK8之前)
虚拟机内存中的方法区被称为永久代,是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
永久代垃圾回收比较少,效率也比较低,但是也必须进行垃圾回收,否则会永久代内存不够用时仍然会抛出OutOfMemoryError异常。
永久代也使用标记-整理算法进行垃圾回收,java虚拟机参数-XX:PermSize和-XX:MaxPermSize可以设置永久代的初始大小和最大容量。(JDK8的这两个参数已经失效,JDK8用Metaspace替代了永久区)
图解
Minor GC与Full GC
-
Minor GC
- 触发条件:当新对象生成,并且在Eden申请空间失败时,就会触发Minor GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。
- 主要包含两个步骤:查找GC Roots,拷贝所引用的对象到 to 区;递归遍历前一步中对象,并拷贝其所引用的对象到 to 区,当然可能会存在自然晋升,或者因为 to 区空间不足引起的提前晋升的情况
- 然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。
- 但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代。
- Minor GC是对新生代的Eden区进行,不会影响到老年代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。一般使用复制算法,使Eden去能尽快空闲出来。
-
Full GC
- 对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。
- 因为FullGC采用的是标记-清除算法,所以在收集垃圾的时候会产生许多的内存碎片,此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。
-
可能导致Full GC的原因
- 老年代(Tenured)被写满,或空间不足。
- 永久代(Perm)被写满
- System.gc()被显示调用,底层调用Runtime.getRuntime().gc()这个本地方法。或jmap -histo:live < pid>、jmap -dump …
- Minor GC晋升到旧生代的平均大小大于老年代的剩余空间
- 堆中分配很大的对象
- CMS GC时出现promotion failed和concurrent mode failure
HotSpot的算法实现细节
1、根节点枚举
所有收集器在根节点枚举这一步骤都必须暂停用户线程,与之前的整理内存碎片一样会面临“Stop The World”的困扰。
当用户线程停下时,虚拟机应当能直接知道哪些地方存放着对象引用的,在HotSpot的解决方案里,使用一组称为OopMap的数据结构来达到这个目的。一旦完成类加载,就会记录,这样收集器在扫描时就可以直接得知这些信息,而不需要从方法区等GC Root开始查找。
2、安全点
导致OopMap变化的指令非常多,为每一条指令生成对应的OopMap将会消耗大量空间。
HotSpot只在“特定位置”记录信息,这些位置称为安全点。用户程序执行时并不能在任意指令流停下,必须到达安全点之后才能暂停。
如何让所有线程都跑到最近的安全点然后停下,有两种方案:抢先式中断和主动式中断。
3、安全区域
安全点的设计解决了执行过程中的程序停顿问题,但是存在程序不执行的时候,比如Sleep,或者Blocked时,线程无法响应虚拟机的终中断请求。这时候就引入安全区解决。
安全区是指能保证某一段代码片段中,引用关系不会发生变化,因此在这个区域中的任意地方开始垃圾收集都是安全的。
当用户线程执行到安全区时,首先标记自己进入安全区,当它要离开时,它会检查虚拟机是否完成根节点枚举。
4、记忆集与卡表
前面说过为了解决跨带引用问题引入的记忆集,记忆集是一种用于记录从非收集区指向收集区的指针集合的抽象数据结构。
卡表是记忆集的一种实现方式,HotSpot的默认卡表逻辑:CARD_TABLE[this address >> 9] = 0 如果存在跨带引用,则对应地址元素会变成1(称为元素变脏),垃圾收集发生时,将他们加入GC Root一并扫描。
5、写屏障
为了解决卡表变脏如何维护的问题,在即时编译之后,需要找到一个机器码层面上的维护卡表的手段。
写屏障可以看做虚拟机层面对“引用类型字段赋值”这个动作的AOP切面。
6、并发的可达性分析
并发扫描时因为引用变化可能发生的对象消失的问题,有两种解决办法:
1) 增量更新
记录变更的引用,并发扫描结束之后,以这些为根节点重新扫描一次。
2) 原始快照
并发扫描结束后,无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照来进行扫描。
JVM常用垃圾回收器
新生代回收器
- Serial
- ParNew
- parallel
老年代回收器
- Serial Old
- CMS
- Parallel Old
新生代和老年代回收器
- G1
Serial
特点:
- 针对新生代;
- 采用复制算法;
- 单线程收集;
- 进行垃圾收集时,必须暂停所有工作线程,直到完成;
优势: 简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。
缺点: 会在用户不知道的情况下停止所有工作线程。
使用场景
-
Client 模式(桌面应用)
在用户的桌面应用场景中,可用内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,这是可以接受的 -
单核服务器
对于限定单个CPU的环境来说,Serial收集器没有线程切换开销,可以获得最高的单线程收集效率
ParNew
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余均和Serial 收集器一致。
使用场景
Server模式下使用,亮点是除Serial外,目前只有它能与CMS收集器配合工作,是一个非常重要的垃圾回收器。
parallel
Parallel Scavenge也是一款用于新生代的多线程收集器,也是采用复制算法。与ParNew的不同之处在于Parallel Scavenge收集器的目的是达到一个可控制的吞吐量,而ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间。
特点:
- 新生代收集器;
- 采用复制算法;
- 多线程收集;
- 关注点与其他收集器不同:
- CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;
- 而Parallel Scavenge收集器的目标则是达一个可控制的吞吐量;
优势: 追求高吞吐量,高效利用CPU,是吞吐量优先,且能进行精确控制。
缺点: 应该说是特点,追求高吞吐量必然要牺牲一些其他方面的优势,不能做到既,又。ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间,原本10s收集一次, 每次停顿100ms, 设置完参数之后可能变成5s收集一次, 每次停顿70ms. 停顿时间变短, 但收集次数变多。
使用场景
根据相关特性,我们很容易想到它的使用场景,即:当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,程序主要在后台进行计算,而不需要与用户进行太多交互等就特别适合ParNew收集器。
例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序等
Serial Old
Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记-整理算法。
特点:
- 针对老年代;
- 采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
- 单线程收集;
优劣势基本和Serial无异,它是和Serial收集器配合使用的老年代收集器。
使用场景
- Client模式;
- 单核服务器;
- 与Parallel Scavenge收集器搭配;
- 作为CMS收集器的后备方案,在并发收集发生Concurrent Mode Failure时使用
CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。采用的算法是“标记-清除”,运作过程分为四个步骤:
- 初始标记,标记GC Roots 能够直接关联到达对象
- 并发标记,进行GC Roots Tracing 的过程
- 重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
- 并发清除,用标记清除算法清除对象。
特点:
- 针对老年代;
- 基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
- 以获取最短回收停顿时间为目标;
- 并发收集、低停顿;
- 需要更多的内存(看后面的缺点)
优点: 停顿时间短;吞吐量大;并发收集。
缺点: 对CPU资源非常敏感。无法收集浮动垃圾。容易产生大量内存碎片。
使用场景
- 与用户交互较多的场景;
- 希望系统停顿时间最短,注重服务的响应速度;
- 以给用户带来较好的体验;
如常见WEB、B/S系统的服务器上的应用。
Parallel Old
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法,可以充分利用多核CPU的计算能力。
特点:
- 针对老年代;
- 采用"标记-整理"算法;
- 多线程收集;
使用场景
- JDK1.6及之后用来代替老年代的Serial Old收集器;
- 特别是在Server模式,多CPU的情况下;
这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge(新生代)加Parallel Old(老年代)收集器的"给力"应用组合;
G1
- 并行与并发:G1能充分利用多CPU,多核环境下的硬件优势。
- 分代收集:能够采用不同的方式去处理新创建的对象和已经存活了一段时间的对象,不需要与其他收集器进行合作。
- 空间整合:G1从整体上来看基于“标记-整理”算法实现的收集器,从局部上看是基于复制算法实现的,因此G1运行期间不会产生空间碎片。
- 可预测的停顿:G1能建立可预测的时间停顿模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
特点:
- 并行与并发
- 分代收集,收集范围包括新生代和老年代
- 结合多种垃圾收集算法,空间整合,不产生碎片
- 可预测的停顿:低停顿的同时实现高吞吐量
- 面向服务端应用,将来替换CMS
优势:
- 能充分利用多CPU、多核环境下的硬件优势;
- 能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
- 不会产生内存碎片,有利于长时间运行;
- 除了追求低停顿处,还能建立可预测的停顿时间模型;
劣势: G1 需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。
使用场景
个人以为G1已经基本全面压制cms、parallel等回收器,缺点见上面的劣势。但如果不是追求极致的性能,基本可以无脑G1
参考