【java基础】JVM小结

JVM类加载

类的生命周期

JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化 ,如下图:

在这里插入图片描述

  1. 加载

加载是类加载过程中的一个阶段, 这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对
象, 作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个 Class 文件获取,这里既
可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),
也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)

  1. 连接
    • 验证
      这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并
      且不会危害虚拟机自身的安全。
      验证字节码文件的正确性,包括: 1.文件格式验证; 2.元数据验证;3.字节码验证;4.符号引用验证
    • 准备
      准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使
      用的内存空间。
    • 解析
      虚拟机将常量池中的符号引用替换为直接引用【类装载器装入类所引用的其它所有类(静态链接)。 包括 类或接口的解析,类方法解析,接口方法解析,字段解析 。
  2. 初始化

对于静态变量、静态初始化块、变量、初始化块、构造器,它们的初始化顺序依次是(静态变量、静态初始化块)>(变量、初始化块)>构造器。

  1. 使用
  2. 卸载

即时编译

类加载器

  • **启动类加载器(Bootstrap ClassLoader) **

这是加载器中的大 Boss,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。
这个加载器是 C++ 编写的,随着 JVM 启动。

  • ** 扩展类加载器(Extension ClassLoader) **

扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。
这个加载器是个 Java 类,继承自 URLClassLoader。

  • **应用程序类加载器(Application ClassLoader) **

用户自定义的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,用户编写的代码,会首先尝试使用这个类加载器进行加载。

  • ** 自定义类加载器**

自定义加载器,支持一些个性化的扩展功能

双亲委派

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class), 子类加载器才会尝试自己去加载。

打破双亲委派机制案例

1.Tomcat

  1. 为了Web应用程序类之间隔离,为每个应用程序创建WebAppClassLoader类加载器
  2. 为了Web应用程序类之间共享,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebAppClassLoader加载器找不到,则尝试用ShareClassLoader进行加载
  3. 为了Tomcat本身与Web应用程序类隔离,用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类
  4. 为了Tomcat与Web应用程序类共享,用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器
  5. ShareClassLoader、CatalinaClassLoader、CommonClassLoader的目录可以在Tomcat的catalina.properties进行配置

