JVM 的垃圾回收机制

Java 引入了垃圾回收机制(GC),更好的应对内存泄漏问题。JVM 专门指派一些线程,这些线程周期性地扫描你代码中已经申请的内存(new出来的对象),自动判定当前这个内存是否已经不再使用了。如果不再使用,释放掉对象/内存。

一、Java的GC要负责回收的内存

程序计数器是很小的区域,只保存一个数字,GC不需要负责回收。栈上的局部变量会随着栈帧自动释放,GC不需要负责。元数据区的类对象通常只需要加载,不需要卸载。因此,是GC的主战场。堆上存放的是对象,GC是以对象为单位进行内存回收的,不是以字节为单位。一个对象,要么整个释放,要么不释放,不会出现 "释放一半"。

二、垃圾回收的流程

1. 找出谁是垃圾(不再使用的对象)

Java 使用对象只有一种方式,通过 "引用" 的方式。把对象想象成 "房间",引用是打开房间的 "钥匙"。如果某个对象,没有引用指向,此时就可以认为这个对象不再使用了。判定对象是否是垃圾,从引用入手。

  • 如何判定对象有没有引用指向呢?

第一种方案:引用计数

引用计数不是Java的方案,而是Python/PHP的方案。面试题:谈一谈Java的垃圾回收机制(可以不谈引用计数);谈一谈垃圾回收机制(就可以谈引用计数)。引用计数给每个对象身上再安排一个空间,这个空间存储一个整数,表示指向这个对象 引用的个数。围绕对象进行 "引用复制" 就会更新这个计数。如果计数是0了,此时就可以释放了。

引用计数方案,存在两个缺点:

(1) 消耗更多的内存空间。如果计数器使用2个字节的,万一整个对象就只有4个字节,且这样的对象数量非常多,此时引用计数就占据了50%;如果对象比较大,引用计数所占用的内存就可以忽略不计。

(2) 产生循环引用,会导致出现误判

Python/PHP 采用引用计数这样的方案,同时也引用了一些环路检测的机制,识别出上述的循环引用。

第二种方案:可达性分析

可达性分析,就是从树根节点出发(根节点可能有多个),尝试遍历这个对象树,遍历过程中凡是经过的对象,都标记成 "可达"。另一方面,JVM自身知道自己一共都有哪些对象,除去可达的,剩下的就是 "不可达"(当成垃圾回收了)。随着代码的运行,对象之间的引用关系是实时变化的,上述可达性分析需要周期性进行。

  • GC Roots 通常包括以下几种元素(都在堆外):

(1)虚拟机栈(栈帧中的本地变量表)中的引用:

  • 通俗讲,就是正在被执行的各个方法中的局部变量(包括参数)。

  • 为什么是 Root? 这些方法正在运行,它们引用的对象显然是程序当前需要的、活跃的对象。如果这些对象都被回收了,程序肯定会出错。

(2)本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象:

  • 为什么是 Root? 和虚拟机栈类似,这些 Native 方法正在执行,它们引用的 Java 对象必须存活。

(3)方法区中类静态属性(static fields)引用的对象:

  • 例如:public static MyClass singleton;

  • 为什么是 Root? 静态变量属于类,而类本身是由类加载器加载的。只要类加载器存活,它的静态变量引用的对象就应该是存活的,因为它们可以被任何地方访问到。

(4)方法区中常量(final static constants)引用的对象:

  • 例如:public static final String NAME = "Java";

  • 为什么是 Root? 常量和静态属性类似,是全局可访问的,其生命周期与类相同。

  • 举例Integer值 JVM把 -128~127 这个范围的数字提前创建好 Integer 对象. 提前创建并缓存,是为了避免频繁的 new Integer() 操作;缓存对象长期存在,减少GC压力。

java 复制代码
public class Integer {
    private static class IntegerCache {
        static final Integer cache[];  // 这个静态常量数组在方法区
    }
    
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }
}

(5)常量池引用指向的对象

  • 字符串常量池本身就是一个GC Root ,它不是通过"方法区中的常量引用"来保持对象存活的,字符串常量池直接持有对String对象的引用
java 复制代码
public void example() {
    String s = "hello";  // "hello"被字符串常量池引用,是GC Root
    // 即使s是局部变量,当方法结束时,"hello"依然不会被GC
    // 因为它被字符串常量池这个系统级的GC Root引用着
}

一轮 GC 就要把所有的 GCRoots 尽可能遍历到,标记为可达。


不可达对象:

java 复制代码
public void createGarbage() {
    Object temp = new Object();  // 创建对象
    // 方法结束,temp引用消失,new Object()变得不可达,成为垃圾
}

可达性分析的优点:没有引入额外的内存空间,没有循环引用问题。缺点:需要消耗更多的CPU资源,更多的时间(用时间换空间),这是C++不愿意引入GC的原因,C++追求极致的性能。

总结:如何识别对象是垃圾?

方法一 通过引用识别。方法二 通过可达性分析的方式,从所有的GCRoots出发,尽可能遍历,访问到的对象都标记为可达,剩下的就是不可达。

