JVM中的垃圾收集(GC)
JVM中的垃圾收集(GC)
0.简介
欢迎来到我的博客:TWind的博客
我的CSDN::Thanwind-CSDN博客
我的掘金:Thanwinde 的个人主页
对于JVM,最重要的莫过于对垃圾的收集,可以说GC是整个JVM最重要的部分了,没有之一
对于GC的研究一直在进行也一直在进化,从一整块到分区,从分区到两态,从单线程到多线程,从阻塞到并发,GC的发展标志着Java的发展
所以,如果能对GC有着独到的理解,会对你学习Java有着不小的裨益
当然,光看本文是完全不够的,充其量只能达到一个大致了解,在此我强烈推荐《JVM高级特性与最佳实践》,是入门的不二选择
1.什么是GC
GC,即Garbage Collection,众所周知,Java在运行时会产生相当多的数据:譬如各种新引用,对象,类等等,而这些东西都存储在堆以及元空间之中(1.8之后),而当这些东西堆积起来,就很可能超过内存上限,然后OOM报错
那当然不能这样,因为Java本来的目标就是构建一个能长时运行且稳定的系统。所以人们注意到,有很多对象其实用不到:那么就可以将其回收,节省出空间,而这种行为就被形象的称为:Garbage Collection,垃圾回收
垃圾回收可以说是Java中最重要的一环,没有之一:它直接决定了整个JVM的稳定性以及性能,各种对于GC收集器的研究也一直在进行,每一次GC技术的突破都是Java性能的突破
下面就让我们来逐渐学习GC。
2.如何判断垃圾
如何判断一个对象是不是垃圾呢?在研究人员的不断尝试下,目前主要出现了两种方法:=-
①.引用计数法
这是最原始的一种方法,通过统计对象的被引用数来判断是否回收
举个例子,假如对象A被引用了一次,假如某个时间这个引用突然断开了,那么对象A就变成了没有被引用,那他就会被回收
但是显而易见的,这种方法有很多额外情况需要注意,就比如AB互相引用,虽然没有其他对象引用他们但由于他们互相引用而无法将他们回收
所以需要很多的额外判断才能将这种方法实际应用,Java并没有使用这种方法,目前也只有python等语言用了这种方法
②.可达性算法
可达性算法是Java采用的算法,它的原理是,从一些必须要保留的对象出发,遍历所有能遍历到的对象,把这些对象加入一个清单中,以此类推
然后清理掉所有的不在这个清单的对象,这就是可达性算法的一个雏形
在Java中,这些必须保留对象被称为GC root,通常是一些很重要的对象,比如:
- 虚拟机栈引用的对象
例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等 - 处于存活状态的线程对象
例如Thread - 方法区静态属性引用的对象
例如java类的引用类型静态变量 - 方法区常量引用的对象
例如字符串常量池的引用 - 本地native方法引用的对象
- Java虚拟机内部的引用
例如基本数据类型对应的class对象,一些常驻的异常对象(如NullPointerException、OutOfMemoryError),还有类加载器 - 所有被同步锁持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
这些对象,以及被他们引用的对象,都会被“标记”,一定不会被标记
至于其他的对象,就会被回收掉
那这个过程是怎么进行的?这里就会涉及到一个三色标记法
具体来说:
把每个对象看成一个带颜色的节点,会从GC root开始,遍历所有节点的引用链,有如下规则:
- 黑:GC root或是被GC root ,其他黑色节点直接或间接引用的节点
- 灰:还没有遍历完应用链的节点
- 白:没有被黑色节点直接或间接引用的节点
我们可以通过下面的图辅助理解:
但是对于一些支持并发的收集器,会在第一次标记后进行第二次标记来处理在这中间新产生的对象
具体来说,如果在一个黑色的对象新接上去一个对象,这个对象由于没有被标记,还是白色,如果不重复标记这个白色对象就会被错误的回收掉
对于这种情况,已经有大佬严格证明了:
只有在一个黑色的对象新接上去一个对象时会发生
只有在删除了所有灰色到白色的引用
只要破坏了这两个条件中的一个,就不会引起任何问题
具体的标记行为会因为垃圾收集器的不同而有所差异,但是基本的行为基本就是这样,只有一些细节上的处理不同,这些会在下文中处理
3.如何处理垃圾
似乎这个标题有点废话,垃圾当然得被清扫,但问题在于:如何清扫,怎么清扫,什么时候清扫,清扫完如何恢复,能不能边产生垃圾边清扫(并发)?
在GC的发展历程中,我们可以看到一个趋势:垃圾收集器越来越复杂,越来越趋向于多线程,效率也越来越高,功能越来越复杂
在最原始的GC中,一切都非常的简单:
暂停所有线程,标记所有没有被GC root引用的对象,全部清除,这样的话会产生大量的内存碎片(内存不连续)
碎片过多到影响到分配新对象,会触发整理,非常消耗性能
然而,随着理论和计算机性能的发展与进步,尤其是分代收集理论:
分代收集
绝大多数对象都是朝生夕灭的
熬过越多次垃圾收集过程的对象就越难以消亡
跨代引用相对于同代引用来说仅占极少数
根据这个理论我们可以发现,我们好像可以把内存分为两个部分:一个来装“新对象”,一个用来装“老对象”,因为老对象一般很稳定,不会很快消亡,新对象基本是朝生夕灭,对于那些经过了很多次GC的对象,就将其加入到老对象
这就基本是**“分代收集”**的大致雏形:分为新生代(新对象),老年代(老对象),两边采用不同的策略,分别管理,能极大的提升效率
下面是大致的描述图:
当内存不够的时候,会优先对新生代进行GC(Minor GC),绝大多数情况下都能基本回收到很多内存,如果内存还是不够,就会触发full GC:对所有区域进行GC,对于一些特殊的垃圾收集器,有major GC,会只对老年代进行GC
但是,这样仅仅是提高了效率,关于内存碎片的问题貌似还是没有被处理?
这里就设计到了不同的清扫策略了:
标记-清除
这是最原始的方法,直接清除,会产生内存碎片
标记-复制
会将内存块再分出一个单独的区域,每次标记完会将存活的对象移到一个单独的区域,并将其他的内存全部清除
这样的方法完美的契合新生代的概念:反正每次收集完,只能留下一小部分,那只需要预留一小部分区域即可,不会造成很大的内存损失
具体的策略会根据不同垃圾收集器的策略而变化,但是大致是这么个思想,下面是一个典型的模型:
在正常工作时,会空出一个Survivor,意味着使用的内存只有 总内存减一个Survivor
在GC的时候,会将所有存活的对象移到这个空着的Survivor,以此往复
这样就能很好的在内存碎片与效率之间做出一个平衡
一般来说,新生代:老年代为1 : 2,新生代中Survivor :其他大概为2 : 9,具体的实现以及比例是由用户和收集器处理的
比如G1,就没有严格的把整个内存将其分成两个新生代和老年代,而是动态的,但根本的思路大致不变
标记-整理
这里和标记-清除不同在于,在清除之后会将其整理,使其内存连续
虽然这种移动对象很花时间,需要处理大量的引用,但是相比起建立一个不连续内存的地址表并维护来说还是挺划算
老年代因为不像新生代那样朝生夕死,并不能采用标记复制这种简单直观的方法,也只能接受这个“必要之恶”了
跨代引用
对于一些新生代的对象,它可能被老年代的对象引用,在原本的minor GC处理之中,原本是不涉及到老年代的,但是由于可能有跨代引用:
新生代的对象也可能存在GC root,那你GC时就得扫描整个老年代找到所有的跨代引用,不然你就会错误的回收掉一些不该回收的对象
为了优化这个操作,JVM采用了**“记忆集”**,最常用的形式本质就是一个大致的表,里面每一个元素对应着一块内存,当这块内存出现了跨代引用,就会把这个元素标记上
那到时候GC的时候就只用扫描这些被标记的内存块,大大加速了这个过程
至于这个内存块要有多大,完全由GC决定,但一般是选择是一块内存区域,这一般称之为“卡精度”,其实现就称之为“卡表”,也就是上面的内容
至于怎么样去实现这个标记,这里用到了写屏障,并不是Java中的那个写屏障,这里是指赋值操作中会看是不是引用了其他区域的对象,如果是老年代到新生代就会把自己的那块内存“变脏”,也就是标记,有点类似于Spring的AOP
4.GC收集器
安全点
SafePoint,绝对是GC中一个非常关键点,顾名思义,它是一个安全点:让GC程序能够在这里放心大胆的操作
因为对于一些需要暂停全部线程的操作,不是每一个时间点都能暂停的:
- 不能太多,这样会严重的拖累效率
- 不能太少,这样会增大GC的负担,也有Out of Memory的风险
所以一般来说安全点都是选在一下会长时间运行的点:抛出异常,方法调用,循环跳转之类的
而如果在更底层来看,安全点设置与否取决于线程状态的转换:
从这个状态机可以看出来,安全点的检测都发生在状态改变的情况下,在/hotspot/src/share/vm/utilities/globalDefinitions.hpp
里面可以看到
// JavaThreadState keeps track of which part of the code a thread is executing in. This
// information is needed by the safepoint code.
//
// There are 4 essential states:
//
// _thread_new : Just started, but not executed init. code yet (most likely still in OS init code)
// _thread_in_native : In native code. This is a safepoint region, since all oops will be in jobject handles
// _thread_in_vm : Executing in the vm
// _thread_in_Java : Executing either interpreted or compiled Java code (or could be in a stub)
//
// Each state has an associated xxxx_trans state, which is an intermediate state used when a thread is in
// a transition from one state to another. These extra states makes it possible for the safepoint code to
// handle certain thread_states without having to suspend the thread - making the safepoint code faster.
//
// Given a state, the xxx_trans state can always be found by adding 1.
//
enum JavaThreadState {_thread_uninitialized = 0, // should never happen (missing initialization)_thread_new = 2, // just starting up, i.e., in process of being initialized_thread_new_trans = 3, // corresponding transition state (not used, included for completness)_thread_in_native = 4, // running in native code_thread_in_native_trans = 5, // corresponding transition state_thread_in_vm = 6, // running in VM_thread_in_vm_trans = 7, // corresponding transition state_thread_in_Java = 8, // running in Java or in stub code_thread_in_Java_trans = 9, // corresponding transition state (not used, included for completness)_thread_blocked = 10, // blocked in vm_thread_blocked_trans = 11, // corresponding transition state_thread_max_state = 12 // maximum thread state+1 - used for statistics allocation
};
_thread_uninitialized = 0
- 未初始化状态:这个状态意味着线程尚未开始初始化(即线程对象还未完全初始化)。这种状态应该是“不应该发生”的状态,因为在 JVM 启动时,线程会被初始化并进入某个可执行的状态。
_thread_new = 2
- 新线程状态:表示线程刚刚开始执行,但尚未执行任何初始化代码,可能正在操作系统初始化过程中。这通常是线程刚创建但还未进入实际的执行阶段。
_thread_new_trans = 3
- 新线程过渡状态:这是线程从
_thread_new
状态过渡到其他状态时的中间状态。该状态通常不常用,主要用于安全点机制的优化。
_thread_in_native = 4
- 在本地代码中:线程正在执行 本地代码(即通过 JNI 调用的本地方法,或是线程执行的原生操作)。这个状态是 安全点区域,因为在本地代码执行时,所有的对象指针(OOPs)会被存储在
jobject
句柄中,因此可以更容易地进行堆栈扫描和垃圾回收。
_thread_in_native_trans = 5
- 本地代码过渡状态:这是线程从
_thread_in_native
状态过渡到其他状态时的中间状态。
_thread_in_vm = 6
- 在 JVM 中:线程正在执行 JVM 代码(即执行 HotSpot 的 C++ 代码)。这通常涉及诸如对象分配、垃圾回收、JIT 编译、线程调度等操作。
_thread_in_vm_trans = 7
- JVM 中代码过渡状态:这是线程从
_thread_in_vm
状态过渡到其他状态时的中间状态。
_thread_in_Java = 8
- 在 Java 代码中:线程正在执行 Java 代码(包括解释执行的字节码、JIT 编译后的代码,或者是正在执行 stub 代码)。这是一个关键的状态,因为它代表着线程在运行 Java 代码,通常会在安全点时暂停线程。
_thread_in_Java_trans = 9
- Java 代码过渡状态:这是线程从
_thread_in_Java
状态过渡到其他状态时的中间状态。
_thread_blocked = 10
- 被阻塞状态:线程正在被某种方式阻塞,通常是在等待某个锁(例如,在同步方法或同步块中等待获取锁)。在这个状态下,线程是阻塞的,不会继续执行,直到它获得相应的资源。
_thread_blocked_trans = 11
- 线程阻塞过渡状态:这是线程从
_thread_blocked
状态过渡到其他状态时的中间状态。
_thread_max_state = 12
- 最大状态值:这是一个常量,用于表示线程状态枚举的最大值 + 1。它通常用于统计和分配资源时的标记。
你可以看出,可以分为三大部分:不可变,可变,过渡态,而只有过渡态会有安全点
那当到达安全点具体会干什么?这还是取决于收集器
- 抢先式中断
这种状态下,当需要执行GC时,系统会强行停止所有线程,对于没有在安全点的线程,会恢复其工作直到其到达安全点,这种方法现在几乎没有使用
- 主动式中断
这里更像Java中对于中断的处理办法:系统只是设置了一个标志位,当线程进入过渡态会轮询这个状态位,如果发现要中断,就会尽可能快的将自己挂起
现在绝大多数GC收集器都用的是这种方法
安全区域
如果在GC中有些线程注定无法响应:比如说已被挂起,处于Sleep,Blocked,在长循环中,就会采用“安全区域”
本质是大号的安全点,保证在这个区域内没有任何引用改变,程序进入区域等同于进入安全点,退出安全区域会看GC完没有,完成了就退出,反之挂起
现在,如果所有线程都被成功停止了,就该收集器大展身手了
Serial收集器(单线程典型)
这是Java最早的收集器,奠定了收集器的基本雏形,由于历史原因它是单线程的,也就是说就算你是多核电脑,GC也只会由一个核心完成
具体的流程如下:
在第一个被称为“SafePoint”的地方,即安全点,Serial会暂停掉所有线程,然后用一个核心完成所有过程,也就是先标记,再处理,非常的简单且易懂,但代价就是对于多核电脑没有性能增益,现在只适合在一些单核电脑上工作
对于minorGC(图中第一次),采用标记-复制,fullGC(第二次)采用标记-整理
CMS收集器(多线程典型)
CMS是Java一个典型且成功的多线程收集器,实现了在最为耗时的标记和清理的并发运行
开始时,会进行初始标记,这里会短暂的暂停所有的线程,然后CMS会标记所有的GCroot
然后恢复,开始并发的标记那些不可达的对象
标记完了,会再次暂停所有线程,重新标记那些中途产生的对象(见上文)
接着恢复,开始并发清理,清理完就会重置该线程回到业务线程
这个便是大多数传统多线程收集器的典型,CMS的问题在于,无法处理一些在并发清理过程中产生的新垃圾,称之为“浮动垃圾”,即便如此,它也很优秀了
G1收集器
G1收集器被称为“全能收集器”,原因在于它在很多方面都很出色,更重要的一点是它突破了原有的收集器的传统架构:
他把所有的内存划为了一个个“区”(Region)
每个Region大小在1MB ~ 32MB之间,并可以根据策略自由的去组成新生代,老年代,Survivor
这种优势让G1有了一个特性:可以自定义停顿时间
G1可以通过参数设定一个停顿参数,让其G1只能在这个时间内去清理垃圾(G1是多线程清理垃圾)
G1的全名是Garbage First ,意为垃圾优先,G1会维护一个价值表,会根据一个Region垃圾的多少和清理大约需要的时间计算,优先清理价值高的
这些全新的设计使得G1成为了一个新一代的垃圾收集器
但是代价是,为了支持这些Region,G1不得不维护一个非常复杂的卡表来应对跨代引用,这带来了大量的写屏障以及内存占用
对于工作流程,没有太大的变化:
G1比起CMS,处理垃圾的时候会暂停所有的线程来处理垃圾
同时,回收也变成了筛选回收:先回收有价值的
ZGC收集器
如果说G1是新时代的,那么ZGC完全可以说是跨时代的,它新颖的设计以及高效的设计完全可以说是收集器中耀眼的新星
篇幅限制,后面我会单独写一篇文章来介绍ZGC