JVM垃圾收集器与内存分配深入剖析

目录

  • I.分区内存回收思考
  • 一、对象存活判定
    • 1.1 引用计数算法
    • 1.2 可达性分析算法
  • 二、引用
    • 2.1 强引用
    • 2.2 软引用
    • 2.3 弱引用
    • 2.4 虚引用
  • 三、回收方法区
  • 四、垃圾收集算法
    • 4.1 分代收集理论
      • 4.1.1 Java堆的分区概念
      • 4.1.2 跨代引用概念
    • 4.2 标记-清除算法
    • 4.3 标记-复制算法
    • 4.4 标记-整理算法
  • 五、内存分配
    • 5.1 年轻代分区介绍
    • 5.2 对象优先在Eden区分配
    • 5.3 大对象直接进入老年代
    • 5.4 长期存活对象将进入老年代
    • 5.5 动态对象年龄判断
    • 5.6 空间分配担保

本文参考自《深入理解Java虚拟机》
垃圾收集器(Garbage Collection)也就是常说GC,这是JVM的一个核心。那么,我们为什么要去了解垃圾收集和内存分配呢?当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

I.分区内存回收思考

首先,我们考虑一个问题,运行时数据区那么多个分区,我们是不是每个分区都需要考虑呢?理性思考下,运行时数据区主要分为两类:线程私有线程共享的内存区,这两类内存区有一个最显著的区别就是线程私有的内存区随着线程的存在而存在,生命周期与线程等同,其内存大小在编译期就可知,也就是说这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。 但是线程共享区(Java堆和方法区)就具有不确定性,例如一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的垃圾收集器所关注的正是这部分内存该如何管理,因此我们考虑内存的分配与回收正是针对这一部分的。

一、对象存活判定

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。只有确定了对象的“死活问题”才能确定是否进行垃圾回收。
判断对象的是否存活主要有两种算法:引用计数算法和可达性分析算法;接下来将分别展开介绍。

1.1 引用计数算法

引用计数法 顾名思义,就是在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。这个还是比较好理解的。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
我们看一段代码就可以很好理解了:

public class ReferenceCountingGC {
	public Object instance = null;
	private static final int _1MB = 1024 * 1024;
	/**
	* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
	*/
	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;
		// 假设在这行发生GC,objA和objB是否能被回收?
		System.gc();
	}
}

分析上面代码:对象objA和objB都有字段instance,objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们,就导致内存垃圾无法回收。

1.2 可达性分析算法

可达性分析算法是当前各大商用编程语言都采取的;这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
我们用一张图来理解下这个算法:
JVM垃圾收集器与内存分配深入剖析_第1张图片

对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。
另外,在Java体系中,固定可作为GC Roots的对象包括以下几种:

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

二、引用

考虑这样一种情况,我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。
那么我们就要对对象的引用情况做一个分类;Java对此做了四种分类,分别为:强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

2.1 强引用

强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

2.2 软引用

软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference类来实现软引用。

2.3 弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。WeakReference类来实现弱引用。

2.4 虚引用

虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference类来实现虚引用。

三、回收方法区

顾名思义就是在在方法区发生垃圾回收的行为,但是在这不是一定的,可以在方法区没有垃圾收集行为,不同的虚拟机设计理念对方法区的处理不同,需要具体情况具体分析,但是如果方法区有垃圾收集行为,主要回收两种类型:废弃的常量和不再使用的类型
我们举个例子就很好理解了:
举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。总结一句话就是没用即回收
但是判断一个常量是否需要回收还是比较简单的,但是要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

注意:只有在同时满足上面三个条件的情况下,该类型才被允许回收,注意用词是被允许,也就是说这种情况下才能被回收,但是执不执行回收就看我们对虚拟机的参数进行设置了,如果设置了Xnoclassgc参数进行控制那么就会回收,如果没有设置参数,那么即使能回收,虚拟机也不会执行垃圾回收。

四、垃圾收集算法

首先我们要知道的是垃圾收集算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法都有差异,不过多讨论算法实现,只重点介绍主流的分代收集理论和几种算法思想

4.1 分代收集理论

分代收集理论并不是具体的算法,这就是一个基础的理论框架,很多垃圾收集算法都是以此为基础,然后建立具体的算法模型,也算是那些收集算法的基础,我们先来了解下这个理论的一些基础概念知识。
首先,虚拟机为了方便管理Java堆,对Java堆进行了分区,比如分为新生代,老年代等等,然后垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”,“Major GC”,“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”,“标记-清除算法”,“标记-整理算法”等针对性的垃圾收集算法。
在正式介绍之前,先说下基础术语做铺垫:
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集,因为 Java 对象大多都是朝生夕死,所有 Minor GC 非常频繁,一般回收速度也非常快;
  2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
  3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

4.1.1 Java堆的分区概念