2. 释放垃圾对象对应的内存

(1)标记---清除

把标记出来的垃圾,直接释放掉

这些被释放的内存的地址是 "离散"的,不连续的,申请内存都是申请连续 的内存空间,可能会导致总的空闲空间充足,但是尝试申请一个大块内存,申请失败!进程中,哪些对象为垃圾是随机的,这种方案很可能会在频繁释放中产生大量的内存碎片

(2)复制算法

可以有效解决内存碎片问题

缺点:1. 空间利用率很低(最多只能用到50%)2. 如果当前存活的对象很多,复制开销就很大

(3)标记---整理

对于空间利用率,得到了改善。

类似于顺序表删除中间元素 => 搬运(开销同样可能很大)

(4)分代回收

Java 的实际情况是综合了上面的策略,构成了一个更复杂的方案。分代回收:根据不同对象的情况/特点,采取不同的方案。GC周期性地进行扫描,根据扫描的轮次 决定 对象的年龄。如果某个对象经过很多轮GC,都没有被释放,说明年龄比较大了。

经验规律:如果某个对象年龄比较大,有很大概率继续存活下去,因为要G早G了。C语言从诞生到现在已经50多年了,同辈的语言有很多,目前存活下来的除了C语言只有SQL,能活下来说明确实很有东西 => 很大概率继续存活50年

整个堆,分成两个大的部分:

  1. 新 new 的对象,放到伊甸区中。
  2. 伊甸区对象经过第一轮GC的时候,把绝大部分淘汰掉(经验规律:大部分新对象的生命周期非常短 "朝生夕死"),没有淘汰的对象 通过复制算法进入到幸存区,幸存区有两个部分,每次只用其中的一部分。
  3. 下一轮GC不仅扫描伊甸区,也会针对幸存区的对象扫描,即使是幸存区的对象还会再次淘汰掉一大批。没有淘汰的对象,通过复制算法进入到另一个幸存区。由于经验规律发现,每轮GC淘汰掉大部分的新对象,因此触发复制的对象就很少,解决了复制算法拷贝开销大的问题;另一方面,幸存区只是占据新生代的10%,浪费的内存空间比较小了。
  4. 随着每一轮GC的进行,对象在新生代的幸存区来回拷贝,每经过一次拷贝,对象的年龄就+1。
  5. 经过一定时间之后,对象的年龄达到一定的阈值,此时就会把这个对象拷贝到老年代。
  6. 对象进入老年代之后,针对老年代的GC频率比新生代低很多了,减少了扫描的开销。老年代发现对象是垃圾,采取标记整理的方式处理,虽然整理一次比较消耗资源,但是频率比较低。
  7. 如果某个对象的内存非常大,可能直接进入老年代,避免在幸存区来回拷贝。

分代回收的过程,和找工作的过程是完全一样。投递简历,进入了伊甸区,此时淘汰掉绝大部分人 => 简历筛选通过,进入笔试/面试环节,公司的面试通常有很多轮,不同的公司不一样 2到5轮都有 => 经过了所有面试后,拿到offer进入公司了 => 进入公司,还有一个绩效考核,采取末位淘汰制,这个就没那么频繁了,可能一年一次

面试题:请解释 Minor GC 和 Full GC 之间的区别

  1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。[ 开销比较大,频率低 ]
  2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。[ 开销比较小,频率高 ]

上面的分代回收过程,严格地说不是真实情况,而是一个简化版,是一种思想方法。垃圾回收器实现的时候,需要考虑到效率问题,对业务代码是否有影响。

垃圾回收器和垃圾回收算法之间的关系:垃圾回收算法是垃圾回收的方法论,垃圾收集器是算法的落地实现。比如:新生代的收集器:serial,ParNew,Parallel Scaveng,都使用的是复制算法;老年代的收集器:SerialOld,Parallel Old,使用的是标记整理算法,CMS是基于标记清除算法实现的。

相关推荐
东方佑8 小时前
构建智能对话系统:Python实现聊天话题管理与摘要生成
jvm·python·oracle
典则9 小时前
STM32FreeRtos入门(五)——同步互斥与通信
java·jvm·stm32
稚辉君.MCA_P8_Java10 小时前
Bash 括号:()、{}、[]、$()、$(() )、${}、[[]] 到底有什么区别?
开发语言·jvm·后端·容器·bash
软件20512 小时前
【JDK、JRE、JVM】
java·开发语言·jvm
学习编程的Kitty13 小时前
JavaEE初阶——多线程(3)线程安全
java·开发语言·jvm
华仔啊1 天前
JVM参数到底配在哪?7大场景全解,新手不再迷茫!
java·jvm
流星5211222 天前
GC 如何判断对象该回收?从可达性分析到回收时机的关键逻辑
java·jvm·笔记·学习·算法
JanelSirry2 天前
我的应用 Full GC 频繁,怎么优化?
jvm
JH30732 天前
jvm,tomcat,spring的bean容器,三者的关系
jvm·spring·tomcat