03 Java垃圾收集和内存分配
3 垃圾收集器与内存分配策略
3.1 概述
垃圾收集器的概念
定义:在应用程序运行时,应用程序会创建许多对象,每个对象都有其生命周期。 在内存中,被其他对象引用的对象被称为live objects 。 不再由任何活动对象引用的对象被视为dead objects ,并称为garbage 。 查找和释放(也称为回收)这些对象使用的空间的过程称为garbage collection 。
范围:在Java内存运行区域中,私有内存(程序计数器、虚拟机栈、本地方法栈)都是随着线程创建或者销毁。栈中的栈帧随着方法的进入和退出进行入栈和出栈操作。每一个栈帧多少内存基本上是在类结构确定下来的时候就是已经知道地,会由及时编译器进行优化。这里讨论的垃圾回收机制,主要针对堆上的内存,只有层序运行期间我们才会知道究竟会创建哪些对象、创建多少个对象,这一部分的内存分配是动态的。
职责:Java中的Memory management是垃圾收集器的职责。
- allocating memory
- 确保所有引用的对象都保留在内存中,并且
- 恢复由执行代码中的引用无法访问的对象使用的内存。
垃圾回收解决了许多但不是全部的内存分配问题。 例如,我们可以无限期地创建对象并继续引用它们,直到没有更多可用内存为止( Out of memory error )。 垃圾收集是一项复杂的任务,需要花费时间和资源。 它在通常由称为堆的大型内存池分配的空间上运行。
垃圾收集的时间取决于垃圾收集器。 通常,整个堆或堆的一部分会在堆满或达到占用率的百分比时收集。
3.2 垃圾判定算法
引用计数算法
概念: 在引用计数技术中,每个对象都有从其他对象和堆栈指向该对象的指针数。 每次引用新对象时,计数器都会增加一。 同样,当任何对象丢失其引用时,计数器将减一。 当count达到’0’时,垃圾回收器可以取消分配对象。
引用计数算法的主要优点是:占用了少量的额外内存进行计数,但是原理十分简单,判定效率很高。 但是它存在循环引用的问题:当第一个对象被第二个对象引用,第二个对象被第一个对象( cyclic references ) cyclic references ,计数永远不会为零,因此它们永远不会被垃圾回收。
可达性分析算法
概念:通过一系列称为GC Roots的跟对象作为起始节点集,从节点开始根据引用关系向下搜索,搜索过程所走过的路称为引用链,如果某个对象到GC Roots没有引用,则该对象不可能再被使用。
常见的GC Roots对象包括
- 在虚拟机栈中引用的对象。例如当前正在运行的方法的参数、局部变量等。
- 在方法区中静态属性引用的对象。例如Java类中的静态变量。
- 在静态方法区中常量引用的对象。例如字符串常量池中的引用。
- Java虚拟机内部的引用,基本数据类型对应的Class对象
- 所有被同步所锁持有的对象
- 反映Java虚拟机内部的情况,JMXBean、JBMTI中注册的回调,本地代码缓存等。
- 分区垃圾收集算法,还需要将跨区引用作为计算的一部分。
引用类型
对引用的类型进行扩充
- 强引用,传统的直接引用。垃圾收集器永远不会回收掉被引用的对象。
- 软引用,SofaReference。系统发生内存溢出前进行回收。
- 弱引用,WeakReference。只能生存到下一次垃圾收集发生为止。
- 虚引用,PhantomReference。虚引用的存在完全不影响垃圾收集。
自我拯救
一个对象真正的死亡会经历两次标记的过程,
- 首先对象在进行可达性分析的时候没有与GCRoots相连接,会被第一次标记。
- 然后给予是否执行过finalizer()方法进行第二次标记,如果执行过则标记结束,如果没执行过需要加入F-Queue队列中,并在稍后由虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalize()方法。
方法区的回收
方法去垃圾手机的性价比非常低。方法去垃圾收集主要回收两部分内容:
- 废弃的常量
- 不再使用的类型
3.3 垃圾收集算法
分代收集理论
分代收集理论建立在两个假说的基础上:
- 弱分代假说:绝大多数对象都是朝生夕灭。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。因为存在引用关系的两个对象,应该倾向于同时生存或者同时消亡。
收集器将Java堆划分成不同的区域,然后将对象依据年龄分配到不同区域中存储。这样不用为了少量的跨代引用去扫描整个老年代,不用浪费空间专门记录每一个对象是否存在,以及存在哪些跨代引用。只需要在新生代上建立一个全局的数据结构——记忆集。这样在发生miniorGC的时候,只需要将存在跨代引用的小块内存里的对象加入到GCRoots进行扫描。
在java堆划分出不同区域之后,垃圾收集器可以每次只回收其中一个或者某些部分的数据。就可以针对不同区域,安排与里面存储对象存亡特征相匹配的垃圾收集算法。因而诞生了三种主要的垃圾收集算法
- 标记复制算法
- 标记清除算法
- 标记整理算法
根据垃圾收集的范围不同,可以将垃圾收集分为以下几种类型:
- 部分收集PratialGC
- 新生代收集MinorGC/YoungGC
- 老年代收集MajorGC/OldGC
- 混合收集MixedGC
- 整堆收集FullGC
标记清除算法

