面试官:你了解GC吗?一文读懂分代回收机制

05月06日 收藏 0 评论 1 java开发

面试官:你了解GC吗?一文读懂分代回收机制

转载声明:文章来源https://blog.csdn.net/FMC_WBL/article/details/107813526

本文思维导图:

一、GC概述

gc全拼Garbage Collection,顾名思义垃圾回收的意思,它的主要作用就是回收程序中不再使用的内存。

那我们首先需要知道是否可以主动通知jvm进行垃圾回收?在Java中不能实时调用垃圾回收器对某个对象或者所有对象进行垃圾回收,但是可以通过System.gc()方法来通知垃圾回收器运行,当然,jvm也并不保证垃圾回收器马上就会运行。由于System.gc()方法的执行会停止所有的响应,去检查内存是否有可回收的对象,对程序的正常运行和性能造成了威胁,所以该方法不能频繁使用而且使强烈不推荐使用。正所谓用人不疑疑人不用,那么了解GC回收机制至关重要。下面会提出几个问题作为本文的开篇。

Java 与 C++等语言最大的技术区别?
自动化的垃圾回收机制(GC)

为什么要了解 GC 和内存分配策略?
1、面试需要
2、GC 对应用的性能是有影响的
3、写代码有好处

JVM中哪些地方会被回收?
栈:栈中的生命周期是跟随线程,所以一般不需要关注
堆:堆中的对象是垃圾回收的重点
方法区/元空间:这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点

问题来了虽然Java不需要开发人员手动管理内存回收,但是你有没有想过,这些内存使如何被回收的?什么是需要被回收的?回收的算法它们各有什么优势?下面我将重点讲解这几个问题

二、分代回收理论

垃圾回收主要发生在堆区,我们先留下垃圾回收的整体框架,在对每一个点进行剖析。

当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的: 1、 绝大部分的对象都是朝生夕死。 2、 熬过多次垃圾回收的对象就越难回收。 根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代(eden,from,to)和老年代(tenured)。

三、GC种类

市面上发生垃圾回收的叫法很多,我大体整理了一下:

1、 新生代回收(MinorGC / YoungGC):指只是进行新生代的回收。

2、 老年代回收(MajorGC / OldGC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。 (MajorGC 定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)

3、 整堆回收(FullGC):收集整个 Java 堆和方法区(注意包含方法区)

四、什么是垃圾

在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去”(死去 代表着不可能再被任何途径使用得对象了) 什么是垃圾?
C 语言申请内存:mallocfree C++: newdelete C/C++ 手动回收内存 Java:new Java 是自动内存回收,编程上简单,系统不容易出错。 手动释放内存,容易出两种类型的问题: 1、忘记回收 2、多次回收 没有任何引用指向的一个对象或者多个对象(循环引用)

五、引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1,当引用失效时,计数器减 1.Python 在用,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率。

/**
* VM Args: -XX: +PrintGC
* 判断对象存活
*/
public class Isalive {
public object instance =null;
//占据内存,便于判断分析GC
private byte[] bigSize = new byte [10*1024*1024];
public static void main(String[] args) {
Isalive objectA = new Isalive();
Isalive objectB = new Isalive();
//相互引用
objectA.instance = objectB;
objectB.instance = objectA;
//切断可达
objectA =null;
objectB =null;
//强制垃圾回收
System.gc();
}
}

在代码中看到,只保留相互引用的对象还是被回收掉了,说明 JVM 中采用的不是引用计数法。

六、根可达性分析算法

来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GCRoots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(ReferenceChain),当一个对象到 GCRoots 没有任何引用链相连时,则证明此对象是不可用的。

作为 GCRoots 的对象包括下面几种(重点是前面 4 种):

1.虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。

2.方法区中类静态属性引用的对象;java 类的引用类型静态变量。

3.方法区中常量引用的对象;比如:字符串常量池里的引用。

4.本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。

5.JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。(非重点)

6.所有被同步锁(synchronized 关键)持有的对象。(非重点)  JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等(非重点)

7.JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念)(非重点)

以上的回收都是对象,类的回收条件: 注意 Class 要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
2、加载该类的 ClassLoader 已经被回收。
3、该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4、参数控制:废弃的常量和静态变量的回收其实就和 Class 回收的条件差不多。

七、Finalize 方法

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是 没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize),我们可以在 finalize 中去拯救。
代码演示:

