如何判定对象已死
判定对象已死主流有两种算法,分别是引用计数算法和可达性分析算法
引用计数算法
即在每个对象里面存放一个计数器,被引用就使计数器加一,引用失效时计数器减一,虽然给虚拟机的内存增添了一些内存负担,但好在实现比较容易,判定效率也高,缺点就是无法解决对象之间相互引用的问题,一旦几个对象相互引用,该计数器就会判定这几个对象都还“未死”,但对于java程序已经不起作用,且因为“未死”垃圾收集器并不会把几个对象标记成为可收集的对象,导致内存泄漏。
可达性分析算法
该算法主要通过一系列java堆中的“GC Roots"也就是根对象,沿着这些GC Roots的引用向下查找,垃圾收集器只标记和收集那些,无法被GC Roots沿着引用关系扫描到的对象
在java中可作为GC Roots的对象主要包括以下几种
- 各个线程执行的方法中所用到的局部变量和参数等
- 方法区中静态变量和常量引用的对象
- 本地方法栈中引用的对象
- 基本数据类型对应的Class对象
- 常驻的异常对象
- 系统加载类对象
- 所有被同步锁(synchronized)持有的对象
引用
如果一个reference类型的数据存储的是另一个对象的起始地址,则成为引用,对于java来说如果一个对象只有被引用和不被引用两种状态,引用状态对于java来说就有些不够用了,java缺乏一些在内存空间很富足的时候可以正常存在,但是如果内存不够用会被垃圾收集器立即收集清理的引用,于是,就多出了几个引用的定义
强引用:即最传统的引用,也是java程序中最普遍的引用赋值,只要强引用关系还在,垃圾收集器便不会回收掉被引用的对象
软引用:软引用是一种引用强度较弱的引用类型,有些作用但并非必须存在的引用,在内存将要发生内存溢出前会第二次回收被软引用的对象,如果还是没有足够内存才会报异常
弱引用:弱引用的强度比软引用更弱一些,被引用的对象只能“存活到”下一次垃圾收集为知
虚引用:又称幽灵引用,是最弱的引用关系,无法通过虚引用创造一个对象,唯一使用虚引用引用一个对象的目的,是在被引用的对象被回收前弹出一个该对象已被回收的通知
对象已死后的行为
即便是在可达性分析算法中分析出来是GC Roots的不可达对象,也并不是非回收不可的,如果一个对象被识别为不可达对象,将会进行第一次标记,但是该对象仍可以通过finalize方法引用一个对象例如this,再存活一段时间,垃圾收集器会检查对象有没有重写finalize方法,有的话垃圾收集器会执行这个方法,到下次垃圾收集器前,如果还是没有对象引用该对象,才会被回收,因为finalize方法只会被调用一次
回收方法区
方法区因为存放的数据易于被使用,所以通过垃圾收集收集到的对象往往很少,在方法区垃圾收集器主要收集的是废弃的常量和不再使用的类
判断一个常量是否废弃相对简单,但判定一个类是否不再使用了一般通过以下几个条件来判断
- 类的字段是否全部废弃
- 是否能通过类的Class对象,通过反射访问该方法
- 该类的类加载器是否被回收
垃圾收集算法
从如何判定对象死亡的角度出发,垃圾收集算法分为引用计数式垃圾收集和追踪式垃圾收集,以下介绍的都为追踪式垃圾收集
分代收集理论
分代收集主要依据两个经验性的结论
- 大部分对象都是朝生夕死
- 如果一个对象经过了多轮垃圾收集仍然存活,那么这个对象就很难被收集了
标记清除算法
顾名思义标记清除算法分为两个阶段,标记和清除,首先标记出所有需要收集的对象,接着在垃圾收集阶段统一进行回收,这个算法的缺点是体现在”大部分对象都是朝生夕死的“这句话中,大部分对象,其实在标记后都是要回收的,导致该算法的效率其实并不高,并且在清除时会产生空间碎片,如果没有大对象分配空间的话还可以接受,但是如果需要给大对象分配空间,很难找到连续的大空间,必须在存放对象之前进行一次垃圾收集
有一种解决方案可以减少清楚后在内存分配上的负担,做法就是平时采用标记清除算法,容忍空间碎片的存在,一旦空间碎片影响到了内存分配,再用标记整理算法收集一次,来获得相对规整的空间
内存碎片就是垃圾对象被清除了,剩下的都是碎片化空间,碎片化导致空间不连续,内存空间白白被占用但是没数据,意思是原本存放对象的空间里的对象被清除了,但这些分布在堆中的原本对象占据的空间里,无法把新对象赋值给这些空间。就好像一张纸上被戳了一个个的小洞
标记复制算法
该算法主要通过划分内存实现,将一块内存分成两半,只用其中的一半分配对象,需要进行垃圾收集时,只需要把未被垃圾收集器标记的对像复制到另一半内存中,两一般内存空间全部清理掉,该算法的缺点就是浪费了一半的内存,
这种算法一般被用到新生代的区域中,新对象和比较小的对象放在新生代中,新生代分为Eden区和两个Survivor,Eden区和Survivor区的默认大小比是8,每次只使用一个Survivor区域和Eden区,一般来说90%对象会在第一次垃圾收集的时候被回收,剩余存活的对象复制到另一个Survivor区,这样就只是浪费了10%的内存。但是不能保证每次都有90%以上的对象被回收,所以还需要老年去做分配担保,在存活对象的大小超过了一个Survivor区的大小时,通过分配担保机制直接进入老年代
标记整理算法
该算法的方法的标记阶段和标记清除算法的一样,但是标记完成后会把存活的对像都想内存的另一端移动,之后清理掉边界以外的内存区域,本算法和标记清除算法的区别就是本算法是移动式的算法,该算法在对象存活率高的情况下会做出更多的复制对象的动作,效率会降低,并且可能因为对象过于多,导致垃圾收集器必须暂停用户使用的线程来进行垃圾回收,非常影响用户体检
经典垃圾收集器
新生代收集器
Serial收集器
Serial收集器是个典型的单线程新生代的垃圾收集器,而且在进行新生代的垃圾收集时需要暂停用户线程,此收集器的缺点就在于垃圾较多的会有停顿,但优点是它是所有收集器中需要额外内存最少的收集器。因为没有线程交互的开销,专心进行垃圾收集自然也会有较高的效率,尤其实在几十兆或者一两百兆的新生代进行垃圾收集,虚拟机的停顿也可以控制在一百毫秒以内
ParNew收集器
ParNew收集器与Serial收集器区别就在于ParNew收集器在进行垃圾收集时可以做到多个线程并行收集,是第一款做到了并行的垃圾收集器,它的特点就是ParNew收集器是除Serial收集器之外,唯一可以配合CMS进行垃圾收集的新生代垃圾收集器,但是,它在单处理器的环境下并不会比Serial有更好的效果,因为它还有线程交互的开销
Parallel Scavenge收集器
Parallel Scavenge同样是一款多线程的垃圾收集器,它再新生代是基于标记-复制算法,和ParNew非常相似,但Parallel Scavenge收集器区别于其他收集器的方面就在于,它的关注点不一样,例如CMS关注点在于减少垃圾收集器进行垃圾收集时带来的用户线程停顿的时间,而Parallel Scavenge收集器更关注于垃圾收集器的吞吐量是如何的,它致力于垃圾收集时达到一个可控的吞吐量,吞吐量是指,用户线程执行的时间比上处理器运行的总时间
用户可以通过-XX MaxGCPauseMills控制收集器垃圾收集器收集的时间,但是降低延迟以吞吐量和新生代空间为代价的。所以Parallel Scavenge收集器比较适用于后台的计算
用户可以通过-XX MaxGCPauseMills控制收集器垃圾收集器收集的时间,但是降低延迟以吞吐量和新生代空间为代价的。所以Parallel Scavenge收集器比较适用于后台的计算
老年代收集器
Serial Old收集器
Serial Old就是Serial收集器的老年代版本,同样是单线程的,再老年代使用的是标记-整理算法,进行垃圾收集时同样需要暂停用户线程。可以和Serial和Parallel Scavenge收集器搭配使用,另外就是可以作为CMS出现故障时的替补收集器
Parallel Old收集器
在Parallel Old收集器出现前,Parallel Scavenge收集器只能搭配Serial Old收集器一起使用,由于Serial Old收集器在单线程运行导致的服务器性能方面的拖累,导致这个组合无法实现吞吐量优先的目标,Parallel Old诞生后,Parallel Scavenge和Parallel Old 终于成为了名副其实的“吞吐量优先”的新老年代垃圾收集器的组合
CMS收集器
CMS收集器是一种关注低延迟的垃圾收集器,因此在B/S之类需要和用户交互,需要低延迟的应用中使用。并且CMS支持并发标记,CMS运行一共分为四个阶段,分别是初始标记、并发标记、重新标记、标记清除。其中初始标记和重新标记仍然需要STW。
- 初始标记:速度很快,只是标记下和GC Roots直接关联到的对象,出师表及不能并发。
- 并发标记:这一阶段是可以和用户线程以及垃圾收集同步运行的,用于做GC Roots产生的整个对象图的遍历,时间比较长,但不会产生STW的现象
- 重新标记:是并发标记后,修正由于并发标记和用户线程一起执行导致的引用关系的变化,这个时间会慢于初始标记,但比并发标记要快。
- 标记清除: 顾名思义就是清除对象图中的不可达对象。
CMS的优点就是并发低停顿,但是缺点有两个
一是无法处理浮动垃圾,浮动垃圾是在并发标记和并发清理时,由于用户线程仍在运行,所有依旧会产生一些垃圾对象,可这些垃圾对象出现在并发标记之后,所以无法被垃圾收集器回收,只能等待下一次垃圾收集才能回收掉
二是空间碎片问题,由于CMS使用的时标记-清除算法自然会产生一些空间碎片,CMS的解决办法,在CMS执行了几次后才进行一次Full GC,进行一次空间碎片的整理和清理,保证有连续的空间存放大对象,在这之前并不执行FullGC
G1收集器
G1收集器并不是过去的新生代收集器或者老年代收集器,G1收集器并不局限于这些,而是通过将Java堆分成大小相等且连续的Region,通过使用时的需要分配角色,每块Region都可以充当老年代和新生代,甚至还有一个Humorous Region专门负责存放大对象。它可以面向java堆的任何区域组成回收集,G1在运行期间会时刻跟踪各个Region,在后台维护一个以回收价值为标准的表,由此可以在一定的时间从回收价值由大到小的回收垃圾对象,以此获得最高的效率,优先处理回收价值大的Region也是它Garbage first名字的由来
对于并发标记时引用更新导致对象图结构的修改,CMS使用的时增量更新的方法,而G1收集器使用的时原始快照(SATB)的方法
如果不考虑线程运行过程的操作例如使用写屏障维护记忆集的操作产生的开销,G1收集器的运行打包包括四个步骤
- 初始标记: 标记一下GC Roots能够直接关联到的对象,这个阶段需要停顿用户线程,但时间很短
- 并发标记: 从GC Roots开始对堆中的对象进行可达性分析,递归扫描出整个对象图,找出需要回收的对象,这个步骤耗时比较长,但是可以和用户线程并发执行,对象图扫描完成后,还要处理原始快照(SATB)记录下产生的变动
- 最终标记: 对用户线程进行短暂的暂停,用于处理并发标记未处理完的少量SATB记录
- 筛选回收: 负责更新Region的统计数据,对多个Region的回收价值进行排序,然后将决定回收的那一部分Resion的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间
G1的回收阶段是支持多线程并行的,但不接受并发
低延迟垃圾收集器
Shenandoah收集器
Shenandoah收集器同样是基于堆内分配的Region空间,但是抛弃了分代思想,所以也就不需要考虑新老年代之间的跨代引用,所以降低了处理跨代指针时的记忆集的维护。Shenandoah使用了一种连接矩阵的数据结构记录Region之间的跨代应用,连接矩阵可以理解为一张二维表格如果RegionN有对象指向了RegionM,就在二维表格N行M列中打一个标记
Shenandual收集器的运行大致分为九个阶段:
- 初始标记: 和G1收集器一样,该阶段首先记录与GC Roots直接关联的对象,这个阶段仍然会STW,但是停顿时间与堆的大小无关,而与GC Roots的数量有关
- 并发标记: 和G1一样,该阶段负责遍历对象图,并对堆中对像进行可达性分析,这个阶段是和用户线程一起并发的,并且同样要处理原始快照(SATB),时间长短取决于,堆内存活对象的数量和对象图的复杂程度
- 最终标记: 与G1一样,这个阶段负责处理剩余的SATB扫描,并且再这个阶段对多个Region的回收价值进行排序
- 并发清理: 这个阶段用于清理完全没有存活对象的Region
- 并发回收: 这个阶段负责把回收集里面的存活对象复制到未使用的Region中
- 初始引用更新: 并发回收阶段结束后,需要更新指向就Region的引用,这个操作称作引用更新,但引用更新的初始阶段只是负责建立一个线程集合点,保证并发回收阶段所使用的线程都接受了引用更新的任务
- 并发引用更新: 这个阶段才开始执行引用更新的操作,这个阶段是与用户线程并发执行的,但是并不需要遍历对象图,只需要按照内存物理地址,线性的搜索出引用类型把旧值改成新值就行了
- 最终引用更新: 这个阶段负责修正GC Roots中的引用,停顿时间 只与GCRoots的数量有关
- 并发清理: 经过并发回收和引用更新后,整个回收集里所有Region都没有存活对象了,最后再调用一次并发清理回收所有Region的内存空间
关于Shenandual的并发整理技术,普通的并发整理是通过在被移动的对象原有的内存设置保护陷阱来实现的,一旦用户程序访问到属于旧对象的空间就会产生自陷中断,进入预设好的异常处理器中,在由其中的代码逻辑转发到赋值后的新对象上,虽然可以实现对象移动和并发,但最终还是需要操作系统的支持,需要用户态切换到核心态。
而Shenandual的转发指针并不需要内存保护陷阱,而是在原有对象的结构最前面即对象头前面同意增加一个新的引用字段,没有发生对象移动时就指向对象自身,一旦发生了对象移动,就使这个新的引用字段指向移动后的对象的对象头,转发指针的缺点也是很明显的每次访问到旧对象就会增加一次访问开销,另外,为了实现Shenandual的转发指针,还用到读屏障进行转发处理,并且辟邪品璋还要多得多,数量庞大的读屏障也会造成巨大的性能开销
ZGC收集器
ZGC收集器和Shenandual收集器是高度相似的,同样也是把堆分为若干个Region空间,但是分为了小型Region(2MB)、中型Region(4MB)、大型Region(不规定大小但要是2MB的整数倍),并且在实现并发标记整理的技术方面也有所不同,ZGC在并发标记整理上主要使用了读屏障、染色指针、内存多重映射等技术。
染色指针是一种将少量信息存储在指针上的技术,区别于Shenandual的转发指针是把新的引用区域放在对象头前面,染色指针则是直接应用在了指向对象的引用上,由于Linux64位的限制,前18位不能用来寻址,但是剩余的46位仍然可以满足大部分服务器的需求,染色指针就是把剩余的46位的前4位当成标志位,通过这些标志为,可以直接从指针中看到其引用对象的三色标记,是否进入了重分配集(即是否移动过),是否重写了finalize方法,这些标志位进一步压缩了原本只有46位的地址空间,也导致了ZGC管理的内存不能够查过4TB。
染色指针的优势:
- 染色指针可以在某一个Region中对象被移走之后,立即释放这个Region,而不用等待引用更新。
- 染色指针可以大幅减少内存屏障的使用数量,由于在指针上存放了对象的引用等信息,不需要一些专门的记录操作,到目前为止,ZGC只使用了读屏障,而未使用任何写屏障
- 染色指针可以作为一种可扩展的存储结构用来存储更多与对象标记、重定位有关的信息,以便日后提高性能,如果开发了64位中的前18位,ZGC的管理内存就会扩展到64TB
ZGC的运行过程分为四个阶段:
- 并发标记: 与G1、Shenandual一样,这个阶段主要负责GCRoots的相关对象的遍历以及遍历对象图完成可达性分析,类似于G1、Shenandual,不同的是ZGC的标记是记录在指针上的
- 并发预备重分配: 这个阶段负责扫描堆中所有的Region,将需要回收的Region组成重分配集,重分配集和回收集的区别在于,ZGC用扫描所有Rrgion的成本取代了维护记忆集的成本
- 并发重分配: 重分配是ZGC执行过程中的核心阶段,这个阶段负责把重分配集中的Region中存活的对象复制到新的Region中,并为重分配集中的每个Region维护一个转发表,用于记录旧对象到新对象的转换关系,得益于染色指针,虚拟机可以通过一个对象的引用就知道对象是否在重分配集中,如果这时用户线程并发访问重分配集的对象,这次访问将会被预置的内存屏障截获,然后依据Region的转发表记录将访问转发到复制的新对象上,并同时修改引用的值,使其指向新对象,这个过程称作染色指针的自愈,对比Shenandoah的转发指针这样做的好处是只会在第一次访问旧对象的时候进行转发,也就是只慢一次,而Shenandoah的转发指针是每次访问都要付出固定开销
- 并发重映射: 重映射所要做的就是修正重分配之中依旧指向旧对象的引用,和Shenandoah中的引用更新类似,但是由于染色指针的自愈功能,ZGC并不是很迫切的完成这个阶段的工作,相反,ZGC把这次并发重映射所要做的工作合并到了下一次垃圾回收的并发标记阶段去做,还少了一次遍历对象图的开销,一旦所有引用都被修正后,原来记录旧对象关系的转发表就可以释放掉了