标记清除算法是第一个开发的able to reclaim cyclic data structures垃圾收集算法。 在这种算法中,GC将首先将某些对象标识为默认可达对象,这些对象通常是堆栈中的全局变量和局部变量。 有所谓的活动对象。在下一步中,算法开始从这些活动对象中跟踪对象,并将它们也标记为活动对象。 继续执行此过程,直到检查所有对象并将其标记为活动。 完全跟踪后未标记为活动的对象被视为死对象。
- 暂停应用程序一段时间外,
- 该技术还需要经常对内存地址空间de-fragmentation清除碎片整理
标记复制算法
像“标记和清除”一样,该算法还取决于识别活动对象并对其进行标记。 区别在于它处理活动对象的方式。停止和复制技术将整个堆设计为两个semi-spaces 。 一次只有一个半空间处于活动状态,而为新创建的对象分配的内存仅发生在单个半空间中,而另一个保持平静。GC运行时,它将开始标记当前半空间中的活动对象,完成后,它将所有活动对象复制到其他半空间中。 当前半空间中的所有其余对象都被视为已死,并已被垃圾回收。
- 接触活动对象。但是如果内存中大部分对象都是存活的,将产生大量内存复制的开销。
- 不需要考虑空间碎片的问题,会直接进行空间的压缩。
- 需要将所需的内存大小增加一倍,因为在给定的时间点仅使用一半的内存。
- 需要在切换半空间时停止世界。
在标记清除算法上做的改进:
- 新生代中有98%的对象熬不过第一轮,所以不需要按照1:1的比例来划分新生代中的内存空间。
- Appel式回收算法,将空间分为一块较大的Eden和两块较小的Survivor,每次空间分配只使用Eden和其中一块Survior。发生垃圾收集的时候,将内存中的对象都复制到空闲的Survivor上。一般情况下空间比例式8:1:1,如果出现例外情况,Survivor的空间不足,就会依赖其他内存区域进行担保。
标记整理算法
与标记复制算法相似,但是整理是将所有存活的对象向内存空间的一端移动,然后直接清理到边界以外的内存。
优缺点:
- 存在大量存活的对象,移动对象并更新引用是一种负重的操作。
- 必须要暂停用户的应用进程才能进行。
3.4 HotSpot算法实现细节
根节点枚举
- 根节点枚举必须暂停用户线程。
- 使用OopMap快速扫描GCRoots
安全点
- OopMap只在安全点进行记录。用户进程运行到安全点的时候才可以安全的挂起等待GCRoots的扫描。
- 安全点在指令复用中才会记录,如方法调用、循环跳转、异常跳转。
- 用户进程采用主动式中断的方式在安全点挂起。
安全区域
上述安全点方案可以解决运行中的用户线程停顿的时机,但是处于阻塞状态的用户线程无法感知到虚拟机GC中断请求。安全区域确保在某一段代码片段中,引用关系不会发生变化。
这样用户进程在进入安全区域的时候,会标识自己已经进入安全区域。当离开的时候,会检测是否已经完成GCRoots的枚举。
记忆集与卡表
为了解决跨代对象引用的问题,避免在新生代垃圾收集的时候将整个老年代的对象加入到GCRoots当中。
记忆集是从非收集区域指向收集区域的指针集合的抽象数据结构。
如果记录全部的跨代指针会浪费时间和空间,所以直接记录某个一存在跨代指针的区域。卡表是记忆集的一种实现,就是用来记录这样的存在跨代引用的内存区域的数据结构,它定义了记忆集的记录精度、与堆内存的映射关系。
卡表中的每一个元素对应其标识的内存区域中一块特定大小的内存块,这个内存块被称为卡页。
写屏障
卡表上记录了跨代引用,需要再对象赋值的时候维护卡表的数据。
通过写屏障技术维护卡表的状态。写屏障可以看做在虚拟机层面对引用类型字段赋值这个动作的AOP切面。在引用对象赋值的时候会产生一个环形通知,供程序执行额外的操作。
并行可达性分析
首先可达性分析算法要求全过程基于一个能够保证一致性的快照当中才能进行分析,必须冻结全部的用户线程。主要包括以下两个步骤
- 根节点枚举。这个步骤必须进行用户线程冻结。
- GCRoots向下遍历对象图,可以进行并行操作。
引入三色标记的理论
- 白色:对象尚未被垃圾收集器访问过。
- 黑色:对象已经被垃圾收集器访问过,并且对象的所有引用都已经扫描过。
- 灰色:对象已经被垃圾收集器扫描过,但是对象上至少存在一个引用还没有被扫描过。
在并发扫描过程中可能会出现以下问题,可以从三色标记理论出发描述一下两种错误发生的场景:
- 原本消亡的对象被错误标记成存活。这个可以容忍,下次GC会回收掉。
- 原本存活的对象被错误标记为消亡。这个无法容忍,会导致用户进程出错。灰色标记中的对象引用断开,并且新增了黑色标记的对象引用,导致该对象应该被扫描但是被错过了。
研究表明出现第二个无法容忍的错误需要满足以下两个条件
- 赋值器插入一条或者多条从黑色对象到白色对象的引用。
- 赋值器删除了全部从灰色对象到该白色对象的直接或者间接引用。
所以解决方案也由两个,只需要破坏其中的一个条件即可。
增量更新。破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新进行一次扫描。即,黑色对象一旦插入了白色对象的引用之后,就会变回灰色对象了。
原始快照。破坏第二个条件。当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束后,再将这些记录过的应用关系中的灰色对象为根,重新扫描一次。即,无论关系是否删除,都会按照刚开始扫描那一刻的对象图来快照进行搜索。
另外,虚拟机的记录操作都是通过写屏障实现的。CMS基于增量更新来做并发表及,G1和Shenandoah基于原始快照来实现。
3.5 经典垃圾收集器

