JVM垃圾回收
JVM逃逸分析
1、方法逃逸
方法内部的局部变量作为参数传递到其他方法中,称为方法逃逸。
2、线程逃逸
方法内部的局部变量赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
3、JVM逃逸分析参数设置
启用逃逸分析参数:-XX:DoEscapeAnalysis
查看分析结果:-XX:+PrintEscapeAnalysis
开启标量替换:-XX:+EliminateAllocations
查看标量替换情况:-XX:+PrintElimminateAllocations
开启同步消除:-XX:+EliminateLocks
简单介绍一下逃逸分析:逃逸分析的实际上就是分析一个方法内的局部变量,它可能被外部方法所引用,譬如,作为参数传递到其他方法中,称为方法逃逸,还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。如果能够证明一个对象不会发生逃逸,那么就可以针对这个变量进行一些高效优化,如:栈上分配、同步消除和标量替换。
从JVM角度看Java对象的创建过程
NEW关键字
通过调用类的有参数或者无参构造方法,实现对象创建
对象克隆
通过调用对象的clone()方法进行对象复制,实现对象创建
反序列化
通过Java反序列化机制实现对象的创建,对象类需实现Serializable接口,通过ObjectOutputStream实现反序列化
注:关于JVM符号引用,请参考:https://blog.csdn.net/luzhensmart/article/details/82627897
虚拟机遇到一条new指令时,首先会检查该指令的参数能否在常量池中定位到一个类的符号引用,并且检查整改符号引用代表的类是否已被加载、解析和初始化过,如果没有,则先执行相应的类加载过程。
对象内存分配
Java内存是如何分配给对象的呢?假设堆内存是规整的,已占用的内存和空闲内存之间没有交叉,内存指针指向空闲内存的起始位置,那么当需要分配内存时只需要把指针移动到指定的偏移位置即可,这种内存分配方式称为“指针碰撞”,如果堆内存不是规整的,已占用的内存和空闲内存之间相互交叉,那么久没有办法通过指针碰撞来分配内存了,这个时候需要从内存列表中找到一块足够大的内存来划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
在使用Serial、ParNew等带Compact过程的收集器时,系统采用分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法收集器时,通常采用空闲列表。
对象内存分配并发场景存在的问题
创建对象在虚拟机中是非常频繁的行为,即使仅仅修改一个指针位置,在并发情况下也不是线程安全的。可能出现对象A在分配内存,指针还没来得及修改位置,对象B又同时使用了原来的指针来分配内存。
对象内存分配(CAS+重试)
解决这个问题有2种方案:一种是对分配内存进行同步处理,实际上虚拟机采用CAS配上失败重试的方式来保证更新操作的原子性的;CAS也是有缺陷的,会出现ABA的问题,关于锁的实现以后有机会单独介绍。
对象内存分配(TLAB)
另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有在TLAB空间用完的情况下才需要采用同步锁定。
如何判断对象是否可销毁?
引用计数器算法的基本思路是:给对象添加一个对象计数器,对象每被引用一次,对象计数器就+1,引用失效就-1,最终当计数器归零的时候表示对象生命走到了尽头,可以被销毁,所占用的内存可以被回收。
这种方式也被很多语言所采用比如Python、ActionScript和Squirrel以及苹果公司开发的Objective-C等。在很多OC的培训课程上会把这种方法称之为“遛狗原理”。
那么JVM是不是采用这种方式呢?
引用计数算法——代码验证
从GC日志来看年轻代中的GC并没有因为这两个对象的相互引用就不回收它们,这也间接证明Java虚拟机并不是通过引用计数算法来判断对象是否存活的。
可达性分析算法(Reachability Analysis)
可达性分析算法的基本思路是通过一系列的“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。如图中的对象object5、object6和object7虽然是相互之间有关联,但它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。可达性分析算法被广泛运用于主流的商用语言比如Java和C#。
哪些对象可以作为GC Roots?
1、虚拟机栈(栈帧中的本地变量表)中引用的对象
2、方法区中类静态属性引用的对象
3、方法区中常量引用的对象
4、本地方法栈中JNI(Native方法)引用的对象
Java中的引用
JDK1.2之后对引用的概念进行了扩充,之前引用定义的很简单也很狭隘:只管对象有没有被引用两种状态。
比如ThreadLocal中的ThreadLocalMap存储的元素Entry就是弱引用,因此在使用ThreadLocal时可能会发生内存泄露。ThreadLocal在ThreadLocalMap中是以一个弱引用身份被Entry中的Key引用的,因此如果ThreadLocal没有外部强引用来引用它,那么ThreadLocal会在下次JVM垃圾收集时被回收。这个时候就会出现Entry中Key已经被回收,出现一个null Key的情况,外部读取ThreadLocalMap中的元素是无法通过null Key来找到Value的。因此如果当前线程的生命周期很长,一直存在,那么其内部的ThreadLocalMap对象也一直生存下来,这些null key就存在一条强引用链的关系一直存在:Thread --> ThreadLocalMap-->Entry-->Value,这条强引用链会导致Entry不会回收,Value也不会回收,但Entry中的Key却已经被回收的情况,造成内存泄漏。
对象的生存还是死亡?
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们会暂时处于“缓刑”阶段,要真正宣告一个对象的死亡,至少需要经历两次标记过程:
通过可达性分析后发现没有与GC Roots相连的引用链,那么它将被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过了,虚拟机将这两种情况视为“没有必要执行”。若对象被判定为有必要执行finalize()方法,那么该对象会被放入F-Queue队列中,并在稍后由一个虚拟机自动建立的Finalizer线程去执行它。GC将会对F-Queue中的对象进行第二次标记,如果对象要自我救赎,只要重新与引用链上的任何对象建立关联即可,譬如把自己的this对象赋值给某个类的变量或者对象的成员,这样就会在第二次标记时被踢出“待回收”集合,如果对象在第二次标记时没有逃脱,那基本上就真的被回收了。
JVM垃圾回收——永久代GC
很多人认为方法区或者说永久代是没有GC的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现GC,而且在方法区中的GC性价比较低,在新生代中的一次GC一般可回收70%~95%的空间,而永久代的GC效率远低于此。
因为永久代中存放的都是静态变量、常量和已被加载类信息。永久代的GC主要回收两部分内容:废弃常量和无用的类。
虚拟机可以对满足上述三个条件的无用类进行回收,这里只是“可以”,并不是和对象一样,不用了就必然会被回收,是否进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备回收无用类的功能,以保证永久代不会出现OOM。
JVM垃圾回收——回收算法
JVM垃圾回收算法——标记-清除算法(Mark-Sweep)
标记-清除算法,顾名思义,分为标记和清除两个阶段,首先标记处所有需要回收的对象,在标记完成后统一进行回收所有被标记的对象,这里的标记过程就是前面介绍过的对象生死判断过程。
这种方法有两个不足之处:一是效率问题,标记和清除两个过程效率都不高,另一个是空间问题,标记清除之后会产生大量内存碎片,空间碎片太多可能会导致后面程序运行过程中需要分配大对象时无法找到足够的连续的内存,而不得不提前触发另一次GC。
JVM垃圾回收算法——复制算法(Copying)
现在的商业虚拟机都采用这种算法来回收新生代内存,大部分的新生代的对象都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间(常被称为Survivor From和Survivor To 或者Survivor 1和Survivor 2),每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才勇敢的Survivor空间。HotSpot虚拟机默认Eden和Survivor空间的大小比例是8:1,也就是每次新生代中可用内存空间为新生代容量的90%(80%+10%),只有10%的内存空间会被“浪费”。当Survivor空间不够用的时候,需要依赖其他的内存,其实就是老年大进行分配担保,这就涉及到对象的晋级。
JVM垃圾回收算法——标记-整理算法(Mark-Compact)
复制收集算法在对象存活率较高的时候要进行较多的复制操作,效率将会变低,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所以对象都100%存活的极端情况,所以老年大一般不能直接采用复制收集算法。根据老年的特点,有人就提出了“标记-整理”算法,和“标记-清除算法类似”,第一步都是进行标记,第二步并不是直接对可回收对象进行清理,而是将所有存活的对象都移向一端,然后直接清理掉边界以外的内存。
JVM垃圾回收算法——分代收集(Generational Collection)
目前商业虚拟机的垃圾回收都采用“分代收集”算法,根据对象存活的周期不同,将内存划分为几块区域,一般是把Java堆分成新生代和老年代,这样就可以根据各个年代的特点采用最合适的收集算法,在新生代中大部分都是“朝生夕死”的对象,因此选择“复制算法”,而老年代因为对象存活率高,没有额外的空间对它进行分配担保,就必须适应“标记-清理”或“标记-整理”算法来进行回收。
垃圾收集器——Serial收集器
Serial是单线程收集器,当它进行垃圾收集时,必须要暂停其他所有工作线程,直至收集工作结束。
垃圾收集器——ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版,收集算法、对象分配规则、回收策略等都与Serial完全相同,实际上也是公用了相当多的代码。
垃圾收集器——Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,它也采用复制算法进行垃圾收集,同时又是并行的多线程收集器,CMS等收集器关注点是尽可能缩短垃圾收集时用户线程的等待时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。
Parallel Scavenge收集器提供了两个精确控制吞吐量的参数:
-XX:MaxGCPauseMillis 最大垃圾收集停顿时间(毫秒值,值须大于0)
-XX:GCTimeRatio 设置吞吐量大小,即百分比(大于0小于100的整数,默认值为99)
垃圾收集器——Serial Old收集器
Serial Old收集器是老年代收集器,与Serial收集器一样它是单线程的,使用“标记-整理算法”
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用“标记-整理”算法,JDK1.6新增的。
垃圾收集器——CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,目前大部分的B/S系统采用该收集器,短暂的停顿时间,能够给用户带来较好的用户体验。
CMS收集器的工作过程相对复杂,整个过程包含:初始标记、并发标记、重新标记和并发清除4个步骤。其中初始标记和重新标记这两个步骤都需要暂停用户线程,初始标记仅仅只标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots 追踪的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的部分对象的标记记录,这个阶段停顿的时间一般会比标记阶段稍长,但远比并发标记的时间短。
垃圾收集器——CMS收集器的缺点
1、CMS收集器对CPU资源非常敏感
在并发阶段,虽然不会导致用户线程停顿,但会因为占用了一部分CPU资源而导致应用程序变慢,此时总吞吐量会降低。回收线程数默认是:(CPU数量+3)/4
2、CMS收集器无法处理浮动垃圾,可能引发Full GC
由于CMS并发清理阶段用户线程还在运行,伴随程序运行自然就会不断产生新的垃圾,CMS无法在本次收集中清理,只能留在下一次GC时清理,初始占有率
-XX:CMSInitatingOccupancyFraction设置过高容易导致大量并发模式失败,降低性能。
3、基于标记-清除算法会产生大量内存碎片,影响内存分配
CMS基于标记-清除算法实现垃圾回收,会产生大量内存碎片,碎片过多会出现老年代剩余空间很多,缺无法找到足够大的连续空间分配给对象,因此会提前触发一次Full GC。应对措施相关参数:
-XX:+UseCMSCompactAtFullCollection FullGC时开启内存碎片整理(默认开启)
-XX:CMSFullGCsBeforeCompaction 设置执行多少次不压缩的Full GC后进行压缩(默认为0)
1、在并发阶段,虽然不会导致用户线程停顿,但会因为占用了一部分CPU资源而导致应用程序变慢,此时总吞吐量会降低。回收线程数默认是:(CPU数量+3)/4,当CPU数量在4个以上时,并发回收垃圾的线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。当CPU不足4个,比如2个时,CMS对用户程序的影响可能变的很大,如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能会导致用户程序的执行速度突然降低50%。为了应对这种情况,虚拟机提供了一种称之为“增量式并发收集器”的CMS收集器变种,工作原理就是在并发标记、清理的时候让GC线程和用户线程交替运行,进来减少GC线程独占资源的时间,这样代理的弊端就是垃圾收集过程会被拉长,但对用户程序的影响会减少,这种优化手段经实践证明收效甚微,以至于在目前的版本中已不再提倡使用。
2、由于CMS并发清理阶段用户线程还在运行,伴随程序运行自然就会不断产生新的垃圾,CMS无法在本次收集中清理,只能留在下一次GC时清理,这部分垃圾称为“浮动垃圾”。也是由于垃圾收集阶段用户线程还需要运行,那也就还需要预留足够的空间给用户线程使用,因此CMS收集器不能像其他收集器哪有等到老年代几乎被占满时再近些收集,需要预留一部分空间提供并发收集时的程序运行使用。在JDK1.5中老年代空间使用率达到68%便会启动CMS收集器,在JDK1.6中已提升至92%。如果在CMS运行期间预留内存不足以满足程序所需,就会出现并发模式失败(Concurrent Mode Failure),这时虚拟机将启动后背预案:临时启动Serial Old收集器来重现进行老年代垃圾收集,此时停顿时间就很长了。所以-XX:CMSInitatingOccupancyFraction设置过高容易导致大量的并发模式失败,性能反而降低 。
3、CMS基于标记-清除算法实现垃圾回收,会产生大量内存碎片,碎片过多会出现老年代剩余空间很多,却无法找到足够大的连续空间分配给对象,因此会提前触发一次Full GC。为了解决这个问题CMS收集器提供了
-XX:+UseCMSCompactAtFullCollection参数,在FullGC时开启内存碎片整理(默认开启),开启之后碎片没有了,但是GC的时间更长了,还提供了另一个参数:-XX:CMSFullGCsBeforeCompaction设置执行多少次不压缩的Full GC后进行压缩(默认为0,每次Full GC都会进行碎片整理)。
垃圾收集器——G1收集器
在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1则是将整个Java堆划分为多个大小相等的独立区域,虽然还保留新生代和老年代的概念,但新生代和老年代之间不再是物理隔离的了,它们都是一部分Region的集合。G1之所以能够建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆进行全区域的垃圾收集。G1跟踪各个Region的垃圾堆积的价值大小(回收所得空间大小以及回收所需的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。因此可以保证G1在有限的时间内可以获取尽可能高的收集效率。
垃圾收集器——堆内存结构对比
以往的垃圾回收算法,如CMS,使用的堆内存结构如下:新生代:eden space + 2个survivor、老年代、1.8之前的永久代和1.8之后的元空间;在G1算法中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块(Region),每个Region是逻辑连续的一段内存。每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色,其中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。
垃圾收集器——G1收集器的工作过程
G1收集器可通过:-XX:+UseG1GC来开启,G1收集器工作过程大致可划分为:初始标记、并发标记、最终标记和筛选回收四个步骤:
G1收集器工作过程大致可划分为:初始标记、并发标记、最终标记和筛选回收四个步骤,G1的前几个步骤与CMS有相似之处。初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,该阶段需要短暂停顿线程。并发标记阶段是从GC Roots开始对堆中的对象进行可达性分析,找出存活对象,这个阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运行而导致标记产生变动的一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs中,最终标记阶段需要把Remembered Set Logs中的数据合并到Remembered Set中,这个阶段需要停顿线程,但可并行执行。最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。
内存分配与回收策略
1、这里多次提到Minor GC和Full GC,它们有什么不同?
新生代GC(Minor GC):指的是发送在新生代的垃圾回收动作,因为大部分Java对象都是朝生夕死,因此Minor GC非常频繁,回收速度也比较快(采用了“复制”算法)。
老年代GC(Major GC/Full GC):指的的发生在老年代的GC,出现Major GC,经常会伴随至少一次的Minor GC。Major GC的速度比Minor GC慢10倍以上(主要是由于采用了“标记-清理”算法)。
2、所谓的大对象指的是需要大量连续内存空间的Java对象,比如超长字符串和数组。大对象对虚拟机分配内存来说是一个头疼的事,比大对象更恶劣的就是一群“朝生夕死”的“短命大对象”,频繁大对象很容易导致提前触发GC来整理内存空间来安置它们。
内存分配与回收策略
4、虚拟机为了更好地适应不同程序的内存状况,并不是永远地要求对象年龄达到设置的最大年岁才晋升至老年代,如果在Survivor空间中,相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就直接晋升老年代。
5、Minor GC触发之前虚拟机先检查老年代中最大可用连续空间是否大于新生代所有对象总空间,若条件成立,则Minor GC是安全的。否则虚拟机将检查当前设置是否允许担保失败,若允许则继续检查老年代中最大可用连续空间是否大于历次晋升到老年代对象大小的平均值。若大于,则尝试进行一次Minor GC,若小于或不允许担保失败则进行一次Full GC。这里的一次尝试性的Minor GC有一定的风险:新生代采用复制算法回收内存,只有一个Survivor空间作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况,就需要老年代进行分配担保,把Survivor空间无法容纳的对象直接进入老年代。因为取平均值是一种动态概率手段,并不能预测到实际即将发生的情况,因此当出现某一次Minor GC后对象存活量突增,此时会出现担保失败,担保失败之后只能触发一次Full GC,来进行空间整理。因此担保失败之后触发Full GC是耗时最长的情况,但是大部分情况下还是会开启允许担保失败,以避免Full GC过于频繁。
JVM垃圾回收——Full GC触发条件总结
生产环境参数设置解读
穆加JVM GC监控就是基于JVM GC日志来进行分析统计的,而其方法监控也是利用了Java探针来实现的。
Java Core与HeapDump
JavaCore是关于CPU的
JavaCore文件主要保存的是Java应用各线程在某一时刻的运行的位置,即JVM执行到哪一个类、哪一个方法、哪一个行上。它是一个文本文件,打开后可以看到每一个线程的执行栈,以stack trace的显示。通过对JavaCore文件的分析可以得到应用是否“卡”在某一点上,即在某一点运行的时间太长,例如数据库查询,长期得不到响应,最终导致系统崩溃等情况。可通过TMDA来进行分析(IBM Thread and Monitor Dump Analyzer)。
HeapDump文件是关于内存的
HeapDump文件是一个二进制文件,它保存了某一时刻JVM堆中对象使用情况,这种文件需要相应的工具进行分析,如IBM Heap Analyzer这类工具。这类文件最重要的作用就是分析系统中是否存在内存溢出的情况。
Java Core分析简介
Pool-27-thread-2578:线程名称,prio:线程优先级,tid:jvm线程id,nid:对应系统线程id,Waiting on condition:线程状态, [0x00007f5c6617e000]:起始栈地址
Pool-27-thread-2578:线程名称,prio:线程优先级,tid:16进制线程ID,Waiting on condition:线程状态
1、死锁(Deadlock)【重点关注】:
一般指多个线程调用间,进入相互资源占用,导致一直等待无法释放的情况。
2、执行中(Runnable)【重点关注】:
一般指该线程正在执行状态中,该线程占用了资源,正在处理某个请求,有可能在对某个文件操作,有可能进行数据类型等转换等。
3、等待资源(Waiting on condition)【重点关注】:
等待资源,如果堆栈信息明确是应用代码,则证明该线程正在等待资源,一般是大量读取某资源、且该资源采用了资源锁的情况下,线程进入等待状态。又或者,正在等待其他线程的执行等。
4、等待监控器检查资源(Waiting on monitor)
5、暂停(Suspended)
6、对象等待中(Object.wait())
7、阻塞(Blocked)【重点关注】:
指当前线程执行过程中,所需要的资源长时间等待却一直未能获取到,被容器的线程管理器标识为阻塞状态,可以理解为等待资源超时的线程。这种情况在应用的日志中,一般可以看到 CPU 饥渴,或者某线程已执行了较长时间的信息。
8、停止(Parked)
Java Core——死锁