![image.png](https://img-blog.csdnimg.cn/img_convert/f4e0949dfc3fabcd1138bd27dfbd7c50.png#clientId=ue6264408-d61d-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=504&id=HLPyV&margin=[object Object]&name=image.png&originHeight=1064&originWidth=1306&originalType=binary&ratio=1&rotation=0&showTitle=false&size=675203&status=done&style=none&taskId=u2e232342-8718-4233-9f14-ff077fb05bb&title=&width=619)

JVM内存空间

JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区域【JAVA 堆、方法区】、直接内存。

综合以上内容,类中方法执行的过程如下:

1.程序计数器(线程私有)

程序计数器(Program Counter Register),也有称作为PC寄存器。保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加 1 或者根据转移指针得 到下一条指令的地址,如此循环,直至执行完所有的指令。也就是说是用来指示执行哪条指令的。

由于在 JVM中,多线程是通过线程轮流切换来获得 CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序 计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的

在JVM规范中规定,如果线程执行的是非 native 方法,则程序计数器中保存的是当 前需要执行的指令的地址;如果线程执行的是 native 方法,则程序计数器中的值是 undefined。

由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此, 对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

异常情况: 不存在

2.虚拟机栈(线程私有)

虚拟机栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表、操作数栈、动态链接、方法返回地址、 额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应 的栈帧必定位于 Java 栈的顶部。

  • 局部变量表:用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数
    形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是 指向对象的引用。局部变量表的大小在编译器就可以确定其大小了。
  • 操作数栈:程序中的所有计算过程都是在借助于操作数栈来完成的。
  • 动态链接:指向运行时常量池中该栈帧所属方法的引用,将符号引用在运行期间转化为直接引用。
  • 方法返回地址:当一个方法执行完毕之后,要返回之前调用它的地方。

异常情况:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度(递归嵌套方法):StackOverflowError
  2. 不断创建线程,如果虚拟机在扩展栈时无法申请到足够的内存空间:OutOfMemoryError

3.本地方法栈(线程私有)

本地方法栈与 Java 栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行**本地方法(Native Method)**服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

异常情况:

  1. 栈深度大于已有深度:StackOverflowError
  2. 可扩展深度大于能够申请的内存:OutOfMemoryError

4.堆(线程共享)

堆是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代VM采用分代收集算法, 因此 Java堆从GC的角度还可以细分为: 新生代(Eden区、From Survivor区和To Survivor区)和老年代。

异常情况:

可以处于物理上不连续的内存空间,逻辑连续即可。既可实现固定大小,也可扩展。如果堆中没有内存 完成实例分配,并且堆无法再扩展是,将会抛出 OutOfMemoryError。

5.方法区(线程共享)

即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java 堆的永久代来实现方法区, 这样HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

JDK1.7以后字符串常量池和运行时常量池逻辑上属于方法区,但是实际存放在堆内存中

异常情况:

  1. 方法区调用递归,内存会溢出,报 OutOfMemoryError;
  2. .当常量池无法再申请到内存时 OutOfMemoryError

方法区的回收:

方法区(Hotspot虚拟机中的永久代)的垃圾回收主要收集两部分的内容:废弃常量无用的类

废弃常量的回收

以常量池中字面量的回收为例,假如一个字符串“abc”已经进入常量池中,但是当前系统没有任何一个string对象叫作“abc”,即没有任何string对象引用常量池中的“abc”常量,也没有其他地方引用这个字面量。如果这时候发生内存回收,且有必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的类(接口),方法,字段的符号引用回收也类似。

类的回收

  1. 首先该类的所有实例对象都已经从Java堆内存里被回收
  2. 其次加载这个类的ClassLoader已经被回收
  3. 最后,对该类的Class对象没有任何引用

JVM垃圾回收机制

判断对象存活

引用计数算法
  • 强引用
    就是我们一般声明对象是时虚拟机生成的引用,强引用环境下,垃圾回收时需要严格判断当前对象是否被强引用,如果被强引用,则不会被垃圾回收。
  • 软引用
    软引用一般被做为缓存来使用。与强引用的区别是,软引用在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。换句话说,虚拟机在发生 OutOfMemory 时,肯定是没有软引用存在的。
  • 弱引用
    弱引用与软引用类似,都是作为缓存来使用。但与软引用不同,弱引用在进行垃圾回收时,是一定会被回收掉的,因此其生命周期只存在于一个垃圾回收周期内。
  • 虚引用
    作用在于跟踪垃圾回收过程,在对象被收集器回收时收到一个系统通知。

虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。

可达性分析算法(GC Roots)

Java通过可达性分析算法来达到标记存活对象的目的,定义一系列的GC ROOT为起点,从起点开始向下开始搜索,搜索走过的路径称为引用链,当一个对象到GC ROOT没有任何引用链相连的话,则对象可以判定是可以被回收的。

  • JVM中局部变量表中引用的对象
  • 方法区中的静态引用、常量引用
  • 本地方法栈native方法(JNI)引用的对象

垃圾回收方法

标记清除

Mark-Sweep

  • 分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清
    除阶段回收被标记的对象所占用的空间。此算法需要暂停整个应用,
    同时,会产生内存碎片。
  • 缺点:效率不稳定,内存碎片化
标记复制

Mark-Copying

  • 按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。
  • 优点:实现简单,内存效率高,不易产生碎片。
  • 缺点:可用内存被压缩到了原本的一半。且存活对象增多的话, Copying 算法的效率会大大降低。
标记整理

Mark-Compact

  • 标记阶段和 Mark-Sweep 算法相同, 标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。

JVM垃圾收集器

回收类型

  • Minor GC:发生在年轻代的 GC
  • Major GC:发生在老年代的 GC
  • Full GC:全堆垃圾回收

分代收集器

ParNew

一款多线程的收集器,采用标记-复制算法,主要工作在 Young 区,可以通过 -XX:ParallelGCThreads 参数来控制收集的线程数,整个过程都是 STW 的,常与 CMS 组合使用。

G1回收器之前,线上系统都是ParNew垃圾回收器作为新生代的垃圾回收器。

新生代的ParNew垃圾回收器主打的就是多线程垃圾回收机制,另外一种Serial垃圾回收器主打的是单线程垃圾回收,二者都是回收新生代的,唯一的区别就是单线程和多线程的区别,但是垃圾回收算法是完全一样的。

指定了使用ParNew垃圾回收器之后,默认设置的垃圾回收线程的数量跟CPU的核数是一样的。

CMS

CMS是老年代垃圾收集器,基于标记-清除算法,以获取最短回收停顿时间为目标,在收集过程中可以与用户线程并发操作。(JDK9 被标记弃用,JDK14 被删除)

它可以与Serial收集器和Parallel New收集器搭配使用。CMS牺牲了系统的吞吐量来追求收集速度,适合追求垃圾收集速度的服务器上。可以通过JVM启动参数:-XX:+UseConcMarkSweepGC来开启CMS。

回收过程

  • 初始标记(STW)
    只标记直接关联 GC root的对象,不用向下追溯,速度快
  • 并发标记
    标记 GC root所有可达的对象,和用户线程并行
  • 重新标记(STW)
    修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要暂停所有的工作线程。
  • 并发清理
    清除GC Roots不可达对象,和用户线程并行

STW

  1. 初始标记,这部分的停顿时间较短;
  2. Minor GC(可选),在预处理阶段对年轻代的回收,停顿由年轻代决定;
  3. 重新标记,由于 preclaen 阶段的介入,这部分停顿也较短;
  4. Serial-Old 收集老年代的停顿,主要发生在预留空间不足的情况下,时间会持续很长;

CMS垃圾回收期间,系统程序要放入老年代的对象大于可用内存空间,就会产生Concurrent Mode Failure。此时就会自动用“Serial Old”垃圾回收器替代CMS,强行STW,把GC root所有可达对象找出来并清理。

  1. Full GC,永久代空间耗尽时的操作,由于会有整理阶段,持续时间较长。

优势

  • 低延迟,尤其对于大堆来说。大部分垃圾回收过程并发执行。

劣势

  • 内存碎片问题。Full GC 的整理阶段,会造成较长时间的停顿。(“- XX:+UseCMSCompactAtFull-Collection”)
  • 需要预留空间,用来分配收集阶段产生的“浮动垃圾”。
  • 使用更多的 CPU 资源,在应用运行的同时进行堆扫描。

分区收集器

G1

G1(复制+标记清除)收集器是JDK9的默认垃圾收集器,而且不再区分年轻代和老年代进行回收。

G1一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。

G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。

回收过程

  • 初始标记
    标记了从GC Root开始直接可达的对象,此阶段需要停顿线程,但是耗时很短
  • 并发标记
    从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息
  • 最终标记
    对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  • 筛选回收
    更新Region的统计数据,对每个Region的回收价值和成本排序,根据用户设置的停顿时间制定回收计划。再把需要回收的Region中存活对象复制到空的Region,同时清理旧的Region。需要STW。

G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。

  • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

由上面的描述可知,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。所以我们可以知道,G1是不提供full GC的。

总的来说除了并发标记之外,其他几个过程也还是需要短暂的STW,G1的目标是在停顿和延迟可控的情况下尽可能提高吞吐量。

ZGC

JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。

Shenandoah

由 Red Hat 的一个团队负责开发,与 G1 类似,基于 Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table 来记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC 接近,下图为与 CMS 和 G1 等收集器的 benchmark。

总结

  • 如果你的堆大小不是很大(比如 100MB),选择串行收集器一般效率最高。参数:-XX:+UseSerialGC。
  • 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有 1C,选择串行收集器依然是合适的,这时候启用一些并行收集器没有任何收益。参数:-XX:+UseSerialGC。
  • 如果你的应用是“吞吐量”优先的,并且对较长时间的停顿没有什么特别的要求。选择并行收集器是比较好的。参数:-XX:+UseParallelGC。
  • 如果你的应用对响应时间要求较高,想要较少的停顿。甚至 1 秒的停顿都会引起大量的请求失败,那么选择 G1、ZGC、CMS 都是合理的。虽然这些收集器的 GC 停顿通常都比较短,但它需要一些额外的资源去处理这些工作,通常吞吐量会低一些。参数:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC 等。

CMS和G1都是现在较为主流的垃圾回收器,两者都有分代的概念,主要内存结构如下:

GC调优

常用工具

命令行终端

  • 标准终端类:jps、jinfo、jstat、jstack、jmap
  • 功能整合类:jcmd、vjtools、arthas、greys

可视化界面

  • 简易:JConsole、JVisualvm、HA、GCHisto、GCViewer
  • 进阶:MAT、JProfiler

GC的时机

当一个新的对象来申请内存空间的时候,如果Eden区无法满足内存分配需求,则触发YGC,使用中的Survivor区和Eden区存活对象送到未使用的Survivor区,如果YGC之后还是没有足够空间,则直接进入老年代分配,如果老年代也无法分配空间,触发FGC,FGC之后还是放不下则报出OOM异常。

Young GC其实一般就是在新生代的Eden区域不够之后就会触发,采用标记-复制算法来回收新生代的垃圾

对象进入老年代:

  1. 如果对象够老,会通过“提升”进入老年代。

对于那些一直在Survivor区来回复制的对象,通过-XX:MaxTenuringThreshold配置交换阈值,默认15次,如果超过次数同样进入老年代。

  1. 当 Survivor 空间不够,就需要依赖老年代进行分配担保。
  2. 大对象直接在老年代分配
  3. 动态年龄的判断机制。

不需要等到MaxTenuringThreshold就能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

调优

核心参数

-Xms:Java堆内存的大小
-Xmx:Java堆内存的最大大小
-Xmn:Java堆内存中的新生代大小,扣除新生代剩下的就是老年代的内存大小了
-XX:PermSize:方法区大小
-XX:MaxPermSize:方法区最大大小
-Xss:每个线程的栈内存大小

日志输出

参数意义
-verbose:gc打印 GC 日志
PrintGCDetails打印详细 GC 日志
PrintGCDateStamps系统时间,更加可读,PrintGCTimeStamps 是 JVM 启动时间
PrintGCApplicationStoppedTime打印 STW 时间
PrintTenuringDistribution打印对象年龄分布,对调优 MaxTenuringThreshold 参数帮助很大
loggc将以上 GC 内容输出到文件中

OOM 时的参数:

参数意义
HeapDumpOnOutOfMemoryErrorOOM 时 Dump 信息,非常有用
HeapDumpPathDump 文件保存路径
ErrorFile错误日志存放路径

调优目标与思路

调优目标从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput)。

