垃圾收集器与内存分配策略
概述
- 垃圾收集器需要完成的三件事
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
java内存运行时区域的各个部分**,java的程序计数器、虚拟机栈、本地方法栈**3个区域生命周期与线程一致。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然也跟着回收了。
而java堆和方法区这两个区域有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少队形,这部分内存的分配和回收是动态的。垃圾收集所关注的正是这部分区域。
引用计数算法
算法思路:
在对象中添加一个·引用计数器,每当有一个地方引用它时,计数器就加一;当引用失效时,计数值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
优点:
- 原理简单
- 判定效率高
缺点:
算法需要考虑许多特殊的情况,例如对象相互循环引用的问题。
public class ReferenceCountingGc {
public Object instance = null;
private static final int_1MB = 1024*1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGc() {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}
对象objA和objB都有instance ,赋值令 objA.instance = objB 及 objB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为相互引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
而我们在最终的运行结果中可以看到gc回收了这个内存,说明java并不使用的这个算法。
可达性分析算法
当前主流的商用程序语言(java ,C# 等)的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。
思路:
通过一系列成为 “GC roots”的根对象最为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索所走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再被使用的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wx2v481F-1665751222661)(https://gitee.com/lxsupercode/picture/raw/master/img/image-20211220203002174.png)]
GC Roots包括的对象
java技术体系中,固定可作为GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如java类的静态引用类型变量。
- 在方法区中常量引用的对象,譬如字符串常量池引用的对象。
- 在本地方法栈中JNI(即通常所说的Native方法) 引用的对象。
- java虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象(比如 NullPointExcepetion 、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象
- 反应java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些该固定的 GC Roots集合以外,根据用户所选用的垃圾收集器 以及 当前回收的内存区域 不同,还可以有其他对象 “临时性” 地加入,共同构成完整的GC Roots集合。
再谈引用
1.2 版本之前 java里面引用的定义:如果reference类型的数据中储存的数值代表的是另一块内存区域的起始地址,就称改reference数据 是代表某块内存、某个对象的引用。
这种定义没有什么不对,但现在看来过于狭隘了。在该定义下java内存只有 “被引用” 和 “未被引用” 两种状态,无法充分的描述所有情况下的对象。
譬如:有一类对象,当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。
1.2 版本之后,java对引用的概念:将引用分为 强引用 、软引用、弱引用、虚引用 四种。
-
强引用是最传统的 “引用” 定义,是指在程序代码之中普遍存在的引用赋值,类似
Object obj = new Object()
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 -
软引用是用来描述一些还有用,但非必须的对象。 **只被软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。**在JDK1.2版之后提供了SoftReference类来实现软引用。
-
弱引用也是用来描述哪些非必须对象,但是它的强度比软引用更弱一些,**被弱引用关联的对象只能生存到下一次垃圾收集发生为止。**当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现软引用。
-
虚引用也称为“幽灵引用” 或者 “幻影引用” 。它是最弱的一种引用关系。**一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。**为一个对象设置虚引用关联的唯一目的是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版本之后,提供了PhantomReference类来实现虚引用。
finalize 方法
一个对象在可达性分析算法中判定为不可达,并不意味着他已经死亡,这时候它还处于缓刑阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,
- 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize() 方法。如果对象没有覆盖finalize()方法,或者finalize()方法已经被执行过,那么虚拟机都将认为该对象“没有必要执行finalize()方法”
- 如果该对象被判定有必要执行fianlize()方法,那么改对象将会被放置在F-Queue队列中,并稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的fianlize()方法。
- 垃圾收集器会对F-Queue中的对象进行第二次小规模的标记,如果在fianlize()方法中,对象重新与引用链上的任何一个对象重新建立起了关联关系,那么就不会被回收。将会被移出“即将回收”的集合
- 二次标记后,回收集合里的对象将会被回收。
缺点:运行代价高昂,不确定性大,无法保证各个对象的调用顺序,不被官方推荐。
回收方法区
方法区垃圾收集的“性价比” 通常也是比较低的:在java堆中,尤其在新生代中,对常规应用垃圾收集通常可以回收70% - 99% 的内存空间,相比下,方法区回收 过于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集回收两部分内容:废弃的常量 和 不再使用的类型。
判断常量是否“废弃”?
例子:假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的”java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类方法、字段的符号引用也与此类似。
判定一个类型是否属于“不再被使用的类” ?
- 该类所有的实例都已经被回收,也就是java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
java 虚拟机被允许对满足上述三个条件的无用类进行回收。这里说的仅仅是“被允许”,而并不是和对象一样,没有引用就必然回收。关于是否要对类型进行回收,Hotspot虚拟机提供了 -Xnoclassgc 参数进行控制。
在大量使用反射、动态代理、CGLib等字节框架,动态生成JSP 以及 OSGi 这类频繁自定义类加载器的场景。通常都要需要java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大压力。
垃圾收集算法
垃圾收集算法可以划分**“引用计数式垃圾收集”和“追踪式垃圾收集” 两大类**,这两类也也被称作**”直接垃圾收集“ 和 ”间接垃圾收集“**。 下面的垃圾收集算法都属于 “追踪式垃圾收集”
分代收集理论
大多数商业虚拟机的垃圾收集器都遵循了”分代收集”的理论进行设计。其建立在两个分代假说之上。
两个分代假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡。
这两个分代假说共同奠定了多款常用垃圾收集器的一致设计原则:收集器应该讲java堆划分出不同的区域,然后将回收对象根据其年龄分配到不同的区域之中储存。
如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把他们集中放在一起,每次回收时只关注如何保留少量的存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。
java划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或其中某一部分的区域——因而有了“Minor GC”,“Major GC”,”Full GC”这样的回收类型的划分。因而提出了针对不同的区域安排与里面储存对象存亡特征相匹配的垃圾收集算法——“标记-复制算法”、“标记-清除算法”、“标记-整理算法”。
设计者一般至少会把java堆划分为新生代 和 老年代 两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象将会逐渐晋升到老年代中存放。
但分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间存在跨代引用(新生代的对象可能被老年代对象所引用)。
为了找到新生代中存活的对象,我们不得不在固定的GC Roots之外,再额外遍历整个老年代所有对象来保证可达性分析的正确性,遍历老年代虽然可行,但会造成很大的性能负担,为了解决改问题需要对分代收集理论添加第三条经验法则
- 跨代引用假说: 跨代引用相对于同代引用来说这是少数
存在相互引用关系的两个对象。是应该倾向于同时生存或者同时消亡的。举个例子:如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活
根据这条假说,我们就不应该为了少量的跨代引用而去扫描整个老年代,也不必要浪费空间专门记录每一个对象是否存在跨代引用,只需要在新生代上建立一个全局的数据结构(“记忆集”),这个结构把老年代分为若干小块,标识出老年代那一块区域会存在跨代引用。后面发生Minor Gc ,只有包含了跨代引用区域的对象才会加到GC Roots进行扫描。
- 部分收集(Partial GC):指目标不是完整收集整个java堆的垃圾收集,其中又分为
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):目标只是老年代的垃圾收集。 目前只有CMS 收集器会有单独收集老年代的行为
- 混合收集(Mixed GC): 指目标是整个新生代和部分老年代的垃圾收集
- 整堆收集(Full GC): 收集整个java堆和方法区的垃圾收集。
标记-清除算法
思路:
首先标记处所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点:
- 执行效率不稳定:如果java堆中·包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都碎对象数量增长而降低
- 内存空间碎片化:标记、清除后会产生大量不连续的内存随便,空间碎片太多可能导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发下一次的垃圾收集。
复制算法
思路:
将可用内存容量划分为大小相等的两块,每次只使用其中一块。当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。
**优点:**实现简单,运行高效。
**缺点:**只能使用一半的内存,空间浪费。
eden 和 survivor
介绍:
大部分商用虚拟机新生代的收集算法
IBM研究发现,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比里来划分空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Suvivor。当回收时,将Eden和Survivor中还存活这的对象一次性的拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和survivor的大小比例是8:1。也就是每次新生代中可用内存空间为整个新生代容量的90(80 + 10)%,只有10% 内存是会被“浪费”的。
内存的分配担保:
我们无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。
内存分配担保就好比我们去银行借款,如果我们信誉良好,在98%情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,内存的分配担保也一样,如果另一块Survivor空间没有足够的空间放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
标记 — 整理算法
思路:
大部分商用虚拟机老年代的收集算法
标记过程与“标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行标记清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
分代收集算法
简介:
当代商业虚拟机算法都采用“分代收集”算法,根据对象的存活周期的不同将内存划分为几块。一般是把java堆分为新生代和老年代。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用标记——复制算法,只需要付出少量存活对象·的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须用“标记—清理”或“标记—整理”算法来进行回收。
垃圾收集器
图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial 收集器
特点:
单线程收集器,在进行垃圾收集时,必须暂停其他所有的工作线程。
优点:简单而高效
缺点:”Stop the World“ 用户体验不好。(妈妈收拾房间的时候不允许你在地上扔纸屑)
运行在Client模式下的默认新生代收集器。
ParNew 收集器
介绍
ParNew 收集器其实就是Serial 收集器的多线程版本。
新生代 复制算法
老年代 标记-整理算法
- 是许多运行在Server模式下的虚拟机首选的新生代收集器
- 除了Serial 收集器外,目前只有它能与CMS收集器配合工作。
Parallel Scavenge 收集器
介绍
- 新生代收集器
- 使用复制算法
- 并行多线程收集器
特点
-
目标是达到一个可控制的吞吐量
cpu 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%
也被称为 “吞吐量优先”收集器
Parallel Old 收集器
介绍
- 是 Parallel Scavenge 的老年代版本
- 使用多线程 和 “标记-整理” 算法。
CMS 收集器 (重点)
介绍
CMS 收集器 是一种以获取最短回收停顿时间为目标的收集器。
CMS 收集器是基于“标记-清除”算法实现的。
运作过程
运作过程比较复杂,包括4个步骤:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中初始标记、重新标记这两个步骤仍需要“stop the world”。初始标记仅仅只是标记一下GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。
优点
- 并发收集
- 低停顿
缺点
- CMS 收集器对CPU资源非常敏感
- CMS 收集器无法处理浮动垃圾 (CMS 并发清理阶段用户线程还在运行着,伴随程序的运行自然还有新的垃圾不断产生,这部分垃圾出现在标记过程之后,CMS 无法在本次收集清理它们,只好下次GC再清除,这部分垃圾成为“浮动垃圾”)
- 由于是基于标记-清除算法,收集结束时会产生大量空间碎片。(CMS 可开启在Full GC 后进行碎片整理,会导致停顿时间增长)
G1 收集器
介绍
G1 是目前最前沿的收集器技术成果
相对于CMS收集器有两个显著改进
- 基于 “标记-整理” 算法
- 可以非常精确的控制停顿,能让使用者明确指定在一个长度为M毫秒的时间片段,消耗在垃圾收集上的时间不得超过N秒。
G1将整个java堆(包括新生代、老年代)划分为多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先了列表,每次根据允许的收集时间,优先回收垃圾最多的区域。