垃圾收集与内存分配策略

5次阅读

共计 6547 个字符,预计需要花费 17 分钟才能阅读完成。

概述
GC 需要完成的三件事情:

哪些内存需要回收?
什么时候回收?
如何回收?

哪些区域的内存需要回收?
Java 内存区域分为程序计数器、Java 虚拟机栈、本地方法栈、Java 堆和方法区共计五部分。前三部分都是线程私有的,也就是说随着线程的生灭而生灭。在线程结束的时候,内存自然就跟着回收了,不需要过多考虑回收的问题。但后两部分,Java 堆和方法区则不一样,其分配和回收都是动态的。垃圾回收所要关注的也正是这部分区域。
什时候回收?
垃圾回收工作是由垃圾回收器来具体执行的,不同的垃圾回收器对于什么时候进行回收可能有着不同的设置,一般是收集器所管理的内存区域快满的时候回进行回收。当然垃圾回收还分为 Minor GC(新生代回收)和 Major GC/Full GC(老年代回收),按照最简单的分代式 GC 策略,按 HotSpot VM 的 serial GC 的实现来看,触发条件是:

Young GC:当新生代中的 eden 区分配满的时候触发。注意 young GC 中有部分存活对象会晋升到 old gen,所以 young GC 后 old gen 的占用量通常会有所升高。
Full GC:当老年代快被填满的时候或者分配大对象到老年代时没有足够大的 (连续) 空间的时候,会进行 Full GC;此外当进行 Minor GC(Young GC)之前,会检查老年代最大可用连续空间大小是否大于新生代所有对象的总空间大小,如是,Minor GC 正常进行。如不大于时,那么如果虚拟机设置不允许担保失败、或者允许担保失败但老年代最大可用连续空间不大于历次晋升平均值、或者允许担保失败并且老年代最大可用连续空间大于历次晋升平均值但进行了 Minor GC 后发生了担保失败 (如 Minor GC 后的存活的对象突增,远远高于历次平均值),这三种情况任何一种都会再发起一次 Full GC;或者 System.gc() 时,默认也是触发 full GC()。

更多内容可以参考下列链接:Major GC 和 Full GC 的区别是什么?触发条件呢?前两高赞的回答 Console 界面里面的 GC 的问题 When does System.gc() do anything
如何回收
如何回收的具体内容也可以分为两部分,首先是如何判断内存是否需要回收,也就是说怎样判断一块内存所存放的内容之后再也不会被访问了,这里根据内存区域的不同,又可分为判断方法区的内存是否需要回收和判断 Java 堆的内存是否需要回收。其次是如何去回收内存,这也就是用到了垃圾收集算法,因为垃圾回收主要针对的是堆中的对象,所以这里的垃圾收集算法也是主要用于 Java 堆中的内存回收。关于方法区的内存回收是否有相应的算法,书上和网上大多未涉及,暂不清楚。
判断方法区的内存是否需要回收
对于方法区而言,要回收的内容就是:废弃常量和无用的类。对于常量 (特别是引用常量) 而言,以常量池中字符串字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做“abc”的,换句话说,就是没有任何 String 对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是“无用的类”:

该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 查看类加载和卸载信息,其中 -verbose:class 和 -XX:+TraceClassLoading 可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参数需要 FastDebug 版的虚拟机支持。在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出.
判断 Java 堆的内存是否需要回收
为了确定对象之中哪些还是存活着哪些已经死去,出现了两种方法:引用计数算法和可达性分析算法,Java 虚拟机中采用的都是可达性分析算法,因为引用计数算法很难解决对象之间循环引用的问题。下面的所有垃圾收集算法的标记阶段都是通过可达性分析算法的思路来完成的。
引用计数算法
可达性分析算法
基本思路就是通过一系列被称为 GC Roots 的对象引用变量作为起始点,从这些结点开始往下搜索,搜索所走过的路被称为引用链,当一个对象到 GC Roots 没有任何引用链相连的时候 (用图论的话来说就是从 GC Roots 到这个对象不可达) 时,则证明此对象是不可用的。在 Java 语言中,可作为 GC Roots 的对象包括下面几种

虚拟机栈 (栈帧中的本地变量表) 中引用的对象。
方法区类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。

