学习Java或者从事Java开发的程序员应该都知道,在Java语言背后有着一套成熟的体系,这个体系支撑着Java项目的生存周期,并且在Java项目生存期间,GC机制为整个项目的运行提供了不可忽略的作用,可以说GC是Java语言的核心
浅谈JVM中运行时的内存区域分布
首先,程序计数器和Java虚拟机栈、本地方法栈3个区域都是线程私有的部分,也就是说,这三个部分上面分配的运行时内存都会伴随着线程执行结束而自动被释放,尤其是在逃逸分析之后的栈上分配更是为了减少GC消耗的性能采用的一种方案
栈上分配这个概念之前在总结中也有提到,这种技术的实现,也就可以将一些方法内创建的实例对象需要的内存大小直接开辟在虚拟机栈上,并且随着方法的执行和结束,伴随着栈帧的入栈和出栈,这一块申请在栈上的内存空间也将直接被回收,不需要GC的参与
堆和方法区
说到方法区,需要清楚的一个概念是,方法区是JVM规范中的运行时数据区的一个部分,其余的组成可以参考之前的博客,至于永久带以及元空间这两种说法,是不同的JVM或不同版本的JVM对方法区的一种具体实现,永久带和元空间都是HotSpot JVM的实现方式,1.7和之前的HotSpot版本的JVM中方法区的实现为永久带,HotSpot在1.8和之后移除了永久带 ,并且引入一个新的实现元空间(metaspace)
我们所听说到的一些GC的实现方式以及一些GC的必要算法其实更多的是面向堆上的失去引用的实例,其实正是因为堆上的对象只有在程序运行期间才会知道创建了什么对象,以及程序运行时可能会出现一些难以分析的场景,也正是因为这种运行时的复杂性,我们才为在堆上执行GC时提供了这么多的GC策略
对象是否还活着
我们判断一个对象该被回收的特点是:不可能再被任何途径使用的对象
引用计数
引用计数是早期出现的一种判断对象是否还存活的方式,判定效率很高,并且在一些场景中也是用到了这种方式,通过为每一个对象设置一个引用计数器,如果存在一条引用则计数器自增,若失去一条引用的计数器自减
问题
- 如果在GC过程中存在两个对象相互引用并且这两个对象没有被其余对象所引用,也就是说这两个对象实际上是应该被GC的对象,但是因为两者的互相引用没有使计数器的值减少为0,也正是因为这个原因,没有办法通知GC收集器去收集他们
引用计数这个方式在书上也有实例,但是说实话我并没从书上的例子中看出JVM没有使用引用计数这种方式去判断一个对象是否是存活的,但是实际情况是,JVM的GC机制确实没有使用这种方式去实现如何判断一个对象是否该被GC
可达性分析算法
可达性分析算法在GC的过程中是判断对象是否存活的一种主要算法,这种算法为我们提供了一个新的概念GC ROOTS
GC ROOTS体系GC ROOTS
通过这个名字我们可以直接对它进行一个翻译,GC的根节点集,从图中的意思我们可以看出,GC ROOTS作为GC的根节点向下不断延伸,形成一颗类似于树的结构,这些路径我们不妨称为链
可作为GC ROOTS的元素
- 虚拟机栈(栈帧中的本地变量表)中的引用对象
- 方法区中类的静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(一般Native方法)引用的对象
上面的四种类型在之前介绍JVM内存区域的时候已经为大家解读过,其实仔细分析能够作为GC ROOTS的元素,能够发现,其实在编写代码的时候大家就能发现
- 栈帧中的本地变量表中引用的对象其实就是声明在方法中的引用对象
- 方法区中的类的静态属性其实就是我们常说的static修饰的"属于类"的引用对象
- 常量区中引用的对象就比如在类成员变量中声明为final的饮用对象
- 本地方法栈的引用对象就有些类似Java方法中的引用对象了
什么是可达性分析算法
JVM从GC ROOTS结点向下搜索,经历过的路径称为 "引用链",当一个对象到GC Roots没有任何引用链相连(如图所示obj 8 -10即为不可达的),这就说明该对象是不可用的,接下来再通过JVM的一些机制将对它进行回收或拯救
增强引用
JDK1.2之前,Java中的引用很传统 如果reference类型的数据中存储的数值代表另一块内存的起始地址,就称这块内存代表一个引用
JDK在1.2之后,丰富了引用的类型,对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,扩充引用类型的目的其实很明确,在GC时,按照引用类型的不同,在回收时采用不同的逻辑。 这几种类型的引用主要在jvm内存缓存、资源释放、对象可达性事件处理等场景会用到
-
强引用
这种类型的引用在代码中最广泛存在,那就是使用new关键字实例化的引用,只要这种强引用只要还存在,GC就不会发生在被强引用所引用的对象身上
-
软引用
这种类型的引用用来描述一些有用但是并非必须的对象,在JVM即将发生OOM的时候,将会把被软引用所引用的对象列进回收范围内进行二次回收
-
弱引用
弱引用所引用的对象,只能生存到下一次GC前,当下一次GC开始的时候,不管内存空间是否足够,都会对这种类型的引用引用的对象实行GC
-
虚引用
为一个对象设置徐引用关联的唯一目的是让这个对象在被回收器回收的时候收到一个系统通知
GC的标记过程
一个对象并不是在可达性分析中若判定为不在引用链上就会被标记为即将被GC的对象,一个对象被GC的过程至少要经过2次标记,下面将为大家介绍如何进行两次标记,以及对象如何进行自我拯救脱离将被GC的命运
-
可达性分析
上文介绍了可达性算法的具体过程,那么在一个对象呗可达性分析出不在GC链上的时候,将会被第一次标记,并且进行一次筛选,筛选的条件是是否重有必要执行finalize方法,这一次筛选也是作为第二次标记的条件
-
finalize方法
当一个对象没有重写finalize方法,或者finalize方法被执行过一次,那么这一次筛选将会视这两种情况为没有必要执行finalize方法
如果这次筛选其中一些对象被判定为了有必要执行finalize方法,那么这些对象将会被放置在一个F-Queue队列中,并且稍后会有一个由JVM创建的,低优先级的Finalizer线程去执行它,当然,这里说的"执行"并不一定意味着进入F-Queue队列中等待执行finalize方法的对象一定会执行finalize方法
如果一个对象在执行finalize方法的时候进入一个死循环,或者说finalize方法执行时间过长,就可能导致队列中其余的等待执行finalize方法的对象不能够继续执行,如果发生这种情况,可能会导致F-Queue队列中的对象发生永久等待,更有可能导致GC崩溃
如果对象尝试通过finalize方法拯救自己,只需要将自己赋值给某个类变量或者对象的成员变量即可(回到引用链上),这样的话这个对象将会在第二次标记的过程中被移除即将被回收的集合,如果这个对象没有完成自我拯救的话,那就真的被GC了
对方法区的回收
永久代的垃圾回收主要存在两个部分:无用的常量和类
对一个字符串"abc"来说,如果程序中没有一个String对象是"abc",也就是说没有任何String对象引用常量池中的"abc"常量,也没有其余地方使用了这个字面量,如果这个时候发生GC,那么这个"abc"常量将会被进行清理
如何判断一个类无用
- 该类的所有实例都已经被回收
- 加载该类的ClassLoader已经被回收
- 该类的Class对象没有在任何地方被引用,无法在其余地方通过反射访问该类的方法
当然,这里说的满足上述三条时,一个类将会被回收,并不是和对象一样不使用了就必然会被回收,可以通过一些JVM参数进行指定
《深入理解JVM》这本书里提到,在大量使用反射,动态代理、CGLib等框架动态生成自定义ClassLoader的场景都需要具备卸载类的功能,以保证永久代不会溢出,想想之前一个SpringBoot项目的部署直接使一个远程服务器宕机的事故,终于能有所体会...
垃圾回收算法
接下来谈到的垃圾回收算法,网上一搜一大堆,电子书中也是一翻就有,所以这里不再介入截图之类的过多说辞,仅仅放上我认为的重点
标记-清除算法
在介绍这个算法之前,希望大家还能够记起之前提到过的指针碰撞和空闲页表这两种分配内存空间的方式,并且思考一下区别
标记清除算法较为简单,首先标记出所有需要回收的对象,接着回收所有被标记的对象
可以看到标记清除算法只是简单地将该回收的对象进行标记,并且下一步并没有对对象的内存区域布局进行改变,这样可以想象的来,整个堆上如果使用这种算法实现GC的话,整个堆上的空间将会是断断续续,需要使用空闲列表来维护使用的和未使用的空间
并且,标记清除算法可能造成较大的内存碎片,若分配较大的内存空间时,可能会因为空间不足提前触发GC
复制算法
为了解决标记清除算法产生的诸多问题,复制算法诞生了
它将可用内存等容量得划分为两个区域,每次只使用其中的一块,当这一块内存用完了之后,会将依旧存活的对象复制到另一块上面,然后再把已经使用过的内存空间一次性清理掉
通过这种算法,可以看出GC的区域只是整个可用空间的一半,并且通过复制算法实现的GC机制,不会出现内存碎片,空闲出来的永远是一个空的半区,只需要移动指针,按照指针碰撞的方式进行分配即可
这只是最原始的复制算法,不过这种算法存在一种最明显的缺点,那就是每次分配空间只能使用可用空间的一半,这种设计方式的代价是不是高了点?
复制算法的改良版
在JVM的世界里,存在这样一种比例 新生代的对象:老年代对象 = 8:1,所以,复制算法不需要使用1:1的内存格局去实现,在后来的JVM中,复制算法有3个比较重要的区域Eden和2个Survivor区,Eden和Survivor的比例为8:1,每次使用的是Eden和其中一块Survivor区域,这样的话新生代可使用的内存空间就占到了9/10,只有1/10的空间会被浪费掉
使用这种方式进行回收的时候,将Eden和其中一块作为新生代空间的Survivor区域中依旧存活的对象复制到另一块Survivor中,并且完全清除Eden和Survivor中的内存区域,其实不难发现,与最开始的复制算法根本没有差别,只是引入了1块Eden和2块Survivor区域来降低1:1的最初的复制算法带来的代价问题
当然,我们没有办法保证每次存活的对象只有小于或等于10%,这个时候,如果Survivor区域的内存空间不足,将会向其余内存(老年代)进行分配担保,内存的分配担保是说,如果另一块Survivor空间不足以存放接下来存活的对象,将会通过这种担保方式直接进入老年代
在我看来,老年代是不存在复制算法的,因为老年代没有别的内存空间给它做担保,也就是说,老年代的无用对象回收需要借助于其余的算法,并且这个观点在书中也被证实
标记-整理算法
复制算法,在对象存活情况较高的情况下它的效率也就下降了,并且需要额外的空间进行担保,所以老年代一般不采用这种算法去实现
标记整理算法是说,标记过程仍然保持与标记清除算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界意外的内存,显而易见,这种算法实现的GC在之后的内存分配的方式上,还是选择了指针碰撞,并且不存在大量的内存碎片
HotSpot算法实现
并且通过GC Roots遍历根节点的这个步骤的过程中,如果这些引用链非常庞大,如果需要一个个检查这里面的引用,必然又是一项耗时操作
现在我们知道了,标记这一个步骤主要依赖的是可达性算法的分析,那么我们可以想象得到,运行的程序中的每一个时刻中的引用链说不定都是在变化的,那么我们就不能时时刻刻都可以进行可达性分析
可达性分析对时间的敏感同时体现在GC停顿上,结合我上面说到的,如果程序正在运行,那么整个GC链中的引用可能是时刻都在变化的,这对于我们分析可达性而言,无疑是一种问题,这个时候,可达性分析就需要一种称作为 "一致性" 的特点,这种特点也导致了GC进行时,必须停止所有Java的执行线程(Stop The World),这使得整个分析期间,程序就像被冻结在某个时间点上,不可以出现对象间的引用持续变化,该点不满足的情况下,分析的结果就不能被保证
接下来我想说的是我个人的理解,这里看书的时候很迷糊,同时也希望如果自己的观点有什么问题,可以指出
书上提到,目前主流的JVM采用的都是准确式GC,所以当系统停顿下来之后,并不需要一个不漏地去检查完所有执行上下文和全局引用的位置,虚拟机应该是有办法直接得知哪些地方存放着对象的引用,并且也介绍了,这种实现是因为一个叫OopMap的数据结构计算出来的
在类加载的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用
浅谈OopMap
书上举了一个String.hashCode()方法编译后的本地代码
0x026eb7a9:call 0x026e83e0 ; OopMap{ebx=Oop [16]=Oop off=[142]}
…………
…………
0x026eb7be:hlt
这段生成的本地代码指明了EBX寄存器和栈中偏移量为16的内存区域各有一个普通对象指针的引用,有效范围从call之灵开始到+142,即hlt指令为止
因为我们需要从GC停顿中去进行可达性分析,又因为可达性分析不能真正地通过各个遍历可作为GC Roots的结点去完成,这样效率过低,又是因为JVM采用准确式GC,并且配合OopMap这种数据结构,能够知道在GC停顿的时候哪个位置会存在引用
首先这是String.hashCode()编译得到的本地代码,我的理解是这样的,可达性分析判断的能作为GC Roots的类型上面也有提及,这个编译得到的本地代码,其实就是在GC停顿时,有执行hashCode方法的常量,或者字符串引用,就保存在EBX寄存器和栈中偏移量为16的内存区域中,通过GC停顿这个操作,可以再这一刻冻结的时间内,找到这个引用并且作为根节点进行可达性分析
安全点
虽然有了OopMap的帮助,但是还是有一个问题的存在,那就是时刻都有可能导致引用关系变化,或者说OopMap内容变化指令非常多,不可能为每一条指令都生成OopMap
HotSpot只是在特定的位置记录了这些信息,而这些信息就称为安全点,程序执行时,并不是在所有地方都可以停下来开始GC,只有在安全点才可以暂停,这种安全点的选择,既不能选取地太多让程序过长时间陷入等待,也不能设置得太少以至于GC等待的时间过长
"长时间"运行的程序,才是作为安全点的选取标准的,长时间执行的明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,具有这些功能的指令才会产生安全点
如何让所有线程都能在GC暂停的时候,运行到安全点
-
抢先式中断
抢先式中断不需要每条线程去配合,在GC发生的时候,JVM会把所有线程全部中断,如果发现有线程不在安全点的话,将恢复该线程,让其运行至安全点
-
主动式中断
采用这种方式的时候,不需要直接对线程操作,只需要设置一个标志,每个线程执行的时候,主动去轮询这个标志,如果发现中断标志为true的时候,就将自己中断挂起,轮询标志的地方是与安全点重合的,再加上创建对象需要分配内存的地方
安全区域
为什么已经有了安全点,还需要引入安全区域这个概念,安全点的机制能够使得在不长的时间内就可以遇到可进入GC的安全点,但是如果程序不执行的时候呢
程序不执行就是指没有分配CPU时间,典型的例子就是线程处于blocked状态,这个时候线程无法响应JVM中的中断请求,JVM同样也不会等待线程重新被分配CPU时间,这个时候就需要安全区域的加入了
在线程执行到安全区域的时候,会标识自己进入了安全区,这样的话如果GC到来,就不用管已经标记自己进入安全区域的线程了,在线程要离开安全区域的时候,JVM会判断是否已经完成了根节点的枚举,或者是整个GC的过程,如果完成了,该线程继续运行,如果没有完成的话,那就必须进行等待
以上是JVM GC 的一些较为重点的内容,其中不少有我的理解,如有误请指出
网友评论