JVM内存模型与 GC 回收机制

JVM内存到GC的算法、回收器等,附带Java四种引用

Posted by BlackDn on November 4, 2021

“行至朝雾里,坠入暮云间。”

前言

这篇真的拖了挺久…懒是一方面吧,还有就是挺忙的(开始找借口)
这篇写的时候有点无从下手的感觉,特别是四种引用那里不知道怎么用代码表达出来
然后之前忙着入党啊面试啊啥的,一结束就松了口气想要放松一下。
于是去看了《沙丘》,还真挺好看,有时间去找小说看。
好吧我就是懒。本来想着作为十月最后一篇博客的,然后拖到十一月了=。=

JVM 内存模型与 GC 回收机制

垃圾回收机制(Garbage Collection),简称 GC,是 JVM 所采用的,对内存空间进行回收的机制。众所周知,安卓对于内存的需求是十分迫切的,多一分内存就是多一分优化。利用 GC 机制,JVM 得以自动检测内存中无用的对象进行回收,从而释放内存。
而对于GC 回收机制,我们可以简单理解为,JVM 进行垃圾回收时采用的算法。当然存在不止一种算法,几个很常见的会在下面进行介绍。

JVM 内存模型

JVM 的内存模型实际上就是其内部的运行时数据区,可以分为以下五个:

  1. 程序计数器(Program Counter Register)
  2. 栈(Stack)
  3. 堆(Heap)
  4. 方法区(Method Area)
  5. 本地方法栈(Native Method Stack)

JVM内存

程序计数器(PC)

在通用的计算机体系中,程序计数器(PC)用来记录当前正在执行的指令,在 JVM 中也是如此。
每个线程启动的时候,都会创建一个 PC,保存当前线程正在执行的 JVM 指令的地址,即正在执行的字节码地址和行号,用于保存现场信息。
PC 是线程私有的,即每一个线程都有它自己的 PC 寄存器。如果执行的是 Native 方法(非 java 代码),则 PC 的值为空。同时这也是 JVM 规范中唯一没有规定 OOM(OutOfMemoryError)的区域

Java 栈(Stack)

当一个线程启动时,JVM 会为其分配一个栈,Java 栈也称 JVM 栈。
当一个方法被调用时,会创建一个栈帧,用于存储局部变量和操作数(对象引用)等数据。其随着方法的结束而销毁
Java 栈只对栈帧进行存储、压栈、出栈的操作。栈帧中又包含以下五个部分:局部变量表(Local Variables)操作数栈(Operand Stack)指向类的运行时常量池的引用(Reference to Runtime Constant Pool)方法返回地址(Return Address)附加信息。要注意栈中只保存基础数据类型的对象和自定义对象的引用(对象本身保存在堆中)
当一个线程执行一个方法时,会随之创建一个栈帧,将其压栈;方法执行完毕后将其出栈。
由于栈的生命周期和线程同步,随线程创建而创建,随线程结束而释放,因此对栈而言不存在垃圾回收

当栈内存是固定值时,我们请求深度大于栈的深度,比如数组下标越界,会导致StackOverflowError错误;
当栈内存是动态增长时,申请的内存大小超过剩下的可用内存,会导致OutOfMemoryError错误。

堆(Heap)

堆用来存放对象和数组(特殊的对象)本身,由所有线程共享
堆内存随着 JVM 启动而创建,因此需要一个回收机制来对其中的对象进行回收,进而释放内存。如果堆的剩余内存不满足对象的创建,JVM 会抛出OutOfMemoryError错误
堆的好处在于运行时动态分配内存,不必告诉编译器其生存期;但也因此其性能受限于自动回收所采用的算法。由于要先分配内存再进行访问,因此存取速度较慢。

方法区(Method Area)

方法区和堆相似,被所有线程共享,其中存储了 JVM 加载类所需的信息(类名称、方法信息、字段信息),静态变量、常量,编译器编译后的代码等。
在 Class 文件中除了类的字段、方法、接口等描述信息外,还有一个常量池,用来存储编译期间生成的字面量和符号引用。
而在方法区中则包含一个运行时常量池,在类和接口被加载到 JVM 后,对应的运行时常量池会被创建。运行期间也可以将新的常量放入运行时常量池。

元空间(Metaspace)