HostPot 的算法实现
在枚举根节点(即找到所有根节点的过程中),所有的垃圾收集器(无论是不是并发),都需要停顿所有的 Java 执行线程(Stop The World)。所以,虚拟机必须尽量的优化 GC 过程的效率,减少暂停的时间。HotSpot 采用了准确式 GC 以提升 GC roots 的枚举速度。所谓准确式 GC,就是让 JVM 知道内存中某位置数据的类型什么。比如当前内存位置中的数据究竟是一个整型变量还是一个引用类型。这样 JVM 可以很快确定所有引用类型的位置,从而更有针对性的进行 GC roots 枚举。HotSpot 是利用 OopMap 来实现准确式 GC 的。当类加载完成后,HotSpot 就将对象内存布局之中什么偏移量上数值是一个什么样的类型的数据这些信息存放到 OopMap 中;在 HotSpot 的 JIT 编译过程中,同样会插入相关指令来标明哪些位置存放的是对象引用等,这样在 GC 发生时,HotSpot 就可以直接扫描 OopMap 来获取对象引用的存储位置,从而进行 GC Roots 枚举。
HotSpot 安全点
通过 OopMap,HotSpot 可以很快完成 GC Roots 的查找,但是,如果在每一行代码都有可能发生 GC,那么也就意味着得为每一行代码的指令都生成 OopMap,这样将占用大量的空间。实际上,HotSpot 也不会这么做。HotSpot 只在特定的位置记录了 OopMap,这些位置就叫做安全点(Safepoint),也就是说,程序并不能在任意地方都可以停下来进行 GC,只有到达安全点时才能暂停进行 GC。在安全点中,HotSpot 也会开始记录虚拟机的相关信息,如 OopMap 信息的录入。安全点的选择不能太少,否则 GC 等待时间太长;也不能太多,否则会增大运行负荷,其选择的原则为“是否具有让程序长时间执行的特征”,如方法调用,循环等等。具体安全点有下面几个:(1) 循环的末尾 (防止大循环的时候一直不进入 Safepoint,而其他线程在等待它进入 Safepoint)(2) 方法返回前(3) 调用方法的 call 之后(4) 抛出异常的位置而安全点暂停线程运行的手段有两种:抢先式中断和主动式中断。
抢先式中断
不需要线程的执行代码主动配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上再暂停。不过现在的虚拟机几乎没有采用此算法的。
主动式中断
GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时去主动轮询查询此标志,发现中断标志为真时就中断自己挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
HotSpot 安全区域
产生原因
安全点机制保证了程序执行时进入 GC 的问题。但是对于非执行态下,如线程 Sleep 或者 Block 下,由于此时程序(线程)无法响应 JVM 的中断请求,JVM 也不太可能一直等待线程重新获取时间片,此时就需要安全区域 (Safe Region) 了。安全区域是指在一段代码片段内,引用关系不会发生变化,在这段区域内,任意地方开始 GC 都是安全的。
运行机理
在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region。当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了;当线程要离开 Safe Region 时,如果整个 GC 完成,那线程可继续执行,否则它必须等待直到收到可以安全离开 Safe Region 的信号为止
引用的分类
引用可以具体分类为以下四种

强引用:常见的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

软引用:用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。

弱引用:也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。

虚引用:最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

两次标记过程
​ 即使在可达性算法中不可达的对象,也并不是一定会被回收,在第一次被标记完之后,会进行一次筛选看此对象是否需要执行 finalize()方法,当对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
​ 如果该对象被判定为有必要执行 finalize()方法,那么这个对象将会放置在一个叫做 F -Queue 的队列之中,并稍后由一个虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。finalize()方法是该对象此时逃脱被回收命运的最后一次机会, 稍后 GC 将对 F -Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己——只要重新与引用链上任何一个对象相关联即可,那再第二次进行标记时它将会被移出”即将回收“的集合,如果这个时候还未逃脱,那基本上就会被真的回收了。
​ 任何一个对象的 finalize()方法都只会被系统调用一次,如果系统面临下一次回收,它的 finalize()方法将不会再次执行。另外,finalize()。能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以建议可以完全忘掉 java 语言中有这个方法的存在。
堆的垃圾收集算法
标记 - 清除算法
最基础的算法,主要存在两处不足:

效率问题,标记和清除两个过程的效率都不高;
空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记 - 复制算法
为了解决标记清除算法的效率问题,出现了复制算法,它将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。优点是每次都是对其中的一块进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点是将内存缩小为原来的一半,代价太高了一点。现在的商业虚拟机都采用复制收集算法来回收新生代,IBM 的研究表明,新生代中的对象 98% 是朝生夕死的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中的一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地拷贝到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存是会被“浪费”的。当然,并不能保证每次回收都只有 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。即如果另外一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
标记 - 整理算法
复制收集算法在对象存活率较高时就需要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用复制收集算法。根据老年代的特点提出了“标记 - 整理”算法,标记过程仍然与“标记 - 清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法
前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记 - 清理”或“标记 - 整理”算法来进行回收。
内存分配策略
对象优先在 Eden 分配
​ 大多数情况下,对象在新生代 Eden 去中分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
大对象直接进入老年代
​ 大对象指的是需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息(更糟糕的是遇到一群朝生夕灭的短命大对象,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置它们。
​ 虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区以及两个 Survivor 区之间发生大量的内存复制(新生代采取复制算法收集内存)。
长期存活的对象将进入老年代
位于新生代的对象,每经过一次 Minor GC 之后,其年龄 Age 都会增加 1(初始值为 0),达到一定程度 (默认是 15 岁) 就会被晋升到老年代中。对象晋升老年代的阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

动态对象年龄判定
​ 为了更好地适应不同程序的状况,虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
空间分配担保

参考文章 Java 中 new String(“ 字面量 ”) 中 “ 字面量 ” 是何时进入字符串常量池的?Java 虚拟机核心知识(五) HotSpot 的准确式 GC 聊聊 JVM(六)理解 JVM 的 safepoint

正文完
 0