垃圾回收要解决的根本问题
这里我们先不区分编程语言,单说垃圾回收。这里涉及三个问题,哪些区域的垃圾需要回收,这些区域的垃圾如何识别,这些区域的垃圾如何回收。
一个对象只有被认定为垃圾之后才能被回收,否则决不能动它,因为一旦移动或者删除就会发生找不值或者找到错误的值的情况。试想一下,一块内存区域本来你在使用着,后来被垃圾回收了(所谓垃圾回收就是指这块内存区域被高(低)电平覆盖,要么全0要么全1,然后这块地址可以继续被用来存放其他的变量的值了)等你在用的时候,发现值没了或者变了,这时候程序就要出错了。
垃圾回收的历史
但是细想一下为什么我们需要垃圾回收呢?为什么c语言就没有垃圾回收机制呢?
一开始人们发明编程语言的时候,受限于当时的硬件和软件理论的限制。觉得程序员就应该自己管理内存,无论向操作系统申请内存还是把内存归还给操作系统(c语言就是这个想法,所以他没有垃圾回收器,加上垃圾回收器之后就变身C++了),而且你用完一块儿内存空间之后,你就应该及时释放掉,注意这里的及时,就是说你越快释放越好,因为那时候的内存空间是很有限的啊,你不及时释放,后面的代码逻辑就可能执行不了啊。所以人们并没有垃圾回收的概念。 后来随着硬件的提高,内存容量的增大,以及软件理论的更新,人们发现我在写代码的时候有大量的申请内存、释放内存的动作,这是重复的行为啊,有程序员就受不了了,代码中应该杜绝重复的代码,如果有重复的代码,那一定是代码写的不够好,不够好怎么办呢?我就要分层、抽象,巴拉巴拉各种软件设计理论就来了。于是人们觉得我们应该吧这些重复的代码抽象出来,最后人们想到,释放内存的逻辑应该交给程序来做,程序员不应该关心内存的释放,当然申请你还是需要关心的。不过释放的工作我放到另外一层来做,这一层就是所谓的垃圾回收器。
垃圾回收器的历史远远比Java语言要早,甚至比c语言还要早,要知道垃圾回收的思想是从Lisp语言开始的,:isp语言也算一一门神奇的语言了,在1956年发明,因为他是一门走在时代前言的编程语言,它当时提出的垃圾回收思想对后世的影响还是很深刻的,甚至第一个垃圾识别算法–引用计数法就是它最先提出来的。
从有无垃圾收集器来区分,编程语言其实可以分成两大阵营,一大阵营是有垃圾回收器的语言,一类是没有垃圾回收器的语言。
有垃圾回收器的说自己简单,不用手动写释放内存的样板代码,不会存在内存泄漏,自诩安全系数高。
没有垃圾回收器的语言就说自己灵活,可以手动控制内存的分配与释放,自诩效率高。
那有没有一种语言既没有垃圾回收器又不用需要关心内存的回收问题呢?有没有这么两全其美的语言呢。
还真有,有一门新的语言叫 Rust 语言的,它既没有垃圾回收器,也不需要手动写代码释放内存。它的实现比较另类。是在编译的时候帮你把释放内存的代码插入到了代码之间。所以看起来不需要你手动释放代码,也没有垃圾收集器,但按照我的理解,其实它还是应该归为有垃圾收集的语言。
Java的垃圾回收
一门语言一旦决定归到垃圾回收器阵营,例如Java,那它就必须要实现一套垃圾回收器来负责回收垃圾,也就是说它运行的时候,后台默默地还会启动一个线程或进程来扫描所使用的内存,而垃圾收集器的目的就是要高效的回收内存以供新的代码使用。要高效的回收可并不容易啊。所以人们针对这个高效的问题提出了一个又一个的解决方案,而每一个方案又会产生一系列新的问题,然后又在下一代的方案中解决。对此我们衍生了很多很多的垃圾回收算法。不同的算法对应不同的垃圾收集器。
第一个问题,哪些区域的垃圾需要回收?
要想确定哪些区域需要回收,就首先要知道有哪些区域。然后才能说哪些区域需要回收,哪些区域不需要回收。一个Java进程想要回收人家QQ的内存空间,这是不可能的!所以,我们要先搞清楚Java运行的时候,到底管理着哪些内存空间,也就是另一个知识点: JVM 的运行时数据区域。这个知识点也比较繁杂,这里简单说一下。JAVA是基于JVM的跨平台实现了所谓的一次编译到处执行的宏伟目标的。因此在各个平台开发对应的JVM的时候一定有一个标准,我们称作JVM标准。其中针对运行时数据区域的划分就有规定,规定称要实现JVM必须将内存划分为5个区域:程序计数器、方法栈、本地方法栈、方法区、堆
那既然知道有上面5部分区域了,那哪些区域是需要被回收的呢?理论上只要是空间只要不用我就应该回收掉。要想了解这个问题就要知道每个区域到底存的是啥。
由上面的 程序计数器、方法栈、本地方法栈 随着线程而生随线程而灭,因此线程退出的时候,空间自动释放。其中程序计数器是一块很小的内存,小到只需要存下一个内存地址就行,每个线程独占一块儿程序计数器,用来指示本线程执行到了代码的哪一行;方法栈、本地方法栈里面是一个个的方法栈帧,对应着方法的调用,入栈之后代表方法运行,运行完毕后自动弹出栈顶,空间就自动释放了,所以垃圾回收器不用操心这三块区域。因此我们需要关心的其实就剩方法区和堆栈了,方法区中本来存放的一些类信息、方法信息等等静态的东西,那么对于类信息在一个类不使用的时候就应该自动释放不在占着内存,方法去里面还有可能存放一些常量,我们叫做常量池,也是需要释放内存的。堆就不用说了,对象分配的地方,用完自然要释放,因此垃圾回收的主战场就是堆了,此外还有方法区。不过随着JVM的发展,这个所谓的方法区也逐渐逐渐归还给堆了,因为方法区本来就是堆得一部分,只不过因为要存一些类信息、静态变量等信息而强行划分出来的,HotSopt虚拟机为了和JRokit虚拟机的一些优秀特性合并,在Java8中已经将方法区中的字符串池、静态变量等移到堆中了。
第二个问题,这些区域的垃圾如何识别
人们最先想到的就是引用计数,引用计数就是一个对象被引用了,引用计数器就+1,一个对象的引用被断开了,引用计数器就-1,实现起来非常简单,前面说过了,甚至第一个垃圾识别算法–引用计数法就是List最先提出来的。但是引用计数无法解决循环引用的问题,为了解决循环引用的问题,人们发明了弱引用的弥补方式,但是只能应用于简单场景,所以一些脚本语言在设计垃圾回收器的时候就选择了引用计数的实现方式。例如 js,python。人们为了解决引用计数的老大难问题:循环引用,又发明了一种方法叫可达性分析法,java 采用了后者。
第三个问题,这些区域的垃圾如何回收
既然JAVA选择了可达性分析,我们知道可达性分析是依赖于根对象的,所以必须知道根对象在哪儿。这需要我们了解一下JVM的内存分布,或者叫内存布局,官方名字叫运行时数据区域,也就是JVM在于行的时候数据所在的区域。这可以看我的另一篇文章 《Java-运行时数据区》。这里我们只说一下跟对象存在的地方。
- 栈
栈里面存的是栈帧,栈帧里面存了对象的引用,这些引用就是根对象 - 本地方法栈
栈里面存的是栈帧,本地方法在执行的时候,会把对象的引用在传递到本地方法栈的栈帧里面,栈帧里面存了对象的引用,这些引用就是根对象 - 方法区:静态引用、常量池中的引用
方法区中村的静态引用和常量池中的应用,这些引用就是根对象
标记清除算法
复制算法
标记整理算法
分代垃圾回收算法
直到统计理论的引入,才开始分代回收
Java的垃圾回收器
垃圾收集器按照代分为两种,新生代一种、老年代一种,这样做的目的是为了让新生代和老年代的垃圾收集器可以随意组合,但事与愿违,有些回收器之间是不可以随意组合的。
一开始为了简单,只有串行的 serial 收集器,因为那个年代计算机可用的内存本来就很小,只有几十兆几百兆,那供JVM可用的内存就更少,因此产生的垃圾也就少,用串行回收器足够了,回收速度很快,用户根本感知不到,相当于运行10s就回收个10ms。但是随着计算机的发展,JVM可控的内存越来越多,产生的来及也就越来越多,用串行回收器不够了,因为产生的垃圾多了,在回收速度一定的情况下,回收时间自然就变长了,长到用户开始感知了,运行10s就回收了1s,于是JAVA不能忍了,这阶段的主要目标就是提高垃圾回收的速度,缩短垃圾回收的时间,而这时候真多核处理技术的发展使得真多线程成为现实,因此为了提高垃圾回收的速度,自然而然的就想到了用多线程来回收,这样就搞出了parNew,现在新生代试试水,所以parNew 诞生的时候老年代还是只有serial old这一种串行的垃圾回收器。但是随着内存的继续发展,达到GB了,这时候即使多线程也不行了,因为用户还是能感到明显的停顿。可能运行了20s就有一次1s的垃圾回收了。所以这时候人们并不是要求垃圾回收的速度越来越快了,而是要求用户最好不要感知到垃圾回收,我们知道 回收时间=垃圾量/垃圾回收速度 ,刚才说到了,我们已经用多线程垃圾回收的方式实现了垃圾回收速度的最大化,这时候回收速度我们优化不了了,为了缩短回收时间,我们只能减少垃圾量的产生,为了减少垃圾量的产生,我们需要让程序运行的时间短一会儿,这样产生的垃圾就少,但是这样垃圾回收的频率就会上来,可是用户无感知了呀,原来10s中回收1s,现在每3s就回收3ms,3ms的时间用户也感知不到,实际上用户程序是暂停了的,这样人么就要求能控制运行多久就执行一次多久的垃圾回收,于是 parell scaveng 诞生,可以控制吞吐率了,人们一看效果不过,于是应用到老年代 parell old 诞生。这时候老年代已经有两种垃圾回收器了。但是到现在,无论如何用户程序都得暂停,人们的胃口越来越大,期望用户程序不暂停,你还得给我管理垃圾回收,于是cms诞生,这个是并行垃圾回收器的第一次尝试,但是老年代会产生内存碎片啊,于是G1诞生,但是G1有效率问题啊,于是ZGC诞生。可以看到GC垃圾收集器的发明过程,就是人类欲望的膨胀过程。