或许我们都听过,“jdk1.8 以后方法区被元空间取代”,但实际上这种说法是错误的。所谓方法区,实际上是JVM 的规范,而元空间则是这种规范的一种实现。
我们都知道 Java 采用 JVM 虚拟机的形式运行程序,而 JVM 实际上不止一个,其中应用最广的 JVM 就是HotSpot,他将方法区实现为永久代(Permanent Generation),而其他类型的 JVM,如 JRockit(Oracle),J9(IBM)是没有永久代的,而是用其他方式实现方法区。因此,正确的说法是,“永久代被元空间取代”

永久代和元空间最大的区别在于,永久代使用的是JVM 内存,而元空间改为使用本地内存,很大程度上避免了OOM: PermGen space的异常。以往,程序中的字符串会存在永久代中,这容易出现性能问题和内存溢出,而元空间取消字符串常量池,将其移动到了中。此外,给永久代指定大小时,太小容易导致永久代溢出,太大容易导致老年代溢出。而分离出元空间后,可以简化 GC,并对并发隔离等方面进行优化。

本地方法栈(Native Method Stack)

所谓本地方法(Native Method),即该方法的实现由非 Java 实现,也就是在 Java 程序中调用非 Java 代码的接口。
在 Java 中,native关键字可以和除abstract外所有其他的标识符连用,毕竟是在 Java 代码中调用非 Java 代码,那么一定是得到实现了的方法。总不能重写一个新的方法后要我用其他语言来实现他吧=。=

GC 回收机制

由于程序计数器、JVM 栈、方法栈随线程而生,随线程而灭,因此 GC 主要集中于 Java 堆和方法区中,这部分内存的分配和使用是动态的。
GC 需要进行回收的对象就是不存活的对象,通常通过以下两个方法判断一个对象是否存活:

  1. 引用计数:每个对象有一个引用计数属性,新增一个引用时+1,引用释放时-1,计数为 0 则进行回收。但是无法解决对象相互循环引用的问题。
  2. 可达性分析(Reachability Analysis):从GC Roots(虚拟机栈引用的对象,方法区常量、类静态属性实体引用的对象,本地方法栈中 JNI 引用的对象)向下搜索,走过的路径成为引用链。当一个对象不存在于任何一条引用链中时,说明其是不可达的,可进行回收。

只要满足一个,则认为这个对象是不存活的,可以进行回收。

要注意的是,GC 进行回收的时机并非由用户或代码掌控,而是由系统自身决定。当程序调用System.gc()时,系统会建议执行 GC 回收,但不是必然执行。
GC 就好比负责小区收垃圾的环卫工人,当垃圾多了的时候,可以建议他早点来;当没什么垃圾的时候,可以迟点来;当垃圾满了,就得尽快来;反正是不能随叫随到的。
因此,对于 GC 回收的算法,主要是对对象进行标记或分类,从而在下次 GC 回收的时候能快速判断当前对象是否要被清理

常用算法

标记-清除算法

该方法为每个对象存储一个标记位,记录对象是否存活,主要分为两个阶段。在标记阶段,会为每个对象更新标记,检查其是否存活;在清除阶段,GC 会对死亡对象进行清除。
优点:对于一个引用,只要找到其一个 存活对象,就可以判断该引用为活。且不用移动对象位置。
缺点:效率较低,标记和清除阶段要遍历所有存活对象;复杂度较高,容易产生无法利用的碎片空间。

标记-整理算法(标记-压缩算法)

标记-清除算法的改进版,也是分为两个阶段。标记阶段相同,对所有对象进行标记;在整理阶段,先将存活对象移动到另一处空间,再清除剩下的死亡对象。
优点:不会产生碎片空间
缺点:若存活对象过多,整理阶段将会执行多次操作,算法效率较低。

复制算法

将内存分成两份,每次只使用其中一份。当其满了后,将其中存活对象复制到另一部分,然后清空之前的内存,之后使用存活对象所在的内存部分,如此循环。
优点:实现简单,不产生碎片空间
缺点:总有一部分内存是空着的,内存利用率较低

进阶:分代收集算法

为了进一步优化 GC 的回收过程,提出了分代收集的策略(Generational Garbage Collection),这种算法基于堆结构的优化。

HotSpot 结构优化

在 HotSpot 虚拟机中,将堆结构分为新生代(Young Generation)老年代(Old Generation),以及作为方法区实现的永久代(Permanent Generation)

struc

