一、类加载器
1、类加载过程
加载:在硬盘上查找并通过IO读入字节码文件,使用到类时才会加载,例如调用类的main()方法时,new对象等等,在加载阶段会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
验证:校验字节码文件的正确性
准备:给类的静态变量分配内存,并赋予默认值
解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的符号引用替换为直接引用
初始化:对类的静态变量初始化指定的值,执行静态代码块
使用
卸载
额外信息:
类被加载到方法区中,主要包含 运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。
类加载器的引用:这个类到类加载器实例的引用
对应class实例的引用:类加载器在加载类信息到方法区中后,会创建一个对应的class类型的对象实例放到堆(Heap)中,作为开发人员访问方法区中类定义的入口了切入点。
主类在运行过程中如果使用到其他类,会逐步加载这些类。jar包或war包里的类不是一次性全部加载的,是使用到才会加载。
2、类加载器和双亲委派机制
2.1 类加载器的种类
引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如:rt.jar、charsets.jar等
扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包
应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的哪些类
自定义类加载器:负责加载用户自定义路径下的类包
2.2 双亲委派机制
说明:
加载某个类时会先委派父类加载器寻找目标,找不到再委托上层父类加载器加载,所有的父类加载器在自己的加载类路径下都找不到目标类,则在自己的类路径中查询并载入目标类
为什么要设计双亲委派机制?
沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样就可以防止核心API库被随意篡改
避免类的重复加载:当父类加载了该类时,没有必要子类ClassLoader再加载一次,保证被加载类的唯一性
注意:
应用程序类加载器的父类是扩展类加载器,扩展类加载器的父类指向为null。在执行双亲委派机制的时候,如果父类加载器为null,直接调用引导类加载器。
2.3 Tomcat打破双亲委派机制
Tomcat使用默认的类加机制行不行?
不行
一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方库的不同版本。默认类加载器不管你是什么版本,只在乎你的全限定名,并且只有一份
web容器支持jsp的热加载。在jsp文件修改后,直接卸载这个jsp的类加载器。重新创建类加载器,重新加载jsp文件
2.4 Tomcat类加载器详解
Tomcat几个主要类加载器:
commonLoader:Tomcat最基本的类加载器。加载路径中的class可以被Tomcat容器本身以及各个Webapp访问
catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于WebApp不可见
sharedLoader:各个Webapp共享的类加载器,加载各个路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见
WebappClassLoader:各个WebApp私有类加载器,加载路径中的class只对当前Webapp可见,比如war包里相关的类,每个war包都有自己的WebAppClassLoader,实现互相隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本
二、JVM内存模型
1、JVM整体结构
2、对象的创建流程
1、类加载检查
当虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应类的加载过程。new 指令对应到语言层面上讲是:new 关键字、对象克隆、对象序列化等。
2、内存分配
在类加载检查通过后,接下来虚拟机将为新生的对象分配内存。对象所需内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。这个步骤有两个问题:
1、如何划分内存
2、在并发情况下,可能出现正在给对象A分配内存,指针还未来得及修改,对象B又同时使用了原来的指针分配内存的情况
划分内存的方法:
指针碰撞(默认使用):如果Java堆中内存是绝对完整的,所有使用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点,那所分配内存就仅仅是把哪个指针空闲空间那边挪动与对象大小相等的距离。
空闲列表:如果Java堆中的内存并不是完整的,已经使用过的内存和空闲的内存相互交错,那就没有办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录那些 内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新到记录表上
解决并发的方法:
CAS:虚拟机采用CAS配上失败重试的方式保证更新操作的原子性对分配空间的动作进行同步处理
本地线程缓冲(TLAB):把内存分配的动作按照线程划分到不同的空间之中进行,即每个线程都在Java堆中预先分配一小块内存。
3、初始化
内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步骤保证了对象的实例在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型对应的零值。
4、设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC年龄等信息。这些信息存放在对象的对象头Object Header中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身运行时数据,例如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向时间戳等。对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
5、执行init方法
即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值和执行构造方法。
3、对象内存分配
3.1 对象内存分配流程
3.2 对象栈上分配
我们通过JVM内存分配可以知道JAVA的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大的压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。然而如果一个对象只在方法中使用,当方法结束这个对象就变成无用对象了,对于这样的对象我们其实可以将其分配在栈内存中,让其在方法结束时跟随栈内存一起被回收掉。(JDK7之后默认开启逃逸分析)
标量替换:通过逃逸分析确定对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所替代,这些替代的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。 开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认 开启。
结论:栈上分配依赖于逃逸分析和标量替换
3.3 对象在Eden区分配
大多数情况下,对象在新生代中Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发生一次Minor GC。
Minor GC/Young GC:指发生新生代的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
Major GC/Full GC:一般会回收老年代,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。
Eden区与Survivor区默认8:1:1
大量的对象被分配到Eden区,满了之后会触发Minor GC,可能会有99%以上的对象成为垃圾数据被回收掉,剩余存活的对象会被挪到Survivor区。第二次触发Minor GC,会把Eden区和Survivor区的垃圾回收掉,将剩余的对象挪到另一个Survivor区。
JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变 化可以设置参数-XX:-UseAdaptiveSizePolicy 。
3.4 大对象直接进入老年代
大对象就是需要大量连续内存的对象。 JVM参数 -XX:PretenureSizeThreshold 可以设置大 对象的大小 。如果对象超过设置的大小会直接进入老年代,不会进入年轻代,这个参数只在Serial和ParNew两个收集器下有效。这样做的好处是避免大对象分配内存时的复制而降低效率。
3.5 长期存活的对象将进入老年代
虚拟机采用分代收集的思想来管理内存,那么回收时就必须识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄计数器。如果对象在Eden出生经过一次Minor GC后让然能存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象没经过一次Minor GC,年龄就会加1.当他的年龄到一定值后,就会到老年代中。而这个阈值可以通过 -XX:MaxTenuringThreshold 设置。
3.6 对象动态年龄判断
当前存放对象的Survivor区域里,一批对象的总大小大于这块内存的50%(- XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,直接进入到老年代。对象动态年龄判断机制一般是在Minor GC之后触发的。
3.7 老年代空间分配担保机制(触发Full GC的时机)
每次Minor GC之前JVM都会计算下老年代剩余可用空间。如果可用空间小于年轻代里现有的所有对象的大小之和(包括垃圾对象), 就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了 。有这个参数,就会看老年代的可用内存大小是否大于之前每一次Minor GC后进入老年代的对象平均大小。如果小于或者没有设置,就会触发一次Full GC,对老年代和年轻代一起垃圾回收。如果回收完还是没有足够的空间存放新的对象就会发生"OOM"。
当然,如果minor gc之后
剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM” 。
4、对象内存回收
4.1 引用计数法
给对象中添加一个引用计数器,每当有一个引用,计数器加一;引用失效,计数器减一;计数器为0的对象就是不可能再被使用的。
这个方法实现简单、效率高,但是目前主流的虚拟机中并没有选择这个算法管理内存,主要是因为它很难解决循环引用问题。
4.2 可达性分析算法
将"GC Roots"对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余的对象为非垃圾对象。
GC Roots:线程栈的本地变量、静态变量、本地方法栈的变量等
4.3 常见引用类型
强引用:普通变量的引用。
软引用:对象用SoftReference软引用类型对象包裹,正常情况下不会被回收,但是GC做完后发现释放不出空间存放新的对象,就会把这些软引用的对象回收掉。软引用可以实现内存敏感的高速缓存。
弱引用:将对象用WeakReference弱引用类型的对象包裹,弱引用跟没有引用差不多,GC会直接回收,很少用。
虚引用: 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用 。
4.4 如何判断一个类是无用的类
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
加载该类的ClassLoader已经被回收
该类对用的java.lang.class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法
三、垃圾收集算法
1、分代收集理论
当前虚拟机的垃圾收集都是采用分代收集算法,根据对象存活的周期的不同将内存分为几块。一般将Java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择适合的垃圾收集算法。比如在新生代中,每次都有大量的对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成垃圾收集。而老年代的存活几率是比较高的,而且没有额外的空间对他进行分配担保,所以我们必须选择 "标记-清除"或"标记-整理"算法。注意:"标记-清除"或"标记-整理"算法比复制算法慢10倍以上。
1.1、标记-复制算法
为了解决效率问题,"复制"收集算法出现了。它将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后把使用的空间一次清理掉。这样每次的内存回收都是对内存区间的一半进行回收。
1.2、标记清除算法
算法分为"标记"和"清除"阶段:标记存活的对象,统一回收所有未被标记的对象;也可以反过来。它是最基础的收集算法,比较简单,但是会带来两个问题:
效率问题:如果需要标记的对象太多,效率不高
空间问题:标记清除后产生大量不连续的碎片
1.3、标记整理算法
根据老年代的特点特出的一种标记算法,标记过程仍然与"标记-清除"算法一样,但是后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清除掉边界以外的内存。
2、垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但是并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体的应用场景选择适合自己的垃圾收集器。
2.1 Serial [ˈsɪriəl] 收集器(-XX:UseSerialGC -XX:UseSerialOldGC)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器。是一个单线程收集器。它的单线程不仅仅意味着它只会使用一条垃圾收集器线程去完成工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程("Stop The World"),直到它结束。
新生代采用复制算法,老年代采用标记-整理算法。
优点:简单高效,Serial收集器没有线程交互的开销,自然可以获得很高的单线程收集效率。
Serial Old是Serial的老年代版本。它主要有两大用途:一是在JDK1.5及以前的版本与Parallel Scavenge收集器搭配使用,另一种是作为CMS收集器的后备方案。
2.2 Parallel Scavenge [ˈpærəlel] [ˈskævɪndʒ] 收集器(-XX:+UseParallelGC, -XX:+UserParallelOldGC)
Parallel 收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial 收集器类似。默认的收集线程数跟cpu核数相同,当然也可以使用参数(-XX:ParallelGCThreads)指定线程数,一般不推荐修改。
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的暂停时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU消耗时间的比值。Parallel Scavenge 收集器提供了很多参数供用户找到一个合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代采用复制算法,老年代采用标记-整理算法。
Parallel Old 收集器是Parallel Scavenge 收集器的老年代版本。使用多线程和 "标记-整理" 算法。在注重吞吐量以及CPU资源的场合,都可以有限考虑 Parallel Scavenge 收集器和 Parallel Old 收集器(JDK 8 默认的新生代和老年代收集器)。
2.3 ParNew 收集器(-XX:+UseParNewGC)
ParNew 收集器其实跟Parallel 收集器类似,区别主要在于它可以和CMS收集器配合使用
新生代采用复制算法,老年代采用标记-整理算法。
它是许多运行在Server模式下的虚拟机首选,除了 Serial 收集器外,只有它能与 CMS(真正意义上的并发收集器)收集器配合工作。
2.4 CMS 收集器(-XX:UseConcMarkSweepGC(Old))
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作.
从名字中的 Mark Sweep 这两个词可以看出,CMS 收集器是一种 "标记-清除" 算法实现的,它的运行过程分为四个步骤:
初始标记:暂停所有的其他线程(STW),并记录下 GC Roots直接能引用的对象,速度很快。
并发标记:并发标记阶段就是从GC Roots 的直接关联 对象开始遍历整个对象图的过程,这个耗时长但是不需要停顿用户线程。但是因为用户线程的运行,可能导致已经标记过的对象状态发生改变。
重新标记:修正并发标记期间因为用户线程运行导致标记产生变动的那一部分对象的标记记录,这个阶段停顿的时间稍长,远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。
并发清理:开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象就会被标记为黑色不做任何处理。
并发重置:重置本次GC过程的标记数据
优点:并发收集、低停顿
缺点:
对CPU资源敏感(会和服务抢资源);
无法处理浮动垃圾(在并发标记和并发清理阶段又产生的垃圾,只能到下一次gc再处理)
它使用的回收算法 "标记-清除" 算法会导致收集结束时有大量空间碎片产生,当然通过参数(-XX:+UseCMSCompactAtFullCollection)可以让JVM 在执行完标记清除后再做整理
执行过程中的不确定性,会存在上一次垃圾回收还没有执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发 Full GC,也就是 "concurrent mode failure",此时会进入 stop the world,用 Serial Old 垃圾收集器来回收。
2.5 G1收集器(-XX:UseG1GC)[ˈɡɑːrbɪdʒ]
G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备有多核处理器及大容量内存的机器。以极高的效率满足GC停顿时间要求的同时,在具备高吞吐量性能特征。
G1将Java堆划分为多个大小相等的独立区域(Region)。JVM最多可以有2048个Region。一般Region大小等于堆大小除以2048,比如堆大小4096,则Region大小为2M, 当然也可以用参数"- XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。 默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个 Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多 的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100 个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能 可能会动态变化。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配 大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一 个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放 入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。 Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。 G1收集器一次GC(主要值Mixed GC)的运作过程大致分为以下几个步骤:
初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
并发标记(Concurrent Marking):同CMS的并发标记
最终标记(Remark,STW):同CMS的重新标记
筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:
并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集。
毫无疑问, 可以由用户指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。 不过, 这里设置的“期望值”必须是符合实际的, 不能异想天开, 毕竟G1是要冻结用户线程来复制对象的, 这个停顿时
间再怎么低也得有个限度。 它默认的停顿目标为两百毫秒, 一般来说, 回收阶段占到几十到一百甚至接近两百毫秒都很正常, 但如果我们把停顿时间调得非常低, 譬如设置为二十毫秒, 很可能出现的结果就是由于停顿目标时间太短, 导致每次选出来的回收集只占堆内存很小的一部分, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。 很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了, 最终占满堆引发Full GC反而降低性能, 所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
G1垃圾收集分类
YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
MixedGC
不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
Full GC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
G1收集器参数设置
-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
-XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
G1垃圾收集器优化建议
假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。
那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。
或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.
什么场景适合使用G1
50%以上的堆被存活对象占用
对象分配和晋升的速度变化非常大
垃圾回收时间特别长,超过1秒
8GB以上的堆内存(建议值)
停顿时间是500ms以内
每秒几十万并发的系统如何优化JVM
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
3、垃圾收集底层算法实现
3.1 三色标记
在并发标记的过程中,因为标记期间应用线程并发执行,对象间的引用可能发生变化,多标和漏标的情况都有可能发生。通过引入"三色标记"来防止这种情况发生。
三色标记把GC Roots 可达性分析的对象,按照 "是否访问过" 这个条件标记成以下三种颜色:
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
灰色:表示对象已经被垃圾收集器访问过,但这个对象至少存在一个引用还没有被扫描过
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分许结束阶段,仍然是白色对象,即代表不可达。
3.2 多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部标量(GC Roots) 被销毁,这个GC Roots 引用到的对象之前又被扫描过,那么本轮GC不会回收这部分内存。这部分该被回收但是却未被回收到的垃圾,被称之为 "浮动垃圾"。另外,针对并发标记、并发清理开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除,这部分也算是浮动垃圾的一部分。
3.3 漏标-读写屏障
漏标会导致被引用的对象当成垃圾错误删除,这是严重的bug。有两种解决方案:增量更新和原始快照
增量更新就是当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以简单理解为,黑色对象一旦新插入了指向白色对象的引用之后,他就变成灰色对象了。
原始快照就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮GC中能存活下来,待下一轮GC的时候重新扫描,这个对象也有可能是浮动垃圾)。以上无论是对引用关系记录的插入还是删除,虚拟机都是通过写屏障实现的。
写屏障:在赋值操作前后,加入一些处理(参考AOP概念)
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障‐写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障‐写后操作
}
写屏障实现增量更新:当对象A的成员变量发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来
void post_write_barrier(oop* field, oop new_value) {
remark_set.add(new_value); // 记录新引用的对象
}
写屏障实现SATB : 当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用 对象D记录下来
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录原来的引用对象
}
读屏障
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障‐读取前操作
return *field;
}
对于读写屏障,以Java HotSpot 为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障 + 增量更新
G1,Shenandoah:写屏障+SATB
ZGC:读屏障
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描 被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代 区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC 再深度扫描。
3.4 记忆集与卡表
在新生代做GC Roots可达性分析过程中可能会碰到跨代引用的对象,如果对老年代扫描效率太低了。为此,新生代可以引入记录集的数据结构(记录冲非收集区到收集去的指针集合),避免把整个老年代加入GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用问题,所有涉及部分区域收集行文的垃圾收集器(G1、ZGC和Shenandoah)都会面临同样的问题。
在垃圾收集场景中,收集器只需要通过记忆集判断出一块非收集区是否存在执行收集区的指针即可,无需了解跨代引用指针的全部细节。
HotSpot 使用一种佳作 "卡表" 的方式实现记忆集,也是目前最常用的一种方式。关于卡边和记忆集的关系,可以类比Java语言中HashMap和Map 的关系。
卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡 页”。 hotSpot使用的卡页是2^9大小,即512字节 。
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变 脏,否则为0.
GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。
卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。 Hotspot使用写屏障维护卡表状态。