调优背书:

  1. 为了打印日志方便排查问题最好开启GC日志,开启GC日志对性能影响微乎其微,但是能帮助我们快速排查定位问题。-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
  2. 一般设置-Xms=-Xmx,这样可以获得固定大小的堆内存,防止动态扩容引起空间震荡,减少GC的次数和耗时,可以使得堆相对稳定
  3. -XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出的时候自动生成内存快照,方便排查问题
  4. -Xmn设置新生代的大小,太小会增加YGC,太大会减小老年代大小,一般设置为整个堆的1/4到1/3
  5. 设置-XX:+DisableExplicitGC禁止系统System.gc(),防止手动误触发FGC造成问题

基本的调优思路可以总结为:

  1. 理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。
  2. 掌握 JVM 和 GC 的状态,定位具体的问题,确定真的有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。这里需要思考,选择的 GC 类型是否符合我们的应用特征,如果是,具体问题表现在哪里,是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。
  3. 通过分析确定具体调整的参数或者软硬件配置。
  4. 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。

频繁FullGC怎么排查?

这种问题最好的办法就是结合有具体的例子举例分析,如果没有就说一般的分析步骤。发生FGC有可能是内存分配不合理,比如Eden区太小,导致对象频繁进入老年代,这时候通过启动参数配置就能看出来,另外有可能就是存在内存泄露,可以通过以下的步骤进行排查:

  1. jstat -gcutil或者查看gc.log日志,查看内存回收情况

