前言:在C/C++中对于变量的内存空间一般都是由程序员手动进行管理的,往往会伴随着大量的 malloc 和 free 操作,常常会有很多问题困扰开发者,这个代码会不会发生内存泄漏?会不会重复释放内存?但是在Java开发中我们却很少有这样的担忧,程序员几乎很少手动管理内存,这是因为在Java虚拟机JVM中这些事情都被JVM的垃圾回收算法管理和代理操作了。
目录
一.什么是GC
二.JVM中的GC
▐ 如何找到要回收的内存
1.使用引用计数器判断某个对象是否具有引用指向(Python、PHP)
2.可达性分析(JVM采取的方案)
▐ 如何对找到的内存垃圾进行释放回收
1.标记-清除
2.复制算法
3.标记-整理
分代回收
一.什么是GC
GC是垃圾回收(Garbage Collection)的缩写,是计算机科学中一种自动化的内存管理机制。在传统的内存管理方式中,程序员需要手动分配和释放内存。而GC则可以自动跟踪和回收不再被程序使用的内存,从而减轻了程序员的负担。要注意的是,GC并不是Java独有的一种机制,现如今GC广泛应用于许多的高级语言,诸如PHP、Python、Lua、Ruby、Go... ...
GC的主要原理是通过检测程序中不再被引用的对象,将其标记为垃圾,然后自动回收这些垃圾对象所占用的内存资源。GC会定期地执行垃圾回收操作,找出不再被使用的对象并释放其内存,从而避免内存泄漏和内存溢出的问题。
垃圾回收机制给程序员带来了许多便利的同时也会产生性能问题,很简单的逻辑,既然要自动跟踪回收部分内存,那就需要分配一定的系统资源给到GC上,如果GC的效率非常差,很可能触发GC的一瞬间就会把系统的负载拉满,严重时会导致服务器无法响应其他的请求,因此,一个优秀且高效率的GC算法就必不可少。
二.JVM中的GC
对于一个Java程序来说,GC回收的是内存,其实就是不同的对象,往往都是堆区上的数据,我们对于JVM中的内存区域大致做个分析:
- 程序计数器:一般是不需要额外回收的,线程销毁了,内存自然就回收了
- 栈区:一般夜市不需要额外回收的,线程销毁了,内存自然也就回收了
- 元数据区:一般也不需要,我们一般进行的都是加载类的操作,很少说是卸载类
- 堆区:GC的主力回收区域
并且GC回收内存的时候,一定回收的是一个完整的对象,比如一个对象有10个成员,那么一定是回收这全部10个成员,不可能只回收一部分。
对于GC回收的内容有了一个了解后,就要关心GC回收的流程,总的来说垃圾回收分为俩个步骤
- 找到要回收的垃圾(内存)
- 释放对应的内存
下文也按照这个流程分为俩部分来讲解
▐ 如何找到要回收的内存
一个对象的创建时间往往是很明确的,但是对于该对象什么时候不再使用,时机往往是模糊不定的。
举个例子来说,就像一个一年级的小学生,做作业的时候很容易被其他事物分心,可能写半个小时作业就去玩一下,过一段时间再来写作业。但是如果我们认为他已经连续2个小时没有写作业了,就在他玩的时候将作业和本子和笔收起来,那么等到他回来准备继续写作业的时候,就会发现根本无从下手,对应到我们的代码中,后面的业务和逻辑就完全无法进行了。
因此,我们必须要保证代码中使用的每一个对象都是有效的,千万不能出现提前释放的情况,我们必须要采取很保守的态度,宁可晚一点回收内存,也不能提前回收打断了原有程序的运行。
那我们需要用什么来作为判断某个对象是否为垃圾的依据呢?JVM是如何判断某个对象是否应该被回收呢?对于小学生写作业的例子中,我们采取了 “上一次使用时间” 进行判断,很显然这是不太合理的,在GC中我们往往使用一种很保守的方法来判断某个对象是否需要释放——即是否存在引用指向该对象。
就拿下面这段示例来说,我们new了一个类对象Test,这时的 t 就是指向该对象的引用,此时这个对象就是有效的,我们则不能回收他。
Test t = new Test();
但如果我们将 t 置为 null ,原先指向Test对象的 t 更改了他的指向,此时我们就说这个Test对象不存在引用指向该对象,即该对象就是我们要回收的垃圾
t = null;
在我们理解了如何判断一个对象是否为垃圾后,还有一个问题需要解决,对于我们刚才方案中提到的这个依据,我们又该如何判断这个依据是否存在呢?刚才的例子很简单,但是实际情况往往是很复杂的,不可能一概全是用 null 来改变指向,在垃圾回收机制中具体是怎么判定某个对象是否有引用指向呢?
这样的策略有很多,主要分为以下俩种
- 使用引用计数器(Python/PHP采用的方案)
- 可达性分析(JVM采用的方案)
1.使用引用计数器判断某个对象是否具有引用指向(Python、PHP)
这种方案为Python和PHP采用的方案,我们知道内存是一块连续的物理空间,那我们在存储对象的时候在对象旁边放置一个引用计数器来统计这个对象目前有多少个引用,每个对象都有自己的引用计数器,当这个计数器为0的时候就说明当前对象没有引用,那么就可以作为GC回收的垃圾进行内存回收了。
这样的方案优点在于简单容易实现,笔者这里还是画图说明一下
当我们new了一个对象,并且用a来指向它,此时引用计数器 +1
Test a = new Test();
然后我们使用一个b来指向a,虽然这一步并没有新建一个对象,但是这个b还是指向的Test这个对象,因此引用计数器 +1
Test b = a;
然后我们如果再更改b的指向,让b不再指向Test这个对象,那么对应的引用计数器就要 -1
b = null;
那么如果我们再更改a的指向,此时的引用计数器则 -1 变为了 0 ,则该对象没有任何的引用,则该对象就是垃圾,需要被回收
a = null;
这样的方案优点在于简单易懂,好实现,但是同样有俩个缺点,那就是会消耗额外的空间以及会参数循环引用的问题。
消耗额外的空间:这很好理解,每个对象都有自己的引用计数器,那么如果对象很多,几百个上千个对象就需要同样数量的引用计数器,每个引用计数器的维护也都需要内存,这无疑会造成很大的资源浪费
循环引用的问题则较为复杂,笔者这里还是使用图文的方式详细解释一下。
假设我们分别new了俩个Test对象,分别用a和b来指向他们。
class Test {Test t;
}Test a = new Test();
Test b = new Test();
那么情况就应该同下图,a和b分别指向俩个地址
然后我们让每个对象的内部成员对象都指向对方,由于每个Test对象都指向了对方,那么理所应当的俩个计数器都应该 +1
a.t = b;
b.t = a;
到这里一切都是很正常的,但是,如果我们此时把 a 和 b 都指向 null 的话会发生什么呢?由于原本指向俩个 Test 对象的 a 和 b 都指向 null ,那么理所应当的俩个计数器也都应该 -1
a = null;
b = null;
所以理所应当的就会变为上图的情况,大家仔细观察一下,这合理吗?明明已经没有任何引用指向俩个 Test 对象了,但是他们的引用计数器却因为之前的种种操作没有合理的清零,就导致了俩个对象永远相互指向对方,俩者的引用计数器都为 1(不为0,不是垃圾,不会被清理),但是外部代码没有任何方式访问到这俩个对象。这就是我们所说的引用循环的问题。
这样的问题能解决吗?当然也是可以解决的,前文也说了,有许多语言是使用的这个策略。为了解决这个问题我们则需要引入其他的机制。JVM并没有使用这种策略。
2.可达性分析(JVM采取的方案)
可达性分析的方案策略是JVM采用的方案,它解决了空间的问题和循环引用的问题,但是付出了时间上的代价,这意味着它需要消耗的时间更多,需要消耗的系统资源也更多。
那么这个方案具体是怎么做的呢?
JVM会把对象之间的引用关系理解为一个树形结构,通过不断的遍历这样的结构,就能把每个对象打上标记,分为“可达”和“不可达”,就像我们在学习离散数学中那样,对于图论的研究,我们会去考虑一个图的可达性问题,我们知道树其实也是一种特殊的图,我们通过研究这颗树的连通性和可达性就可以判断出他们每个节点之间的关系,节点与节点之间如果可达就说明他们有引用关系,如果不可达就说明他们没有引用关系,自然而然的我们就知道了哪些节点(对象)不存在引用关系,从而判断出哪些对象属于垃圾,需要回收。
如果其中某个对象没有任何对象指向它,那么该对象则被判定为垃圾,需要被回收
对于之前提到的循环引用的情况,由于他们与跟节点不可达,因此也会被判定为垃圾,从而进行回收。如图所示:
这样就可以解决引用计数器中出现的俩个问题,当然这需要额外消耗系统资源。
一个Java程序中往往有很多的遍历和类对象,这就意味着有很多上述这样的树结构,具体树有多复杂都取决于实际的代码结构,在这其中有一个很关键的概念——GC roots,也就是这些树的根节点,在Java代码中对于栈上的局部遍历,常量池中引用的对象、方法区中的静态成员这些都是GC roots,JVM会周期性的对这些树进行遍历,不断的标记可达和不可达,不断的回收掉不可达的对象。
由于可达性分析需要消耗一定的时间,因此Java垃圾回收没法做到“实时性”,JVM会提供一组专门复杂GC的线程,不停的进行扫描工作。
▐ 如何对找到的内存垃圾进行释放回收
解决了找到垃圾的策略,接下来要思考的就是回收垃圾的策略。
对于回收垃圾我们也有三种策略:
- 标记-清除
- 复制算法
- 标记-整理
以下分为三部分讲解
1.标记-清除
这种做法简单粗暴,直接将标记为垃圾的对象对应的内存释放掉,如下图所示
但是这样的策略带来的最大的问题在于:它会存在“内存碎片”的问题,就会导致后续很难申请到一块大的连续的内存了。因为我们申请内存都是要申请连续的内存空间的,这样会使得空间利用率极低。
这就好比放假,假如一个人一个月有15天假期,尽管数量多但是都不是连续的,都是工作一天休息一天,那么这个人就算这么多假期,也还是不能出省出国的旅游,只能在家休息,毕竟隔一天就要上班。
因此,这种方案并不实用。
2.复制算法
这种方案会预先留出一段空间,当发生GC的时候,会将有用的空间全部复制到预留空间里面去,然后再将原来复制前的空间清空回收。
举例子来说,假如我们现在需要释放2、4、6三块内存空间,保留1、3、5、7共四块内存空间
首先将需要保留的空间复制到预留空间里面去
最后再将复制前的前半部分空间全部回收
这样的方案解决了空间碎片化的问题,但是需要保留的空间越多,复制的时间也就月多,因此也会有浪费系统资源的问题
3.标记-整理
这种策略类似于顺序表中删除元素的流程,它既能解决内存碎片问题,也能解决空间利用率的问题
还是这个例子,假如我们现在需要释放2、4、6三块内存空间,保留1、3、5、7共四块内存空间
就像顺序表删除元素一样,后面的元素依次向前覆盖,最终只保留前半部分内容,对后半部分进行回收
但是这样搬运覆盖对时间又有损耗
综上所述,三种方案各有各的优点,各有各的缺点,那么JVM是如何进行选择的呢?JVM表示“小孩子才做选择,我都要”。JVM综合了以上三种方案 ,试用了更复杂的策略——分代回收。
分代回收
在该方案中JVM会根据对象的年龄来进行分类,对于年龄这个概念需要做出解释:
年龄:GC中有一组线程,周期性扫描,对于某个对象,经历了一轮GC后,如果还是存在,没有成为垃圾的话,年龄就+1
对于GC在堆区的操作我们大概可以分为以下几个部分,我们将堆区分为新生代和老年代,对于新生代我们又可以细分为Eden(伊甸区)S0(生存区)S1(幸存区)
对于新创建的对象,基本上都是放在伊甸区,在伊甸区中大部分的对象生命周期都是比较短的,第一轮GC到达的时候,大多数对象都会成为垃圾,只有少数对象能够活过第一轮GC。
对于伊甸区存活下来的对象,会通过复制算法转移到生存区,由于存活对象很少,复制开销也很低,因此生存区空间也不必很大。
每经历一轮GC,生存区都会淘汰掉一批对象,对于生存区存活下来的对象,会同样通过复制算法转移到幸存区,同样进入幸存区的还可能会有伊甸区进来的对象。
其实对于生存区和幸存区,他们二者之间没有什么特别的区别,因此,将其二者都称为生存区或者幸存区都是可以的,重点在于理解思想,二者的名称并没有那么重要。
某些对象经历了很多轮的GC都没有变为垃圾,那么他们就会从生存区/幸存区经历复制算法,转移到老年代,老年代的对象也是需要GC的,但是对于老年代的对象,他们的生命周期往往都比较长,因此可以降低GC的频率。
上述过程就是分代回收的基本逻辑。
对象在 伊甸区 --> 生存区/幸存区 --> 老年代 的过程中主要体现了复制算法的思想;对象在老年代则通过标记-整理的策略进行回收。
整个过程其实很像玩“吃鸡”游戏,一波一波的刷毒圈,一波一波的淘汰人,同样也很像找工作面试的情况。
本次的分享就到此为止了,希望我的分享能给您带来帮助,创作不易也欢迎大家三连支持,你们的点赞就是博主更新最大的动力!如有不同意见,欢迎评论区积极讨论交流,让我们一起学习进步!有相关问题也可以私信博主,评论区和私信都会认真查看的,我们下次再见