【JVM系列】- 垃圾收集器与内存分配策略
文章目录
一、对象死亡判断
-
程序计数器、虚拟机栈、本地方法栈的生命周期与线程相同,栈帧随着方法的开始和结束执行出栈和入栈操作。回收具备确定性,当方法或线程结束时,内存自然就跟着回收了
-
堆和方法区的内存回收具有不确定性;一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的
1. 引用计数算法
基本思路:给每个对象添加⼀个引用计数器,每当有⼀个地方引用它时,计数器值加⼀,引用失效时减⼀,当值为0时,判断对象为死亡
问题:相互循环引用的问题,互相引用对方导致引用计数器都不为0,无法被回收
优点:原理简单,判定效率高
2. 可达性分析算法
Java、C#等通过可达性分析算法判断对象是否存活
基本思路:以⼀系列定义为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,所走过的路径称为“引用链”,如果某个节点到GC Roots之间没有任何引用链相连,即从GC Roots到这个对象不可达,则认为对象为死亡
- GC Roots包含:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程方法堆栈中使用到的参数、局部变量、临时变量等
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
- 在方法区中常量引用的对象,譬如字符串常量池里的引用
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,⼀些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器
- 所有被同步锁(synchronized关键字)持有的对象
- 反映Java虚拟机内部情况的JM XBean、JVM TI中注册的回调、本地代码缓存等
- 除了以上可以固定作为GC Roots的集合外,由于所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其它对象“临时性”加入
3. 引用
在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用
JDK 1.2对引用进行了扩充,分为四种:
- 强引用是最传统的“引用”的定义,指在程序代码之中普遍存在的引用赋值,类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference类来实现软引用
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。WeakReference类来实现弱引用
- 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference类来实现虚引用
4. 对象死亡过程
-
真正宣告对象死亡至少要经历两次标记过程:
-
进行可达性分析后对象没有与GC Roots相连接的引用链
-
根据是否有必要执行finalize()方法来筛选对象
-
没有必要指:对象没有被覆盖finalize()方法或方法已经被虚拟机调用过
-
对象被判定为有必要执行finalize()方法时,其会被放置在F-Queue队列中,并在稍后由⼀条由虚拟机自动建立的、低调度优先级的Finalizer线程去调用它们的finalize()方法,但不承诺等待否法运行结束
-
finalize()方法是对象逃脱死亡命运的最后机会,即对象可以在finalize()方法中进行⼀次自救
-
对象要在finalize()中只要重新与引用链上的任何一个对象建立关联即可,譬如把自己this关键字赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合
-
为任何一个对象的finalize()方法都只会被系统自动调用一次
-
-
5. 方法区的回收
- 方法区垃圾回收的性价比很低,虚拟机可以不实现方法区的垃圾收集
- 方法区回收的内容:
- 废弃的常量:回收与Java堆中对象类似,字面量不被任何字符串对象引用时
- 不被使用的类和接口:回收的必要条件式:
- 该类所有的实例都已被回收,即Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
二、垃圾收集算法
1. 分代收集理论
-
分代假说:
- 弱分代假说: 绝大多数对象都是朝生夕灭的
- 强分代假说: 熬过越多次垃圾收集过程的对象就越难以消亡
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
-
设计者一般至少会把Java堆划分为分为新生代(Young Generation)和老年代(Old Generation) 两个区域:
- 在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放
- 新生代中存放大多数朝生夕灭的对象,这个区域在回收时只需要关注少数存活的对象而不是大量要被回收的对象,因此这个区域可以以较低的代价进行回收
- 老年代中集中存放了难以消亡的对象,对于这⼀区域虚拟机可以以较低的频率进行回收
-
使用记忆集来记录这少量的跨代引用,从而使得不需要去遍历GC Roots引用的老年代对象,用少量的空间来换取时间效率
-
在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set)
- 该结构把老年代划分成若干小块,标识出哪块内存会存在跨代引用
- 当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描
- 虽然这种方法需要在对象改变引用关系时维护记录数据的正确性,增加一些运行时开销,但比起收集时扫描整个老年代来说仍是划算的
-
垃圾收集类型:
-
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为
-
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
-
2. 标记-清除算法
- 算法过程:
- 首先标记出所有需要回收的对象,统一回收所有被标记对象
- 也可以先标记存活的对象,统一回收所有未被标记的对象
- 优点:简单,基础
- 缺点:
- 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低
- 内存空间的碎片化问题,产生大量不连续的内存碎片,可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
3. 标记-复制算法
为解决标记-清除算法面对大量可回收对象时执行效率低的问题
- 算法过程:
- 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
- 当一块的内存用完了,就将还存活着的对象复制到另一块上,再把使用过的内存空间一次清理掉
- 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可
- 优点:实现简单,运行高效,解决标记清除算法效率低和产生碎片的问题
- 缺点:代价是将可用内存缩小为了原来的一半,空间浪费太多
-
这种收集方法主要用于回收新生代,由于新生代中的对象有98%熬不过第⼀轮收集,因此不需要按照1:1的比例来划分新生代的内存空间
-
同时使用Appel式回收(将新生代分为⼀个Eden区和两个Survivor区,每次使用Eden加⼀块Survivor,发生垃圾收集时将存活对象复制到未使用的Survivor区,并清理Eden和已使用的Survivor),HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1
-
使用老年代进行分配担保,以防⼀块Survivor无法容纳Minor GC后存活的对象
4. 标记 -整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低;且老年代用作分配担保,故在老年代一般不能直接选用标记-复制算法
- 算法过程:
- 标记过程仍然与“标记-清除”算法一样,但后续步骤是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
- 标记-清除与标记-整理算法的本质差异在于前者是非移动式回收算法,而后者是移动式的
- 是否移动回收后的存活对象是一项优缺点并存的风险决策:
- 移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,尤其是在老年代这种每次回收都有大量对象存活区域,必须全程暂停用户应用程序才能进行,像这样的停顿被描述为“Stop The World”
- 但如果完全不考虑移动和整理存活对象的话,空间碎片化问题就只能通过“分区空闲分配链表”来解决内存分配问题
- 内存访问是用户程序最频繁的操作,没有之一,在这个环节增加额外负担,势必直接影响应用程序的吞吐量
- 移动则内存回收时会更复杂,不移动则内存分配时会更复杂;从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,但是从整个程序的吞吐量来看,移动对象会更划算;HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的
- 另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,让虚拟机平时多数时间都采用标记-清除算法,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间
三、HotSpot虚拟机算法实现细节
1. 根节点枚举
-
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中
-
所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰
-
目前主流的JVM使用的都是准确式收集:当用户线程停顿下来之后并不需要⼀个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当有办法直接得到哪些地方存放着对象引用
- HotSpot中的解决方案是使用⼀组称为OopMap的数据结构,OopMap存储两种引用:
- 栈里和寄存器内的引用:在即时编译中,在特定的位置记录下栈里和寄存器里哪些位置是引用
- 对象内的引用:类加载动作完成时,HotSpot 就会计算出对象内什么偏移量上是什么类型的数据
- HotSpot中的解决方案是使用⼀组称为OopMap的数据结构,OopMap存储两种引用:
2. 安全点
-
HotSpot没有为每条指令都生成OopMap,只在安全点记录了这些信息
-
用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停
-
选择安全点:
- 用户程序必须到达安全点才可以停顿下来进行垃圾收集,安全点的选择不能太少也不能太频繁
- 基本上只对能长时间执行的程序设置安全点,通常指令序列复用会长时间执行(如方法调用、循环跳转、异常跳转)
-
线程在垃圾收集时跑到最近安全点的方式:
- 抢先式中断:在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上
- 主动式中断:设置⼀个标志位,各个线程执⾏过程时会不停地主动去轮询这个标志,⼀旦发现中断标志为真时就在最近的安全点上主动中断挂起
- 设置轮询标志的地方和安全点是重合的,加之所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象
- 由于轮询操作在代码中会频繁出现,这要求它必须足够高效;HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有⼀条汇编指令的程度
3. 安全区域
-
虽然安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集的安全点
-
但程序不执行即没有分配处理器时间,用户线程处于Sleep状态或者Blocked状态,这时线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间
对于这种情况,必须引入安全区域(Safe Region)来解决
-
安全区域指确保在某一段代码片段之中,引用关系不会发生变化,在这个区域中任意地方开始垃圾收集都是安全的
-
执行过程:
- 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,虚拟机要发起垃圾收集时不必管这些已声明在安全区域的线程
- 当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,线程继续执行;否则必须一直等待,直到收到可以离开安全区域的信号
4. 记忆集和卡表
4.1 记忆集
-
为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围,缩减GC Roots扫描范围
-
不只新生代-老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,如G1、ZGC和Shenandoah收集器,都会面临跨代引用问题
记忆集用于记录从非收集区域指向收集区域的指针集合
- 收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节
- 记忆集可供选择的精度范围包括:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
4.2 卡表
-
卡精度所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,是目前最常用的一种记忆集实现形式,卡表就是记忆集的一种具体实现,定义了记忆集的记录精度、与堆内存的映射关系等
-
HotSpot虚拟机的卡表通过一个字节数组实现:
-
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)
-
一般来说,卡页大小都是以2的N次幂的字节数,HotSpot中使用的卡页是2的9次幂,即512字节
-
一个卡页的内存中通常包含不止一个对象,只要卡页内有对象的字段存在跨代指针,就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0
-
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描
-
5. 写屏障
解决卡表元素如何维护的问题
-
卡表元素何时变脏:有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻
-
卡表如何变脏,即如何在对象赋值的那一刻去更新维护卡表:
- 解释执行的字节码,相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间
- 但在编译执行的场景中,经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中
-
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的
-
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障
-
HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障
-
卡表在高并发场景下还面临着“伪共享”(False Sharing)问题
- 现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题
- 为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏
6. 并发的可达性分析
-
主流编程语言的GC基本都靠可达性分析算法判定对象是否存活,要求全过程都基于一个能保障一致性的快照中,须全程冻结用户线程的运行;该“标记”阶段是所有追踪式垃圾收集算法的共同特征,会随着堆变大而等比例增加停顿时间
-
三色标记:研究为何需要保障一致性
- 把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成三种颜色
- 白色:表示对象尚未被垃圾收集器访问过
- 在可达性分析刚开始阶段,所有的对象都是白色的
- 若在分析结束的阶段,仍然是白色的对象,即代表不可达
- 黑色:对象已被GC访问过,且这个对象的所有引用都已经扫描过
- 黑色的对象代表安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍
- 黑色对象不可能直接(不经过灰色对象)指向某个白色对象
- 灰色:对象已被GC访问过,但这个对象上至少存在一个引用还没有被扫描过
-
“对象消失”的问题:
- 原本应该是黑色的对象被误标为白色
- 当且仅当以下两个条件同时满足:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
-
对象消失的两种解决方案:
- 增量更新破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
- 原始快照破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发扫描结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索
-
CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现