S0 S1 分别代表两个Survivor区占比

E代表Eden区占比,图中可以看到使用43%

O代表老年代,M代表元空间,YGC发生37次,YGCT代表YGC累计耗时,GCT代表GC累计耗时。

从图里面看能到是否进行FGC,FGC的时间花费多长,GC后老年代,年轻代内存是否有减少,得到一些初步的情况来做出判断。

  1. dump出内存文件再具体分析,比如通过jmap命令jmap -dump:format=b,file=dumpfile pid,导出之后再通过Eclipse Memory Analyzer等工具进行分析,定位到代码,修复

G1的最佳实践

关键参数项

  • -XX:+UseG1GC,告诉JVM使用G1垃圾收集器
  • -XX:MaxGCPauseMillis=200,设置GC暂停时间的目标最大值,这是个柔性的目标,JVM会尽力达到这个目标
  • -XX:INitiatingHeapOccupancyPercent=45,如果整个堆的使用率超过这个值,G1会触发一次并发周期。记住这里针对的是整个堆空间的比例,而不是某个分代的比例。

最佳实践

不要设置年轻代的大小

通过-Xmn显式设置年轻代的大小,会干扰G1收集器的默认行为:

  • G1不再以设定的暂停时间为目标,换句话说,如果设置了年轻代的大小,就无法实现自适应的调整来达到指定的暂停时间这个目标
  • G1不能按需扩大或缩小年轻代的大小