新生代:新生代细分为Eden 区Survivor 区,Survivor 区又细分为S0 区S1 区(或称from 区to 区)。所有新的对象都存在新生代,当新生代满了后,会触发Minor GC,即仅针对新生代的垃圾回收。而存活的对象会不断“变老”,达到一定程度后会被移至老年代。
官方称 Minor GC 为“Stop the World”事件,即进行 Minor GC 时,所有线程都进入阻塞状态,直到 Minor GC 结束。

老年代:老年代的区域又称Tenured,用来存放一些长时间存活的对象。通常会有一个门槛被设立给在新生代新建的对象,类似于一个“age属性”,每次进行 Minor GC,这个属性都会+1,直到达到设立的门槛,然后这个对象就会从新生代移动到老年代。
当老年代满了后,会触发一次Major GC仅对老年代进行垃圾回收,因为涉及到所有存活对象,所以执行速度会比 Minor GC 慢一些。
MajorGC 也是“Stop the World”事件。

永久代:永久代存放一些描述类或方法的数据,用于 JVM 加载类,因为是方法区的实现所以和上面方法区的介绍相似。在 jdk8 后被元空间代替。
当触发Full GC时,永久代也会被清理。

各种 GC

这里我们涉及到了 Minor GC,Major GC,Full GC。为了避免概念的混淆,这里梳理一下他们的区别。
实际上,HotSpot 存在一种垃圾回收策略,G1(Garbage First)算法,他允许对整个新生代和部分老年代进行回收。因此,从大类上分我们可以将 GC 的回收分为全部回收部分回收
全部回收指的是Full GC,他对整个新生代、老年代、永久代进行回收。
部分回收(Partial GC) 则包括Young GC(仅对新生代 GC)Old GC(仅对年老代 GC)Mixed GC(对部分的 GC,如 G1 算法)
而我们所说的Minor GCMajor GC只不过是个俗称,官方文档并没有对其做出明确定义。由于 HotSpot 的发展和各种 GC 算法策略的提出,业界对其的解读并没有统一,所以Major GC在这里可能指Full GC,在那里可能就指的是Old GC
所以通常现在直接说 Minor GC 和 Major GC 会容易造成歧义,通常在比较浅显易懂的文档里使用。在常见的 GC 算法(GC 收集器)中,如 Serial GC、Parallel GC、CMS、G1 等,会明确 Young GC 和 Old GC,所以是不影响大家深入学习的。

分代收集的过程

之前提到了所有对象都在新生代分配空间,现在我们进一步细化。实际上,所有新建的对象都是先存在新生代的Eden 区中,此时from 区to 区是空的。
Eden 区满了后,触发了Minor GC。存活的对象(存在引用的对象)被移动到 from 区,而死亡的对象(没有引用的对象)就被清理回收了。以此清理出Eden 区
直到下一次 Eden 区满了,再次触发Minor GC。这次会把Eden 区from 区的死亡对象回收(to 区为空),将其中的存活对象移动到to 区,这样Eden 区from 区就再次空出来了。
下一次的 Minor GC 则会清理Eden 区to 区的对象,将存活对象移到from 区。如此循环, 对象在from 区to 区之间反复横跳,每进行一次 Minor GC,对象的“age”属性就+1,当达到一个门槛后(比如 9),就被移动到老年代(Tenured)
老年代满了后,触发Major GC,于是老年代被清理并整理,腾出新的空间。

process

垃圾收集器(Garbage Collectors)

之前介绍了垃圾回收的算法,不过并不是所有算法都会被用到。什么时候回收,对哪里进行回收,采用什么算法回收,实际上是由垃圾收集器来决定的。当然存在许多不同的收集器,这里我们认识一下。深入学习就靠自己啦~

串行收集器(Serial GC)

是 Java SE 5,SE 6 中默认的收集器。特点是用一个虚拟 CPU(一个线程),进行 Minor GC 和 Major GC。此外,他采用标记整理算法,每次清理完后把剩余空间放在堆尾部,减少碎片空间。
许多对暂停时间要求不高的、且是客户端类型的程序都选择串行收集器。此外,他还适用于一个机器多个 JVM(即 JVM 数量多于处理器)的情况。这种情况下,只用一个处理器来进行垃圾回收能减少对其他 JVM 的干扰。

并行收集器(Parallel GC)

并行收集器也称吞吐量收集器(Throughput Collector)。相对于Serial GCParallel GC使用多线程来进行垃圾回收,当然这要求机器本身是多核的。如果只有一个处理器,即使要求使用Parallel GC,机器仍会采用Serial GC。在多核的情况下,Parallel GC能有效减少垃圾回收时的停顿时间。
他适用于需要完成大量工作并且可以接受长时间的停顿的场景,如打印工作(批处理任务)或执行大量数据库查询等操作。

