当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节
之前一篇我们知道JVM管理的内存被分为程序计数器、虚拟机栈、本地方法栈和Java堆还有方法区(JDK1.8之前)。而程序计数器、虚拟机栈、本地方法栈随着方法或者线程的结束,所使用的内存被系统回收,但是Java堆却不是这样,这部分内存的分配和回收都是动态的,也是垃圾收集器所关注的地方。
对于Java来说,垃圾回收的内容,也就是我们使用的对象。要回收对象首先我们要做的就是找到已经“死去”的对象。
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。
具体算法:
给每个对象添加一个引用计数器。当有其他地方引用它的时候计数器+1。当引用失效的时候-1。当计数器为0的时候判断对象不再被使用。此时这个对象可以被回收。
引用计数算法的缺点
引用计数的缺点也很明显。当存在两个对象(A、B),当A对象的参数引用了B对象,而B对象的参数引用了A对象。此时当A、B对象都不在使用了,但是因为两者都互相引用对方。这样两个对象计数器永不为0则回收器不会对其进行收集。
目前商用语言的主流实现中,大多数都是通过可达性分析来判定对象是否存活的。
具体算法:
通过被认定为“GC Roots”的对象为起始点,从这个起点向下检索,当一个对象到“GC Roots”质检没有任何引用链相连。证明对象不可达。这个时候垃圾回收器就会判断他们将会是可回收对象。
GC Roots对象包括
在JDK 1.2以前,Java中的引用的定义是:如果reference类型的数据的值为另外一块内存的地址就称这块内存代表着一个引用。
1.2之后,Java对引用的概念进行了扩充,引用被细分开
指的是类似
Object obj=new Object()
,只要这种强引用还存在,垃圾回收器就永远不会回收掉被引用的对象。
用来描述一些有用单并非必要的对象,系统在发生内存溢出之前会把这些对象列进回收范围进行二次回收。如果回收后依旧没有获得足够的内存,则抛出异常。
也是用来描述非必须对象,但是它的强度更弱一些,被弱引用关联的对象只能生存到下一次GC之前。当GC发生时会被回收掉。
又称为幽灵引用,最弱的一种引用关系。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到系统通知。
上面的引用关系冲上到下依次减弱
一次标记并不代表对象的死亡。
如果对象进行可达性分析后发现对象不可达的时候,它会被第一次标记并且进行筛选。当对象覆盖finalize()方法后,会将对象放置在一个F-Queue的队列之中。稍后由一个虚拟机建立、低优先级的Finalizer线程去执行对象的finalize()方法。当然系统可能并不会等待finalize()方法执行完毕(如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将很可能会导致F-Queue队列中其他对象永久处于等待),稍后GC对F-Queue中的对象进行第二次小规模的标记,如果对象在Finalizer线程中重新建立引用链,那么它将避免被回收的命运,如果对象此时还没完成链接,那么它就会回收。
finalize()是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时。
此部分主要回收废弃常量和无用的类
废弃常量的条件假如一个字符串已经进入了常量池中,但没有任何String对象引用常量池中的这个常量,也没有其他地方引用了这个字面量。如果这个时候发生GC的话、这个常量就会被系统清理出常量池。
无用的类的条件
它是最基础的收集算法算法。清除过程被分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
缺点
针对上面的算法,为了解决效率问题提出了“复制”的收集算法。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面。然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收。
缺点
这种算法的代价是将内存缩小为了原来的一半。使系统的有效内存大大减少。
新生的对象大多数是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间。所以针对上面的问题,对内存分布进行重新划分。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。
当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)
虽然通过优化进来减少对内存的浪费但是使用复制收集算法,尤其是在对象存活率很高的极端情况下。浪费总是不能避免。所以在老年代一般不能直接选用这种算法。
标记过程仍然与“标记-清除”算法一样,但清除步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
无论是“标记-清除”还是“标记整理”亦或是“复制算法”总是满足某种情况下的效率而在一些其他情况下显得低效率。
分代收集就是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法。
在老年代因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
HotSpot虚拟机上实现上面的算法时,对算法的执行效率进行了严格的考量,以保证虚拟机高效运行。
GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
而这项工作又需要保证一致性,不可以出现分析过程中引用关系还在改变的情况。否则无法保证结果的准确性,这就导致GC的时候JAVA执行线程必须停顿的一个重要原因。
所以HotSpot为了解决这个问题就使用了一组称为OopMap的数据结构来达存放着对象引用,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来。在编译的时候也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC扫描的时候就可以直接知道这些信息了。
虽然有OopMap可以准确且快速的完成GC Roots枚举。但是假如为每一条指令都生成对应的OopMap,会需要大量的额外空间,这样GC的空间成本将会变得很高。
为了避免这种情况,HotSpot只会在特定位置上进行数据记录,这些位置被称为安全点。
但这时候存在一个问题如何在GC的时候让所有线程都在安全点停下来。目前有两种方式:
Safe Region看做是被扩展了的Safepoint,指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。
在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,就继续执行,否则就等待可以离开Safe Region的信号。
安全区域主要是解决当线程处于“不执行”的情况,比如在睡眠或者阻塞状态,此时线程无法响应JVM的中断请求。