响应时间度量

不要根据平均响应时间(ART)来设置-XX:MaxGCPauseMillis=n这个参数,应该设置希望90%的GC都可以达到的暂停时间。这意味着90%的用户请求不会超过这个响应时间,记住,这个值是一个目标,但是G1并不保证100%的GC暂停时间都可以达到这个目标

常见场景分析与解决

场景一:动态扩容引起的空间震荡

现象

服务刚刚启动时 GC 次数较多,最大空间剩余很多但是依然发生 GC,这种情况我们可以通过观察 GC 日志或者通过监控工具来观察堆的空间变化情况即可。GC Cause 一般为 Allocation Failure,且在 GC 日志中会观察到经历一次 GC ,堆内各个空间的大小会被调整,如下图所示:

原因

在 JVM 的参数中 -Xms-Xmx 设置的不一致,在初始化时只会初始 -Xms 大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次 GC。具体是通过 ConcurrentMarkSweepGeneration::compute_new_size() 方法计算新的空间大小。

另外,如果空间剩余很多时也会进行缩容操作,JVM 通过 -XX:MinHeapFreeRatio-XX: MaxHeapFreeRatio 来控制扩容和缩容的比例,调节这两个值也可以控制伸缩的时机,例如扩容便是使用 GenCollectedHeap::expand_heap_and_allocate() 来完成的。

整个伸缩的模型理解可以看这个图,当 committed 的空间大小超过了低水位/高水位的大小,capacity 也会随之调整:

策略

定位:观察 CMS GC 触发时间点 Old/MetaSpace 区的 committed 占比是不是一个固定的值,或者像上文提到的观察总的内存使用率也可以。

解决:尽量将成对出现的空间大小配置参数设置成固定的,如 -Xms-Xmx-XX:MaxNewSize-XX:NewSize-XX:MetaSpaceSize-XX:MaxMetaSpaceSize 等。

小结

一般来说,我们需要保证 Java 虚拟机的堆是稳定的,确保 -Xms-Xmx 设置的是一个值(即初始值和最大值一致),获得一个稳定的堆,同理在 MetaSpace 区也有类似的问题。不过在不追求停顿时间的情况下震荡的空间也是有利的,可以动态地伸缩以节省空间,例如作为富客户端的 Java 应用。

场景二:显式 GC 的去与留

现象

除了扩容缩容会触发 CMS GC 之外,还有 Old 区达到回收阈值、MetaSpace 空间不足、Young 区晋升失败、大对象担保失败等几种触发条件,如果这些情况都没有发生却触发了 GC ?这种情况有可能是代码中手动调用了 System.gc 方法,此时可以找到 GC 日志中的 GC Cause 确认下。那么这种 GC 到底有没有问题,翻看网上的一些资料,有人说可以添加 -XX:+DisableExplicitGC 参数来避免这种 GC,也有人说不能加这个参数,加了就会影响 Native Memory 的回收。先说结论,笔者这里建议保留 System.gc,那为什么要保留?我们一起来分析下。

原因

找到 System.gc 在 Hotspot 中的源码,可以发现增加 -XX:+DisableExplicitGC 参数后,这个方法变成了一个空方法,如果没有加的话便会调用 Universe::heap()::collect 方法,继续跟进到这个方法中,发现 System.gc 会引发一次 STW 的 Full GC,对整个堆做收集。

保留 System.gc

此处补充一个知识点,CMS GC 共分为 Background 和 Foreground 两种模式,前者就是我们常规理解中的并发收集,可以不影响正常的业务线程运行,但 Foreground Collector 却有很大的差异,他会进行一次压缩式 GC。此压缩式 GC 使用的是跟 Serial Old GC 一样的 Lisp2 算法,其使用 Mark-Compact 来做 Full GC,一般称之为 MSC(Mark-Sweep-Compact),它收集的范围是 Java 堆的 Young 区和 Old 区以及 MetaSpace。由上面的算法章节中我们知道 compact 的代价是巨大的,那么使用 Foreground Collector 时将会带来非常长的 STW。如果在应用程序中 System.gc 被频繁调用,那就非常危险了。

