《深入理解Java虚拟机》读书笔记(第三章)
垃圾收集器与内存分配策略(第三章)
前言,众所周知,Java是由c++进化而来,c++在内存需自己申请,自己释放,于是就有了Java的动态内存分配。书的第三章开篇,有这样一句话描述的很妙——Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外的人想进去,墙内的人却想出来。
如何判断对象已经死去
引用计数器法
概述:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;引用失效,计数器就减1;当一个对象的引用计数器为0时,表示对象已死,可回收
优点:实现简单,并且高效
缺点:无法解决对象之间的循环引用问题
可达性分析算法
概述:解决引用计算器法的无法判断循环引用的问题,基本思路是,以一系列称为“GC Roots”的对象作为起点,从这些节点开始向周围搜索,通过引用链(Reference Chain)的方式。当对象A引用了对象B,那么对象A和对象B之间便有了引用链,如果对象A是“GC Roots”,那么对象B就被称为可达。当对象B引用了对象C,因为B是可达的,所以A通过B也能到达C,对象C也被称作可达。引用链:A -> B -> C
哪些对象可作为GC Roots的对象?
- 虚拟机栈(栈帧中的本地变量)中引用的对象
- 方法区(注意:方法区这个概念,在JDK1.8便移除了,改为了元空间)中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中引用的对象
优点:全面的分析对象是否存活
缺点:实现复杂,效率低下
引用
判断对象是否存活,离不开引用。JDK1.2以前,定义引用的方法很纯粹:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这就导致了引用只有两种状态,有或者无。两个对象之间,存在着引用就是有,不存在引用就是没有,没有就被垃圾收集器给回收。但是我们对于一些“食之无味,弃之可惜”的对象就显得无能为力了。当我们的内存足够时,我们并不想毁灭它,以此来避免后面的重复创建,浪费时间。所以有了以下四种定义
- 强引用:程序代码中普遍存在,类似“Objec obj = new Object()”,个人理解为,就是GC Roots能可达的对象,这些对象之间的引用链就是强引用,只要强引用在,就永远不会被回收掉。
- 软引用:SoftReference,描述一些还可能有用,但是非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。当然,如果这次回收还没有收集足够的空间供新对象使用,才会抛出内存溢出的异常,导致OOM。
- 弱引用:WeakReference,也是用来描述一些非必需的对象,但是它比软引用更加的弱一些。每一次垃圾回收,不管内存够不够都要回收它们。只能生存在下一次垃圾回收之前。
- 虚引用:PhantomReference,最弱的引用,一个对象是否有虚引用,完全不会对其生存时间构成影响,唯一的目的是:能在这个对象被收集器回收时收到一个系统通知
回收方法区
即JDK1.8后的元空间,回收对象主要有两部分内容:废弃的常量和无用的类。
满足三个条件才算是无用的类:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的对象
垃圾收集算法
标记-清除算法
-
概述:算法分为两个阶段,首先标记所有需要回收的对象,在标记完成后统一回收所有标记的对象。最基础的收集算法。
-
缺点:效率不高,空间上会产生大量的空间碎片。如果程序遇到大对象需要分配时,无法找到足够的连续内存而不得不提前触发另一次的垃圾收集动作。
复制算法
-
概述:把内存分为大小相等的两块,每次只使用其中的一块。当这一块的内存快用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次性的清理掉。现代的商业机都是采用的这种收集算法,但是IBM研究表明,新生代中的对象98%都是“朝生夕死”的,并不需要按1:1来分配内存空间,而是将内存分为一块较大的Eden和两块较小的Survivor1空间,每次使用Eden和其中的一块Survivor空间。当回收时,将Eden和Survivor中还存活的对象一次性的复制到另外一块Survivor上去,最后在清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。但是可能会出现复制到Survivor过程中,存活的对象太多了,装不下,Survivor空间不够用。所以还需要进行分配担保(Handle Promotion),依赖其他内存来复制(这里指老年代)。
-
优点:简单高效,不用考虑内存碎片等复杂情况。
-
缺点:内存利用会减少一半
标记-整理算法
适用于老年代,存活率高的对象。让所有存活的对象向一端移动,然后清理掉端边界以外的内存。
分代收集算法
当前商业虚拟机都是采用的“分代收集”算法,把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。新生代用复制算法,因为有大量对象死去,老年代使用“标记-清理”或者“标记整理”,因为老年代存活率高。
HotSpot如何实现垃圾回收
枚举根节点
从GC Roots出发,也就是根节点,进行可达性分析,这个过程非常消耗时间。而且可达性分析的话,需要保持“一致性”,一致性是指整个可达性分析期间的执行过程应该看起来是要停留在某个时间点上。不可以出现分析过程中,对象之间的引用还在发生变化。要保证可达性分析结果的正确性,准确性。所以GC会导致系统的“Stop The World”
当然由于枚举根节点消耗大量的时间,所以引入了OopMap这种数据结构。GC时,当执行系统停顿下来时,并不需要一个不漏的把所有执行上下文和全局的引用位置,虚拟机而是通过OopMap直接得知哪些地方存放着对象的引用
安全点
为了配合OopMap,引入了安全点。HotSpot通过OopMap确实可以快速且准确地完成GC Roots的枚举,但是OopMap内容的变化的指令非常的多,如果位每一条指令都生成对应的OopMap,那将会需要大量的额外空间。所以虚拟机只在特定的时间位置,记录了这些引用信息,并没有为每条指令都生成OopMap,这些位置便称作安全(SafePoint),即程序执行时并非能在所有的地方停下来,而是只能在达到安全点时才能暂停。
如何在GC发生时,让所有的线程都“跑”到最近的安全点去?
- 抢先式中断:强制把所有的线程中断,如果有线程不在安全点上,就恢复线程,让它跑到安全点上,几乎没有虚拟机使用这种方法,
- 主动式中断:当线程需要中断时,不直接对线程操作,而是通过一个标志,各个线程执行时,主动的去轮询这个标志,需要中断时,改变这个标志。线程在轮询的时候发现中断标志为真,就自己中断挂起。轮询标志的地方和安全点是重合的。
SafePoint很完美吗?不见得,safePoint关注的点是正在运行的线程,那么有些线程处于Sleep状态或者Blocked状态,这时候线程无法响应中断请求。所以引入了安全区域(Safe Region)
安全区域是指:在一段代码中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。具体描述:在线程执行到safe region中的代码时,首先标识自己已经进入了safe region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为safe region状态的线程了。在线程要离开safe region时,它要检查系统是否已经完成了根节点枚举(即整个GC过程),如果完成了,那线程就继续执行,否则他就必须等待直到收到可以安全离开safe region的信号为止
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待阶段。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
Serial收集器
最基本,发展历史最悠久的收集器。特点:在他进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束,Stop The World。优点:简洁又高效。在运行在client模式下的虚拟机来说是一个很好的选择。
ParNew收集器
是serial收集器的多线程版本。它是许多运行在Server模式下的虚拟机中首选的新生代收集器。原因很简单,目前只有它与serial收集器能与CMS收集器配合工作。
Parallel Scavenge收集器
目标是达到一个可控的吞吐量。所谓吞吐量用于运行用户代码的时间与CPU总消耗时间的比值。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。它也被称为吞吐量优先的收集器。可以设置一个参数,让虚拟机GC自适应的调节。即虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或最大吞吐量。这是它与parnew收集器的一个重要区别。
Serial Old收集器
是serial收集器的老年代版本,同样是一个单线程收集器,使用标记_整理算法。
Parallel Old收集器
是Parallel Scavenge收集器的老年代版本,使用多线程的标记_整理算法。
CMS(Concurrent Mark Sweep)
是一种以获取最短回收停顿时间为目标的收集器。重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。是基于标记-清除算法实现的,它的运作过程:初始标记,并发标记,重新标记,并发清除。第一步和第三步仍然需要Stop The World。
- 初始标记:仅仅标记一下GC Roots能直接关联到的对象,速度很快。需要STW
- 并发标记:进行GC Roots Tracing的过程,时间较长
- 重新标记:则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那些对象的标记记录,比初始标记稍微长一些,但远比并发标记的时间短。需STW
- 并发清除:执行sweep,时间较长
优点:并发收集,低停顿,又称之为并发低停顿收集器。
缺点:
-
CMS对CPU资源非常的敏感,在并发阶段虽然不会导致用户线程停顿,但是会因为占用CPU资源而导致应用程序变慢,总吞吐量会降低。多CPU的时候,可以用回收默认线程数(CPU数量+3)/4。使并发回收垃圾线程所占的CPU资源使用百分比降低。CPU越多,降的多。但是CPU很少的情况下,问题就很大,由此虚拟机提供了i-CMS增量式并发收集器,就是让GC线程,用户线程交替运行,尽量减少GC线程独占CPU的时间,这样会是整个垃圾回收时间增长,但对用户程序的影响就少些。但是效果一般,不提倡使用。
-
CMS无法处理浮动垃圾,由于在并发清理阶段用户线程还在运行,可能产生新的垃圾,这一部分垃圾出现在重新标记阶段之后。因此需要预留提供一部分空间给并发时的用户线程使用,以供用户程序正常运行,而不能像其他收集器一样,等到老年代几乎被填满了再进行收集。
-
是标记-清除算法的缺点,会有大量的空间碎片产生,碎片过多时,将会给大对象带来很大的麻烦,如果没有空间给大对象,就不得不提前触发一次fullGC。为了解决这个问题,可以设置一些参数来优化,如当FullGC执行了多少次的时候,来一次空间碎片压缩,开启内存碎片的整理过程。但是这样使得停顿时间变长了。
G1(Garbage-First)收集器
G1是一款面向服务端应用的垃圾收集器,它的使命是替换掉CMS,弥补CMS空间碎片等缺点。不同于其他的分代回收算法、G1将堆空间划分成了互相独立的区块。每块区域既有可能属于O区、也有可能是Y区,且每类区域空间可以是不连续的(对比CMS的O区和Y区都必须是连续的)。这种将O区划分成多块的理念源于:当并发后台线程寻找可回收的对象时、有些区块包含可回收的对象要比其他区块多很多。虽然在清理这些区块时G1仍然需要暂停应用线程、但可以用相对较少的时间优先回收包含垃圾最多区块。这也是为什么G1命名为Garbage First的原因:第一时间处理垃圾最多的区块。
- 并行与并发:G1能充分利用多CPU、多核环境的硬件优势,使用多个CPU来减少STW的停顿时间
- 分代收集:还是与其他收集器一样采用分代收集,不过older与young不再是连续的空间了
- 空间整合:整体是感觉采用“标记-整理”;局部之间是靠两个region基于“复制算法”;这两种策略所以不会产生空间碎片
- 可预测停顿:建立可预测的停顿时间模型,能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒,这几乎是实时Java(RTSJ)的垃圾收集器的特征了
平时工作中大多数系统都使用CMS、即使静默升级到JDK7默认仍然采用CMS、那么G1相对于CMS的区别在:
- G1在压缩空间方面有优势
- G1通过将内存空间分成区域(Region)的方式避免内存碎片问题
- Eden, Survivor, Old区不再固定、在内存使用效率上来说更灵活
- G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象
- G1在回收内存后会马上同时做合并空闲内存的工作、而CMS默认是在STW(stop the world)的时候做
- G1会在Young GC中使用、而CMS只能在O区使用
就目前而言、CMS还是默认首选的GC策略、可能在以下场景下G1更适合:
- 服务端多核CPU、JVM内存占用较大的应用(至少大于4G)
- 应用在运行过程中会产生大量内存碎片、需要经常压缩空间
- 想要更可控、可预期的GC停顿周期;防止高并发下应用雪崩现象
GC模式:G1中提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的条件下被触发。
-
young gc:发生在年轻代的GC算法,一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。
参数 含义 -XX:MaxGCPauseMillis 设置G1收集过程目标时间,默认值200ms -XX:G1NewSizePercent 新生代最小值,默认值5% -XX:G1MaxNewSizePercent 新生代最大值,默认值60% -
mixed gc:当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。
那么mixed gc什么时候被触发?
先回顾一下cms的触发机制,如果添加了以下参数:
`-XX:CMSInitiatingOccupancyFraction=``80` `-XX:+UseCMSInitiatingOccupancyOnly`
当老年代的使用率达到80%时,就会触发一次cms gc。相对的,mixed gc中也有一个阈值参数
-XX:InitiatingHeapOccupancyPercent
,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc.mixed gc的执行过程有点类似cms,主要分为以下几个步骤:
- initial mark: 初始标记过程,整个过程STW,标记了从GC Root可达的对象
- concurrent marking: 并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息
- remark: 最终标记过程,整个过程STW,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象
- clean up: 垃圾清除过程,如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中
-
full gc:如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc.
G1摘抄借鉴:
http://www.importnew.com/27793.html
https://juejin.im/entry/5af0832c51882567244deb44
内存分配与回收策略
主要是以下五种策略,目的都是在结合JVM实际情况下,尽可能的提高效率,且做到安全可靠
- 对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代GC)。
- 大对象直接进入老年代:大对象对虚拟机来说是一个很坏的消息,经常出现大对象会容易导致内存还有不少空间,但是满足不了这个大对象,所以提前触发垃圾收集以获取足够的空间来容纳安置它们。可以开启一个参数,是大对象直接进入老年代,在老年代分配,这样做的目的是避免在Eden区以及两个Survivor区之间大量的内存复制(新生代采用的是复制算法)。
- 长期存活的对象将进入老年代:给对象加一个年龄计数器,每熬过一次Minor GC后仍然存活,并且能够被Survivor容纳的话,对象年龄就加一。可以设置一个参数,当年龄足够就晋升老年代之中去。
- 动态对象年龄判定:并不一定要求,所有对象的年龄必须要达到某一个年龄值,如果Survivor空间中相同年龄所有对象的大小的总和大于Survivor空间的一半,年龄大于等于该年龄对象就可以直接进入老年代,无须一定要达到某个年龄才能进去。
- 空间分配担保:发生在Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,那么会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么就会继续检查老年代的最大可连续空间是否大于历次晋升到老年代对象的平均大小,如果大于就尝试进行一次Minor GC,虽然有风险。如果小于就进行Full GC,来回收老年代的空间。圈子虽然绕的大,但是可以避免频繁的FULL GC。