(五)Java虚拟机——垃圾回收机制
对比其他语言
C++/C(手动回收)
这类语言没有自动垃圾回收机制,一个对象如果不再使用,需要手动释放,否则就会出现内存泄漏。
内存泄漏:指的是不再使用的对象再系统中未被回收,内存泄漏的积累可能会导致内存溢出。
- 优点:回收及时性高,由程序员把控回收时机。
- 缺点:编写不当容易出现悬空指针、重复释放、内存泄漏等问题。
Java/C#/Go/Python(自动回收)
通过垃圾回收器对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。
- 优点:降低程序员实现难度、降低对象回收bug的可能性。
- 缺点:程序员无法控制内存回收的及时性。
应用场景:
- 解决系统僵死的问题:大厂的系统出现许多系统僵死问题都与频繁的垃圾回收有关。
- 性能优化:对垃圾回收器进行合理的设置可以有效地提升程序的执行性能。
方法区回收
回收内容主要就是不再使用的类
线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。
方法的栈帧在执行完方法之后,就会自动弹出出栈并释放掉对应的内存。
判定一个类可以被卸载,需要同时满足下面的三个条件:
- 此类所有实例对象都以及被回收,在堆中不存在任何该类的实例对象以及类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用。
可以手动触发垃圾回收,可以调用 System.gc()方法
注意:System.gc()仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收,Java虚拟机会自行判断。
堆回收
Java中的对象是否能被回收,是根据对象是否被引用来决定的。
判断对象是否被引用:引用计数法和可达性分析法。
引用计数法
应用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用减1。
引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法
缺点:
- 每次引用和取消引用都需要维护计数器,对系统性能有一定影响。
- 存在循环引用问,所谓循环引用就是当A引用B,B也引用A会出现对象无法回收的问题
以下段代码为例,A的对象里面引用B,B的对象引用A,将栈中的AB去掉,实例对象在栈中已经没有变量引用,由于A、B的引用计数器是1,不会被回收,出现了内存泄漏。
可达性分析法
可达性分析法将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。
如下图:A到B再到C和D,形成了引用链,可达性分析法指出,如果从某个到GC Root对象是可达的,对象就不可被会回收。
GC Root对象:
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等。
- 系统类加载器加载的java.lang.Class对象,引用类中的静态变量(eg:应用类加载器)
- 监视器对象,用来保存同步锁synchronized关键字持有的对象。
- 本地方法调用时使用的全局对象。
下面代码使用可达性分析法,实例对象AB在栈中已经没有变量引用,GC Root不能引用到AB的实例对象,AB会被回收。
对象引用
- 强引用:可达性分析描述的引用
- 软引用:软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。(常用于缓存)
- 弱引用:弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。
- 虚引用:也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回 收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道 直接内存对象不再使用,从而回收内存,使用了虚引用来实现。(常规开发不会使用)
- 终结器引用:指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后 由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该 对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。(常规开发不会使用)
软引用
软引用的执行过程:
- 将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。
- 内存不足时,虚拟机尝试进行垃圾回收。
- 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
- 如果内存依然不足,抛出OutOfMemory异常。
垃圾回收算法(GC算法)
垃圾回收需要做的事情:
- 找到内存中存活的对象。
- 释放不再存货对象的内存,使得程序能再次利用这部分空间。
垃圾回收算法的评价标准
STW(Stop The World):Java垃圾回收过程会通过单独的GC线程来完成,不管哪一种GC算法,都会有部分阶段需要停止所有的用户线程。
判断算法是否优秀
- 吞吐量:指的是CPU用与执行用户代码的时间与CPU总执行时间的比值,即吞吐量 = 执行用户代码时间 / (执行用户代码时间 + GC时间)吞吐量数值越高,垃圾回收效率越高。
- STW:STW最大停顿时间越短,越好。
- 堆使用效率:不用垃圾回收算法,对堆的使用方式不用。
这三中评价标准:吞吐量、STW最大停顿时间和堆的使用效率不可兼得。
一般来说,堆内存越大,STW最大停顿时间越长;减少最大停顿时间,就会降低吞吐量。
所以不用的垃圾回收算法适用于不同的场景。
标记清除算法(整个内存处理)
标记清除算法分为两个阶段:
- 标记阶段,将所有存活对象进行标记。使用可达性分析法,从GC Root开始通过引用链遍历出所有存活对象。
- 清除阶段,从内存中删除没有被标记也就是非存活对象。
标记清除算法的优缺点:
- 优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
- 缺点:
- 碎片化:由于内存是连续的,在对象被删除之后,内存中会出现很多细小的可用内存单元。如果需要一个比较大的空间,很可能这些内存单元的大小过小无法进行分配。
- 分配速度慢:由于内存碎片化,需要维护一个空闲链表,极有可能发生每次需要遍历到链表最后才能获得合适的内存空间。
复制算法(一半内存处理)
复制算法的核心思想:
- 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用一半空间。
- 在垃圾回收GC阶段,将From中存活的对象复制到To空间。
- 将两块空间From和To名字互换。
复制算法的优缺点:
- 优点:
- 吞吐量高:复制算法只需要遍历一次存活对象复制带To空间即可
- 不会发生碎片化:复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
- 缺点:内存使用率低:每次只能让一半空间来为创建对象使用。
标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想:
- 标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段:将存活对象移动到堆的一端,清理掉存活对象的内存空间。
标记整理算法的优缺点:
- 优点:
- 内存使用效率高:整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
- 不会发生碎片化:在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。
- 缺点:整理阶段效率不高 ,比如Lisp2整理算法需要堆整个堆中的对象搜索三次。
分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代回收算法。
分代垃圾回收将整个内存区域划分为年轻代和老年代。
年轻代:存放存活时间比较短的对象。
老年代:存放存活时间比较长的对象,是一整块区域。
分代回收时,创建出来的对象,首先会被放入Eden区
随着对象在Eden区越来越多,如果Eden区满了,新创建的对象已经无法放入,就会触发年轻代GC,称为Minor GC或Young GC。
Minor GC运用复制算法,使用的区域是S0和S1,把需要回收的对象回收。
注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
如果Minor GC后对象的年龄达到阈值(最大值为15,默认值与垃圾回收器有关),对象就会被晋级升至老年代。
当老年代空间不足时,先尝试Minor GC,如果还是不足,就会触发FulL GC,Full GC会对整个堆进行垃圾回收。
特殊情况:
- 年轻代空间不足时,且经过Minor GC仍然不足时,会将年龄未达到的对象,放入老年代中。
- 老年代空间不足时,且经过Minor GC仍然不足时,将先前因年轻代空间不足提前放到老年代的对象,放回年轻代。
分代GC算法将堆分为年轻代和老年代注意原因:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 年轻代和老年代使用不同的垃圾回收算法,年轻代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 分代的设计中只允许回收年轻代,如果能满足对象分配的要求就不需要对整个堆进行回收,STW时间就会减少。
垃圾回收器组合——Serial 和 Serial Old
年轻代——Serial垃圾回收器
serial垃圾回收器是一种单线程串行回收年轻代的垃圾回收器。
当进行垃圾回收的时候,会停止所有用户线程。
- 回收年代:年轻代
- 算法:复制算法
- 优点:单CPU处理器下吞吐量非常出色
- 缺点:多CPU吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
- 适用场景:Java编写的客户端程序或者硬件配置有限的场景
老年代——Serial垃圾回收器
Serial Old是Serial垃圾回收器的老年代版本,采用单线程串行回收。
当进行垃圾回收的时候,会停止所有用户线程。
- 回收年代:老年代
- 算法:标记-整理算法
- 优点:单CPU处理器下吞吐量非常出色
- 缺点:多CPU吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
- 适用场景:与Serial垃圾回收器搭配使用或者在CMS特殊情况下使用
垃圾回收器组合——ParNew和CMS
年轻代——ParNew垃圾回收器
ParNew垃圾回收器本质是对Serial在多CPU下的优化,使用多线程进行垃圾回收
当进行垃圾回收的时候,会使用多线程进行回收,这是与Serial最大的不用。
- 回收年代:年轻代
- 算法:复制算法
- 优点:多CPU处理器下停顿时间较短
- 缺点:吞吐量和停顿时间不如G1
- 适用场景:JDK8之前的版本中,与CMS老年代垃圾回收器搭配使用
老年代——CMS垃圾回收器
CMS(Concurrent Mak)垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
执行步骤:
- 初始标记:用极短的时间标记出GC Root能直接关联的对象。(可达性分析算法标记)
- 并发标记:标记所有的对象(可回收和不可回收),用户线程不需要停止。
- 重新标记:由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。
- 并发清除:清理死亡的对象,用户线程不需要暂停。
- 回收年代:老年代
- 算法:标记清除算法
- 优点:系统由于垃圾回收出现的停顿时间较短,用户体验好
- 缺点:内存碎片化、退化问题、浮动垃圾问题。
- 适用场景:大型 互联网系统中用户请求数据量大、频率高的场景。比如订单接口、商品接口等。
CMS垃圾回收器存在的问题
- CMS使用了标记-清除算法,在垃圾回收结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片化的整理。
- 无法处理在并发清理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收。
- 如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
垃圾回收器组合——Parallel Scavenge和Parallel Old
年轻代——Parallel Scavenge垃圾回收器
Parallel Scavenge是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量。具备自动调整堆内存大小的特点。
- 回收年代:年轻代
- 算法:复制算法
- 优点:吞吐量高,而且手动可控,为了提高吞吐量,虚拟机会动态调整堆的参数
- 缺点:不能保证单次的停顿时间
- 适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象。比如:大数据的处理,大文件导出
老年代——Parallel Old垃圾回收器
Parallel Old是为了Parallel Scavenge收集器设计的老年代版本,利用多线程并发收集。
- 回收年代:老年代
- 算法:标记-整理算法
- 优点:并发收集,在多核CPU下效率较高
- 缺点:暂停时间会比较长
- 适用场景:与Parallel Scavenge配套使用
G1垃圾回收器
JDK9之后默认的垃圾回收器G1(Garbage First)垃圾回收器
Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小。
CMS关注暂停时间,但是吞吐量方面会下降。
而G1设计目标就是将上述两种垃圾回收器的优点融合:
- 支持巨大的堆空间回收,并由较高的吞吐量。
- 支持多CPU并行垃圾回收。
- 允许用户设置最大暂停时间。
G1的整个堆会被划分成多个大小相等的区域,称之为Region,区域不要求是连续的,分为Eden,Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数指定大小,Region size必须是2的指数幂,取值范围从1M到32M。
G1垃圾回收方式;
- 年轻代回收(Young GC):回收Eden区和Survivor区不用的对象。会导致STW,G1中可以通过参数调整每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
- 混合回收(Mixed GC)
G1垃圾回收器执行流程
- 新创建的对象会存放在Eden区。当G1判断年轻代不足(年轻代设置大小的60%),无法分配对象时需要回收时会执行Young GC。
- 标记出Eden和Survivor区域中的存活对象。
- 根据配置的最大暂停时间选择某些区域将存放对象复制到一个新的Survivor区中(年龄加1),清空这些区域。
- 后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
- 当某个对象年龄达到阈值时,将会被放入老年代。
- 部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。
- 多次回收之后,会出现很多老年代区,此时总堆占有率达到阈值,会触发混合回收MixedGC。回收所有年轻代和部分老年代的对象及大对象区。(复制算法完成)
G1垃圾回收器-混合回收
- 混合回收分为:初始标记、并发标记、最终标记、并发清理
- G1堆老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收率最高
G1回收器-Full GC
如果清理过程中发现没人足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停,所以尽量保证应该用的堆内存有一定多余的空间。
G1垃圾回收器-Garbage First垃圾回收器
- 回收年代:老年代和年轻代
- 算法:复制算法
- 优点:对比较大的堆如超过6G的堆回收时,延迟可控;不会产生内存碎片;并发标记的SATB算法效率高
- 缺点:JDK8之前还不够成熟
- 适用场景:JDK8最新版本、JDK9之后建议默认使用