Serial收集器

主要包括两个算法。算法对年轻一代使用mark-copy,称为Serial收集器;对老一代使用mark-sweep-compact,成为Serial Old收集器。 它在单个线程上工作。 执行时,它将冻结所有其他线程,直到垃圾回收操作结束。由于串行垃圾回收具有线程冻结特性,因此仅适用于非常小的程序。要使用串行GC,请使用以下JVM参数:
1 | -XX:+UseSerialGC |
特点:
- 单线程工作收集器。垃圾收集时必须暂停其他所有工作线程,直到它收集结束。
- 简单有效,额外消耗最小的收集器。由于没有现成交互的开销,专心做垃圾收集可以获得最高的单线程收集效率。
应用场景
- 桌面引用场景或者部分微服务应用中,分配给虚拟机的内存一般不会太大。可以容忍一百毫秒以上的垃圾收集延迟。
Parallel收集器

主要包括两个算法。与串行GC相似,它在年轻代中使用mark-copy,称为Parallel Scavenge;在老年代中使用mark-sweep-compact,称为Parallel Old收集器。与串行GC最大的不同,就是使用多个并发线程用于标记和复制/压缩阶段。
可以使用-XX:ParallelGCThreads=N选项配置线程数。如果您的主要目标是通过有效利用现有系统资源来提高吞吐量,那么Parallel Garbage Collector将适用于多核计算机。 使用这种方法,可以大大减少GC循环时间。
1 | -XX:+UseParallelGC |
可以使用一下两个参数精确的控制吞吐量
1 | -XX:MaxGCPauseMills:最大 GC 停顿的秒数; |
特点:
- 吞吐量优先。其他收集器关注于尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的是达到一个可控的吞吐量。
吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )
适用场景:
- Parallel Scavenge 收集器不管是新生代还是老年代都是多个线程同时进行垃圾收集,十分适合于应用在注重吞吐量以及 CPU 资源敏感的场合。
ParNew+CMS收集器
这两个收集器经常配合使用。
ParNew收集器采用标记复制算法,支持多线程并行,同时使用多条线程进行垃圾收集。一般搭配CMS在服务端使用。

CMS垃圾回收实质上是一种升级的标记-清除方法。 它using multiple threads扫描堆内存。 对其进行了修改,以利用更快的系统并增强了性能。它尝试通过与应用程序线程concurrently执行大多数垃圾回收工作来最大程度地减少由于垃圾回收导致的暂停。 它在年轻一代中使用并行的世界停止mark-copy算法,而在老一代中使用大多数并发的mark-sweep算法。
1 | -XX:+UseConcMarkSweepGC |
特点:
- 以获取最短回收停顿时间为目标的收集器。
- 标记清除算法
适用场景:
- 互联网站的服务端上。关注服务的响应速度。停顿时间尽可能短,以给客户较好体验。
运行原理
它是初始且非常基本的算法,分为两个阶段运行:
- Marking live objects –找出所有仍然存在的对象。
- Removing unreachable objects -摆脱所有其他东西-所谓的已死和未使用的对象。
第一阶段介绍:
- mark live objects。首先,GC将某些特定对象定义为“ Garbage Collection Roots 。 例如,当前执行方法的局部变量和输入参数,活动线程,已加载类的静态字段和JNI引用。 现在,GC遍历了内存中的整个对象图,从这些根开始,然后是从根到其他对象的引用。 GC访问的每个对象都被标记为活动对象。
第二阶段介绍
mark-sweep。Normal deletion 普通删除将未引用的对象删除以释放空间并保留引用的对象和指针。 内存分配器(某种哈希表)保存对可分配新对象的可用空间块的引用。它通常被称为mark-sweep算法。

