垃圾回收机制 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. 找到垃圾/判定垃圾
方法其实有很多种, 关键之处在于抓住这个对象, 看看到底有没有”引用”指向它, 因为在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 却没有使用, 因为这个方法还是有一些缺点的:
- 内存空间浪费的多(利用率低): 每个对象都要分配一个计数器, 按4个字节算的话, 如果代码中的对象非常少, 那无所谓, 但是如果对象特别多, 那么占用的额外空间就会很多, 尤其是每个对象都比较小的情况下. 假如一个对象为1k, 多4个字节无所谓. 但是如果说一个对象仅4字节, 此时多了4字节相当于体积扩大了1倍!!
- 存在循环引用的问题:
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. 复制算法
解决了内存碎片问题.
这样的做法也有很明显的缺点:
- 空间利用率低
- 如果要是垃圾少, 有效对象多, 那么复制成本就比较大了
3. 标记整理
标记整理可以解决复制算法的一个缺点, 保证了空间利用率, 同时也解决了内存碎片问题. 当然, 很明显, 这种做法的缺点是效率也不高, 如果要搬运的空间比较大的话, 开销也会很大.
基于上述这些基本策略, 搞了一个复合策略
“分代回收”
把垃圾回收, 分成不同的场景, 有的场景用这个策略, 有的场景用那个策略, 各展所长, 扬长避短的发挥各自的优点.
那么这个分代是怎么分的呢?
基于一个经验规律: 如果一个东西, 存在的时间比较长了, 那么大概率这个东西还会长时间的持续存在下去
在 Java 中就表示为, java 的对象要么就是生命周期特别短, 要么就是特别长. 根据生命周期的长短, 分别使用不同的算法.
给对象引入一个概念—-年龄, 注意, 这个”年龄”的单位不是年, 而是熬过GC 的轮次. 年龄越大, 代表这个对象存在的时间就越久.
JVM把堆划分成一系列区域:
刚被 new 出来的对象, 放在伊甸区, 熬过一轮 GC, 对象就要被放到幸存区. 虽然看起来幸存区比伊甸区小了很多, 但是根据上述经验规律, 大部分的java 对象都是”朝生夕死”, 生命周期非常短, 所以说一般够放.
伊甸区 ==> 幸存区, 采用复制算法
到了幸存区之后, 也要周期性的接受 GC 的”考验”, 如果变成垃圾, 就要被释放, 如果不是垃圾, 拷贝到另外一个幸存区(这两个幸存区同一时间只用一个, 在两者之间来回拷贝, 就是复制算法), 由于幸存区体积不大, 空间浪费也能接受.
如果这个对象已经在两个幸存区被来回拷贝很多次, 这个时候就要进入老年代. 老年代都是年纪大的对象, 生命周期普遍更长, 针对老年代, 也要进行周期性 GC 扫描, 但是频率更低了. 如果老年代的对象是垃圾了, 使用标记整理的方式进行释放.