一.垃圾回收的基本概念
1.什么是垃圾回收机制.
- JVM(Java虚拟机)垃圾回收机制是Java内存管理的重要组成部分,它负责自动回收程序中不再使用的对象所占用的内存空间。这样可以有效地防止内存泄漏和内存溢出问题,提高程序的稳定性和性能。简单来说就是不需要靠手动来进行内存的释放,而是依靠程序的自动发判定,某个内存是否会继续使用~~,如果内存后续不用了,就会自动释放掉了
2.垃圾回收机制的STW问题.
- 垃圾回收的机制的有点很明显, 因此大部分的变成语言都引入了垃圾回收机制, 但是有优点必然会付出一定的代价,STW(stop the world ) 问题触发垃圾回收的时候,很可能会使当前程序的其他业务逻辑被暂停
3.垃圾回收机制的主要工作场所.
- 堆是GC的主要战场, 其实这里说的垃圾回收, ==说的就是回收内存, 更准确的说是"回收对象".==每次垃圾回收的时候, 释放的若干个对象.(实际的单位都是对象)
二.识别出垃圾
- 识别出垃圾:就是判定这个对象后续是否要继续使用.(当然也有一个例外 ,匿名对象: new MyThread().start();但是这行代码执行完,对应的MyThread对象就会被当作垃圾~~
void func(){
Test t = new Test();
t.start();
-
执行到完这个代码快的时候, 此时局部变量t就被释放了,此时再进一步讲, 上述的new Test()对象,也就没有引用指向它了. 此时,这个代码就无法访问使用这个对象,这个对象就是垃圾了.
-
上述的情况是一个比较简单的垃圾回收的实例,但是如果有多个引用只想同一个对象,此时就不知道何时回收了.
解决方法一:引入计数.
- 给每个对象安排一个额外的空间, 空间之中保存当前这个对象有几个引用. 这种思想方法,并没有在JVM中使用.但是广泛应用于其他主流语言的垃圾回收之中(Python,PHP)
- 此时的垃圾回收机制会有专门的扫描线程, 去获得到当前每个对象的引用技术的情况, 当发现对象的引用计数为0的时候,说明这个对象就可以释放了.
引入计数存在的问题
问题一:消耗额外的空间存储.
- 引入计数要给每个对象都安排一个计数器. (假设每个计数器按照两个字节计算),如果程序中对象数目很多,总的消耗空间也会非常多. 再举一个极端的例子—>如果每个对象体积比较小(假设每个对象4个字节),计数器消耗的空间占对象空间的一半.相当于你买了个100平米的房子,但是公摊面积就已经达到了50平米,这样计算非常的不划算.
问题二:循环引用问题
class Test{
Test t;
}
Test a = new Test();
Test b = new Test();
a.t = b;
b.t = a;
a = null;
b = null;
此时代码就会出现问题, 这时的两个对象,引用计数都不是0~~不能被当作垃圾进行回收,但是这两个对象有无法使用,这里的情况非常类似于死锁.
解决方法二: 可达性分析(JVM就是用的这个)
- 可达性分析的本质就是用时间换空间, 相比较于引用计数, 需要消耗更多额外的时间,但是总体来说,还是可以控制的,不会产生类似于循环引用的问题. 在写代码的时候会定义很多变量(比如栈上的局部变量/方法区的静态类型变量/常量池中引用的对象…) 就是可以从这些变量为起点出发,尝试进行遍历. 所谓的遍历就是会沿着这些变量中持有的引用类型的成员, 再进一步往下进行访问…所有能被访问的对象,自然就不是垃圾了,剩下的遍历一圈也访问不到的对象自然就是垃圾.
class Node{
char val;
Node left;
Node right;
}
Node BuildTree(){
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.left = b;
a.right = c;
b.left = d;
b.right = e;
e.left = g;
c.right = f;
return a;
}
Node root = buildTree();
-
此时就会生成一个如下的图:
-
JVM中存在扫描线程,会不停的尝试对代码中已有的变量进行遍历, 尽可能多的去访问对象. JVM自身知道一共有哪些对象,通过可达性分析的遍历, 把可达的对象都标记出来, 身下的就是不可达的(也就是GC回收的对象了)
举个例子
- root.right.right = null : 此时的 f 就会被断开链接. 此时从root出发进行遍历, f 都是不可达的,此时就说明 f 的引用为零(为GC回收的对象)
- root.right = null : 此时c节点就不可达了, 由于访问 f 必须经过 c ,因此 c 不可达,就会导致 c 和 f 都不可达了.此时 f 和 c 就都是垃圾了.
三.把标记为垃圾的对象的内存空间进行释放
释放方式一:标记–清除
什么是标记清除
- 标记清除 本质上就是将标记为垃圾的对象进行清除(内存空间直接释放)
- 此时就会产生碎片化的问题.
什么是碎片化问题
- 碎片化问题就是本来变量存储在一块连续的内存空间之中,但是由于有些对象为垃圾,被直接释放了,此时就会造成, 内存空间的不连续性.
- 危害由于内存空间的不连续,此时在向这一块内存空间中申请内存的时候,就会造成申请不成功的情况 . 造成这个情况的本质就是由于内存空间的不连续性, 造成即使这一块空间有很多空闲的空间,但是由于不连续,在申请空间的时候,就会造成申请不成功.
释放方式二:复制算法
什么是复制算法
- 复制算法的核心就是不直接释放内存, 而是把不是垃圾的对象,复制到内存的另一半之中. 然后把左侧的空间释放掉. 能够规避内存碎片化的问题.
复制算法的弊端
-
- 总的内存空间变小的—>相当于你买了两个煎饼果子,买了一个, 扔了一个.
-
- 如果每次要复制的对象比较多, 此时的复制开销也就大了. (就意味着这一轮的GC大部分的对象不需要回收.).
释放方式三:标记-整理
什么是标记整理.
- 标记整理;类似于顺序表删除中间元素的过程. 能够解决碎片化问题, 也不需要浪费过多的空间,但是搬运的成本非常大,会浪费大量的时间.
分代回收.
- 由于上述的释放方式都存在一定的弊端, 因此JVM中没有直接使用上述的释放方案, 而是综合上述思想, 搞出了一个"综合性方案" -->分代回收.
什么是分代回收?
- 分代回收 : 这里的分代可以理解为分年代(也可以理解为对象的年龄), JVM中有专门的线程负责周期性的扫描/释放. 一个对象如果被线程扫描了一次,并且是可达的,年龄就会加1(初始的年龄为0).
分代回收的基本流程.
-
- 当代码中new 出一个新的对象, 这个对象就是被创建在伊甸区的. 因此伊甸区就会有很多对象. 一个经验规律:伊甸区中的对象,大部分是活不过第一轮的GC的,这些对象都是"朝生夕死" ,生命周期非常短.
-
- 第一轮的GC扫描完成之后, 少数的伊甸区中的幸存的对象, 就会通过复制算法, 拷贝到的生存区. 后续的GC的扫描线程还会持续进行扫描,不仅要扫描伊甸区,也要扫描生存区的对象.
-
- 生存区的大部分对象也会在扫描中被标记为垃圾. 少数存活, 就会继续使用复制算法,拷贝到另一个生存区中. 往复循环,只要这个对象能够在生存区中继续存活,就会被复制算法继续拷贝到另一半的生存区之中. 每经历一轮GC的扫描. 对象的年龄都会+1.
-
- 如果这个对象在生存区中经历了若干次GC之后,仍然建在~~此时JVM就会认为,这个对象生命周期大概率很长,就会把这个对象从生存区拷贝到老年代.
-
- 老年代的对象,当然也要被GC扫描,但是扫描的频次就会大大降低了.
-
- 对象在老年代G了,此时JVM就会按照标记整理的方式,释放内存.
整个上述的过程概括下来就是,先放到伊甸区进行扫描,此时就会淘汰大部分对象,然后幸存下来的对象,就会被放到生存区/幸存区之中, 然后经过很多伦的GC仍然没有被淘汰此时,就会进入老年代,进入老年代之后,扫描的概率也就小了.这样能够提高效率.
举一个具体的例子:
伊甸区:就相当于一个公司在招聘,在第一轮的笔试之中就会刷下来一大批人,.
生存区/幸存区: 相当于进入了面试,但要面试好几轮.
老年代: 相当于成为了正式员工, 此时淘汰的概率就比较低了.