/**
* @author Mac
* @date 2020/8/15 - 11:36
*/
public class FinalizeGC {
public static FinalizeGC instance = null;

public void isAlive() {
System.out.println("I am still alivel");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
FinalizeGC.instance = this;
}

public static void main(String[] args) throws Throwable {
instance = new FinalizeGC();
// 对象进行第1次GC
instance = null;
System.gc();
Thread.sleep(1000); // Finolizer 方法优先级很低。需要等待
if (instance != null) {
instance.isAlive();
} else {
System.out.println("I am dead! ");
}
// 对象进行第2 XGC
instance = null;
System.gc();
Thread.sleep(1000);
if (instance != null) {
instance.isAlive();
} else {
System.out.println("I am dead! ");
}
}
}

运行结果: 

finalize method executed
I am still alivel
I am dead!  

可以看到,对象可以被拯救一次(finalize 执行第一次,但是不会执行第二次) 代码改一下,再来一次。
代码演示:

/**
* @author YXH
* @date 2020/8/15 - 11:36
*/
public class FinalizeGC {
public static FinalizeGC instance = null;
public void isAlive() {
System.out.println("I am still alivel");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
FinalizeGC.instance = this;
}
public static void main(String[] args) throws Throwable {
instance = new FinalizeGC();
// 对象进行第1次GC
instance = null;
System.gc();
// Thread.sleep(1000); // Finolizer 方法优先级很低。需要等待
if (instance != null) {
instance.isAlive();
} else {
System.out.println("I am dead! ");
}
// 对象进行第2 XGC
instance = null;
System.gc();
// Thread.sleep(1000);
if (instance != null) {
instance.isAlive();
} else {
System.out.println("I am dead! ");
}
}
}

运行结果:

finalize method executed
I am dead!
I am dead!

这次对象没有被拯救,这个就是 finalize 方法执行缓慢,还没有完成拯救,垃圾回收器就已经回收掉了。 所以建议大家尽量不要使用 finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了 finalize 方法!因为在 finalize 方法能做的工作,java 中有更好的,比如 try-finally 或者其他方式可以做得更好

八、四种引用

1.强应用

一般的 Objectobj=newObject() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。

2.软引用

一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的 空间,才会抛出内存溢出)。参见代码: VM 参数 -Xms10m -Xmx10m-XX:+PrintGC.

例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费 内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都 要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。

3.弱引用

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。

4.虚引用

幽灵引用,最弱(随时会被回收掉) 垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。

代码演示:

import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.util.LinkedList;
import java.util.List;

/**
* @author Mac
* @date 2020/8/15 - 11:36
*/
public class GC {
public static void main(String[] args) {
User u = new User(1, "Mac"); //new是强引用
SoftReference<User> userSoft = new SoftReference(u);
u = null; // 干掉强引用,确保这个实例只有userSoft的软引用
System.out.println(userSoft.get());
System.gc(); // 进行一次GC垃圾回收
System.out.println("After gc");
System.out.println(userSoft.get());
// 往堆中填充数据,导致OOM
List<byte[]> list = new LinkedList();
try {
for (int i = 0; i < 100; i++) {
System.out.println("**********+userSoft.get()");
list.add(new byte[1024 * 1024 * 1]); //1M的对象
}
} catch (Throwable e) {
// 抛出了00M异常时打印软引用对象
System.out.println("Except*********" + userSoft.get());
}

WeakReference<User> userWeak = new WeakReference(u);
u = null;//干掉强引用,确保这个实例只有userWeak的弱引用
System.out.println(userWeak.get());
System.gc();//进行一次GC垃圾回收
System.out.println("After gc");
System.out.println(userWeak.get());
}
}

九、垃圾回收算法

复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使 用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可, 实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。

但要注意:内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。 复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。

Appel 式回收

一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1 和 Survivor2) 专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较 小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1]。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才用过的 Survivor 空间。 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要 依赖其他内存(这里指老年代)进行分配担保(HandlePromotion)

标记-清除算法(Mark-Sweep)

算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。 回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。 它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连 续内存而不得不提前触发另一次垃圾回收动作。 回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。

标记-整理算法(Mark-Compact)

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端 边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。 我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用 对象的地方都需要更新(直接指针需要调整)。 所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。



C 1条回复 评论
山田心的平方

我想问一下前端面试的时候可以自选语言嘛?平常刷题都是用的java语言…

发表于 2022-11-09 23:00:00
0 0