1. 什么是垃圾回收机制(GC)
在早期的计算机语言,比如 C 和 C++,需要开发者手动的来跟踪内存,这种机制的优点是内存分配和释放的效率很高 。但是它也有它的缺点,如果程序员不小心忘记释放内存,从而造成内存的泄露
内存泄露:申请内存之后,忘记释放了 导致 可用的内容越来越少,最终无内存可用
新的编程语言,比如 JAVA,Go,Python,PHP… 现在市面上的大部分主流编程语言,都采取了一个方案,那就是 “垃圾回收机制”,运行时自身会运行相应的垃圾回收机制。程序员只需要申请内存,而不需要关注内存的释放 。垃圾回收器(GC)会在适当的时候将已经终止生命周期的变量 的内存给释放掉。
1.1 垃圾回收机制的优缺点
GC的优点:
- 它大大简化了应用层开发的复杂度(不需要开发者再去手动跟踪内存)
- 降低了内存泄露的风险
GC的缺点:
- 消耗额外的开销(消耗的资源更多了)
- 会影响程序的流畅运行
2. 哪些内存需要回收
JVM的内存结构包括四大区域:1.程序计数器 2.栈 (虚拟机栈,本地方法栈)3.堆 4.方法区
举个例子,任何组织里,人都有三个派别,1.积极派 2.消极派 3.中间摇摆派,如图,对于上述三个派别,哪些是要进行回收释放内存的?
正在使用的内存中的对象 代表 积极派
不再使用,但是尚未回收的内存中的对象 代表消极派
中单部分为中间摇摆派 需要进行回收释放资源的:消极派
为什么中间摇摆派不回收释放内存呢?对于这种部分仍在使用,一部分不在使用的对象,整体来说是不释放的!等到这个对象彻底完全不使用,才真正的释放!!
注意:
垃圾回收的基本单位是“对象”,而不是“字节”
3. 垃圾回收具体是如何回收的
分为两个阶段:
- 找垃圾/判定垃圾
- 回收垃圾(释放内存)
3.1 找垃圾/判定垃圾
如何找 垃圾/判定垃圾呢?当下主流的思路,有两种方案:
- 基于引用计数(不是Java中采取的方案,这是别的语言,像Python采取的方案)
- 基于可达性分析(这个是Java采取的方案)
3.11 基于引用计数
什么是基于引用计数:简单来说,针对每个对象,都会额外引入一小块内存,保存这个对象有多少个引用指向他 举个例子 1.Test t = new Test(); ,此时 new 了一个对象,那么我们就会额外引入一小块内存,此时 t 指向这个对象的引用,因此 引用计数 加 1
2.Test t2 = t ; 此时 t 和 t2 都是指向这个对象的引用,此时引用计数 从1 变为 2 3.
void func() {
Test t = new Test();
Test t2 = t;
}
func() //调用方法过程中,创建了对象(分配内存),在方法执行过程中,引入计数量是2,当方法执行结束,由于 t 和 t2 都是局部变量,跟着栈帧一起释放了,这一释放就导致引用计数为0了(没有引用指向这个对象了,也就没有代码能够访问到这个对象了),此时就认为这个对象是个垃圾!
注意:引用计数为0的时候,就不再使用了,这个内存不再使用,就释放了(为后面理解做铺垫)
3.12 引用计数的优缺
引用技术,简单可靠高效,但是有个两个致命缺陷!!
- 空间利用率比较低,每个 new 的对象都得搭配个 计数器,计数器假设 4个字节,如果对象本身很大(几百个字节),多出来4个字节,就不算什么,但是如果本身对象很小(自己才4个字节),多出4个字节,相当于空间被浪费了一半
- 会有循环引用的问题
循环引用问题:写个代码举例子便于理解
// 先创建一个类
class Test {
// 成员变量
Test t = null;
}
// 创建实例
Test t1 = new Test();
Test t2 = new Test();
画出内存布局:
t1.t = t2;//把 t2 赋值给了 t1里面的t属性,此时对象2有两个引用
引用计数加1,变为2
t2.t = t1// 把 t1 赋值给 t2 里面的 t 属性,此时对象1 有两个引用
引用计数加1,变为2
接下来,烧脑的环节:
t1 = null
t2 = null
此时此刻,两个对象的引用计数,不为0,所以无法释放,但是由于引用长在彼此的身上,外界的代码也无法访问到这两个对象,此时此刻,这俩对象,就不能使用,又不能释放,就出现了“内存泄露”的问题。
所以,像 Python,PHP里进行GC也不只靠引用计数,还依赖其他的机制配合,但是Java可以直接采用可达性分析,来判断垃圾
3.13 基于可达性分析
基于可达性分析:简单的来说,通过额外的线程,定期的针对整个内存空间的对象进行扫描,有一些起始位置(称为 GCRoots),会类似于 深度优先遍历一样,把可以访问到的对象都标记一遍(带有标记的对象就是可达对象),没有被标记的对象,就是不可达,也就是垃圾!
什么才算 GCRoots
- 栈上的局部变量
- 常量池中的引用指向的对象
- 方法区中的静态成员指向的对象
举个栗子吧,比如:写个代码,构造一个二叉树 如果我们在外面的代码中
Node root = a
代码中只要拿到 树 根节点,就可以掌握所有的节点,树上的任意节点,都可以通过 a 直接/间接的获取到
换句话说,GC在进行可达性分析的时候,当 GC 扫描到 a 的时候,就会把 a 能访问到的所有元素都去访问一遍,并且进行标记,所标记的节点 表示 都不是 垃圾
如果代码中,写了如下代码
c.right = null
则此时意味着,从 a 出发,访问不到 f,f 就是 不可达,f 就是垃圾,f 就应该被回收
如果代码中
a.right = null
此时从 a 出发,c 和 f 都是不可达了,也就都被标记成垃圾了!
从上面的这几点我们可以看出,可达性分析是去遍历每一个对象,如果内存中的对象特别多,这个遍历就会很慢,因此 GC 还是比较消耗时间和系统资源的!
3.14 可达性分析的优缺点
优点:
缺点:
tips:
找垃圾,核心就是确认这个对象未来是否还会使用,什么算不使用了?没有引用,就不使用了
明确了谁是垃圾之后,接下来就要回收垃圾了!
3.2 回收垃圾(释放内存)
3.21 回收垃圾(释放内存)三种基本策略
标记 - 清除
如图:这是一块内存,上面被分成了很多小块,其中有些部分是垃圾(打钩的)
这里的 标记 ,就是可达性分析的过程
清除,就是直接释放内存 ,灰色区域代表释放内存
此时如果直接释放,虽然内存还是还给了系统,但是被释放的内存是离散的(不是连续的)
分散开带来的问题就是:“内存碎片”,这个问题其实非常影响程序的执行!
内存碎片:比如,空闲的内存,有很多,假设一共是 1G,如果要申请 500M 内存,也是可能申请失败的,因为要申请 500M 的内存 必须是连续的,每次申请,都是申请的连续的内存空间,而这里的 1G 可能是多个 碎片加在一起 才 1G,可用的并不多
为了解决内存碎片因此我们引入了复制算法!
复制算法
如图:一块内存,分成两半,左边一半有很多对象,打钩的标记为垃圾,右边为 左边不是垃圾的,拷贝过来
然后再将左边全部标记为垃圾(灰色),全部释放掉,我们就能保证,左右两侧空间都是整体连续的
此时内存碎片问题就迎刃而解了!
注意:复制算法的问题有如下几点
- 内存空间利用率低(只能用一般的空间)
- 如果要保留的的对象多,要释放的对象少,此时复制开销就很大
针对复制算法我们进行改进!–》 标记 - 整理
标记 - 整理
如图:还是一块内存,上面有一些对象,其中一些被标记为垃圾(打钩的)
如何进行标记 - 整理呢?
类似于顺序表删除中间元素,有一个搬运操作,我们将 3 搬运到 2 ,再将 5 搬运到 3 ,再把 7 搬运到 4 然后再把后面的部分整体的释放掉
这个方案空间利用率是高了,但是仍然没有解决复制/搬运元素开销大的问题~
3.22 分代回收
上述的三个方案,虽然能解决回收垃圾的问题,但是都有缺陷,实际 JVM 中的实现,会把多种方案结合起来一起使用,这个思路我们称为 “分代回收”
我们这个对象,他是怎样在这个区域里来回 轮转 的呢?
- 刚创建出来的对象,就放在伊甸区
- 如果伊甸区的对象熬过一轮 GC 扫描,就会被拷贝到 幸存区(伊甸区 到 幸存区 应用了复制算法)
- 在后续的几轮 GC 中,幸存区的对象就在两个幸存区之间来回拷贝(复制算法),每一轮都会淘汰一波幸存者
- 在持续若干轮之后,对象终于,进入老年代,老年代有个特点,里面的对象都是比较老的(年级大的),因此老年代的 GC 扫描频率大大低于新生代。老年代中使用标记整理的方式进行回收!
上述过程是面试中的经典问题!!!一定要重点掌握啊!
注意:
注意!!! 分代回收中,还有一个特殊情况,有一类对象可以直接进入老年代(大对象,占有内存多的对象),大对象拷贝开销比较大,不适合使用复制算法!
4. 垃圾回收器
上面说的找垃圾,和释放垃圾,说的都是算法思想,不是具体落地实现,在JVM里,真正实现上述算法的模块称为“垃圾回收器”
🎉?总结
“种一颗树最好的是十年前,其次就是现在”
所以,
“让我们一起努力吧,去奔赴更高更远的山海”
如果有错误?,欢迎指正哟😋
🎉如果觉得收获满满,可以动动小手,点点赞👍,支持一下哟🎉
以梦为马,不负韶华
|