GC算法的评判标准
GC算法的评判标准主要是以下4点:
1、吞吐量:即单位时间内的处理能力。
2、最大暂停时间:因执行GC而暂停执行程序所需的时间。
3、堆的使用效率:鱼与熊掌不可兼得,堆使用效率和吞吐量、最大暂停时间是不可能同时满足的。即可用的堆越大,GC运行越快;相反,想要利用有限的堆,GC花费的时间就越长。
4、访问的局部性:在存储器的层级构造中,我们知道越是高速存取的存储器容量会越小(具体可以参看我写的存储器那篇文章)。由于程序的局部性原理,将经常用到的数据放在堆中较近的位置,可以提高程序的运行效率。
最基础的收集算法是 “标记-清除”(Mark-Sweep)算法,顾名思义,算法分为 “标记” 和 “清除” 两个阶段:首先(通过可达性分析)标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思想并对其不足之处进行改进而得到的
它的主要不足之处有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除后悔产生大量的不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。“标记-清除” 算法的执行过程如图所示
1、标记和清除过程的效率都不高。
2、标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不触发另一次垃圾收集动作。
为了解决效率问题,“复制” 算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。只是这种算法的代价是将内存缩小为原来的一半
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有 10% 的内存会被 “浪费”。当 Survivor 空间不够用时,需要依赖分配担保(Handle Promotion)机制进入老年代
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代不能使用该算法
“标记-整理” 算法的标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。“标记-整理” 算法的示意图如下:
现在虚拟机的垃圾收集器都采用 “分代收集” 算法。这种算法并没有什么新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记-清理” 或者 “标记-整理” 算法进行回收
在用代码分析之前,我们对内存的分配策略明确以下三点:
1、对象优先在Eden分配。
2、当Eden没有足够空间分配时,将发起一次Minor GC大对象(需要大量连续空间的java对象,如长的字符串和数组)直接进入老年代。
3、由于新生代使用复制算法回收内存,这样可以避免在Eden和两个Survivor区之间发生大量的内存复制。长期存活的对象将进入老年代。
哪些对象可以作为GCRoot:
1、所有Java线程当前栈帧引用的,也就是正在被调用的方法的引用类型的参数、局部变量以及临时值。
2、所有的静态数据结构引用的对象
3、String常量池里的引用
4、运行时常量池里引用的类型
G1将新生代,老年代的物理空间划分取消了,不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。
取而代之的是,G1算法将堆划分为若干个区域(Region),它仍然属于分代收集器。
不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。
老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。
在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。
这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。
如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC
PS:在java 8中,永久代也移动到了普通的堆内存空间中,改为元空间。
我们不得不谈谈对象的分配策略。它分为3个阶段:
TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
Eden区中分配
Humongous区分配
TLAB为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。在Eden空间中,每一个线程都有一个固定的分区用于分配对象,即一个TLAB。分配对象时,线程之间不再需要进行任何的同步。
对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。
最后,G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的。下面我们将分别介绍一下这2种模式。
Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
这时,我们需要考虑一个问题,如果仅仅GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set,作用是跟踪指向某个heap区内的对象引用
在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
但在G1中,并没有使用point-out,这是由于一个分区太小,分区数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的分区引用也扫描了。
于是G1中使用point-in来解决。point-in的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。
如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在G1 中又引入了另外一个概念,卡表(Card Table)。
一个Card Table将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于128到512字节之间。Card Table通常为字节数组,由Card的索引(即数组下标)来标识每个分区的空间地址。默认情况下,每个卡都未被引用。当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外RSet也将这个数组下标记录下来。一般情况下,这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。
Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。
它的GC步骤分2步:
全局并发标记(global concurrent marking)
拷贝存活对象(evacuation)
三色标记算法
提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。
黑色:根对象,或者该对象与它的子对象都被扫描
灰色:对象本身被扫描,但还没扫描完该对象中的子对象
白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象
1、根对象被置为黑色,子对象被置为灰色。
2、继续由灰色遍历,将已扫描了子对象的对象置为黑色。
3、遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。
这样的话,我们就会遇到一个问题:对象丢失问题, 如下
1、这时候应用程序执行了以下操作:
A.c=C
B.c=null
这样,对象的状态图变成如第二个情形
2、这时候垃圾收集器再标记扫描的时候就会变成第三个
这样是有问题的,如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下2中可行的方式:
在插入的时候记录对象
在删除的时候记录对象
刚好这对应CMS和G1的2种不同实现方式:
1、CMS采用的是增量更新(Incremental update)
只要在写屏障(write barrier,具体是什么后面会做介绍)里发现要有一个白对象的引用被赋值到一个黑对象 的字段里,那就把这个白对象变成灰色的。即插入的时候记录下来。
2、G1采用快照标记
使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有3个步骤:
1,在开始标记的时候生成一个快照图标记存活对象
2,在并发标记的时候所有被改变的对象入队(在write barrier里把所有旧的引用所指向的对象都变成非白的)
3,可能存在游离的垃圾,将在下次被收集
这样,G1到现在可以知道哪些老的分区可回收垃圾最多。 当全局并发标记完成后,在某个时刻,就开始了Mix GC。这些垃圾回收被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集如下图
混合式GC也是采用的复制的清理策略,当GC完成后,会重新释放空间。
3、写屏障 (write barrier)
如果是STW(Stop The World)的,三色标记没有什么问题。但是如果允许用户代码跟垃圾回收同时运行,需要维护一条约束条件:
黑色对象绝对不能引用白色对象
为什么不能让黑色引用白色?因为黑色对象是活跃对象,它引用的对象是也应该属于活跃的,不应该被清理。但是,由于在三色标记算法中,黑色对象已经处理完毕,它不会被重复扫描。那么,这个对象引用的白色对象将没有机会被着色,最终会被误当作垃圾清理。 STW中,一个对象,只有它引用的对象全标记后才会标记为黑色。所以黑色对象要么引用的黑色对象,要么引用的灰色对象。不会出现黑色引用白色对象。 对于垃圾回收和用户代码并行的场景,用户代码可能会修改已经标记为黑色的对象,让它引用白色对象。看一个例子来说明这个问题:
stack -> A.ref -> B
A是从栈对象直接可达,将它标记为灰色。此时B是白色对象。假设这个时候用户代码执行:
localRef = A.ref
A.ref = NULL
localRef是栈上面的一个黑色对象,前一行赋值语句使得它引用到B对象。后一行A.ref被置为空之后,A将不再引用到B。A是灰色但是不再引用到B了,B不会着色。localRef是黑色,处理完毕的对象,引用了B但是不会被再次处理。于是B将永远不再有机会被标记,它会被误当作垃圾清理掉!
如果实现满足这种约束条件呢?write barrier! 来自wiki的对这个术语的解释:”A write barrier in a garbage collector is a fragment of code emitted by the compiler immediately before every store operation to ensure that (e.g.) generational invariants are maintained.” 即是说,在每一处内存写操作的前面,编译器会生成的一小段代码段,来确保不要打破一些约束条件。
增量和分代,都需要维护一个write barrier。 先看分代的垃圾回收,跨越不同分代之间的引用,需要特别注意。通常情况下,大多数的交叉引用应该是由新生代对象引用老生代对象。
当我们回收新生代的时候,这没有什么问题。但是当我们回收老生代的时候,如果只扫描老生代不扫描新生代,则老生代中的一些对象可能被误当作不可达对象回收掉!为了处理这种情况,可以做一个约定–如果回收老生代,那么比它年轻的新生代都要一起回收一遍。另外一种交叉引用是老生代对象引用到新生代对象,这时就需要write barrier了,所有的这种类型引用都应该记录下来,放到一个集合中,标记的时候要处理这个集合。
再看三色标记中,黑色对象不能引用白色对象。这就是一个约束条件,write barrier就是要维护这条约束
CMS全称Concurrent Mark Sweep(并发标记清除),是一款以获取最短回收停顿时间为目标的 老年代收集器,适合基于B/S的服务器上,系统停顿时间短,用户体验较好。 另外,CMS也是一款真正意义上的并发收集器,能够与用户线程同时进行。虽然,并发回收过程中也有几个阶段需要Stop the world,但是由于任务简单,所以停顿时间非常短。 可以通过-XX:+ UseConcMarkSweepGC来标识开启CMS收集器,会使用ParNew对新生代的无用对象进行回收。
标记老年代中所有的GC Roots引用的对象
标记老年代中被年轻代中活着的对象引用的对象
由于需要对所有的对象进行标记,为了防止标记过程中有对象状态发生改变,所以需要Stop the world,停止用户线程,但是整个标记的过程耗时短。
从初始化标记阶段找到的GC Roots开始进行Tracing,找到所有的存活对象。
并发标记阶段会与用户线程同时进行,因此会有一些对象的引用状态发生改变。
标记在并发标记阶段引用发生变化的对象,如果发现对象的引用发生变化,则JVM会标记堆的这个区域为Dirty Card。
那些能够从Dirty Card到达的对象也被标记(标记为存活),当标记做完后,这个Dirty Card区域就会消失。
该阶段是一个并发阶段,能够与用户线程同时运行,不会中断他们。
移除那些不同的对象,并回收占用的内存空间。
4.1CMS失败处理
在运行CMS收集器的时候,可能会出现两种类型的失败
4.11为什么晋升失败(promoration failure)
年代有足够的空间,但是由于碎片化严重,无法容纳新生代中晋升的对象,发生晋升失败。
晋升失败的原因是碎片化严重,所以这个问题的解决方案就是如何减少碎片化的问题。CMS提供了两个参数来对碎片进行整理和压缩。-XX:+UseCMSCompactAtFullCollection这个设置的作用是在进行FullGC的时候对碎片进行整理和压缩。-XX:CMSFullGCsBeforeCompaction=这个参数是设置在进行多少次FullGC的时候对老年代的内存进行一次碎片整理压缩。通过设置这两个参数可以有效的对碎片问题进行优化。同样需要注意的是对碎片进行整理压缩是一个比较耗时的操作,所以也需要谨慎设置。
4.12并发模式失败日志(concurrent mode failure)
当老年代无法容纳新生代GC晋升的对象时发生并发模式失败,并发模式失败意味着CMS退化成完全STW的Full GC,也就是Serial GC
-XX:MetaspaceSize=128m (元空间默认大小)
-XX:MaxMetaspaceSize=128m (元空间最大大小)
-Xms1024m (堆最大大小) -Xmx1024m (堆默认大小)
-Xmn256m (新生代大小)
-Xss256k (棧最大深度大小)
-XX:SurvivorRatio=8 (新生代分区比例 8:2)
-XX:+UseConcMarkSweepGC (指定使用的垃圾收集器,这里使用CMS收集器)
-XX:+PrintGCDetails (打印详细的GC日志)
知识点
JDK8之后把-XX:PermSize 和 -XX:MaxPermGen移除了,取而代之的是 -XX:MetaspaceSize=128m (元空间默认大小) -XX:MaxMetaspaceSize=128m (元空间最大大小)
JDK 8开始把类的元数据放到本地化的堆内存(native heap)中,这一块区域就叫Metaspace,中文名叫元空间。使用本地化的内存有什么好处呢?最直接的表现就是java.lang.OutOfMemoryError: PermGen 空间问题将不复存在,因为默认的类的元数据分配只受本地内存大小的限制,也就是说本地内存剩余多少,理论上Metaspace就可以有多大(貌似容量还与操作系统的虚拟内存有关?这里不太清楚),这解决了空间不足的问题。不过,让Metaspace变得无限大显然是不现实的,因此我们也要限制Metaspace的大小:使用-XX:MaxMetaspaceSize参数来指定Metaspace区域的大小。JVM默认在运行时根据需要动态地设置MaxMetaspaceSize的大小。
可视化工具
GCeasy
对于类的过多,可以加大MetaSpace的内存空间,吞吐量可能达到98,99,
第二次调优,添加吞吐量和停顿时间参数:-XX:GCTimeRatio=99 -XX:MaxGCPauseMills=10,根据硬件进行调优,硬件不行可能会引起反效果
调优过程
1、主要针对full GC(STW时间卡顿久)进行优化,如果young GC(卡顿少)比较频繁也要调优
2、每秒大概300单,下单涉及库存对象,优惠券对象,积分对象,大概300KB * 20 * 10 = 60M数据要回收
3、eden区分800M,s0和s1各100M,元空间和老年代 2G,方法区512M,栈各1M
4、如果一个对象超过S0区的50%大小,就会直接挪到老年代,动态年龄判断在minor gc之后
对于4核8G的分配
-Xms2g
与 -Xmx2g
:堆内存大小,第一个是最小堆内存,第二个是最大堆内存,比较合适的数值是2-4g,再大就得考虑GC时间
-Xmn1g
或 (-XX:NewSize=1g
和 -XX:MaxNewSize=1g
) 或 -XX:NewRatio=1
:设置新生代大小,JDK默认新生代占堆内存大小的1/3,也就是-XX:NewRatio=2
。这里是设置的1g
,也就是-XX:NewRatio=1
。可以根据自己的需要设置
-XX:MetaspaceSize=128m
和 -XX:MaxMetaspaceSize=512m
,JDK8
的元空间几乎可用完机器的所有内存,为了保护服务器不会因为内存占用过大无法连接,需要设置一个128M
的初始值,512M
的最大值保护一下
-XX:SurvivorRatio
:新生代中每个存活区的大小,默认为8,即1/10的新生代, 1/(SurvivorRatio
+2)
-Xss256k
:在堆之外,线程占用栈内存,默认每条线程为1M
-XX:MaxDirectMemorySize
:堆外内存/直接内存的大小,默认为堆内存减去一个Survivor区的大小
GC日志打印主要
-XX:+PrintTenuringDistribution
:查看每次minor GC后新的存活周期的阈值
-XX:+PrintGCDetails
:启用gc日志打印功能
-Xloggc:/path/to/gc.log
:指定gc日志位置
-XX:+PrintHeapAtGC
:打印GC前后的详细堆栈信息
-XX:PrintFLSStatistics=1
:打印每次GC前后内存碎片的统计信息