去掉 System.gc

如果禁用掉的话就会带来另外一个内存泄漏问题,此时就需要说一下 DirectByteBuffer,它有着零拷贝等特点,被 Netty 等各种 NIO 框架使用,会使用到堆外内存。堆内存由 JVM 自己管理,堆外内存必须要手动释放,DirectByteBuffer 没有 Finalizer,它的 Native Memory 的清理工作是通过 sun.misc.Cleaner 自动完成的,是一种基于 PhantomReference 的清理工具,比普通的 Finalizer 轻量些。

为 DirectByteBuffer 分配空间过程中会显式调用 System.gc ,希望通过 Full GC 来强迫已经无用的 DirectByteBuffer 对象释放掉它们关联的 Native Memory。

策略

通过上面的分析看到,无论是保留还是去掉都会有一定的风险点,不过目前互联网中的 RPC 通信会大量使用 NIO,所以笔者在这里建议保留。此外 JVM 还提供了 -XX:+ExplicitGCInvokesConcurrent-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 参数来将 System.gc 的触发类型从 Foreground 改为 Background,同时 Background 也会做 Reference Processing,这样的话就能大幅降低了 STW 开销,同时也不会发生 NIO Direct Memory OOM。

小结

不止 CMS,在 G1 或 ZGC中开启 ExplicitGCInvokesConcurrent 模式,都会采用高性能的并发收集方式进行收集,不过还是建议在代码规范方面也要做好约束,规范好 System.gc 的使用。

场景三:MetaSpace 区 OOM

现象

JVM 在启动后或者某个时间点开始,MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决

原因

在讨论为什么会 OOM 之前,我们先来看一下这个区里面会存什么数据,Java7 之前字符串常量池被放到了 Perm 区,所有被 intern 的 String 都会被存在这里,由于 String.intern 是不受控的,所以 -XX:MaxPermSize 的值也不太好设置,经常会出现 java.lang.OutOfMemoryError: PermGen space 异常,所以在 Java7 之后常量池等字面量(Literal)、类静态变量(Class Static)、符号引用(Symbols Reference)等几项被移到 Heap 中。而 Java8 之后 PermGen 也被移除,取而代之的是 MetaSpace。

在最底层,JVM 通过 mmap 接口向操作系统申请内存映射,每次申请 2MB 空间,这里是虚拟内存映射,不是真的就消耗了主存的 2MB,只有之后在使用的时候才会真的消耗内存。申请的这些内存放到一个链表中 VirtualSpaceList,作为其中的一个 Node。

在上层,MetaSpace 主要由 Klass Metaspace 和 NoKlass Metaspace 两大部分组成。

  • Klass MetaSpace: 就是用来存 Klass 的,就是 Class 文件在 JVM 里的运行时数据结构,这部分默认放在 Compressed Class Pointer Space 中,是一块连续的内存区域,紧接着 Heap。Compressed Class Pointer Space 不是必须有的,如果设置了 -XX:-UseCompressedClassPointers,或者 -Xmx 设置大于 32 G,就不会有这块内存,这种情况下 Klass 都会存在 NoKlass Metaspace 里。
  • NoKlass MetaSpace: 专门来存 Klass 相关的其他的内容,比如 Method,ConstantPool 等,可以由多块不连续的内存组成。虽然叫做 NoKlass Metaspace,但是也其实可以存 Klass 的内容,上面已经提到了对应场景。

MetaSpace 的对象为什么无法释放,我们看下面两点:

  • MetaSpace 内存管理: 类和其元数据的生命周期与其对应的类加载器相同,只要类的类加载器是存活的,在 Metaspace 中的类元数据也是存活的,不能被回收。每个加载器有单独的存储空间,通过 ClassLoaderMetaspace 来进行管理 SpaceManager* 的指针,相互隔离的。
  • MetaSpace 弹性伸缩: 由于 MetaSpace 空间和 Heap 并不在一起,所以这块的空间可以不用设置或者单独设置,一般情况下避免 MetaSpace 耗尽 VM 内存都会设置一个 MaxMetaSpaceSize,在运行过程中,如果实际大小小于这个值,JVM 就会通过 -XX:MinMetaspaceFreeRatio-XX:MaxMetaspaceFreeRatio 两个参数动态控制整个 MetaSpace 的大小,具体使用可以看 MetaSpaceGC::compute_new_size() 方法(下方代码),这个方法会在 CMSCollector 和 G1CollectorHeap 等几个收集器执行 GC 时调用。这个里面会根据 used_after_gcMinMetaspaceFreeRatioMaxMetaspaceFreeRatio 这三个值计算出来一个新的 _capacity_until_GC 值(水位线)。然后根据实际的 _capacity_until_GC 值使用 MetaspaceGC::inc_capacity_until_GC()MetaspaceGC::dec_capacity_until_GC() 进行 expand 或 shrink,这个过程也可以参照场景一中的伸缩模型进行理解。

