Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。-《深入理解Java虚拟机》
程序计数器、虚拟机栈、本地方法栈这3个区域生命周期是和线程同步的,所以不用过多考虑回收问题。
而Java堆和方法区则有着明显的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所指向的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。
垃圾就是内存中已经没用的对象。Java虚拟机使用可达性分析算法来决定哪些对象是垃圾,是否可以被回收。
垃圾收集器在堆进行回收前,需要判断对象是否不被使用了。有以下2种方式:
1.引用计数法
给对象添加一个引用计数器,每当有一个地方引用时,计数器值加一。当引用失效时,计数器值减一;任何时刻计数器为零的对象就是不可能再被使用的。 引用计数法实现简单,判断效率高,但是Java虚拟机里面没有选用引用计数法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
2.可达性分析算法
可达性分析算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当有一个对象到GC Roots没有任何引用链相连,即不可达,则证明此对象是不可用的。
可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 方法区中类静态属性引用的对象,比如Java类的引用类型静态变量
- 方法区中常量引用的对象,比如字符串常量池(String Table)里的引用
- 本地方法栈JNI(Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NPE,OOM)等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收。
- Allocation Failure : 在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次GC
- System.gc(): 在应用层,可以主动调用此API来建议虚拟机执行一次GC。
如果一个对象具有强引用,那垃圾收集器不会回收它。指在程序代码之中普遍存在的引用赋值,即类似“Objectobj=new Object()”这种引用关系。
在内存实在不足时,会对软引用进行回收。在JDK 1.2版之后提供了SoftReference类来实现软引用
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一个垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
一个对象是否有虚引用的存在,完全不会对齐生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
标记之后原地清除。
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:
- 效率问题:标记和清除两个过程的效率都不高
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能导致无法分配较大对象而不得不提前触发另一次垃圾收集动作
平时只用一半空间,需要回收时,将存活的全部复制到另一半空间,将之前的一半空间全部清除。
标记-复制算法也称为复制算法。
为了解决效率问题,复制算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这块内存用完了,就将还存活的对象复制到另外一块,然后将已使用那块内存空间一次清理掉。实现简单,运行高效。但是这种算法的代价是可使用内存缩小为原来的一半。
现在虚拟机都采用复制算法来回收新生代。按照历史经验,新生代的对象98%的对象都是朝生夕死,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden区和两块较小的Survivor空间,每次使用Eden区和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。当然,如果Survivor空间装不下时,需要依赖其他内存(一般是老年代)进行分配担保。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是只有10%的内存会浪费。
复制算法在对象存活率比较高的时候是非常低效的,更关键的是,如果不想浪费50%的内存空间,就要有额外的空间进行分配担保,所以老年代一般不会选用复制算法。
标记之后,将对象全部复制到空间的一边,将复制之后占用内存的边界之外的空间全部清理。
和标记清除算法的标记过程一直,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情。迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的。
确保一致性的快照:这项分析工作必须在一个能确保一致性的快照中进行-在整个分析期间整个指向系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。
使用OopMap标记对象引用:在HotSpot中,使用一组OopMap的数据结构来标记对象引用的位置。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来。在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举。
什么是安全点:导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。实际上,HotSpot也的确没有为每条指令都生成OopMap,指数在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增加运行时的负荷。
如何选择安全点:安全点的选定是以“是否具有让程序长时间执行的特性”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。
在安全点暂停的方式:抢先式中断和主动式中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
当程序不执行的时候(比如sleep状态)就不能到达安全点,对于这种情况就需要安全区域来解决。安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的安全点。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
卡表:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。以这种方式实现记忆集,这也是目前最常用的一种记忆集实现形式。
如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。为什么有那么多的垃圾收集器:因为场景不同。
Serial收集器是一个单线程工作的收集器,它进行垃圾收集时,必须暂停其他所有工作线程,知道它收集结束。迄今为止,使用非常广泛(客户端模式默认新生代的收集器),它简单而高效,对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew收集器是Serial收集器的多线程版本,它是运行在不少服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器。除了Serial收集器收集器外,目前只有它能与CMS收集器配合工作。
CMS收集器是JDK 5发布时推出的,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。
Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。关注服务的响应速度,则CMS刚好。CMS是基于标记-清除算法实现的。CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿。
Garbage First收集器,简称G1,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1是一款主要面向服务端应用的垃圾收集器。JDK 9发布之日,G1宣告取代Parallel Scavenge加ParallelOld组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。G1基于Region堆内存布局,虽然G1也仍是遵循分代收集理论设计的,但其对内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间或者老年代。收集器根据Region的不同角色采用不同的策略去处理。G1会根据用户设定允许的收集停顿时间去优先处理回收价值收益最大的那些Region区,也就是垃圾最大的Region区,这就是Garbage First名字的由来。
G1收集器的运作过程可划分为以下四个步骤:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。需停顿线程,但耗时很短。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期待的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。
对于内存分配,大方向上就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲区,将按线程优先在TLAB上分配。少数情况下也可以直接分配在老年代。
Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是JVM的内存分代策略。在HotSpot中除了新生代和老年代,还有永久代。
分代回收的中心思想:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下下来,则将它们转移到老年代中。
新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的GC回收算法就是复制算法。
新生代又可以继续细分为3部分:Eden、Survivor0、Survivor1。这3部分按照8:1:1的比例来划分新生代。
大多数情况下,对象在新生代Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
- 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快
- 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现Major GC,经常会伴随至少一次Minor GC,Major GC的速度一般会比Minor GC慢10倍以上
一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。
如果对象比较大(比如字符串或者大数组),并且新生代的剩余空间不足,则这个大对象直接被分配到老年代上。我们可以使用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记整理的回收算法。
长期存活的对象将进入老年代:既然虚拟机采用了分代收集的思想来管理内存,那么内存回收就必须能识别哪些对象应该放在新生代还是老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区每熬过一次Minor GC,年龄就会增加一岁。当它的年龄增加到一定程度,默认是15,就将会被晋升到老年代中。
对于老年代可能存在一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代维护了一个512byte的card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发送GC时,只需要检查这个card table即可,大大提高了性能。
- 《深入理解Java虚拟机》
- Android 工程师进阶 34 讲