并发标记清除收集器(CMS Collector)

并发标记清除收集器(Concurrent Mark Sweep,CMS) 又称并发短暂停收集器(Concurrent Low Pause)。它尝试通过与应用程序线程并发执行大部分垃圾收集工作来最大程度地减少垃圾回收造成的暂停。通常并发低暂停收集器不会复制或压缩对象,即不移动活动对象完成垃圾回收,因此会造成碎片空间的问题。
CMS适用于要求低暂停时间并且可以与垃圾收集器共享资源的应用程序,比如桌面 UI 应用程序、响应请求的网络服务器或响应查询的数据库等。

G1 收集器(Garbage First GC)

G1是最新推出的收集器,在 Java7 后可用,指望他能代替CMS。他是个并行、并发、增量压缩的短暂停收集器。
首先为了解决CMS碎片空间的问题,G1采用标记整理算法来解决。同时G1实现可预测停顿,指让使用者明确指定在一个长度为 N 毫秒的时间片段内,消耗在垃圾收集上的事件不得超过 N 毫秒。
此外G1引入了Mixed GC,它将堆划分为若干大小相等的独立区域(Region),虽然保留新生代和老年代的感念,但他们可以是一段不连续的区域集合,以此实现回收时进行部分区域的回收。

四种引用

其实关于 GC 的东西已经讲的差不多了,不过回头看好像东西还挺少,于是再来讲讲四种引用的机制,毕竟这一部分在面试的时候也被问到挺多的。算是加餐吧。
从 Java SE 2 开始提供了四种引用并一直沿用至今,主要是为了让开发者通过代码一定程度上决定对象的生命周期,其次也便于 JVM 进行垃圾回收。

实际中很少使用弱引用与虚引用,更多会用软引用。因为软引用可以减少 OOM 等错误,又不会胡乱回收对象导致重新加载资源等问题。

强引用(Strong Reference)

强引用是最常见的引用,当一个对象具有强引用,那么程序会认为这个对象 “必不可少”。当内存空间不足时,JVM 宁愿终止程序,抛出 OOM 错误,也不会回收该对象。
当我们用new进行声明时,所产生的对象都属于强引用。如果想中断强引用和某个对象的关联,可以显示地将引用赋值为null
举个例子,当我们强引用太多导致内存满了后,即使会有 GC,但并不会回收我们的强引用对象,最终导致 OOM 错误。

    public static void main(String[] args) {
        Object[] objects = new Object[Integer.MAX_VALUE];
    }

运行结果是抛出了 OOM 的错误,因为长度超出了限制:

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
	at OtherTest.main(OtherTest.java:12)

下面这个图来自GeeksforGeeks 文章中的例子,当我们用Gfg g = new Gfg()创建一个对象的时候,在内存中会开辟空间给这个Gfg的对象,我们可以看成g这个变量引用了这个对象。当我们显式调用g = null的时候,虽然g为空了,但是Gfg这个对象的空间仍然存在。在没有其他变量引用Gfg的时候,我们判断Gfg这个对象是没用的,所以通过 GC 来将这处空间进行回收释放。

strongRef

软引用(Soft Reference)

软引用来描述一些有用但不是必须的对象,用java.lang.ref.SoftReference表示。在内存充足的情况下,即使一个软引用能够被回收,GC 也不会将其回收,只有当内存不足的时候 JVM 才会回收该对象,因此多用软引用来解决 OOM 问题,如网页缓存、图片缓存等资源可以和软引用关联。
我们用代码关联一个 String 对象和软引用:

    public static void main(String[] args) {
        Object object = new Object();
        SoftReference<Object> softReference = new SoftReference<>(object);	//关联软引用
        obj = null; //解除强引用关联
        System.gc();	//通知JVM进行垃圾回收
        System.out.println(softReference.get());		//尝试获取引用
		byte[] bytes = new byte[memorySize];	       	//模拟因为内存满了而触发GC
        System.gc();	//通知JVM进行垃圾回收
        System.out.println(softReference.get());	//尝试获取引用
    }

运行结果如下:

java.lang.Object@74a14482
null

上面的例子中我修改了 JVM 的内存大小为memorySize,然后进行 GC。由于内存满了后 GC 会回收软引用,因此再次获取引用对象就失败了,只能得到null
不过建议还是不用乱改内存,一不小心就改不回去了······看看就好。

