心得:Java中垃圾回收和内存可以实现高度的自动化,栈帧可以由JVM自动分配和回收,局部变量表和操作数栈也可以在编译时就确定好,堆中的内存分配和回收才是JVM关注的重点,JVM实现大多采用可达性分析来标记存活对象,什么时候标记?让用户线程主动跑到那些安全的地方(引用关系不变的时候,SafePoint和Safe Region),再由GC收集器来标记进行处理。
不同的垃圾收集器甚至可以决定堆的内存布局,比如G1的“化增为零”一方面借助Remember Set可以更细粒度的进行并发标记和回收。
分代是GC种重要的思想,对不同特点对象进行各自适合的回收策略,Minor GC一般是新生代采用Copying算法,“空间换时间”,也是因为新生代大部分对象“朝生夕死”;Full GC在老年代一般是Mark and Compact两个步骤,不用额外空间,但停顿长,可谓“用时间省空间”;
CMS和G1更是采用并行+并发的手段,但一个新的问题,就是并发期间的用户线程的内存开销(CMS和G1各有对应策略)和对象引用关系的变化,因此它们都有remark的过程。
总之,不同的场景用不同的技术,“知其然”的同时能够“知其所以然”才能在实际的场景下选择“对的”技术。
垃圾回收是一个复杂的系统问题,本人认识还是十分有限。。。
学习参考资料:
(1)《深入Java虚拟机》(第二版);
(2)RednaxelaFX的回答—现代JVM中的Safe Region和Safe Point到底是如何定义和划分的?
(3)G1垃圾收集器入门
(4)我在知乎的提问(多谢RednaxelaFX大神的回答)
(5)CMS的原始论文:A Generational Mostly-concurrent Garbage Collector
一个小栗子(我在知乎的提问)
一个问题Java中的对象到底占多少内存?
JVM规范也不能回答这个问题,因为它是一个公有设计;
The Java Virtual Machine does not mandate any particular internal structure for objects.
看过书的同学应该都知道,对象由对象头+实例数据+padding组成;我利用Instrumentation做了一个小小的实验,基于64位JDK 8的Hotspot:
/*
基本信息:对象内存布局,对象的大小
注意:以HotSpot为例,Java中的对象内存布局包括:MarkWord,ClassPointer,实例数据,padding
如果是数组还有数组长度;如果开启了字段压缩,会进行指针压缩,子类的窄变量会插入父类的宽变量之中
*/
public static void main(String[] args) {
//Object
System.out.println(ClassUtils.sizeOf(new Object())); //16 8字节MarkWord,4字节klass指针,4字节padding
//数组
System.out.println(ClassUtils.sizeOf(new byte[0])); //16 8字节MarkWord,4字节klass指针,4字节数组长度
System.out.println(ClassUtils.sizeOf(new byte[7])); //24 padding补齐
System.out.println(ClassUtils.sizeOf(new byte[_1MB])); //1024 * 1024 + 16 = 1048592
//窄对象
System.out.println(ClassUtils.sizeOf(new Integer(1))); //16 int和klass补齐
System.out.println(ClassUtils.sizeOf(new Byte("1"))); //16 byte和klass补齐
System.out.println(ClassUtils.sizeOf(new Character('a'))); //16 char和klass补齐
}
因为对象的大小一定是8的倍数,可以看到Hotspot很节省的将类型指针和数组长度或者int,byte,char合并保存了,而64位的Hotspot中reference的长度仍然是4个字节,一些博客上有说成8个字节的。
1. 确定回收对象
引用计数和可达性分析(counting和tracing)
前者通过对象的引用计数器来记录被引次数,显然的一个问题是循环引用;Java采用的是可达性分析;
从GC Roots出发,延引用链对对象进行搜索,没有任何引用链和GC Roots相连的对象就被成为不可达的,被判定为可回收的对象;
GC Roots(方法区和栈中):
(1)虚拟机栈(栈帧中的局部变量表);
(2)本地方法栈JNI引用的对象;
(3)方法区中类静态属性;
(4)方法区中常量引用;
引用类型
就像进程的状态不能由简单的运行和终止描述一样;引用也需要进行一步细分:
强引用:永远不会被回收掉的对象;
软引用:如果一次回收后,内存还是不足,才进行回收,如果再不够,OOM;
弱引用:发生GC时,无论内存是否足够都会被回收;
虚引用:程序不能引用到,但是被回收时可以收到一个通知;
一个很重要的应用就是缓存,在内存中缓存一定要注意防止内存泄漏,在Java Collection Framework中,容器在删除是都执行置空的操作;
另一个注意的是可以使用WeakHashMap作为缓存容器;如果不是WeakHashMap一定要控制数量和及时清除(Integer.valueOf等就控制了数量);
终结(finalize)
从对象的可触及性来说还有2个状态:
可复活和不可触及(两次标记):
在判定为不可达后:
(1)可复活:一次标记,对象分为没有必要执行finalize方法(包括没有覆盖和已经执行两种)和需要执行finalize方法,前者直接就可以被回收;
(2)有必要执行finalize的方法被放在F-Queue队列中,有JVM中一个低优先级的Finalizer线程去触发它们的finalize方法(不会等待方法结束),如果在finalize方法中对象有引用链建立了连接就会被“复活”,否则就Over;
一个对象的finalize方法只能被执行一次,也就是说一个对象甭想自救两次!
方法区中的回收
对于常量来说,没有任何东西引用,那么也是可以被回收的;
类的卸载:条件非常苛刻(JVM规范没有要求在方法区中实现垃圾回收,Hotspot中有但是类还是很难被卸载)
(1)该类所有的实例被回收;
(2)对应的ClassLoader被回收;
(3)对应的Class对象没有引用;
栈的回收是虚拟机静态分配和回收,栈帧的大小可以在编译时确定,JVM通过栈帧的分配和回收很容易(开销很低)就完成;
方法区的回收中类的卸载很棘手,对于大量用反射,动态代理,CGLIB等技术的程序,JVM要能够卸载类;
主要的一个问题就是堆中内存的分配和卸载;
2. 垃圾回收算法
标记-清除算法(Mark-Sweep)
问题:
(1)空间碎片;
(2)效率:标记和清除两个过程相对来说不高;
复制算法(Copying)
原理:两块内存来回复制,因为有一块空白的内存可以直接复制,因此不用再分Mark,Sweep或者Compact多个阶段了,空间换时间;
当然我们知道最后的设计是:一个Eden+两个较小的Survivor,这是由于Java中对象98%的新对象都可以被回收的统计数据得来的经验;
标记-整理算法
区别与“标记-清除”,整理指的是不再原来位置直接进行回收,而是存活的对象向一端移动,最后界限之外的部分直接清理掉;
根据不同对象的特点,采用分代的方式垃圾回收;
3. HotSpot的垃圾回收算法实现
什么时候回收垃圾,怎样尽量降低对用户线程的影响不同的业务需求对垃圾回收有什么不同的要求?
枚举根节点
一致性:进行引用链分析显然要基于一个一致性的快照,不能因为分析过程中引用关系变化而导致错误;
Stop the world:一个简单直接的办法,但是显然会产生停顿;
OopMap:为了避免进行全盘扫描,借助与OopMap这样的数据结构保存对象的被引用范围,告诉JVM哪些地方存折对象的引用;
安全点(节省开销,安全性)
定义:the thread’s representation of it’s Java machine state is well described
OopMap可以帮助快速的完成GC Roots枚举,但是显然并不能每条指令都带上OopMap;通过SafePoint的地方保存OopMap,运行到安全点上可以进行GC活动;
如果要触发一次GC,那么JVM里的所有Java线程都必须到达GC safepoint;
哪些地方可以选为SafePoint(不同的JVM实现可能不同):
因此防止“长时间运行”,而导致GC活动等待某个线程迟迟不能进入;
方法调用,循环跳转,异常跳转等:
(1)循环的末尾;
(2)方法返回之前;
(3)调用方法的call之后;
(4)抛出异常的位置;
PS:Java中用到SafePoint的地方
1. Garbage collection pauses;
2. Code deoptimization;
3. Flushing code cache
4. Class redefinition (e.g. hot swap or instrumentation)
5. Biased lock revocation
6. Various debug operation (e.g. deadlock check or stacktrace dump)
主动式中断:
设置标志,让工作线程主动轮询到标志进行挂起;
在JIT执行方式下:JIT编译的时候直接把safepoint的检查代码加入了生成的本地代码,当JVM需要让Java线程进入safepoint的时候,只需要设置一个标志位,让Java线程运行到safepoint的时候主动检查这个标志位,如果标志被设置,那么线程停顿,如果没有被设置,那么继续执行。
例如hotspot在x86中为轮询safepoint会生成一条类似于“test %eax,0x160100”的指令,JVM需要进入gc前,先把0x160100设置为不可读,那所有线程执行到检查0x160100的test指令后都会停顿下来;
在解释器执行方式下:JVM会设置一个2字节的dispatch tables,解释器执行的时候会经常去检查这个dispatch tables,当有SafePoint请求的时候,就会让线程去进行SafePoint检查。
VMThread会一直等待直到VMOperationQueue(消息队列)中有操作请求出现,比如GC请求。而VMThread要开始工作必须要等到所有的Java线程进入到SafePoint。
JVM维护了一个数据结构,记录了所有的线程,所以它可以快速检查所有线程的状态。当有GC请求时,所有进入到SafePoint的Java线程会在一个Thread_Lock锁阻塞,直到当JVM操作完成后,VM释放Thread_Lock(通知),阻塞的Java线程才能继续运行(STW)。
GC stop the world的时候,所有运行Java code的线程被阻塞,如果运行native code线程不去和Java代码交互,那么这些线程不需要阻塞。VM操作相关的线程也不会被阻塞。
PS:输出安全点统计信息
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1
安全区域(Safe Region)
GC活动设置状态等待Java线程主动轮询到状态被Thread_Lock阻塞,但是如果处于阻塞状态的线程怎么办,它们已经被阻塞了,这就需要Safe Region;
相对的,Safe Region指在一段代码片段之后,引用关系不会发生变化;线程是BLOCKED那么它的引用关系就不会被修改,JVM可以安全进行标记;
过程:如果线程进入到Safe region时,首先标识自己进入了Safe region,在退出时(比如一个阻塞的线程被唤醒),先检查是否能够离开,如果GC已经完成,那么可以离开,否则等待直到GC完成;
4. 垃圾回收器
从实现框架上来看:
Serial,ParNew,Serial Old,CMS是一个分代式GC框架中的,可以任意的搭配;
Parallel Scavenge和G1有各自的框架,Parallel Scavenge不能和CMS搭配,只能和Serial Old(MSC,MarkSweepCompact),然后有了Parallel Old(标记-整理,PS Mark Sweep);
这个PS MarkSweep默认的实现实际上是一层皮,它底下真正做mark-sweep-compact工作的代码是跟分代式GC框架里的serial old(这个collector名字叫做MarkSweepCompact)是共用同一份代码的。也就是说实际上PS MarkSweep与MarkSweepCompact在HotSpot VM里是同一个collector实现,包了两张不同的皮;一个并行,一个串行。
从串行,并行,并发执行方式看
串行的有:Serial,Serial Old, CMS的Concurrent Mode Fail情况(使用Serial Old);
并行的有:ParNew,Parallel Scavenge,CMS的remark,G1的最终标记,筛选回收(也可并发);
并发的有:CMS的concurrent mark,G1;
从性能特点和适用情景看
Serial:简单,快捷,适合与内存不大(几十上百MB)的client模式;
Parallel Scavenge:从吞吐量的角度控制堆的划分和GC活动;
CMS:目标也是低停顿,在停顿控制上不如G1,但是如果在停顿都接受可以接受的范围内,吞吐量ParNew+CMS的组合可能要比G1更好;
G1:G1的首要目标是为需要大量内存的系统提供一个保证GC低延迟的解决方案,也就是说堆内存在6GB及以上,稳定和可预测的暂停时间小于0.5秒;
如果应用程序具有如下的一个或多个特征,那么将垃圾收集器从CMS或ParallelOldGC切换到G1将会大大提升性能.
(1)Full GC 次数太频繁或者消耗时间太长;
(2)对象分配的频率或代数提升(promotion)显著变化;
(3)受够了太长的垃圾回收或内存整理时间(超过0.5~1秒);
CMS收集器(低停顿,B/S系统,侧重响应速度)
过程:
(1)初始标记(initial mark,停顿);
(2)并发标记(concurrent mark);
(3)重新标记(parallel remark,停顿);
(4)并发清除(concurrent sweep);
(5)重置(reset,清理数据结构,为下次并发收集做准备);
问题:
(1)对CPU资源敏感,很简单,因为它使用了并发,在CPU核数较少的机器上会对用户线程影响较大;
(2)浮动垃圾,并发标记的过程可能会产生新的垃圾,这一部分垃圾只能在下一次GC进行清理;并且垃圾回收阶段也是并发的,必须为用户线程预留一些内存空间,因此:
JDK 1.5老年代68%,激活CMS,JDK 1.6 默认为92%;
-XX:CMSInitiatingOccupacyFraction设置;
如果预留空间无法满足,造成“Concurrent Mode Failure”,临时使用Serial Old进行回收;
(3)碎片,通过-XX:+UseCMSCompactAtFullCollection开启在要FullGC前进行异常整理;-XX:+CMSFullGCsBeforeCompaction可以设置多少次不压缩的FullGC之后来一次带压缩的FullGC(默认为0);
G1收集器(Garbage First,可预测的低停顿,服务器,化整为零)
将整个Java堆划分成多个大小相等的独立区域(Region),新生代和老年代分布在这些region上(可以不连续);
G1并不是实时垃圾收集器:基于以前收集的各种监控数据,G1会根据用户指定的目标时间来预估能回收多少个heap区。因此,收集器有一个相当精确的heap区耗时计算模型,并根据该模型来确定在给定时间内去回收哪些heap区。
优点:
(1)并行与并发;
(2)分代收集:虽然不像其他收集器老年代和新生代是物理隔离的,但是老对象,仍然会采用不同的方式处理;
(3)空间整合:从整体上看是“标记-整理”,从局部上来看是“复制”,不会产生空间碎片;
(4)可预测的停顿:建立了可预测的停顿时间模型(并且后台维护了一个优先列表),可以通过参数控制在M毫秒的时间段内,GC消耗的时间不得超过N毫秒;
G1如何进行垃圾回收的:
后台维护一个优先列表,每次回收先会回收GC价值最大的region;
问题:G1如何保证各个Region在各自回收时和其他region对象之间引用关系被正确处理?
Remembered Set,记录其他region对自己region中对象的引用记录(write barrier+CardTable),以避免扫描全局;
G1的回收步骤:
(1)初始标记(Initial mark);
(2)并发标记(Concurrent mark);
(3)最终标记(final mark,将并发过程对象变化的Rememered Set log合并,将并发标记中的空区域回收,计算所有区域的活跃度(live的程度);
(4)筛选扫描(live data counting and evacuation,拷贝和回收);
G1中的转移失败(Evacuation Failure)):
对Survivor或promoted Objects进行GC时如果JVM的heap区不足就会发生提升失败(promotion failure),堆内存不能继续扩充,因为已经达到了最大值,日志输出to-space overflow;
也就是说GC的效率赶不上空间的消耗导致“碰到天花板”了;
解决(加快GC的速度,增加对内存):
-XX:G1ResrvePercent:保留内存,也就是来个“假天花板”;
-XX:ConcGCThreads=n增加标记线程数量;
5. 对象分配与回收策略
注意启动了本地线程分配,按线程优先分配在TLAB上;
优先在Eden分配
大对象直接分配在老年代
通过-XX:PretenureSizeThreshold控制;
防止提前触发minor GC,减少新生代的复制;
长期存活的对象将进入老年代
根据Age计数器大小,设置-XX:MaxTenuringThreshold设置;
动态对象年龄判断
如果survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold设置的大小;
空间分配担保
在Minor GC前(关键是Old最大可用连续大小):
问题和总结
1. Minor GC,Major GC和Full GC的区别和联系?
首先这些术语在Java语言规范和JVM规范中都没有正式的定义,但是Hotspot中使用了它们,并在日志中进行输出,在分析问题时也不能够简单的用“Full GC发生次数多少”来作为一个标准,比如一次CMS GC产生了两次STW(initial mark和remark)因此在日志中会输出为2次Full GC,但是CMS的STW时间一般很短。
总之,这些概念只是辅助标识,关键还是监控延迟或者吞吐量,结合GC分析导致问题的原因。
Minor GC是发生在新生代的,基于Serial(DefNew),ParNew,Parallel Scavenge(PS Yong)都是STW的,大多数年轻代中对象都进不了老年代,也就是说能挺到一次Minor GC的对象并不多,这也为什么大部分年轻代GC都使用Copying,一个大的Eden和两个小的Survivor;
如果Minor GC的时间很长,可能是因为新生代存活的对象太多了,都要进行复制,超过了Survivor区的大小的话,要进入老年代,这就有关空间担保分配了。
Major GC和Full GC可以看作是相同的意思,对老年代/永久代进行垃圾回收,因为一些历史原因,这两个概念的定义也挺混乱的,纠结这两个概念并没有什么意义。
Full GC:在Serial GC(UseSerialGC)、Parallel GC(UseParallelGC)中,只有Full GC才会收集老年代(实际收集整个GC堆,包括老年代在内),使用Mark-Compact算法;
Full GC的次数等于老年代GC时STW的次数,时间为STW总时间;
对于CMS收集器来说,Full GC只是一次CMS两个阶段或者在担保失败的情况下用Serial Old来代替了,因此也不能简单Full GC的情况来分析。
Minor GC和Full GC的联系:从一般的编程习惯来看,老的对象引用新创建的对象的情况要多于新对象引用老的对象,因此老年代中的GC一般要从年轻代的引用链开始分析,故而可以设置Full GC进行一次Minor GC,来提高老年代GC的速度。
2. GC和Stop The World(STW):
GC总是会发生停顿,也是“Stop the World”,问题是停顿时间的长短,从这个角度上看,不同GC算法是在努力减少停顿的同时权衡对吞吐量影响或者其他开销。并行是为了利用多核CPU来缩短停顿的时间总量,并发是为了尽可能找出那些可以并行的部分,是进一步利用多核CPU将任务细化,减少STW,G1更是添加了预测模型来控制(尽可能)停顿的时间。
从具体的实现算法来看,Copying和Compact的过程需要移动对象,因此在整理内存阶段需要暂停用户线程。
同步用户线程和GC活动有两种方式,一是read barrier,而是write barrier,前者的开销更大,很少有GC使用read barrier,如果使用write barrier那么在“移动对象”必须要暂停用户线程,从而产生STW。
基于下面的列表,Serial,ParNew,PS,PS old要么是Copying,要么是Mark-Compact,当然它们也都是STW的。
CMS采用了以Mark-Sweep为主的方式,因此可以并发标记和并发重置;
G1从整体上来是Mark-Compact的,局部(region之间)是复制的,但是它是把内存分成一个个region来处理的,可以做到每次compact一部分,而不像Serial等是一口气Compact老年代,因此也可以缩短STW,甚至实现增量式/并发;
Serial:单线程STW,Copying;
Serial Old:单线程STW,Mark&Compact;
ParNew:多线程并行STW,Copying;
Parallel Scavenge:多线程并行,Copying;
PS Old:并行STW,Mark&Compact;
CMS:多线程并发/并行,initial mark,remark是STW,Mark&Sweep/Mark&Compact;
G1:多线程并发/并行,initial mark,remark,Mark&Compact;
3. CMS为什么不采用Mark&Compact而是Mark&Sweep?
CMS在老年代上工作,采用的是Mark&Sweep,不直接使用Mark&Compact, 而是通过-XX:+UseCMSCompactAtFullCollection和-XX:+CMSFullGCsBeforeCompaction两个参数来决定什么时候采用一个压缩,这可以说是一种混合的方式;
使用Mark&Sweep的考虑有:
(1)老年代中一个传统的假设是对象的存活率比较高,我们可以以通过相关的参数控制进入老年代对象的大小和年龄(也就是说进入老年代的对象本来就是经过一次或多次筛选的)。基于这样的场景,使用Copying算法显然是不划算的;
(2)另外一个考虑就是,Mark-Compact和Copying都是要移动对象的,因此还需要修改引用链中的直接引用的地址值,这对于并发重置的CMS来说显然是一个更为复杂的问题,在Mark-Sweep模式下,不需要修改所有指针,因此也不需要暂停用户线程,从而实现并发;
因此,CMS使用这种以Mark-Sweep为主,Mark-Compact为辅的GC方式是一种基于场景的折中方案;