垃圾收集与对象内存分配策略.md
本文讲述JVM垃圾回收过程中的一些内容,包括哪些内存应该被回收、何时回收、如何回收以及对象在内存中的分配策略
哪些内存需要回收?
当对象不再存活,而是“死去”状态的时候,JVM就应该将对象所占用的内存空间进行回收。所以在判断哪些内存应该回收的时候,应该首先判断哪些对象“已死”,而对象死去的特征就是再也没有任何引用指向该对象。 那么如何判断对象是否还存在引用关系呢。常见的策略包括计数器法、可达性分析法,接下来着重说明这两种策略
对象已死的判断方法
计数器法
该方法是给每一个对象设定一个计数器,每当有引用指向这个对象,计数器就+1,当计数器的值为0的时候,就代表没有引用指向该对象了,此时就可以进行对象的回收了。但是该方法存在着无法解决循环引用的问题,所以不常用。
循环引用造成引用计数器无法归零示意图
A类中的属性引用指向B类,B类中的属性引用指向A类。此时即使A、B都没有任何的外部引用,但是引用计数器也不会归零,因为存在着属性的相互引用。计数器方式很难解决这样的循环引用对象,所以在一般的对象生命存活判断中不使用该方式。
可达性分析
可达性分析法是指从一系列GC ROOT出发,判断能否到达对象内存,中间走过的路径称作引用链(Reference Chain),如果某个对象到GC ROOT没有引用链相连,或者用图论的话术来说是不可达的则认为对象已死。
谁可以作为GC ROOT?
- 在Java虚拟机栈(本地变量表)中引用的变量,比如一些函数调用时候用到的局部变量、临时变量、参数等
- 方法区中常量引用的对象,比如字符串常量池里面的引用
- 所有被同步锁持有的对象
通过以上两种策略判断出来的已死对象都是JVM垃圾收集的目标。
已死对象的最后一次机会
被判断“已死”的对象其实也不是绝对的就死定了。这时候他们仍处于一个缓刑的状态,一个对象真的被清除要经过两次标记的过程。当通过可达性分析发现一个对象已经没有引用链了,此时会将这个对象进行第一次的标记,随后会进行一次筛选,查看这些对象是否有必要执行finalize()方法。如果对象没有复写finalize()方法或者该方法已经被调用过,那么虚拟机将认为这个对象是没必要执行该方法的。
如果这个对象有必要执行finalize方法,则会分配一个最低优先级的线程去执行该方法,在执行该方法的过程中,可以给对象重新建立引用关系,这个时候对象就能够“起死回生”了。
方法区的回收
方法区的回收包括废弃的常量回收和不在使用的类型的回收。
常量的回收和Java堆中的对象回收比较类似,当发现常量池中的字符串或引用没有在被使用的时候,就会回收。比如一个字符串“abc”,当系统中在没有一个字符串对象的值是“abc”,且虚拟机中也没有其他地方引用这个字面量,这个时候如果发生了内存回收,并且垃圾收集器认为有必要清理方法区,此时就会把该常量清除出去。
类的回收相对就要麻烦一些,需要满足如下条件:
- 该类的所有实例都被回收了
- 加载该类的类加载器被回收了
- 类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
引用的分类
Java中的引用分为强引用,软引用,弱引用,虚引用。这些引用的强度依次递减。
- 强引用:最传统的引用定义,如 Object ob = new Object(),指在程序中普遍存在的引用赋值,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会收掉被引用的对象。
- 软引用:用来描述一些还有用,但非必须的对象。此类引用在系统将要发生内存溢出的时候,会被列入回收范围之中进行第二次回收,如果这次回收还没有释放足够的内存,就会抛出OOM
- 弱引用:也是用来描述非必需的对象,但是强度更弱一些,被弱引用关联的对象只能生存到下一轮垃圾收集
- 虚引用:也叫做幽灵引用,他对于一个对象来说的唯一目的就是在对象被收回的时候能够接受到一个系统通知
垃圾收集算法
上面讲述了哪些对象应该被收回以及判定对象已死的方法,那么具体怎么回收呢,接下来将介绍几种典型的垃圾收集算法。
分代收集理论
现在所有的垃圾收集器都基于“分代理论”来进行设计,所谓理论其实就是一套经验规范。分代理论有三条,分别如下:
- 弱分代假说:绝大多数对象都是朝生夕死的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数
基于上述假说促使垃圾收集器依据对象年龄将Java堆划分为新生代或老年代。也就是因为有区域的划分,因此才有了“Minor GC”、“Major GC”或者“Full GC”这样的回收类型的划分。而针对不同的区域中的对象特点,又随之出现了不同的垃圾收集算法。
标记-清除算法
过程:首先进行对象的标记,找对堆中需要清理的对象进行标记,然后将标记的对象进行清理。
这种方式的缺点在于如果对象十分的多,那么标记本身的效率就不会很高。其次,标记的对象被删除后很可能会出现内存碎片的现象,导致下次有大对象分配时找不到空间从而又触发垃圾收集。
标记-复制算法
过程:又称为半区复制,他的思想是将内存分为两部分,一部分空闲,一部分分配对象,当触发垃圾收集的时候会对对象进行标记,将存活的对象复制到空闲半区,然后清理掉被标记对象。
优势:相比于标记清除算法,复制算法的优势在于新对象空间分配的时候,他只要移动空间指针给对象分配内存就好了,不需要考虑内存碎片的问题。并且标记的时候可以根据对象的存活规律来选择是标记存活的还是标记未存活的,这样标记效率也会有所提高。
缺点:这种方式的缺点在于会有一半的空间空闲,导致整体的内存使用率变低
使用:目前主流的虚拟机都采用这个垃圾收集算法。IBM公司曾对新生代对象的“朝生夕死”特点进行过量化计算,最终发现大约98%的对象都熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间。后来便有人提出了更优化的半区复制分代策略。将新生代分为一个大的Eden区域和两个小的Survivor区域,对象的分配都在Eden+一个Survivor上进行,当发生垃圾收集时候就把存活对象复制到另一个空闲的Survivor上,然后清理掉Eden区域和另一个Survivor区域。
老年代的分配担保机制:Eden和Survivor的大小比例通常为8:1,也就是占用整个新生代的90%,留10%作为复制算法的可用空间。那么一旦留用的空闲空间不足以分配新的对象,这个时候就会触发堆的分配担保机制,此时创建的大对象会直接分配到老年代中。
标记-整理算法
过程:将对象进行标记,然后将存活的对象移动到内存中的一片区域后清理掉除过这个区域外的垃圾对象。
使用:这个算法主要是针对老年代设计出来的,所以主要使用的区域是老年代。与上述的算法不同,这个算法是基于“移动”操作的。
移动对象的利弊:由于老年代中的对象基本都是存活的,需要进行垃圾收集的比较少,所以标记完成怎么处理大量存活的对象才是问题的关键。如果采用复制算法那么复制的成本就太高了,而且需要准备的空闲空间也太大了,所以不能采用复制原理。而如果选择不移动对象,只是清除掉未被标记的对象这样就会造成内存碎片化,一旦碎片程度加重无法在进行对象的分配,此时就需要调用系统内核进行内存的重新整理与分配,这个代价就比较高了,所以最终选择了对象的移动整理。这种做法虽然也会造成“stop the world”的发生,但是相比较于内存的重新分配,引用的重新创建,移动的成本相对还是比较小的
内存分配与回收策略
从本质上来说,对象的分配都是分配在堆上的,但是随着技术的发展,即时编译产生的有些对象也会被分配到栈上。基于经典分代理论,新生对象一般都被分配到新生代,少数情况下被直接分配到老年代(比如触发了老年代内存担保)
对象在内存中如何分配
创建的对象优先分配在Eden区域,当Eden区域快要满不足以分配新的对象时会发生Minor GC,此时会触发内存分配担保策略,根据判断结果判断如何进行垃圾收集,这时候会先去判断老年代中的最大连续空间大小是否大于新生代中的对象内存使用总和,如果大于则这次内存回收是安全的,进行Minor GC;如果不大于,这时候就要查看是否允许担保失败,如果允许,则查看老年代最大连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于则进行Minor GC,如果不大于或者本身就不允许担保失败则直接进行Full GC。也有部分对象会被直接分配到老年代,比如如果使用了-XX:PretenureSizeThreshold参数,那么创建的对象大小如果大于参数规定的值,这个时候就会被直接分配进入老年代。所以在创建对象的过程中我们应该严格注意不要创建大对象,尤其是“朝生夕死”的大对象,他们的对象复制以及空间占用对JVM来说都是一个不小的负担。
长期存活的对象会从新生代进入老年代。Eden区域中的对象都是新创建出来的对象,当经过依次Minor GC之后如果没有被销毁,他就会进行Survivor区域,此时对象头中的年龄计数器就会+1,此后每经过依次垃圾收集,对象的年龄就会加1,到达一定的年龄后,对象就会被转移进入老年代。年龄阈值可以功过-XX:MaxTenuringThreshold设置。然后也有一种情况是对象的年龄没有到达阈值就被转移到了老年代,那就是在Survivor空间中的同龄对象的空间占用超过了Survivor空间的一半,此时会将这些对象统一转移到老年代中。
HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况
垃圾的回收时机是什么
综上,当Eden区不足以分配新的对象时会触发Minor GC,当堆内存不足或者触发了内存担保并发现可能无法担保成功时会触发Full GC。
一些故障处理工具
Java程序经常会因为一些编码错误或者内存问题发生程序崩溃,其中部分情况下可能都是因为堆中内存空间发生了异常,接下来对一些工具进行总结,在异常的场景下希望能够挑选出来合适的工具解决问题
jps -lv
:可以查看当前虚拟机中跑的java进程,并能够看到具体的启动命令以及启动时候指定的参数jinfo
: jps -lv能够看到进程启动时候指定的参数,但是没法看到默认的参数,这个时候就可以使用jinfo命令来看默认的参数是什么,比如jinfo -flag CMSInitiatingOccupancyFractionjstat
: 监视虚拟机各种运行状态信息,比如-gc参数监视Java堆状况、-gcutil显示Java堆各个区域空间占用百分比、-class参数监视类加载、卸载数量、总空间以及类装载所耗费的时间jmap
:生成堆转储文件,他有几个参数: -dump,生成堆转储文件、-heap,显示堆详细信息,如使用哪种回收器,参数配置,分代状况等、-histo,显示堆中对象统计信息,包括类,实例数量等。一般在JVM参数设定+HeapDumpOnOutOfMemoryError,效果就和-dump命令相似jstack
:生成虚拟机当前时刻的线程快照。jhat
:可以配合jmap命令查看生成的堆存储快照,不过一般没人用,推荐使用VisualVM和Jprofiler等工具
垃圾收集与对象内存分配策略.md