弱引用(Weak Reference)

弱引用和软引用类似,来描述非必须的对象。不同的是,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收弱引用对象。而软引用的对象仅在内存不足时进行的垃圾回收中被回收。因此弱引用的生命周期往往比软引用更短。
也就是说,软引用的对象被回收有两个条件,即内存不足+GC;而弱引用的对象被回收只有一个条件,即GC。因此,并不是所有 GC 都会回收软引用对象,但是所有 GC 都会回收弱引用对象。

弱引用用java.lang.ref.WeakReference表示,

举个例子,将一个String匿名对象关联弱引用,然后调用 System.gc()进行垃圾回收。之所以用匿名对象主要是为了保证其只被引用一次,这样在 GC 时确保其被回收。

    public static void main(String[] args) {
        WeakReference<String> weakReference  =new WeakReference<>(new String("I am a String Object"));	//弱引用关联对象
        System.out.println("before gc: " + weakReference.get());	//尝试获取引用对象
        System.gc();	//通知JVM进行垃圾回收
        System.out.println("after gc: " + weakReference.get());		//尝试获取引用对象
    }

结果如下:
一开始我们可以拿到 String 的弱引用,但是进行垃圾回收之后,该弱引用象就被回收了。

before gc: I am a String Object
after gc: null

当一个对象有自己的生命周期,且我们不想介入他的生命周期,或者一个对象只有偶尔使用且能随时获取到,那么我们可以考虑使用弱引用。比如和数据库的连接(DBConnection),这可以保证即使我们没有手动释放连接,在程序关闭时也可以对其进行回收释放。

虚引用(Phantom Reference)

java.lang.ref.PhantomReference来表示虚引用。持有虚引用的对象相当于没有引用,可以随时被垃圾回收,主要用来跟踪对象被垃圾回收的过程。虚引用还必须和引用队列(Reference Queue) 一起使用,在从内存上被回收之前,JVM 会对它们调用finalize()方法,并将他们放到引用队列中。通常一个对象调用finalize()后会被释放,但是一个虚可达(Phantom Reachable) 对象仍不会释放。(虚可达对象:该对象与 GC Roots 之间仅存在虚引用)
当 JVM 对一个持有虚引用的对象进行回收时,会先销毁对象,并将虚引用加入引用队列中。在这个对象所关联的虚引用出队前,不会彻底销毁该对象。所以可以通过判断这个虚引用是否在引用队列中,程序可以确定持有这个引用的对象是否应该被回收。由此,可以得知对象的回收过程。

    public static void main(String[] args) {
        String str = new String("string obj");	//实际上是个强引用
        ReferenceQueue<String> queue = new ReferenceQueue<>();	//引用队列
        PhantomReference<String> phantomReference = new PhantomReference<>(str, queue);		//虚引用
        str = null; //解除强引用关联
        System.out.println(phantomReference.get());		//尝试获取引用对象
    }

结果如下:

null

事实上,在源码中PhantomReference类get()方法永远返回null。因为一个虚引用对象永远是不可达的。
因此,我们应该从引用队列中查看,而非尝试获取虚引用对象。

	/** -in PhantomReference.java-
     * Returns this reference object's referent.  Because the referent of a
     * phantom reference is always inaccessible, this method always returns
     * <code>null</code>.
     * @return  <code>null</code>
     */
    public T get() {
        return null;
    }

后记

这篇虽然不长,但是花了挺多时间的,你看下面的参考就知道了…
主要是国内的大部分博客都挺千篇一律的,很多过程也不是很明白
所以去看了许多英文的博客啥的,结合各个内容花了点时间
不过也算是终于填上了这个坑吧=。=

参考

  1. jvm 内存空间
  2. Java JVM 中 堆,栈,方法区 详解
  3. Java 的 native 方法
  4. Java8 内存模型—永久代(PermGen)和元空间(Metaspace)
  5. 通过图文给你讲明白 java GC 的垃圾回收机制
  6. Java GC 机制详解
  7. Oracle 官方文档
  8. Plumbr:minor gc/major gc/full gc
  9. 知乎:Major GC 和 Full GC 的区别是什么?
  10. Java:强引用,软引用,弱引用和虚引用
  11. 你不可不知的 Java 引用类型之——虚引用
  12. GeeksforGeeks:Types of References in Java
  13. Do You Really Know the 4 Reference Types in Java?