mark-sweep-compact 。Deletion with compacting仅删除未使用的对象效率不高,因为可用内存块分散在整个存储区域中,并且如果创建的对象足够大且找不到足够大的内存块,则会导致OutOfMemoryError 。为了解决此问题,删除未引用的对象后,将对其余的引用对象进行压缩。 这里的压缩指的是将参考对象一起移动的过程。 这使得新的内存分配变得更加容易和快捷。它通常被称为mark-sweep-compact算法。

mark-copy 。Deletion with copying –与标记和补偿方法非常相似,因为它们也会重新放置所有活动对象。 重要的区别是重定位的目标是不同的存储区域。它通常被称为mark-copy算法。

G1收集器

G1(垃圾优先)垃圾收集器已在Java 7中提供,旨在长期替代CMS收集器。 G1收集器是并行的,并发的,渐进压缩的低暂停垃圾收集器。此方法涉及将内存堆分段为多个小区域(通常为2048)。 每个区域都被标记为年轻一代(进一步划分为伊甸园地区或幸存者地区)或老一代。 这样,GC可以避免立即收集整个堆,而可以逐步解决问题。 这意味着一次只考虑区域的一个子集。

特点
- G1跟踪每个区域包含的实时数据量。 此信息用于确定包含最多垃圾的区域。 因此它们是首先收集的。
- 这就是为什么它是名称garbage-first集合。与其他算法一样,不幸的是,压缩操作是使用Stop the World方法进行的。 但是根据其设计目标,您可以为其设置特定的性能目标。 您可以配置暂停持续时间,例如在任何给定的秒内不超过10毫秒。 垃圾优先GC将尽最大可能(但不能确定,由于OS级线程管理,这很难实时实现)来尽力实现该目标。

控制参数:
1 | -XX:+UseG1GC |
3.6 更高效的收集器
ZGC收集器
- 内存占用、吞吐量、延迟不可能三角。
3.7 选择合适的收集器
如何选择

GC日志分析

内存分配与回收策略
自动内存管理主要包括两部分:自动给对象分配内存和自动回收分配给对象的内存。
以下是最基本的内存分配原则:
对象优先在Eden中分配
- 大多数情况下对象在Eden中分配。
- 当Eden没有足够的空间进行分配时,虚拟机将发起一次MinorGC
大对象直接进入老年代
- 需要大量连续内存空间的对象直接进入老年代。很长的字符串或者元素数量很庞大的数组。
- 因为如果分配在年轻代会进行标记复制算法,导致大对象在Eden和Survior区之间来回复制。
长期存活的对象进入老年代
- 每个对象都有年龄计数器,在对象头中。每熬过一次minorGC年龄就会增加1
- 当年龄增加到15时,就会被晋升到老年代。可以通过-XX:MaxTenuringThreshold=15设置。
- 大量对象在young gc后任然存活。survivor空间不够,在 Young GC 时,JVM 会检查 目标 Survivor 区(通常是 S1)是否足够容纳 Eden 区中存活的对象。如果 S1 区空间不足(比如 S1 当前使用率已经是 100%),JVM 会绕过 Survivor 区,直接将存活对象晋升到老年代。
动态对象年龄判断
- 当Survior区中低于或等于某年龄的所有对象总和大于Survivor空间的一半,大于等于该年龄的对象就可以直接进入老年代。
空间分配担保
风险:新生代使用标记复制收集算法。需要留有足够的空间,但是如果情况非常不理想所有的对象都存活则需要老年代有足够的空间担保能够完全存下Eden区。
- MinorGC虚拟机必须先检查老年代中最大可用的连续空间是否大于新生代所有对象的总空间。如果条件成立则MinorGC可以确保是安全的。
- 如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败。如果允许会检查最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则进行MinorGC,但需要冒风险。
- 否则进行FullGC
总结
- 对象生命周期分为三个阶段,即对象创建,对象使用和对象销毁。
- mark-sweep , mark-sweep-compact和mark-copy机制如何工作。
- 不同的单线程和并发GC算法。
- 直到Java 8,并行GC才是默认算法。
- 从Java 9开始,将G1设置为默认GC算法。