收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数,一般使用一个计数器,来记录对象逃过新生代GC的次数,当该值超过一定时,就进入老年代)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

4.1.2 跨代引用概念

另外,假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样 。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,提出来跨代引用的概念(就是不同年代的区域对象之间的引用)。
如此一来,我们就不用再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

4.2 标记-清除算法

标记-清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。(对象是否属于垃圾的判定就是上文所讲的GC Roots的算法判定的)
缺点:

  1. 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  2. 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

回收前如下图:(灰色:可回收;绿色:存活对象;白色:未使用内存)
JVM垃圾收集器与内存分配深入剖析_第2张图片

回收后:
JVM垃圾收集器与内存分配深入剖析_第3张图片

4.3 标记-复制算法

标记-复制算法常被简称为复制算法。它先将内存分为两个大小相等的内存块,每次只使用其中的一块(活动区间)另一块就则暂时不用,是空闲的(空闲区间)。
当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。
此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。
我们用一张图看看:
JVM垃圾收集器与内存分配深入剖析_第4张图片
GC复制算法处理过后的结果:
JVM垃圾收集器与内存分配深入剖析_第5张图片
可以看到,1和4号对象被清除了,而2、3、5、6号对象则是规则的排列在刚才的空闲区间,也就是现在的活动区间之内。此时左半部分已经变成了空闲区间,不难想象,在下一次GC之后,左边将会再次变成活动区间。
很明显,复制算法弥补了清除算法内存布局混乱的缺点,但是,它也存在比较多的缺点:

  1. 它浪费了一半的内存。
  2. 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。

复制算法适合存活率比较低的区域。
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间(分别叫from和to),每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

4.4 标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。而针对老年代的存亡特性,采用的是标记-整理算法。
标记-整理算法与标记-清除算法非常相似,它也是分为两个阶段:标记整理

标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。
我们用一张图来看看该算法GC前后的情况:
GC前:
JVM垃圾收集器与内存分配深入剖析_第6张图片
GC后:
JVM垃圾收集器与内存分配深入剖析_第7张图片
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。但是,如果存活对象很多,每次回收需要移动大量的对象,耗费资源操作。
复制算法和标记-整理算法参考自博客https://www.cnblogs.com/zuoxiaolong/p/jvm5.html

五、内存分配

Java技术体系的自动内存管理,总结起来无非就是两点:第一、自动分配内存,第二,自动回收内存;回收内存在前文已经有详细的介绍了,接下来我们讨论下在Java中如何进行内存分配的(博主的前一篇博客中,内存的分配在虚拟机层面已经深入底层介绍,想了解的可以看该篇博客中2.1小节–对象的创建博客连接https://blog.csdn.net/qq_34275277/article/details/111308472,在此就结合分代理论做一个简要介绍)。
在开始介绍之前,我们要明白一点,不同的虚拟机采取的内存分配策略是不同的,取决于虚拟机当前使用的是哪一种垃圾收集器,以及虚拟机中与内存相关的参数!但是虚拟机对于内存的分配还是会遵循以下几种【普世】规则:
在概念上,对象的内存分配,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散为标量类型并间接地在栈上分配 )。在经典分代的设计下,新生对象通常会分配在新生代中(如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配)。,少数情况下(例如对象大小超过一定阈值)也可能会直接分配在老年代

5.1 年轻代分区介绍

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
解下来我们来了解下一个对象如何从GC进入到老年代的流程:
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在==“From”区中,仍存活的对象会根据他们的年龄值来决定去向==。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
JVM垃圾收集器与内存分配深入剖析_第8张图片
该段引用自http://ifeve.com/jvm-yong-generation/

接下来介绍虚拟机遵循的几种【普世】规则:

5.2 对象优先在Eden区分配

多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。

5.3 大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。
这里解释下为什么大对象不在新生代分配?因为新生代采用复制算法来处理垃圾回收的,如果大对象在新生代分配,就会导致Eden和两个 Survivor 区(from区和to区)之间发生大量的内存复制,导致效率低下,资源占用。因此对于大对象都会直接在老年代进行分配。

5.4 长期存活对象将进入老年代

虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。

5.5 动态对象年龄判断

为了更好的适应不同程序的内存情况,虚拟机并不是永远要求对象的年龄必需达到某个固定的值(比如前面说的 15)才会被晋升到老年代,而是会去动态的判断对象年龄。如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 空间(from区内存的一半,to区永远是空的)的一半,年龄大于等于该年龄的对象就可以直接进入老年代。

5.6 空间分配担保

在新生代触发 Minor GC 后,如果 Survivor 中任然有大量的对象存活就需要老年代来进行分配担保,让 Survivor 区(GC之后的from区)中无法容纳的对象直接进入到老年代。

你可能感兴趣的:(JVM,jvm,java)