JVM的垃圾回收机制

垃圾回收机制 GC

  所谓的垃圾就是不再使用的内存, 垃圾回收就是把不用的内存帮我们用户自动释放了.
  如果垃圾不能被释放, 就意味着你内存被占着但是没有被利用, 就会导致剩余空间越来越小, 进一步导致后续的内存申请操作失败!!
  GC就是垃圾回收机制中最主流的一种方式, Java/Go/Python/PHP/JS 等大部分主流语言都是使用 GC 来解决垃圾回收问题. GC 的好处就是让程序员省心一些, 写代码简单一些, 不容易出错. 当然, 也有坏处, 就是需要消耗额外的系统资源, 有额外的性能开销.
  另外 GC 还有一个比较关键的问题, STW(stop the world) 问题:如果有的时候, 内存中的垃圾已经很多了, 此时触发一次 GC 操作的开销非常大, 大到可能就把系统资源吃了很多;另一方面, GC 回收垃圾的时候可能会涉及到一些锁操作, 导致业务代码无法正常执行.而这样的卡顿, 在极端情况下, 可能是几十毫秒甚至几百毫秒的卡顿. 就好像打游戏正打得很嗨的时候, 突然母亲拿了水果过来非要我吃上一些, 我就迫不得已, 只能放下鼠标先吃水果了.
  当然, 随着技术的进步, 新版java(java 13开始), 引入zgc这个垃圾回收器, 这个已经设计的非常精细了, 可以是STW控制在1ms一下.

GC 释放的垃圾所在的空间

  JVM中有很多内存区域:堆, 栈, 程序计数器, 元数据区等. GC 主要针对堆进行释放. GC 回收的基本单位是"对象", 不是字节.
可以将内存大概划分为三个部分:正在使用的, 待回收的, 未被分配的. 内存中一些对象是正在使用, 一些对象一部分使用一部分不使用, 还有一些对象完全不使用, 如下图:

GC 对象回收的是整个对象都不使用的情况, 而一部分使用, 一部分不使用的对象, 暂且先不回收. 这也能体现出回收的基本单位为"对象", 不会回收"对象的某一部分".

GC 的实际工作过程

  在大的方向上可以分为两步:

  1. 找到垃圾/判定垃圾: 哪个对象是垃圾? 哪个不是? 按个对象以后一定不用了? 哪个对象后面还可能使用?
  2. 再进行对象的释放

1. 找到垃圾/判定垃圾

  方法其实有很多种, 关键之处在于抓住这个对象, 看看到底有没有"引用"指向它, 因为在java中, 只有通过引用才能使用对象. 如果一个对象, 有引用指向它, 那么它就有可能被使用到; 如果一个对象, 没有引用指向它, 那么它就不会再被使用了.

具体如何知道对象是否有引用指向呢?

两种典型实现:
1) 引用计数[不是 java 的做法. 是python/PHP 的做法]:
  给每个对象都分配了一个计数器(一个整数), 每次创建一个引用指向这个对象, 计数器就 +1, 每次改引用被销毁了, 计数器就 -1. 举个例子:

{
Test t = new Test();     // Test 对象的引用计数为 1
Test t2 = t;                  // t2 也指向了 t1, 引用计数变为 2
Test t3 = t;                  // 引用计数变为 3
}
// 大括号结束, 上述的三个引用超出作用域, 于是失效, 此时引用计数就是 0 了, 即 此时 new Test() 对象就是垃圾了.

这个方法简单有效, 但是 java 却没有使用, 因为这个方法还是有一些缺点的:

  1. 内存空间浪费的多(利用率低): 每个对象都要分配一个计数器, 按4个字节算的话, 如果代码中的对象非常少, 那无所谓, 但是如果对象特别多, 那么占用的额外空间就会很多, 尤其是每个对象都比较小的情况下. 假如一个对象为1k, 多4个字节无所谓. 但是如果说一个对象仅4字节, 此时多了4字节相当于体积扩大了1倍!!
  2. 存在循环引用的问题:
class Test {
	Test t = null;
}
public class Main {
	public static void main(String[] args) {
		Test a = new Test(); // 一号对象的引用计数为 1
		Test b = new Test(); // 二号对象的引用计数为 1
		a.t = b; // a.t 也指向了 二号对象, 二号对象的引用计数变为 2
		b.t = a; // b.t 也指向了 一号对象, 一号对象的引用计数变为 2
	}
}

上述代码中 a 和 b 就形成了循环引用. 如果 a 引用和 b 引用都被销毁, 此时一号对象和二号对象的引用计数都 -1, 但是结果都不是 0, 在这种情况下就不能释放内存, 但是实际上这两个对象都已经没有办法被访问到了!!!
其他使用这个机制的语言, 需要搭配其他的机制, 来避免循环引用.
2) 可达性分析[ java 的做法]
   作为 Java 程序猿, 大家都知道 Java 中的对象, 都是通过引用来指向并访问的, 经常是一个引用指向一个对象, 这个对象里的成员, 又指向别的对象. 比如说二叉树, 链表等. 整个 Java 中的对象, 就通过类似于上述的关系, 通过这种链式/树形结构, 整体给串起来.
  而所谓的可达性分析, 就是把所有这些对象被组织的结构视为是数. 就从树的根节点出发, 遍历树, 所有能被访问到的对象, 就标记为"可达", 换句话说, 不能被访问到的, 就是不可达. 举个例子:

