Java相较于其余编程语言更加容易学习,这其中很大一部分缘由要归功于JVM的自动内存管理机制。 对于从事C语言的开发者来讲,他们拥有每个对象的「全部权」,更大的权力也意味着更多的职责,C开发者须要维护每个对象「从生到死」的过程,当对象废弃不用时必须手动释放其内存,不然就会发生内存泄漏。而对于Java开发者来讲,JVM的自动内存管理机制解决了这个让人头疼的问题,不容易出现内存泄漏和内存溢出的问题了,GC让开发者更加专一于程序自己,而不用去关心内存什么时候分配、什么时候回收、以及如何回收。前端
在聊GC前,有必要先了解一下JVM的内存模型,知道JVM是如何规划内存的,以及GC的主要做用区域。 如图所示,JVM运行时会将内存划分为五大块区域,其中「方法区」和「堆」随着JVM的启动而建立,是全部线程共享的内存区域。虚拟机栈、本地方法栈、程序计数器则是随着线程的建立被建立,线程运行结束后也就被销毁了。java
程序计数器(Program Counter Register)是一块很是小的内存空间,几乎能够忽略不计。 它能够看做是线程所执行字节码的行号指数器,指向当前线程下一条应该执行的指令。对于:条件分支、循环、跳转、异常等基础功能都依赖于程序计数器。算法
对于CPU的一个核心来讲,任意时刻只能跑一个线程。若是线程的CPU时间片用完就会被挂起,等待OS从新分配时间片再继续执行,那线程如何知道上次执行到哪里了呢?就是经过程序计数器来实现的,每一个线程都须要维护一个私有的程序计数器。数据库
若是线程在执行Java方法,计数器记录的是JVM字节码指令地址。若是执行的是Native方法,计数器值则为Undefined
。编程
程序计数器是惟一一个没有规定任何OutOfMemoryError状况的内存区域,意味着在该区域不可能发生OOM异常,GC不会对该区域进行回收!数组
虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,生命周期和线程相同。缓存
虚拟机栈描述的是Java方法执行的内存模型,JVM要执行一个方法时,首先会建立一个栈帧(Stack Frame)用于存放:局部变量表、操做数栈、动态连接、方法出口等信息。栈帧建立完毕后开始入栈执行,方法执行结束后即出栈。服务器
方法执行的过程就是一个个栈帧从入栈到出栈的过程。数据结构
局部变量表主要用来存放编译器可知的各类基本数据类型、对象引用、returnAddress类型。局部变量表所需的内存空间在编译时就已经确认,运行期间不会修改局部变量表的大小。多线程
在JVM规范中,虚拟机栈规定了两种异常:
本地方法栈(Native Method Stack)也是线程私有的,与虚拟机栈的做用很是相似。 区别是虚拟机栈是为执行Java方法服务的,而本地方法栈是为执行Native方法服务的。
与虚拟机栈同样,JVM规范中对本地方法栈也规定了StackOverflowError和OutOfMemoryError两种异常。
Java堆(Java Heap)是线程共享的,通常来讲也是JVM管理最大的一块内存区域,同时也是垃圾收集器GC的主要管理区域。
Java堆在JVM启动时建立,做用是:存放对象实例。 几乎全部的对象都在堆中建立,可是随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得“全部对象都分配在堆上”不那么绝对了。
因为是GC主要管理的区域,因此也被称为:GC堆。 为了GC的高效回收,Java堆内部又作了以下划分:
JVM规范中,堆在物理上能够是不连续的,只要逻辑上连续便可。经过-Xms -Xmx
参数能够设置最小、最大堆内存。
方法区(Method Area)与Java堆同样,也是线程共享的一块内存区域。 它主要用来存储:被JVM加载的类信息,常量,静态变量,即时编译器产生的代码等数据。 也被称为:非堆(Non-Heap),目的是与Java堆区分开来。
JVM规范对方法区的限制比较宽松,JVM甚至能够不对方法区进行垃圾回收。这就致使在老版本的JDK中,方法区也别称为:永久代(PermGen)。
使用永久代来实现方法区不是个好主意,容易致使内存溢出,因而从JDK7开始有了“去永久代”行动,将本来放在永久代中的字符串常量池移出。到JDK8中,正式去除永久代,迎来元空间。
垃圾收集(Garbage Collection)简称为「GC」,它的历史远比Java语言自己久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。
要想实现自动垃圾回收,首先须要思考三件事情: 前面介绍了JVM的五大内存区域,程序计数器占用内存极少,几乎能够忽略不计,并且永远不会内存溢出,GC不须要对其进行回收。虚拟机栈、本地方法栈随线程“同生共死”,栈中的栈帧随着方法的运行有条不紊的入栈、出栈,每一个栈帧分配多少内存在编译期就已经基本肯定,所以这两块区域内存的分配和回收都具有肯定性,不太须要考虑如何回收的问题。
方法区就不同了,一个接口到底有多少个实现类?每一个类占用的内存是多少?你甚至能够在运行时动态的建立类,所以GC须要针对方法区进行回收。
Java堆也是如此,堆中存放着几乎全部的Java对象实例,一个类到底会建立多少个对象实例,只有在程序运行时才知道,这部份内存的分配和回收是动态的,GC须要重点关注。
实现自动垃圾回收的第一步,就是判断到底哪些对象是能够被回收的。通常来讲有两种方式:引用计数算法和可达性分析算法,商用JVM几乎采用的都是后者。
在对象中添加一个引用计数器,每引用一次计数器就加1,每取消一次引用计数器就减1,当计数器为0时表示对象再也不被引用,此时就能够将对象回收了。
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间,可是它原理简单,也很高效,在大多数状况下是一个不错的实现方案,可是它存在一个严重的弊端:没法解决循环引用。
例如一个链表,按理只要没有引用指向链表,链表就应该被回收,可是很遗憾,因为链表中全部的元素引用计数器都不为0,所以没法被回收,形成内存泄漏。
目前主流的商用JVM都是经过可达性分析来判断对象是否能够被回收的。 这个算法的基本思路是:
经过一系列被称为「GC Roots」的根对象做为起始节点集,从这些节点开始,经过引用关系向下搜寻,搜寻走过的路径称为「引用链」,若是某个对象到GC Roots没有任何引用链相连,就说明该对象不可达,便可以被回收。
对象可达指的就是:双方存在直接或间接的引用关系。 根可达或GC Roots可达就是指:对象到GC Roots存在直接或间接的引用关系。
能够做为GC Roots的对象有如下几类: 可达性分析就是JVM首先枚举根节点,找到一些为了保证程序能正常运行所必需要存活的对象,而后以这些对象为根,根据引用关系开始向下搜寻,存在直接或间接引用链的对象就存活,不存在引用链的对象就回收。
关于可达性分析的详细描述,能够看笔者的文章:《大白话理解可达性分析算法》。
JVM将内存划分为五大块区域,不一样的GC会针对不一样的区域进行垃圾回收,GC类型通常有如下几大类:
何时触发GC,以及触发什么类型的GC呢?不一样的垃圾收集器实现不同,你还能够经过设置参数来影响JVM的决策。
通常来讲,新生代会在Eden
区用尽后才会触发GC,而Old
区却不能这样,由于有的并发收集器在清理过程当中,用户线程能够继续运行,这意味着程序仍然在建立对象、分配内存,这就须要老年代进行「空间分配担保」,新生代放不下的对象会被放入老年代,若是老年代的回收速度比对象的建立速度慢,就会致使「分配担保失败」,这时JVM不得不触发Full GC,以此来获取更多的可用内存。
定位到须要回收的对象之后,就要开始进行回收了。如何回收对象又成了一个问题。 什么样的回收方式会更加的高效呢?回收后是否须要对内存进行压缩整理,避免碎片化呢?针对这些问题,GC的回收算法大体分为如下三类:
具体算法的回收细节,下面会介绍到。
JVM将堆划分红不一样的代,不一样的代中存放的对象特色不同,针对不一样的代使用不一样的GC回收算法进行回收能够提高GC的效率。
目前大多数JVM的垃圾收集器都遵循“分代收集”理论,分代收集理论创建在三个假说之上。
绝大多数对象都是朝生夕死的。
想一想看咱们写的程序是否是这样,绝大多数时候,咱们建立一个对象,只是为了进行一些业务计算,获得计算结果后这个对象也就没什么用了,便可以被回收了。 再例如:客户端要求返回一个列表数据,服务端从数据库查询后转换成JSON响应给前端后,这个列表的数据就能够被回收了。 诸如此类,均可以被称为「朝生夕死」的对象。
熬过越屡次GC的对象就越难以回收。
这个假说彻底是基于几率学统计来的,经历过屡次GC都没法被回收的对象,能够假定它下次GC时仍然没法被回收,所以就不必高频率的对其进行回收,将其挪到老年代,减小回收的频率,让GC去回收效益更高的新生代。
跨代引用相对于同代引用是极少的。
这是根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,应该倾向于同时生存或者同时消亡的。 举个例子,若是某个新生代对象存在跨代引用,因为老年代对象难以消亡,该引用会使得新生代对象在收集时一样得以存活,进而在年龄增加以后晋升到老年代中,这时跨代引用也随即被消除了。
跨代引用虽然极少,可是它仍是可能存在的。若是为了极少的跨代引用而去扫描整个老年代,那每次GC的开销就太大了,GC的暂停时间会变得难以接受。若是忽略跨代引用,会致使新生代的对象被错误的回收,致使程序错误。
JVM是经过记忆集(Remembered Set)来解决的,经过在新生代创建记忆集的数据结构,来避免回收新生代时把整个老年代也加进GC Roots的扫描范围,减小GC的开销。
记忆集是一种由「非收集区域」指向「收集区域」的指针集合的抽象数据结构,说白了就是把「年轻代中被老年代引用的对象」给标记起来。记忆集能够有如下三种记录精度:
字长精度和对象精度太精细化了,须要花费大量的内存来维护记忆集,所以许多JVM都是采用的「卡精度」,也被称做:“卡表”(Card Table)。卡表是记忆集的一种实现,也是目前最经常使用的一种形式,它定义了记忆集的记录精度、与对内存的映射关系等。
HotSpot使用一个字节数组来实现卡表,它将堆空间划分红一系列2次幂大小的内存区域,这个内存区域就被称做「卡页」(Card Page),卡页的大小通常都是2的幂次方数,HotSpot采用2的9次幂,即512字节。字节数组的每个元素都对应着一个卡页,若是某个卡页内的对象存在跨代引用,JVM就会将这个卡页标记为「Dirty」脏的,GC时只须要扫描脏页对应的内存区域便可,避免扫描整个堆。
卡表的结构以下图所示:
卡表只是用来标记哪一块内存区域存在跨代引用的数据结构,JVM如何来维护卡表呢?何时将卡页变脏呢?
HotSpot是经过「写屏障」(Write Barrier)来维护卡表的,JVM拦截了「对象属性赋值」这个动做,相似于AOP的切面编程,JVM能够在对象属性赋值先后介入处理,赋值前的处理叫做「写前屏障」,赋值后的处理叫做「写后屏障」,伪代码以下:
void setField(Object o){ before();//写前屏障 this.field = o; after();//写后屏障 }
开启写屏障后,JVM会为全部的赋值操做生成相应的指令,一旦出现老年代对象的引用指向了年轻代的对象,HotSpot就会将对应的卡表元素置为脏的。
请将这里的「写屏障」和并发编程中内存指令重排序的「写屏障」区分开,避免混淆。
除了写屏障自己的开销外,卡表在高并发场景下还面临着「伪共享」的问题,现代CPU的缓存系统是以「缓存行」(Cache Line)为单位存储的,Intel的CPU缓存行的大小通常是64字节,多线程修改互相独立的变量时,若是这些变量在同一个缓存行中,就会致使彼此的缓存行无端失效,线程不得不频繁发起load指令从新加载数据,而致使性能下降。
一个Cache Line是64字节,每一个卡页是512字节,64✖️512字节就是32KB,若是不一样的线程更新的对象处在这32KB以内,就会致使更新卡表时正好写入同一个缓存行而影响性能。为了不这个问题,HotSpot支持只有当元素未被标记时,才将其置为脏的,这样会增长一次判断,可是能够避免伪共享的问题,设置-XX:+UseCondCardMark
来开启这个判断。
标记清除算法分为两个过程:标记、清除。
收集器首先标记须要被回收的对象,标记完成后统一清除。也能够标记存活对象,而后统一清除没有被标记的对象,这取决于内存中存活对象和死亡对象的占比。
缺点:
为了解决标记清除算法产生的内存碎片问题,标记复制算法进行了改进。
标记复制算法会将内存划分为两块区域,每次只使用其中一块,垃圾回收时首先进行标记,标记完成后将存活的对象复制到另外一块区域,而后将当前区域所有清理。
缺点是:若是大量对象没法被回收,会产生大量的内存复制开销。可用内存缩小为一半,内存浪费也比较大。 因为绝大多数对象都会在第一次GC时被回收,须要被复制的每每是极少数对象,那么就彻底不必按照1:1去划分空间。 HotSpot虚拟机默认Eden区和Survivor区的大小比例是8:1,即Eden区80%,From Survivor区10%,To Survivor区10%,整个新生代可用内存为Eden区+一个Survivor区即90%,另外一个Survivor区10%用于分区复制。
若是Minor GC后仍存活大量对象,超出了一个Survivor区的范围,那么就会进行分配担保(Handle Promotion),将对象直接分配进老年代。
标记复制算法除了在对象大量存活时须要进行较多的复制操做外,还须要额外的内存空间老年代来进行分配担保,因此在老年代中通常不采用这种回收算法。
可以在老年代中存活的对象,通常都是历经屡次GC后仍没法被回收的对象,基于“强分代假说”,老年代中的对象通常很难被回收。针对老年代对象的生存特征,引入了标记整理算法。
标记整理算法的标记过程与标记清除算法一致,可是标记整理算法不会像标记清除算法同样直接清理标记的对象,而是将存活的对象都向内存区域的一端移动,而后直接清理掉边界外的内存空间。 标记整理算法相较于标记清除算法,最大的区别是:须要移动存活的对象。 GC时移动存活的对象既有优势,也有缺点。
缺点 基于“强分代假说”,大部分状况下老年代GC后会存活大量对象,移动这些对象须要更新全部reference引用地址,这是一项开销极大的操做,并且该操做须要暂停全部用户线程,即程序此时会阻塞停顿,JVM称这种停顿为:Stop The World(STW)。
优势 移动对象对内存空间进行整理后,不会产生大量不连续的内存碎片,利于后续为对象分配内存。
因而可知,无论是否移动对象都有利弊。移动则内存回收时负责、内存分配时简单,不移动则内存回收时简单、内存分配时复杂。从整个程序的吞吐量来考虑,移动对象显然更划算一些,由于内存分配的频率比内存回收的频率要高的多的多。
还有一种解决方式是:平时不移动对象,采用标记清除算法,当内存碎片影响到大对象分配时,才启用标记整理算法。
按照《Java虚拟机规范》实现的JVM就不胜枚举,且每一个JVM平台都有N个垃圾收集器供用户选择,这些不是一篇文章能够说的清楚的。固然,开发者也不必了解全部的垃圾收集器,以Hotspot JVM为例,主流的垃圾收集器主要有如下几大类: 串行:单线程收集,用户线程暂停。 并行:多线程收集,用户线程暂停。 并发:用户线程和GC线程同时运行。
前面已经说过,大多数JVM的垃圾收集器都遵循“分代收集”理论,不一样的垃圾收集器回收的内存区域会有所不一样,大多数状况下,JVM须要两个垃圾收集器配合使用,下图有虚线链接的表明两个收集器能够配合使用。
最基础,最先的垃圾收集器,采用标记复制算法,仅开启一个线程完成垃圾回收,回收时会暂停全部用户线程(STW)。 使用
-XX:+UseSerialGC
参数开启Serial收集器,因为是单线程回收,所以Serial的应用范围很受限制:
使用标记复制算法,多线程的新生代收集器。 使用参数
-XX:+UseParallelGC
开启,ParallelGC的特色是很是关注系统的吞吐量,它提供了两个参数来由用户控制系统的吞吐量: -XX:MaxGCPauseMillis:设置垃圾回收最大的停顿时间,它必须是一个大于0的整数,ParallelGC会朝着这个目标去努力,若是这个值设置的太小,ParallelGC就不必定能保证了。若是用户但愿GC停顿的时间很短,ParallelGC就会尝试减少堆空间,由于回收一个较小的堆确定比回收一个较大的堆耗时短嘛,可是这样会更频繁的触发GC,从而下降系统的吞吐量。
-XX:GCTimeRatio:设置吞吐量的大小,它的值是一个0~100的整数。假设GCTimeRatio为n,那么ParallelGC将花费不超过1/(1+n)
的时间进行垃圾回收,默认值为19,意味着ParallelGC用于垃圾回收的时间不会超过5%。
ParallelGC是JDK8的默认垃圾收集器,它是一款吞吐量优先的垃圾收集器,用户能够经过-XX:MaxGCPauseMillis
和-XX:GCTimeRatio
来设置GC最大的停顿时间和吞吐量。但这两个参数是互相矛盾的,更小的停顿时间就意味着GC须要更频繁进行回收,从而增长GC回收的总体时间,致使吞吐量降低。
ParNew也是一个使用标记复制算法,多线程的新生代垃圾收集器。它的回收策略、算法、及参数都和Serial同样,只是简单的将单线程改成多线程而已,它的诞生只是为了配合CMS
收集器使用而存在的。CMS
是老年代的收集器,可是Parallel Scavenge
不能配合CMS
一块儿工做,Serial是串行回收的,效率又过低了,所以ParNew就诞生了。
使用参数-XX:+UseParNewGC
开启,不过这个参数已经在JDK9以后的版本中删除了,由于JDK9默认G1收集器,CMS已经被取代,而ParNew就是为了配合CMS而诞生的,CMS废弃了,ParNew也就没有存在价值了。
使用标记整理算法,和Serial同样,单线程独占式的针对老年代的垃圾收集器。老年代的空间一般比新生代要大,并且标记整理算法在回收过程当中须要移动对象来避免内存碎片化,所以老年代的回收要比新生代更耗时一些。
Serial Old做为最先的老年代垃圾收集器,还有一个优点,就是它能够和绝大多数新生代垃圾收集器配合使用,同时它还能够做为CMS并发失败的备用收集器。
使用参数-XX:+UseSerialGC
开启,新生代老年代都将使用串行收集器。和Serial同样,除非你的应用很是轻量,或者CPU的资源十分紧张,不然都不建议使用该收集器。
ParallelOldGC是一款针对老年代,多线程并行的独占式垃圾收集器,和Parallel Scavenge同样,属于吞吐量优先的收集器,Parallel Old的诞生就是为了配合Parallel Scavenge使用的。
ParallelOldGC使用的是标记整理算法,使用参数-XX:+UseParallelOldGC
开启,参数-XX:ParallelGCThreads=n
能够设置垃圾收集时开启的线程数量,同时它也是JDK8默认的老年代收集器。
CMS(Concurrent Mark Sweep)是一款里程碑式的垃圾收集器,为何这么说呢?由于在它以前,GC线程和用户线程是没法同时工做的,即便是Parallel Scavenge,也不过是GC时开启多个线程并行回收而已,GC的整个过程依然要暂停用户线程,即Stop The World。这带来的后果就是Java程序运行一段时间就会卡顿一会,下降应用的响应速度,这对于运行在服务端的程序是不能被接收的。
GC时为何要暂停用户线程? 首先,若是不暂停用户线程,就意味着期间会不断有垃圾产生,永远也清理不干净。 其次,用户线程的运行必然会致使对象的引用关系发生改变,这就会致使两种状况:漏标和错标。
为了实现并发收集,CMS的实现比前面介绍的几种垃圾收集器都要复杂的多,整个GC过程能够大概分为如下四个阶段: 一、初始标记 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。初始标记的过程是须要触发STW的,不过这个过程很是快,并且初试标记的耗时不会由于堆空间的变大而变慢,是可控的,所以能够忽略这个过程致使的短暂停顿。
二、并发标记 并发标记就是将初始标记的对象进行深度遍历,以这些对象为根,遍历整个对象图,这个过程耗时较长,并且标记的时间会随着堆空间的变大而变长。不过好在这个过程是不会触发STW的,用户线程仍然能够工做,程序依然能够响应,只是程序的性能会受到一点影响。由于GC线程会占用必定的CPU和系统资源,对处理器比较敏感。CMS默认开启的GC线程数是:(CPU核心数+3)/4,当CPU核心数超过4个时,GC线程会占用不到25%的CPU资源,若是CPU数不足4个,GC线程对程序的影响就会很是大,致使程序的性能大幅下降。
三、从新标记 因为并发标记时,用户线程仍在运行,这意味着并发标记期间,用户线程有可能改变了对象间的引用关系,可能会发生两种状况:一种是本来不能被回收的对象,如今能够被回收了,另外一种是本来能够被回收的对象,如今不能被回收了。针对这两种状况,CMS须要暂停用户线程,进行一次从新标记。
四、并发清理 从新标记完成后,就能够并发清理了。这个过程耗时也比较长,且清理的开销会随着堆空间的变大而变大。不过好在这个过程也是不须要STW的,用户线程依然能够正常运行,程序不会卡顿,不过和并发标记同样,清理时GC线程依然要占用必定的CPU和系统资源,会致使程序的性能下降。
CMS开辟了并发收集的先河,让用户线程和GC线程同时工做成为了可能,可是缺点也很明显: 一、对处理器敏感 并发标记、并发清理阶段,虽然CMS不会触发STW,可是标记和清理须要GC线程介入处理,GC线程会占用必定的CPU资源,进而致使程序的性能降低,程序响应速度变慢。CPU核心数多的话还稍微好一点,CPU资源紧张的状况下,GC线程对程序的性能影响很是大。
二、浮动垃圾 并发清理阶段,因为用户线程仍在运行,在此期间用户线程制造的垃圾就被称为“浮动垃圾”,浮动垃圾本次GC没法清理,只能留到下次GC时再清理。
三、并发失败 因为浮动垃圾的存在,所以CMS必须预留一部分空间来装载这些新产生的垃圾。CMS不能像Serial Old收集器那样,等到Old区填满了再来清理。在JDK5时,CMS会在老年代使用了68%的空间时激活,预留了32%的空间来装载浮动垃圾,这是一个比较偏保守的配置。若是实际引用中,老年代增加的不是太快,能够经过-XX:CMSInitiatingOccupancyFraction
参数适当调高这个值。到了JDK6,触发的阈值就被提高至92%,只预留了8%的空间来装载浮动垃圾。 若是CMS预留的内存没法容纳浮动垃圾,那么就会致使「并发失败」,这时JVM不得不触发预备方案,启用Serial Old收集器来回收Old区,这时停顿时间就变得更长了。
四、内存碎片 因为CMS采用的是「标记清除」算法,这就意味这清理完成后会在堆中产生大量的内存碎片。内存碎片过多会带来不少麻烦,其一就是很难为大对象分配内存。致使的后果就是:堆空间明明还有不少,但就是找不到一块连续的内存区域为大对象分配内存,而不得不触发一次Full GC,这样GC的停顿时间又会变得更长。 针对这种状况,CMS提供了一种备选方案,经过-XX:CMSFullGCsBeforeCompaction
参数设置,当CMS因为内存碎片致使触发了N次Full GC后,下次进入Full GC前先整理内存碎片,不过这个参数在JDK9被弃用了。
介绍完CMS垃圾收集器后,咱们有必要了解一下,为何CMS的GC线程能够和用户线程一块儿工做。
JVM判断对象是否能够被回收,绝大多数采用的都是「可达性分析」算法,关于这个算法,能够查看笔者之前的文章:大白话理解可达性分析算法。
从GC Roots开始遍历,可达的就是存活,不可达的就回收。
CMS将对象标记为三种颜色: 标记的过程大体以下:
这个过程正确执行的前提是没有其余线程改变对象间的引用关系,然而,并发标记的过程当中,用户线程仍在运行,所以就会产生漏标和错标的状况。
漏标 假设GC已经在遍历对象B了,而此时用户线程执行了A.B=null
的操做,切断了A到B的引用。 原本执行了
A.B=null
以后,B、D、E均可以被回收了,可是因为B已经变为灰色,它仍会被当作存活对象,继续遍历下去。 最终的结果就是本轮GC不会回收B、D、E,留到下次GC时回收,也算是浮动垃圾的一部分。
实际上,这个问题依然能够经过「写屏障」来解决,只要在A写B的时候加入写屏障,记录下B被切断的记录,从新标记时能够再把他们标为白色便可。
错标 假设GC线程已经遍历到B了,此时用户线程执行了如下操做:
B.D=null;//B到D的引用被切断 A.xx=D;//A到D的引用被创建
B到D的引用被切断,且A到D的引用被创建。 此时GC线程继续工做,因为B再也不引用D了,尽管A又引用了D,可是由于A已经标记为黑色,GC不会再遍历A了,因此D会被标记为白色,最后被当作垃圾回收。 能够看到错标的结果比漏表严重的多,浮动垃圾能够下次GC清理,而把不应回收的对象回收掉,将会形成程序运行错误。
错标只有在知足下面两种状况下才会发生:
只要打破任一条件,就能够解决错标的问题。
原始快照和增量更新 原始快照打破的是第一个条件:当灰色对象指向白色对象的引用被断开时,就将这条引用关系记录下来。当扫描结束后,再以这些灰色对象为根,从新扫描一次。至关于不管引用关系是否删除,都会按照刚开始扫描时那一瞬间的对象图快照来扫描。
增量更新打破的是第二个条件:当黑色指向白色的引用被创建时,就将这个新的引用关系记录下来,等扫描结束后,再以这些记录中的黑色对象为根,从新扫描一次。至关于黑色对象一旦创建了指向白色对象的引用,就会变为灰色对象。
CMS采用的方案就是:写屏障+增量更新来实现的,打破的是第二个条件。
当黑色指向白色的引用被创建时,经过写屏障来记录引用关系,等扫描结束后,再以引用关系里的黑色对象为根从新扫描一次便可。
伪代码大体以下:
class A{ private D d; public void setD(D d) { writeBarrier(d);// 插入一条写屏障 this.d = d; } private void writeBarrier(D d){ // 将A -> D的引用关系记录下来,后续从新扫描 } }
G1的全称是「Garbage First」垃圾优先的收集器,JDK7正式使用,JDK9默认使用,它的出现是为了替代CMS收集器。
既然要替代CMS,那么毫无疑问,G1也是并发并行的垃圾收集器,用户线程和GC线程能够同时工做,关注的也是应用的响应时间。
G1最大的一个变化就是,它只是逻辑分代,物理结构上已经不分代了。它将整个Java堆划分红多个大小不等的Region,每一个Region能够根据须要扮演Eden区、Survivor区、或者是老年代空间,G1能够对扮演不一样角色的Region采用不一样的策略去处理。
G1以前的全部垃圾收集器,回收的范围要么是整个新生代(Minor GC)、要么是整个老年代(Major GC)、再就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它能够面向堆内任何部分来组成回收集(Collection Set,简称CSet
)进行回收,衡量标准再也不是它属于哪一个分代,而是判断哪一个Region垃圾最多,选择回收价值最高的Region回收,这也是「Garbage First」名称的由来。
虽然G1仍然保留了分代的概念,可是新生代和老年代再也不是固定不变的两块连续的内存区域了,它们都是由一系列Region组成的,并且每次GC时,新生代和老年代的空间大小会动态调整。G1之因此能控制GC的停顿时间,创建可预测的停顿时间模型,就是由于它将Region做为单次回收的最小单元,每次回收的内存空间都是Region大小的整数倍,这样就能够避免在整个Java堆内进行全区域的垃圾收集。
G1会跟踪每一个Region的垃圾数量,计算每一个Region的回收价值,在后台维护一个优先级列表,而后根据用户设置的容许GC停顿的时间来优先回收“垃圾最多”的Region,这样就保证了G1可以在有限的时间内回收尽量多的可用内存。
G1的整个回收周期大概能够分为如下几个阶段:
和CMS同样,由于并发回收时用户线程仍然在运行,即分配内存,所以若是回收速度跟不上内存分配的速度,G1也会在必要的时候触发一个Full GC来获取更多的可用内存。
使用参数-XX:+UseG1GC
来开启G1收集器,-XX:MaxGCPauseMillis
来设置目标最大停顿时间,G1会朝着这个目标去努力,若是GC停顿时间超过了目标时间,G1就会尝试调整新生代和老年代的比例、堆大小、晋升年龄等一系列参数来企图达到预设目标。 -XX:ParallelGCThreads
用来设置并行回收时GC的线程数量,-XX:InitiatingHeapOccupancyPercent
用来指定整个Java堆的使用率达到多少时触发并发标记周期的执行,默认值是45。
ZGC是在JDK11才加入的具备实现性质的低延迟垃圾收集器,它的目标是但愿在尽量对吞吐量影响不大的前提下,实如今任意堆内存大小下均可以把GC的停顿时间控制在十毫秒之内。
ZGC面向的是超大堆,最大支持4TB
的堆空间,它和G1同样,也是采用Region的内存布局形式。
ZGC最大的一个特色就是它采用着色指针Colored Pointer
技术来标记对象。以往,若是JVM须要在对象上存储一些额外的、只供GC或JVM自己使用的数据时(如GC年龄、偏向线程ID、哈希码),一般会在对象的对象头上增长额外的字段来记录。ZGC就厉害了,直接把标记信息记录在对象的引用指针上。
Colored Pointer
是什么?为何对象引用的指针自己也能够存储数据呢? 在64位系统中,理论上能够访问的内存大小为2的64次幂字节,即16EB。可是实际上,目前远远用不到这么大的内存,所以基于性能和成本的考虑,CPU和操做系统都会施加本身的约束。例如AMD64架构只支持54位(4PB)的地址总线,Linux只支持46位(64TB)的物理地址总线,Windows只支持44位(16TB)的物理地址总线。
在Linux系统下,高18位不能用来寻址,剩余的46位能支持最大64TB的内存大小。事实上,64TB的内存大小在目前来讲也远远超出了服务器的须要。因而ZGC就盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。经过这些标志位,JVM能够直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能经过finalize()方法才能被访问到。这就致使JVM能利用的物理地址总线只剩下42位了,即ZGC能管理的最大内存空间为2的42次幂字节,即4TB。 目前ZGC还处于实验阶段,能查到的资料也很少,笔者之后再整理更新吧。
待写......
6. GC的调优待写......