策略

了解大概什么原因后,如何定位和解决就很简单了,可以 dump 快照之后通过 JProfiler 或 MAT 观察 Classes 的 Histogram(直方图) 即可,或者直接通过命令即可定位, jcmd 打几次 Histogram 的图,看一下具体是哪个包下的 Class 增加较多就可以定位了。不过有时候也要结合InstBytes、KlassBytes、Bytecodes、MethodAll 等几项指标综合来看下。

小结

原理理解比较复杂,但定位和解决问题会比较简单,经常会出问题的几个点有 Orika 的 classMap、JSON 的 ASMSerializer、Groovy 动态加载类等,基本都集中在反射、Javasisit 字节码增强、CGLIB 动态代理、OSGi 自定义类加载器等的技术点上。另外就是及时给 MetaSpace 区的使用率加一个监控,如果指标有波动提前发现并解决问题。

场景四:过早晋升

现象

这种场景主要发生在分代的收集器上面,专业的术语称为“Premature Promotion”。90% 的对象朝生夕死,只有在 Young 区经历过几次 GC 的洗礼后才会晋升到 Old 区,每经历一次 GC 对象的 GC Age 就会增长 1,最大通过 -XX:MaxTenuringThreshold 来控制。

过早晋升一般不会直接影响 GC,总会伴随着浮动垃圾、大对象担保失败等问题,但这些问题不是立刻发生的,我们可以观察以下几种现象来判断是否发生了过早晋升。

分配速率接近于晋升速率,对象晋升年龄较小。

GC 日志中出现“Desired survivor size 107347968 bytes, new threshold 1(max 6)”等信息,说明此时经历过一次 GC 就会放到 Old 区。

Full GC 比较频繁,且经历过一次 GC 之后 Old 区的变化比例非常大

比如说 Old 区触发的回收阈值是 80%,经历过一次 GC 之后下降到了 10%,这就说明 Old 区的 70% 的对象存活时间其实很短,如下图所示,Old 区大小每次 GC 后从 2.1G 回收到 300M,也就是说回收掉了 1.8G 的垃圾,只有 300M 的活跃对象。整个 Heap 目前是 4G,活跃对象只占了不到十分之一。

过早晋升的危害:

  • Young GC 频繁,总的吞吐量下降。
  • Full GC 频繁,可能会有较大停顿。

原因

主要的原因有以下两点:

  • Young/Eden 区过小: 过小的直接后果就是 Eden 被装满的时间变短,本应该回收的对象参与了 GC 并晋升,Young GC 采用的是复制算法,由基础篇我们知道 copying 耗时远大于 mark,也就是 Young GC 耗时本质上就是 copy 的时间(CMS 扫描 Card Table 或 G1 扫描 Remember Set 出问题的情况另说),没来及回收的对象增大了回收的代价,所以 Young GC 时间增加,同时又无法快速释放空间,Young GC 次数也跟着增加。
  • 分配速率过大: 可以观察出问题前后 Mutator 的分配速率,如果有明显波动可以尝试观察网卡流量、存储类中间件慢查询日志等信息,看是否有大量数据被加载到内存中。

同时无法 GC 掉对象还会带来另外一个问题,引发动态年龄计算:JVM 通过 -XX:MaxTenuringThreshold 参数来控制晋升年龄,每经过一次 GC,年龄就会加一,达到最大年龄就可以进入 Old 区,最大值为 15(因为 JVM 中使用 4 个比特来表示对象的年龄)。设定固定的 MaxTenuringThreshold 值作为晋升条件:

  • MaxTenuringThreshold 如果设置得过大,原本应该晋升的对象一直停留在 Survivor 区,直到 Survivor 区溢出,一旦溢出发生,Eden + Survivor 中对象将不再依据年龄全部提升到 Old 区,这样对象老化的机制就失效了。
  • MaxTenuringThreshold 如果设置得过小,过早晋升即对象不能在 Young 区充分被回收,大量短期对象被晋升到 Old 区,Old 区空间迅速增长,引起频繁的 Major GC,分代回收失去了意义,严重影响 GC 性能。

