jvm中的垃圾回收机制
大家好!我是渣渣鑫。前面我讲了垃圾回收机制中的标志算法,今天我们来聊一聊垃圾回收清除算法。
目录
简单了解jvm中的内存区域
标志清除算法
复制算法
标记压缩(整理)算法
分代收集算法
一、简单了解jvm中的内存区域
在聊垃圾回收算法之前,我们应该先了解一下哪些内存区域有垃圾回收。所以大家先来看一下jvm内存区域。
这里先简单介绍一下这些内存区域
本地方法栈:本地方法栈与虚拟栈相同,都是方法执行时的内存模型,虚拟机栈为虚拟机执行java方法服务,本地方法栈为虚拟机执行本地方法服务。所以本地方法栈也不需要GC。
这里给大家补充一下java方法与本地方法的区别:
java方法是由java语言编写的,编译成字节码,保存在class文件中。
本地方法是由其他语言编写的,编译成和处理成其他代码。
程序计数器:他是线程私有的,可以简单理解,他就是当前线程执行的字节码对应的行号。为什么要有程序计数器呢?就是当我们在执行多线程的时候。第一个线程执行完程序去执行另一个线程的时候,怎么知道执行到哪里呢,这里就需要用程序计数器来起作用了,起到了记录线程运行时状态的作用。方便唤醒时能从上一次停止的地方继续执行。程序计数器也是java虚拟机规范中唯一没有内存溢出(OOM)和不需要垃圾回收的区域。
本地内存:本地内存是线程共有的,在java8中,本地内存包括元空间和直接内存。在java8之前,方法区是在堆空间的,当时的方法区又叫做是永久代,主要是用来存储类的信息,常量,静态变量,及时编译后的代码等等,这部分是在堆中分布的,所以会存在GC,而且很容易造成OOM,所以在java8开始就把方法区移到了本地内存的元空间中,这样方法区就不受jvm的控制了,也就不会进行GC,同时也提高了性能,(因为在发生GC的时候都会发生stop the word)所以在java8之后这一区域也就不需要GC了。
堆:前面的区域都不进行垃圾回收,那么只剩下堆空间了,没错,堆空间就是要进行垃圾回收的重点区域。
二、标记清除算法
步骤:
1、根据可达性分析算法先标记出来。
2、对可回收的内存回收。
存在的问题:
大家看图可以看出来,清除的内存块是不连续的,这样会导致,如果我们想在堆中分配一块连续的内存区域,很明显不行,这就是标记清除算法的缺点。
三、复制算法
步骤
1、可达性分析算法标记
2、把堆分成两块区域,A区域,B区域(B区域什么都不做),A区域进行对象分配,然后标记,注意这里先不进行清除,而是把存活的对象一并复制到B区域(连续存放),然后把A区域对象全部清除掉释放内存。这样就解决了标记清除算法的内存不连续问题。
存在的问题:
复制算法的缺点也是很明显的,就是内存资源的利用率低,同时要进行复制操作,效率也会下降。
四、标记整理算法
步骤:
1、可达性分析算法标记要回收的对象。
2、整理,讲要回收的对象移动到一端,不回收的对象移动到另一端,这样就解决了内存不连续的问题,同时也使空间空间利用率提高了。
缺点:
每进行一次垃圾回收都要频繁的移动存活的对象,效率低下。
五、分代收集算法
整合了上面几种算法的优缺点,可以这样理解分代收集算法,他相当于是一种策略,在不同的情况使用不同的算法。最大程度避免以上算法的缺点。那为什么要用分到收集算法呢?(字面意思就是分成年轻代和老年代两个区)
大家可以看这个图发现,大部分的对象存活时间是很短的,所以我们对其进行分代回收效率是最高的。
根据对象存活周期不同,我们将他分为年轻代和老年代两部分。
这里的年轻代又分为三个区,分别是Eden区,from Survivor区(SO区),to Survive区(S1区)三者的比例为(8:1:1)。这里我们就可以更具新老代的特点选择其合适的垃圾回收器,年轻代Young GC(Minor GC),老年代Old GC (Full GC)。
分代收集器的工作原理:
1、对象在新生代的分配与回收
分配:新生代对象存活的时间都很短,所以一般分配在Eden区。
当Eden区将满时,出发Minor GC回收机制。
因为大部分对象在短时间内都会被回收(98%的对象都被回收了),剩下2%的对象被分配到S0区(同时对象年龄加一),最后吧Eden区域对象全部清理释放出来。
当我们进行下一次垃圾回收的时候,继续重复上面的操作(这里要注意,再次进行垃圾回收的时候,S0区也将进行复制清除操作),只不过此时是将Eden区和S0区的存活对象被复制到了S1区,来回往复操作,这次是S0往S1复制,下一轮就是S1往S0转。也就是说我们在Eden区进行复制算法。因为大部分对象在Eden区就已经消亡了,所以对于复制开销会小很多,这里使用复制算法是比较合适的。
2、年轻代晋升成老年代
当我们年轻代对象的年龄达到一定阈值的时候,我们就讲S0(或者S1)转换成老年代。
如图所示,当我们年轻代的阈值达到15的时候,我们就在进行Minor GC的时候将这些对象晋升成老年代。
有两种特殊情况可以直接晋升为老年代:
-
大对象 当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代.
-
还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
3、空间分配担保
在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么Minor GC 可以确保是安全的,如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Minor GC,否则可能进行一次 Full GC。
4、Stop The World
当老年代满了,会触发Full GC,Full GC会同时触发Eden和SO,S1的回收,(既对整个堆进行GC),此时会触发STW,造成不小的性能开销。
什么是 STW ?所谓的 STW, 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
一般Full GC 会导致线程短暂的停顿这就会导致用户交互体验差,如果此时有其他服务来抢线程,就会被拒绝,所以我们应该尽量减少Full GC。(这里说明一下,对于Minor GC也会触发GC,只不过对于Eden区大部分对象已经被回收了,只有少部分存活对象需要复制转移,所以效率还可以)
现在我们应该明白把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。想想新生代如果只设置 Eden 会发生什么,后果就是每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。
这里就是说明年轻代的出现和三个分区就是为了减少Full GC的发生,而SO与S1就是起缓存作用,让对象不会过早的复制到老年代。
由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:
-
循环的末尾 -
方法返回前 -
调用方法的 call 之后 -
抛出异常的位置 另外需要注意的是由于新生代的特点(大部分对象经过 Minor GC后会消亡), Minor GC 用的是复制算法,而在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销(复制算法在对象存活率较高时要进行多次复制操作,同时浪费一半空间)所以根据老生代特点,在老年代进行的 GC 一般采用的是标记整理法来进行回收。