开放性问题:你碰到过哪些JVM问题
- 你遇到过的印象最深的JVM问题是什么?
- 这个问题是怎么分析和解决的?
- 这个过程中有哪些值得分享的经验?
什么是JVM?
JVM是Java Virtual Machine的简称,即Java虚拟机。 JVM是Java程序运行的底层平台,与Java支持库一起构成了Java程序的执行环境。分为JVM规范和JVM实现两个部分。简单来说,Java虚拟机就是指能执行标准Java字节码的虚拟计算机。
什么是字节码
Java的字节码是指Java源代码编译后的中间代码格式,即字节码文件。
字节码文件包含哪些内容
版本号信息 静态常量池 类相关的信息 字段相关的信息 方法相关的信息 调试相关的信息
常量池
静态常量池:class文件中的一部分,里面保存的是类相关各种符号常量; 运行时常量池: 其内容主要由静态常量池解析得到,但也可以由程序添加。
JVM运行时数据区
程序计数器 Java虚拟机栈 堆内存 方法区 运行时常量池 本地方法栈
对象头包括哪些部分
- 标记值, 8个字节
- 类型指针, 8个字节 如果堆内存小于32G,默认会开始指针压缩,则只有4字节 如果是数组,对象头中还会多一个【数组长度】,一般是int值,占用4字节
常见的垃圾回收器
- 串行垃圾收集器: ‐XX:+UseSerialGC
- 并行垃圾收集器: ‐XX:+UseParallelGC
- CMS垃圾收集器: ‐XX:+UseConcMarkSweepGC
- G1垃圾收集器: ‐XX:+UseG1GC
什么是GC停顿 GC pause?
因为在GC过程中,有一部分操作需要等所有应用线程都到达安全点,暂停之后才能执行,这个时候就叫做GC停顿,或者叫GC暂停。
如果CPU使用率飙升,应该怎么排查?
- 收集不同的指标(CPU,内存,磁盘IO,网络等等)
- 分析应用日志
- 分析GC日志
- 获取线程转储并分析
- 获取堆转储来进行分析
如果系统响应变慢,应该怎么排查?
- 一般根据APM监控来排查应用系统本身的问题。
- 有时候也可以使用Chrome浏览器等工具来排查外部原因,比如网络问题。
系统性能一般怎么衡量?
- 系统容量:比如硬件配置,设计容量;
- 吞吐量:最直观的指标是TPS;
- 响应时间:也就是系统延迟,包括服务端延时和网络延迟。
类的生命周期
- 加载loading:通过类的全限定名来获取该类的二进制字节流,然后将该字节流的静态存储结构转化为方法区的运行时结构数据,即在方法区中生成该类的java.lang.Class对象,作为该类各种数据的访问入口;
- 链接linking
- 2.1 校验verification:验证格式、依赖等;
- 2.2 准备preparation: 给静态字段分配内存并设置初始值;
- 2.3 解析resolution: 符号引用解析为直接引用;
- 初始化initialization:调用构造器、静态变量赋值、静态代码块执行;
- 使用using:直接new对象或者反射
- 卸载unloading:gc回收;
类的加载时机
- 当虚拟机启动时,初始化用户指定main方法所在的主类;
- 当遇到新建目标类实例的new指令时,new一个类时要初始化;
- 如果一个接口定义了default方法时,则实现类初始化时会初始化该接口类;
- 使用反射API对某个类进行反射调用时,会初始化这个类;
- 子类的初始化会触发父类的初始化;
- 当遇到调用静态方法的指令时,会初始化该静态方法所在的类;
- 当遇到调用静态字段的指令时,会初始化该静态方法所在的类;
不会初始化类的场景(可能会加载)
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作(会加载类,但是不会初始化);
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类的初始化;
- 通过类名获取Class对象,也不会触发类的初始化;
- 常量在编译期间会保存到调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类;
- 定义对象数组,不会触发该类的初始化;
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化;
类加载器
- 启动类加载器bootstrapClassLoader
- 扩展类加载器extClassLoader
- 应用类加载器AppClassLoader
- 自定义classloader
加载器特点
负责依赖:当一个类加载器负责加载某个class时,该class所依赖和引用的其他class也应该由该类加载器负责载入,除非显示由另外一个类加载器来载入。 双亲委托:优先让父类加载器加载该Class,当父类加载器无法加载该类时,会尝试从自己的类路径中加载该类; 缓存加载:已经加载的class将会被缓存起来,当程序中需要使用Class时,类加载器将会先从缓存中先搜索该Class,只有当缓存中不存在该Class时,系统才会读取该类的字节码,然后将其转化为Class对象存入缓存中。
自定义classloader(打破双亲委派模型)
ClassLoader类有两个关键的方法: protected Class loadClass(String name, boolean resolve): name为类名,resove如果为true,在加载时解析该类。 protected Class findClass(String name) : 根据指定类名来查找类。 所以,如果要实现自定义类,可以重写这两个方法来实现。但推荐重写findClass方法,而不是重写loadClass方法,因为loadClass方法内部会调用findClass方法。
loadClass加载方法流程: 1.检查此类是否已经加载,如果加载过了,就不需要再加载,直接返回。 2. 如果此类没有加载过,那么,再判断一下是否有父加载器:如果有父加载器,则调用parent.loadClass(name, false)父加载器加载,如果没有,则调用bootstrap类加载器来加载。 3. 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。 所以,我们重写findClass方法即可简单方便的实现自定义类加载。
JVM内存结构
栈stack:Java方法栈,本地方法栈。线程执行过程中,一般会有多个方法组成调用栈,每执行到一个方法调用,就会创建对应的栈帧Frame; 堆heap:是所有线程公用的内存空间。JVM将堆内存分为年轻代和老年代两部分,年轻代划分为新生代和存活区两部分,存活区分为S0和S1; 元空间metaspace: Java8之前叫做持久代,Java8之后叫做元空间; CCS: compressed class space,存放class信息; code cache: 存放JIT编译器编译之后的本地机器代码;
CPU指令重排
在实际运行时,代码指令可能并不是严格按照代码语句顺序执行的。大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。而这就是指令重排。典型的就是单例模式中的DCL。
volatile
可见性 MESI缓存一致性协议
内存屏障
内存屏障,也叫内存栅栏,是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。 LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕; StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见; LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕; StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。 在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
happen-before原则
单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。 volatile的happen-before原则:对一个volatile变量的写操作happen-before对此变量的任意操作(当然也包括写操作了)。 happen-before的传递性原则:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。 线程中断的happen-before原则 :对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。 线程终结的happen-before原则: 线程中的所有操作都happen-before线程的终止检测。 对象创建的happen-before原则: 一个对象的初始化完成先于他的finalize方法调用。
JVM堆内存启动参数
-Xss1m:设置每个线程的堆栈大小,与-XX:ThreadStackSize=1m等价; -Xmx, 指定最大堆内存; -Xms, 指定堆内存空间的初始大小; -Xmn, 等价于-XX:NewSize,设置新生代,使用G1垃圾收集器不应该设置该选项官方建议设置为 -Xmx 的 1/2 ~ 1/4; -XX:MaxPermSize=size, 这是 JDK1.7 之前使用的。Java8 默认允许的Meta空间无限大,此参数无效; -XX:MaxMetaspaceSize=size, Java8 默认不限制 Meta 空间, 一般不允许设置该选项; -XX:MaxDirectMemorySize=size,系统可以使用的最大堆外内存,这个参数跟-Dsun.nio.MaxDirectMemorySize效果相同;
JVM GC启动参数
-XX:+UseG1GC:使用G1垃圾回收器 -XX:+UseConcMarkSweepGC:使用CMS垃圾回收器 -XX:+UseSerialGC:使用串行垃圾回收器 -XX:+UseParallelGC:使用并行垃圾回收器 // Java 11+ -XX:+UnlockExperimentalVMOptions -XX:+UseZGC
各个JVM版本的默认GC是什么?
jdk1.7和jdk8默认是Parallel Scavenge(新生代)+Parallel GC(老年代),jdk9之后默认是G1
JVM命令行工具
jps -mlv/jinfo: 查看Java进程; jstat: 查看JVM内部GC相关信息; jmap: 查看heap或者类占用空间统计; jstack: 查看线程信息; jcmd: 执行JVM相关分析命令; jrunscript/jjs:执行js命令
JVM图形化工具
jconsole jvisualvm VisualGC Java Mission Control
GC算法思路
引用计数--优点:简单粗暴;缺点:循环依赖,内存泄露 可达性分析--优点:遍历所有的可达对象,并在本地内存中分门别类标记marking,然后会清除sweeping不可达对象占用的内存,还会做碎片压缩;缺点:STW
可以作为GC Roots对象
- 当前正在执行的方法里的局部变量和输入参数;
- 活动线程;
- 所有类的静态字段;
- JNI引用; 此阶段暂停的时间,由存活对象(alive objects)的数量来决定,与堆内存大小、以及对象的总数没有直接关系。所以增加堆内存的大小并不会直接影响标记阶段占用的时间。
垃圾回收常用算法
- 标记-清除算法 mark-sweep
- 标记-复制算法 mark-copy
- 整理算法 标记-清除-整理算法 mark-sweep-compact
新生代串行收集/并行回收器:复制算法; 老年代串行收集/并行回收器:标记/压缩算法; 并行收集器:将串行收集器多线程化,回收策略和串行收集器一致,因此该收集器是新生代为复制算法,老年代为标记-压缩算法; CMS收集器:Concurrent Mark Sweep,从名字就可以看出来用的是标记清除算法;
对象与分代
分代假设:大部分新生对象很快无用;存活较长时间的对象,可能存活更长时间。因为内存划分为:年轻代和老年代。年轻代又划分为新生代。S0和S1。新生代中又有TLAB。
TLAB
TLAB是Thread Local Allocation Buffer的简称,即线程本地分配缓存区。 正常场景下,对象会分配在堆上,每次对象分配都必须要进行同步,虚拟机采用CAS失败重试的方式保证更新操作的原子性,在高并发下可能会同时出现多个线程在堆上申请空间,激烈的竞争会导致对象分配的效率进一步下降。因此,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。 -XX:+UseTLAB 使用TLAB -XX:+TLABSize 设置TLAB大小 -XX:TLABRefillWasteFraction设置维护进入TLAB空间的单个对象大小 -XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小,默认是1% 缺点:
- 因为TLAB通常很小,所以放不下大对象。
- 如果TLAB分配不成功,则会尝试在eden区分配,如果对象满足直接进入老年代的条件,则会直接分配在老年代;
串行Serial GC
-XX:+UseSerialGC 配置串行GC -XX:+USeParNewGC 改进版本的Serial GC,可以配合CMS使用 串行GC对年轻代使用mark-copy(标记-复制)算法,对老年代使用mark-sweep-compact(标记-清除-整理)算法。 两者都是单线程的垃圾收集器,不能进行并行处理,所以都会触发全线暂停(STW),停止所有的应用线程。 因此这种GC算法不能充分利用多核CPU。不管有多少CPU内核,JVM在垃圾收集时都只能使用单个核心。 简单粗暴CPU利用率高,暂停时间长。 该选项只适合几百MB堆内存的JVM,而且是单核CPU时比较有用。
并行Parallel GC
-XX:+UseParallelGC -XX:+UseParallelOldGC 年轻代和老年代的垃圾回收都会触发STW事件。 在年轻代使用标记-复制(mark-copy)算法,在老年代使用标记-清除-整理(mark-sweep-compact)算法。 -XX:ParallelGCThreads=N来指定GC线程数,其默认值为CPU核心数。 并行垃圾收集器适用于多核服务器,主要目标是增加吞吐量。因为对系统资源的有效使用,能达到更高的吞吐量。 在GC期间,所有CPU内核都在并行清理垃圾,所以总暂停时间更短; 在两次GC周期的间隔期,没有GC线程在运行,不会消耗任何系统资源。
CMS GC
-XX:+UseConcMarkSweepGC 其对年轻代采用并行STW方式的 mark-copy (标记-复制)算法,对老年代主要使用并发H^_^mark-sweep (标记-清除)算法。 CMS GC的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:
- 不对老年代进行整理,而是使用空闲列表(free-lists)来管理内存空间的回收;
- 在mark-and-sweep(标记-清除)阶段的大部分工作和应用线程一起并发执行。 也就是说,在这些阶段并没有明显的应用线程暂停。但值得注意的是,它仍然和应用线程争抢CPU时间。默认情况下,CMS使用的并发线程数等于CPU核心数的1/4。 如果服务器是多核CPU,并且主要调优目标是降低GC停顿导致的系统延迟,那么使用CMS是个很明智的选择。进行老年代的并发回收时,可能会伴随着多次年轻代的minor GC。
CMS GC六个阶段
阶段 1: Initial Mark(初始标记):此阶段会STW停顿,初始标记的目标是标记所有的根对象,包括根对象直接引用的对象,以及被年轻代中所有存活对象所引用的对象; 阶段 2: Concurrent Mark(并发标记):此阶段会从前一阶段的根对象开始遍历老年代,标记所有的存活对象,此阶段会与应用程序同时运行,不用暂停; 阶段 3: Concurrent Preclean(并发预清理):因为前一阶段【并发标记】与程序并发运行,可能会有一些引用关系发生变化,此时JVM会通过“Card 卡片”的方式将发生了改变的区域标记为“脏”区,这就是“卡片标记card marking”,此阶段会与应用程序同时运行,不用暂停; 阶段 4: Final Remark(最终标记):此阶段会STW停顿,会标记完老年代中的所有存活对象。因为上一个阶段是并发执行的,有可能GC线程跟不上应用程序的修改速度。所以需要STW来处理各种复杂的情况; 阶段 5: Concurrent Sweep(并发清除):此阶段会与应用程序同时运行,不用暂停;JVM在此阶段删除不再使用的对象,并回收占用的内存空间; 阶段 6: Concurrent Reset(并发重置):此阶段会与应用程序同时运行,不用暂停;此阶段会重置CMS算法相关的内部数据,为下一次GC做准备;
阶段1、阶段4 会有STW暂停; CMS垃圾收集器: 优点:在减少停顿时间上做了很多复杂而有用的工作,用于垃圾回收的并发线程执行的同时,并不需要暂停应用线程; 缺点:因为不压缩,导致老年代内存碎片问题,在某些情况下GC会造成不可预测的暂停时间,特别是堆内存较大的情况下。
G1 GC
-XX:+UseG1GC 启用G1 GC; -XX:MaxGCPauseMillis 每次GC最大的停顿毫秒数; -XX:G1NewSizePercent:初始年轻代占整个 Java Heap 的大小,默认值为 5%; -XX:G1MaxNewSizePercent:最大年轻代占整个Java Heap的大小,默认值为 60%; -XX:G1HeapRegionSize:设置每个Region的大小,单位MB,默认是堆内存的1/2000。如果这个值设置比较大,那么大对象就可以进入Region; -XX:ConcGCThreads:与Java应用一起执行的 GC线程数量,默认是Java线程的1/4,减少这个参数的数值可能会提升并行回收的效率,提高系统内部吞吐量。如果这个数值过低,参与回收垃圾的线程不足,也会导致并行回收机制耗时加长。 -XX:+InitiatingHeapOccupancyPercent(简称 IHOP):G1 内部并行回收循环启动的阈值,默认为 Java Heap 的 45%。这个可以理解为老年代使用大于等于45%的时候,JVM会启动垃圾回收。这个值非常重要,它决定了在什么时间启动老年代的并行回收。 -XX:G1HeapWastePercent:G1停止回收的最小内存大小,默认是堆大小的5%。GC会收集所有的Region中的对象,但是如果下降到了5%,就会停下来不再收集了。就是说,不必每次回收就把所有的垃圾都处理完,可以 遗留少量的下次处理,这样也降低了单次消耗的时间。 -XX:G1MixedGCCountTarget:设置并行循环之后需要有多少个混合GC启动,默认值是8个。老年代Regions的回收时间通常比年轻代的收集时间要长一些。所以如果混合收集器比较多,可以允许G1延长老年代的收集时间。 -XX:+GCTimeRatio:这个参数就是计算花在 Java 应用线程上和花在GC线程上的时间比率,默认是9,跟新生代内存的分配比例一致。这个参数主要的目的是让用户可以控制花在应用上的时间,G1 的计算公式是 100/(1+GCTimeRatio)。这样如果参数设置为9,则最多10% 的时间会花在GC工作上面。Parallel GC 的默认值是99,表示1%的时间被用在GC上面,这是因为Parallel GC贯穿整个GC,而G1则根据Region来进行划分,不需要全局性扫描整个内存堆。
G1是Gabage-First的简称,意思是垃圾优先。G1最主要的设计目标是:将STW停顿的时间变成可预期且可配置的。 首先, G1的堆内存不再单纯划分为年轻代和老年代,而是划分为多个(通常是 2048 个)可以存放对象的小块堆区域(smaller heap regions)。 每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。 这样划分之后,使得 G1 不必每次都去回收整个堆空间,而是以增量的方式来进行处 理: 每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。 下一次GC时在本次的基础上,再选定一定的区域来进行回收。增量式垃圾收集的好处 是大大降低了单次GC暂停的时间。
年轻代模式转移暂停 Evacuation Pause
G1 GC会通过前面一段时间的运行情况来不断的调整自己的回收策略和行为,以此来比较稳定地控制暂停时间。在应用程序刚启动时,G1还没有采集到什么足够的信息,这时候就处于初始的fully-young模式。当年轻代空间用满后,应用线程会被暂停,年轻代内存块中的存活对象被拷贝到存活区。如果还没有存活区,则任意选择一部分空闲的内存块作为存活区。拷贝的过程称为转移(Evacuation),这和前面介绍的其他年轻代收集器是一样的工作原理。
并发标记(Concurrent Marking)
G1并发标记的过程与CMS基本上是一样的。G1的并发标记通过Snapshot-At-The-Beginning(起始快照)的方式,在标记阶段开始时记下所有的存活对象。即使在标记的同时又有一些变成了垃圾。通过对象的存活信息,可以构建出每个小堆块的存活状态,以便回收集能高效地进行选择。这些信息在接下来的阶段会用来执行老年代区域的垃圾收集。 有两种情况是可以完全并发执行:
- 如果在标记阶段确定某个小堆块中没有存活对象,只包含垃圾;
- 在STW转移暂停期间,同时包含垃圾和存活对象的老年代小堆块。 当堆内存的总体使用比例达到一定数值就会触发并发标记。这个默认比例是45%,也可以通过JVM参数InitiatingHeapOccupancyPercent 来设置。和CMS一样,G1的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。
G1 GC处理步骤
阶段 1: Initial Mark(初始标记):此阶段标记所有从GC根对象直接可达的对象。 阶段 2: Root Region Scan(Root区扫描):此阶段标记所有从 "根区域" 可达的存活对象。根区域包括:非空的区域,以及在标记过程中不得不收集的区域。 阶段 3: Concurrent Mark(并发标记):此阶段和 CMS 的并发标记阶段非常类似:只遍历对象图,并在一个特殊的位图中标记能访问到的对象。 阶段 4: Remark(再次标记) 和 CMS 类似,这是一次 STW 停顿(因为不是并发的阶段),以完成标记过程。 G1收集器会短暂地停止应用线程,停止并发更新信息的写入,处理其中的少量信息,并标记所有在并发标记开始时未被标记的存活对象。 阶段 5: Cleanup(清理):最后这个清理阶段为即将到来的转移阶段做准备,统计小堆块中所有存活的对象,并将小堆块进行排序,以提升 GC 的效率,维护并发标记的内部状态。 所有不包含存活对象的小堆块在此阶段都被回收了。有一部分任务是并发 的:例如空堆区的回收,还有大部分的存活率计算。此阶段也需要一个短暂的 STW 暂停。
转移暂停: 混合模式(Evacuation Pause (mixed))
并发标记完成之后,G1将执行一次混合收集(mixed collection),就是不只清理年轻代,还将一部分老年代区域也加入到回收集中。混合模式的转移暂停不一定紧跟并发标记阶段。有很多规则和历史数据会影响混合模式的启动时机。比如,假若在老年代中可以并发地腾出很多的小堆块,就没有必要启动混合模式。 因此,在并发标记与混合转移暂停之间,很可能会存在多次 young 模式的转移暂停。具体添加到回收集的老年代小堆块的大小及顺序,也是基于许多规则来判定的。其中包括指定的软实时性能指标,存活性,以及在并发标记期间收集的 GC 效率等数据,外加一些可配置的 JVM 选项。混合收集的过程,很大程度上和前面的 fully-young gc 是一样的。
G1 GC注意事项
特别需要注意的是,某些情况下G1触发Full GC,这时G1会退化使用Serial收集器来完成垃圾的清理工作,它仅仅使用单线程来完成GC工作,GC暂停时间将达到秒级别的。
- 并发模式失败 G1 启动标记周期,但在Mix GC之前,老年代就被填满,这时候G1会放弃标记周期。解决办法:增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThread 等)。
- 晋升失败 没有足够的内存供存活对象或晋升对象使用,由此触发了Full GC(to-space exhausted/to-space overflow)。 解决办法: a) 增加 –XX:G1ReservePercent 选项的值(并相应增加总的堆大小)增加预留内存量。 b) 通过减少 –XX:InitiatingHeapOccupancyPercent 提前启动标记周期。 c) 也可以通过增加 –XX:ConcGCThreads 选项的值来增加并行标记线程的数目。
- 巨型对象分配失败 当巨型对象找不到合适的空间进行分配时,就会启动 Full GC,来释放空间。 解决办法:增加内存或者增大 -XX:G1HeapRegionSize
常用的GC组合
- Serial+Serial Old 实现单线程的低延迟垃圾回收机制;
- ParNew+CMS,实现多线程的低延迟垃圾回收机制;
- Parallel Scavenge和Parallel Scavenge Old,实现多线程的高吞吐量垃圾回收机制。
GC如何选择
选择正确GC算法,唯一可行的方式就是去尝试,一般性的指导原则:
- 如果系统考虑吞吐优先,CPU资源都用来最大程度处理业务,用 Parallel GC;
- 如果系统考虑低延迟有限,每次GC时间尽量短,用 CMS GC;
- 如果系统内存堆较大,同时希望整体来看平均GC时间可控,使用 G1 GC。 对于内存大小的考量:
- 一般 4G 以上,算是比较大,用G1的性价比较高;
- 一般超过8G,比如16G-64G内存,非常推荐使用G1 GC。
ZGC
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx16g ZGC最主要的特点包括:
- GC最大停顿时间不超过10ms;
- 堆内存支持范围广,小至几百MB的堆空间,大至4TB的超大堆内存(JDK13 升至 16TB);
- 与G1相比,应用吞吐量下降不超过15%;
- 当前只支持 Linux/x64 位平台,JDK15 后支持 MacOS 和Windows 系统。
ShennandoahGC
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -Xmx16g Shenandoah GC立项比ZGC更早,设计为GC线程与应用线程并发执行的方式,通过实现垃圾回收过程的并发处理,改善停顿时间,使得GC 执行线程能够在业务处理线程运行过程中进行堆压缩、标记和整理,从而消除了绝大部分的暂停时间。 Shenandoah团队对外宣称Shenandoah GC 的暂停时间与堆大小无关,无论是200 MB还是200GB的堆内存,都可以保障具有很低的暂停时间(注意:并不像ZGC那样保证暂停时间在10ms以内)。
JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代
Java堆 = 老年代 + 新生代 新生代 = Eden + S0 + S1 当 Eden 区的空间满了, Java虚拟机会触发一次 Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到 Survivor区。 大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年态; 如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一次Minor GC,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年态。 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和年老代。 Major GC 发生在老年代的GC,清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上。
A a = new A() 经历过什么过程
1)检测类是否被加载 当虚拟机遇到 new 指令时,首先先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就执行类加载过程。 2)为对象分配内存 类加载完成以后,虚拟机就开始为对象分配内存,此时所需内存的大小就已经确定了。只需要在堆上分配所需要的内存即可。 具体的分配内存有两种情况:第一种情况是内存空间绝对规整,第二种情况是内存空间是不连续的。 对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为“指针碰撞”。 对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为“空闲列表”。 多线程并发时会出现正在给对象 A 分配内存,还没来得及修改指针,对象 B 又用这个指针分配内存,这样就出现问题了。解决这种问题有两种方案: 第一种,是采用同步的办法,使用 CAS 来保证操作的原子性。 另一种,是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),分配内存的时候再TLAB上分配,互不干扰。可以通过 -XX:+/-UseTLAB 参数决定。 3)为分配的内存空间初始化零值 对象的内存分配完成后,还需要将对象的内存空间都初始化为零值,这样能保证对象即使没有赋初值,也可以直接使用。 4)对对象进行其他设置 分配完内存空间,初始化零值之后,虚拟机还需要对对象进行其他必要的设置,设置的地方都在对象头中,包括这个对象所属的类,类的元数据信息,对象的 hashcode ,GC 分代年龄等信息。 5)执行 init 方法 执行完上面的步骤之后,在虚拟机里这个对象就算创建成功了,但是对于 Java 程序来说还需要执行 init 方法才算真正的创建完成,因为这个时候对象只是被初始化零值了,还没有真正的去根据程序中的代码分配初始值,调用了 init 方法之后,这个对象才真正能使用。
full GC可能的场景
- System.gc()方法的调用
- 老年代代空间不足,老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误:java.lang.OutOfMemoryError: Java heap space
- 对于采用CMS进行老年代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。 promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入老年代,而此时老年代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC)
- 统计得到的Minor GC晋升到老年代的平均大小大于老年代的剩余空间
- 所谓大对象,是指需要大量连续内存空间的java对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC
JVM调优经验
1、高分配速率(High Allocation Rate) 分配速率(Allocation rate)表示单位时间内分配的内存量。通常使用 MB/sec 作为单位。上一次垃圾收集之后,与下一次GC开始之前的年轻代使用量,两者的差值除以时间,就是分配速率。 分配速率过高就会严重影响程序的性能,在 JVM 中可能会导致巨大的 GC 开销。 因为new出来的对象,分配在Eden,假如我们增加 Eden,类似蓄水池效应,最终的效果是,影响 Minor GC的次数和时间,进而影响吞吐量。在某些情况下,只要增加年轻代的大小,即可降低分配速率过高所造成的影响。增加年轻代空间并不会降低分配速率,但是会减少 GC 的频率。如果每次 GC 后只有少量对象存活,minor GC 的暂停时间就不会明显增加。 正常系统:分配速率较低 ~ 回收速率 -> 健康 内存泄漏:分配速率 持续大于 回收速率 -> OOM 性能劣化:分配速率较高 ~ 回收速率 -> 亚健康
2、过早提升(Premature Promotion) 提升速率(promotion rate)用于衡量单位时间内从年轻代提升到老年代的数据量。一般使用 MB/sec 作为单位, 和分配速率类似。 JVM 会将长时间存活的对象从年轻代提升到老年代。根据分代假设,可能存在一种情况,老年代中不仅有存活时间长的对象,也可能有存活时间短的对象。这就是过早提升:对象存活时间还不够长的时候就被提升到了老年代。major GC 不是为频繁回收而设计的,但 major GC 现在也要清理这些生命短暂的对象,就会导致 GC 暂停时间过长。这会严重影响系统的吞吐量。 一般来说过早提升的症状表现为以下形式:
- 短时间内频繁地执行 full GC
- 每次 full GC 后老年代的使用率都很低,在10-20%或以下
- 提升速率接近于分配速率
解决这类问题,需要让年轻代存放得下暂存的数据,有两种简单的方法:
- 是增加年轻代的大小,设置 JVM 启动参数,类似这样:-Xmx64m -XX:NewSize=32m,程序在执行时,Full GC 的次数自然会减少很多,只会对 minor GC 的持续时间产生影响。
- 是减少每次批处理的数量,也能得到类似的结果。 至于选用哪个方案,要根据业务需求决定。在某些情况下,业务逻辑不允许减少批处理的数量,那就只能增加堆内存,或者重新指定年轻代的大小。如果都不可行,就只能优化数据结构,减少内存消耗。 但总体目标依然是一致的:让临时数据能够在年轻代存放得下。
GC排查思路
1、查询业务日志,可以发现这类问题:请求压力大,波峰,遭遇降级,熔断等等, 基础服务、外部 API 依赖 。 2、查看系统资源和监控信息:硬件信息、操作系统平台、系统架构;排查 CPU 负载、内存不足,磁盘使用量、硬件故障、磁盘分区用满、IO 等待、IO 密集、丢数据、并发竞争等情况;排查网络:流量打满,响应超时,无响应,DNS 问题,网络抖动,防火墙问题,物理故障,网络参数调整、超时、连接数。 3、查看性能指标,包括实时监控、历史数据。可以发现 假死,卡顿、响应变慢等现象;排查数据库, 并发连接数、慢查询、索引、磁盘空间使用量、内存使用量、网络带宽、死锁、TPS、查询数据量、redo日志、undo、 binlog 日志、代理、工具 BUG。可以考虑的优化包括: 集群、主备、只读实例、分片、分区;大数据,中间件,JVM 参数。 4、排查系统日志, 比如重启、崩溃、Kill 。 5、APM,比如发现有些链路请求变慢等等。 6、排查应用系统排查配置文件: 启动参数配置、Spring 配置、JVM 监控参数、数据库参数、Log 参数、APM 配置、内存问题,比如是否存在内存泄漏,内存溢出、批处理导致的内存放大、GC 问题等等;GC 问题, 确定 GC 算法、确定 GC 的 KPI,GC 总耗时、GC 最大暂停时间、分析 GC 日志和监控指标: 内存分配速度,分代提升速度,内存使用率等数据。适当时修改内存配置;排查线程, 理解线程状态、并发线程数,线程 Dump,锁资源、锁等待,死锁;排查代码, 比如安全漏洞、低效代码、算法优化、存储优化、架构调整、重构、解决业务代码 BUG、第三方库、XSS、CORS、正则;单元测试: 覆盖率、边界值、Mock 测试、集成测试。 7、排除资源竞争、坏邻居效应 8、疑难问题排查分析手段:DUMP 线程\内存;抽样分析\调整代码、异步化、削峰填谷。