相同应用在不同时间的表现不同,特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面问题,所以 Hotspot 会使用动态计算的方式来调整晋升的阈值。

可以看到 Hotspot 遍历所有对象时,从所有年龄为 0 的对象占用的空间开始累加,如果加上年龄等于 n 的所有对象的空间之后,使用 Survivor 区的条件值(TargetSurvivorRatio / 100,TargetSurvivorRatio 默认值为 50)进行判断,若大于这个值则结束循环,将 n 和 MaxTenuringThreshold 比较,若 n 小,则阈值为 n,若 n 大,则只能去设置最大阈值为 MaxTenuringThreshold。动态年龄触发后导致更多的对象进入了 Old 区,造成资源浪费

策略

知道问题原因后我们就有解决的方向,如果是 Young/Eden 区过小,我们可以在总的 Heap 内存不变的情况下适当增大 Young 区,具体怎么增加?一般情况下 Old 的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给 Young 区。

拿笔者的一次典型过早晋升优化来看,原配置为 Young 1.2G + Old 2.8G,通过观察 CMS GC 的情况找到存活对象大概为 300~400M,于是调整 Old 1.5G 左右,剩下 2.5G 分给 Young 区。仅仅调了一个 Young 区大小参数(-Xmn),整个 JVM 一分钟 Young GC 从 26 次降低到了 11 次,单次时间也没有增加,总的 GC 时间从 1100ms 降低到了 500ms,CMS GC 次数也从 40 分钟左右一次降低到了 7 小时 30 分钟一次。

如果是分配速率过大:

  • 偶发较大:通过内存分析工具找到问题代码,从业务逻辑上做一些优化。
  • 一直较大:当前的 Collector 已经不满足 Mutator 的期望了,这种情况要么扩容 Mutator 的 VM,要么调整 GC 收集器类型或加大空间。

小结

过早晋升问题一般不会特别明显,但日积月累之后可能会爆发一波收集器退化之类的问题,所以我们还是要提前避免掉的,可以看看自己系统里面是否有这些现象,如果比较匹配的话,可以尝试优化一下。一行代码优化的 ROI 还是很高的。

如果在观察 Old 区前后比例变化的过程中,发现可以回收的比例非常小,如从 80% 只回收到了 60%,说明我们大部分对象都是存活的,Old 区的空间可以适当调大些。

场景五:哪些场景会产生OOM?怎么解决?

  • 内存溢出。内存的容量太小了,需要扩容,或者需要调整堆的空间。
  1. Java虚拟机的堆内存设置不够。
    比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms 、-Xmx来调整。
  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
  • 错误的引用方式,发生了内存泄漏。没有及时的切断与 GC Roots 的关系。比如线程池里的线程,在复用的情况下忘记清理 ThreadLocal 的内容。

单例模式
单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
一些提供close()的资源未关闭导致内存泄漏
数据库连接 dataSourse.getConnection(),网络连接socket和io连接必须手动close,否则是不能被回收的。ThreadLocal 使用后未手动释放。

  • 接口没有进行范围校验,外部传参超出范围。比如数据库查询时的每页条数等。
  • 对堆外内存无限制的使用。这种情况一旦发生更加严重,会造成操作系统内存耗尽。

堆内存溢出

堆内存溢出太常见,大部分人都应该能想得到这一点,堆内存用来存储对象实例,我们只要不停的创建对象,并且保证GC Roots和对象之间有可达路径避免垃圾回收,那么在对象数量超过最大堆的大小限制后很快就能出现这个异常。

一般的排查方式可以通过设置-XX: +HeapDumpOnOutOfMemoryError在发生异常时dump出当前的内存转储快照来分析,分析可以使用Eclipse Memory Analyzer(MAT)来分析。

栈内存溢出

栈是线程私有,它的生命周期和线程相同。每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息,方法调用的过程就是栈帧入栈和出栈的过程。

在java虚拟机规范中,对虚拟机栈定义了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  2. 如果虚拟机栈可以动态扩展,并且扩展时无法申请到足够的内存,抛出OutOfMemoryError异常
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值