class Node {
    public int val;
    public Node left;
    public Node right;
}

public class Test {
    public static Node build() {
        Node a = new Node();
        Node b = new Node();
        Node c = new Node();
        Node d = new Node();
        Node e = new Node();
        Node f = new Node();
        Node g = new Node();
        a.val = 1;
        b.val = 2;
        c.val = 3;
        d.val = 4;
        e.val = 5;
        f.val = 6;
        g.val = 7;

        a.left = b;
        a.right = c;
        b.left = d;
        b.right = e;
        e.left = g;
        c.right = f;
        return a;
    }

    public static void main(String[] args) {
        Node root = build();
        // 此时这个 root 就相当于树的根节点
        // 当前代码中只有一个引用 root, 但是它管理了 N 个对象
    }
}

在这里插入图片描述
此时如果使 root.left.left = null; 就会导致 d 不可达, d 就是垃圾了.
在这里插入图片描述

  JVM自己掌握着一个所有对象的名单. 通过上述遍历, 把可达的标记出来, 剩下的不可达的就可以作为来及进行回收了.
  由此可见 , 可达性分析需要进行类似于"树遍历"的操作, 这个操作相对于引用计数来说肯定时要慢一些的, 但是这个可达性分析并不需要一直执行, 只需要每隔一段时间分析一次就行.
  进行可达性分析遍历的起点, 称为 GCroots , 主要有这么几种对象可以被称为 GCroots:
1. 栈上的局部变量
2. 常量池中的对象
3. 静态成员变量
一个代码中可能有很多个 这样的起点, 把每个起点都往下遍历一遍, 就完成了一次扫描过程.

2.清理垃圾

主要是三种基本做法:
1. 标记清除
在这里插入图片描述
这个方法简单粗暴, 但是存在内存碎片问题. 这些被释放的空闲空间是零散的, 不是连续的. 但是申请内存要求的是连续的空间, 可能会有这种情况: 总的空闲空间很大, 但是每一个具体的空间都很小, 就可能导致申请一块大一点的内存的时候就失败了!! 比如说总的空闲空间为 20k, 但是每一小块的空间为 2k , 分为 10 个. 此时如果申请一个 3k 的空间就会失败!! 为了解决这个问题, 引入了复制算法.
2. 复制算法
解决了内存碎片问题.
在这里插入图片描述
这样的做法也有很明显的缺点:

  1. 空间利用率低
  2. 如果要是垃圾少, 有效对象多, 那么复制成本就比较大了

3. 标记整理
标记整理可以解决复制算法的一个缺点, 保证了空间利用率, 同时也解决了内存碎片问题. 当然, 很明显, 这种做法的缺点是效率也不高, 如果要搬运的空间比较大的话, 开销也会很大.
在这里插入图片描述
基于上述这些基本策略, 搞了一个复合策略 “分代回收”
把垃圾回收, 分成不同的场景, 有的场景用这个策略, 有的场景用那个策略, 各展所长, 扬长避短的发挥各自的优点.
那么这个分代是怎么分的呢?
基于一个经验规律: 如果一个东西, 存在的时间比较长了, 那么大概率这个东西还会长时间的持续存在下去
  在 Java 中就表示为, java 的对象要么就是生命周期特别短, 要么就是特别长. 根据生命周期的长短, 分别使用不同的算法.
  给对象引入一个概念----年龄, 注意, 这个"年龄"的单位不是年, 而是熬过GC 的轮次. 年龄越大, 代表这个对象存在的时间就越久.
  JVM把堆划分成一系列区域:
在这里插入图片描述
  刚被 new 出来的对象, 放在伊甸区, 熬过一轮 GC, 对象就要被放到幸存区. 虽然看起来幸存区比伊甸区小了很多, 但是根据上述经验规律, 大部分的java 对象都是"朝生夕死", 生命周期非常短, 所以说一般够放.
伊甸区 ==> 幸存区, 采用复制算法
  到了幸存区之后, 也要周期性的接受 GC 的"考验", 如果变成垃圾, 就要被释放, 如果不是垃圾, 拷贝到另外一个幸存区(这两个幸存区同一时间只用一个, 在两者之间来回拷贝, 就是复制算法), 由于幸存区体积不大, 空间浪费也能接受.
  如果这个对象已经在两个幸存区被来回拷贝很多次, 这个时候就要进入老年代. 老年代都是年纪大的对象, 生命周期普遍更长, 针对老年代, 也要进行周期性 GC 扫描, 但是频率更低了. 如果老年代的对象是垃圾了, 使用标记整理的方式进行释放.

  • 7
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不想菜的鸟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值