文章目录
- 0. 前言
- 1. 为什么要学 JVM
- 2. 什么是 JVM
- 3. JVM 的好处
- 3.1 一次编写,到处运行
- 3.2 自动内存管理(基于垃圾回收机制)
- 4. 要学习哪些 JVM 的哪些内容
- 5. JVM 的组成
- 5.1 程序计数器
- 5.2 堆
- 5.3 什么是虚拟机栈
- 常见问题一:垃圾回收是否涉及栈内存
- 常见问题二:虚拟机栈内存分配越大越好吗
- 常见问题三:方法内的局部变量是不是线程安全的
- 常见问题四:栈内存溢出的情况
- 5.4 方法区
- 5.4.1 方法区的概念
- 5.4.2 常量池
- 5.4.3 运行时常量池
- 5.4.4 可能遇到的问题
- 5.5 直接内存
- 6. 类加载器
- 6.1 什么是类加载器,类加载器有哪些
- 6.2 什么是双亲委派模型
- 6.3 JVM 为什么要采用双亲委派机制
- 6.4 类装载的执行过程
- 6.4.1 加载
- 6.4.2 验证
- 6.4.3 准备
- 6.4.4 解析
- 6.4.5 初始化
- 6.4.6 使用
- 6.4.7 卸载
- 7. 垃圾回收
- 7.1 对象什么时候可以被垃圾回收器回收
- 7.1.1 引用计数法
- 7.1.2 可达性分析算法
- 7.2 垃圾回收算法有哪些
- 7.2.1 标记清除算法(用的比较少)
- 7.2.2 标记整理法
- 7.2.3 复制法
- 7.3 JVM 的分代回收
- 7.3.1 分代收集算法-工作机制
- 7.3.2 MinorGC、Mixed GC、FullGC 有什么区别
- 7.4 JVM 有哪些垃圾回收器
- 7.4.1 串行垃圾收集器
- 7.4.2 并行垃圾收集器
- 7.4.3 CMS(并发)垃圾回收器
- 7.4.4 G1 垃圾收集器
- 7.5 强引用、软引用、弱引用、虚引用的区别
- 8. JVM实践
- 8.1 如何设置 JVM 的参数
- 8.1.1 war 包部署在 tomcat 中的设置
- 8.1.2 jar 包部署在启动参数设置
- 8.2 JVM 有哪些参数可以调优
- 8.2.1 调整堆空间的大小
- 8.2.2 虚拟机栈的设置
- 8.2.3 年轻代中 Eden 区和两个 Survivor 区的大小比例
- 8.2.4 年轻代晋升老年代的阈值
- 8.2.5 设置垃圾回收收集器
- 8.3 JVM 调优的工具
- 8.3.1 jps
- 8.3.2 jstack
- 8.3.3 jmap
- 8.3.4 jstat
- 8.3.5 jconsole
- 8.3.6 VisualVM
- 8.3.7 VisualVM 汉化
- 8.4 内存泄漏的排查思路
- 8.4.1 使用 jmap 命令获取运行中程序的 dump 文件
- 8.4.2 使用 vm 参数获取 dump 文件
- 8.5 CPU 飚高的排查方案与解决思路
- 8.6 解决 CPU 飚高问题的简单案例
0. 前言
Java 虚拟机(也就是 JVM )如今已经是 Java 程序员必学的内容了,主要原因有两个:
- 面试:尤其是对于要参加校招的应届生来说,JVM 基本上是必问(特别是大厂面试),掌握越深越好,而社招会更偏重于考察 Java 虚拟机调优的经验
- 线上环境优化:随着线上环境用户量和访问量的激增,Java 虚拟机越来越容易出现与内存、执行性能等相关的问题,所以掌握 Java 虚拟机的故障解决以及调优技术是非常有必要的
与 JVM 相关的知识整体是比较难的,并且大多数都是理论知识,需要花费不少时间理解,必要的时候还需要大家专门去记忆一下
1. 为什么要学 JVM
主要有三个原因:
- 应对面试:如果说在面试的时候,你对与 JVM 相关的知识一点都不了解的话,那面试官对你的印象会大打折扣
- 中高程序员必备技能:如果你说我只是一个 CRUD 的程序员,那压根就不需要了解 JVM,因为它跟我们日常开发几乎没啥关系。但如果你是一个有追求的程序员,想在这个行业长期发展,也期望从一个小白晋升成一个大牛的话,掌握 JVM 相关的知识就至关重要了
- 深入理解 Java:一旦你掌握了 JVM ,就知道了 Java 的运行机制,对问题的排查能力会有大幅度的提升,像内存泄漏、CPU 飚高等问题都与 JVM 相关,如果你能够解决这些问题,那你就会不断地靠近大佬这个级别
2. 什么是 JVM
JVM:Java Virtual Machine,是一个能够执行 Java 字节码的虚拟机进程
Java 代码要想运行的话,必须要先编译成 class 文件(也就是字节码文件)
简而言之,任何 Java 代码的运行都离不开 JVM 的支持,它是确保 Java 程序能够在不同平台上运行的基础
3. JVM 的好处
JVM 主要有两个好处:
- 一次编写,到处运行
- 自动内存管理(基于垃圾回收机制)
3.1 一次编写,到处运行
JVM 是运行在操作系统中的,我们平时都说 Java 是一个跨平台语言,它是怎么跨平台的呢,就是因为JVM,因为 JVM 帮你屏蔽了操作系统的差异,不管是在 Windows 系 还是在 Linux 系统,真正运行代码的是我们的 JVM,不是操作系统,所以说才能做到一次编写,到处运行
3.2 自动内存管理(基于垃圾回收机制)
这个好处一般会跟 C/C++ 进行对比,因为 C/C++ 需要管理员自己去管理内存,如果程序员编码不当,很容易造成内存泄漏的问题,而 JVM 的垃圾回收机制大大减轻了程序员的负担,减少了程序员出错的几率
4. 要学习哪些 JVM 的哪些内容
5. JVM 的组成
5.1 程序计数器
程序计数器(Program Counter,PC):用于记录线程执行的字节码指令的地址,相当于记录了线程执行到了哪一行字节码。每个线程都有自己的程序计数器,这意味着每个线程在执行Java代码时都有自己独立的程序计数器
上面说的可能比较抽象,我们来看一个简单的例子
public class Application
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
上面是一个打印 Hello World 的代码
运行程序后我们在 target 目录下找到 Application 类的字节码文件,然后在终端中打开
补充知识:javap -v xxx.class 打印堆栈大小,局部变量的数量和方法的参数
然后输入以下指令
javap -v Application.class
控制台会输出很多信息,我们主要看 main 方法,以下信息详细记录了 main 方法的执行过程
我们的源码中只有一行代码,但在 class 字节码中却拆成了多行执行,我们对每一行做一个简单的分析
第 1 行是 getstatic ,它的含义就是获取一个静态的变量,那哪一个是静态的变量呢?静态变量指的是 System 类里面的 out 属性,这个属性是静态的,而且这个属性的类型是 PrintStream,以下是 System 类的源码
第 2 行是 ldc(load constant),加载一个常量,这个常量是一个字符串(Hello World)
第 3 行是 invokevirtual,表示要调用一个方法,调用哪个方法呢?从输出信息中可以看到调用的是 PrintStream 类的 println 方法
第 4 行是 return,意思就是这个方法结束了
为了方便大家理解,找一个代码行数比较多的代码
现在有一个线程要执行当前代码,当线程执行到第 10 行的时候时间片被其它线程夺走了,也就是说这个线程目前没有 CPU 的执行权了
为了下一次获取到 CPU 的执行权的时候,该线程能够继续执行第 10 行代码,该线程会记录当前执行到了第 10 行代码,等到下一次线程获取到 CPU 的执行权的时候,直接从第 10 行代码开始运行就可以了
5.2 堆
堆:线程共享的区域,主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则会抛出 OutOfMemoryError
异常(内存溢出)
我们主要关注三个部分(年轻代、老年代、元空间):
- 年轻代被划分为三部分,Eden 区(Eden 区主要存放新创建的对象)和两个大小严格相同的 Survivor 区根据 JVM 的策略,在经过几次垃圾回收后,仍然存活于 Survivor 的对象将被移动到老年代区间
- 老年代主要保存生命周期长的对象,一般是一些老的对象
- 元空间保存主要保存类信息、静态变量、常量、编译后的代码
补充:Java 1.7 和 Java 1.8 的堆的区别是什么
Java 1.7 的堆中有一个方法区(也叫永久代),而 Java 1.8 中没有,这是因为 Java 1.8 之后,将方法区(也叫永久代)代放到了本地内存的元空间中
为什么要放到本地内存呢,因为方法区(也叫永久代)主要存放的是一些类或常量,随着动态类加载越来越多,方法区(也叫永久代)部分的内存将变得不可控,如果该部分内存小了,很容易会出现内存溢出的现象,如果大了,又有点浪费内存
所以 Java 1.8 之后做了优化,将方法区(也叫永久代)放到了本地内存,就是为了能够节省堆的内存空间,从而避免内存溢出
5.3 什么是虚拟机栈
Java Virtual Machine Stacks:Java 虚拟机栈,每个线程运行时所需要的内存,称为虚拟机栈(具备先进后出的特点)
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
每个线程的栈空间由-Xss
参数指定,举个例子,在内存足够的情况下,如果每个线程需要 1 MB的栈,并且有 200 个线程,那么这 200 个线程总共会占用 200 MB的内存空间
常见问题一:垃圾回收是否涉及栈内存
答:垃圾回收不涉及栈内存,垃圾回收主要涉及堆内存,当栈帧从栈中弹出后,栈内存就会被释放
常见问题二:虚拟机栈内存分配越大越好吗
答:未必,默认的虚拟机栈内存通常为 1024 k(1M),虚拟机栈内存过大会可能导致线程数变少。例如,如果机器当前的可用内存为 512 M,目前能活动的线程数则为 512 个,如果把虚拟机栈内存改为 2048 K,那么能活动的线程数就会减半。一般栈内存不需要调整,使用默认值即可
常见问题三:方法内的局部变量是不是线程安全的
- 如果方法内成局部变量没有脱离方法的作用范围,那这个局部变量是线程安全的
- 如果是局部变量引用了对象,并且脱离方法的作用范围,需要考虑这个局部变量的线程安全问题
常见问题四:栈内存溢出的情况
- (常见)栈帧过多导致栈内存溢出,典型问题:递归调用(一般是没有出口的递归调用)
- (少见)栈帧过大导致栈内存溢出
5.4 方法区
5.4.1 方法区的概念
Method Area:方法区,各个线程共享的内存区域,主要存储类的信息、运行时常量池
方法区在虚拟机启动的时候创建,在虚拟机关闭时释放
如果方法区中的内存无法满足分配请求,则会抛出 OutOfMemoryError: Metaspace
本地内存指的是操作系统的内存
下面演示内存不够的情况,先看下面的类
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;
/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=8m
*/
public class MetaspaceDemo extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
MetaspaceDemo metaspaceDemo = new MetaspaceDemo();
for (int i = 0; i < 100000; i++) {
// ClassWriter 作用是生成类的二进制字节码
ClassWriter classWriter = new ClassWriter(0);
// 版本号, public, 类名, 包名, 父类, 接口
classWriter.visit(Opcodes.V17, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回 byte[]
byte[] code = classWriter.toByteArray();
// 执行了类的加载
metaspaceDemo.defineClass("Class" + i, code, 0, code.length); // Class 对象
}
}
}
运行以上代码后,发现控制台没有任何报错信息,因为元空间(MetaSpace)的大小是没有上限的
我们可以手动设置元空间的大小
第一步:编辑启动类的配置
第二步:添加虚拟机选项
在虚拟机选项中填入以下内容
-XX:MaxMetaspaceSize=8m
第三步:测试
运行代码后,在控制台就可以看到以下信息(java.lang.OutOfMemoryError: Metaspace)
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1012)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:874)
at cn.edu.scau.MetaspaceDemo.main(MetaspaceDemo.java:21)
5.4.2 常量池
常量池可以看作是一张表,虚拟机根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
我们还是以 打印 Hello World 的程序 为例来了解常量池,运行以下指令
javap -v Application.class
在输出的信息中找到与常量池相关的内容(关键字:Constant pool)
在控制台中找到与 main 方法有关的信息
我们来分析一下 #7 ,#7 指的就是常量表中的第 7 行,查看常量池的第 7 行,发现是一个字段引用(Print Stream),同时该字段引用又需要常量表的第 8 行和第 9 行的内容,常量表的第 9 行的类型类 Name And Type ,记录的是字段的名称和类型,同时常量表的第 9 行又需要常量表的第 11 行和第 12 行的内容
5.4.3 运行时常量池
常量池是 *.class
文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址(符号引用)变为真实的内存地址
5.4.4 可能遇到的问题
如果你在运行上述代码时遇到以下问题
Exception in thread "main" java.lang.IllegalAccessError: class cn.edu.scau.MetaspaceDemo (in unnamed module @0x4eec7777) cannot access class jdk.internal.org.objectweb.asm.ClassWriter (in module java.base) because module java.base does not export jdk.internal.org.objectweb.asm to unnamed module @0x4eec7777
at cn.edu.scau.MetaspaceDemo.main(MetaspaceDemo.java:15)
是因为在 Java 9 及以后的版本中,引入模块系统(JEP 261: The Java Module System)时导致的
错误信息表明 jdk.internal.org.objectweb.asm.ClassWriter
类位于 java.base
模块中,且该模块未向包含 cn.edu.scau.MetaspaceDemo
类的无名模块导出 jdk.internal.org.objectweb.asm
包
在 Java 中使用自定义类加载器加载类时,如果类使用到了内部类或一些私有 API ,可能会出现此类 IllegalAccessError
错误,这通常是因为模块系统限制了对某些类或包的访问权限
可以通过在运行 Java 程序时添加以下 JVM 参数来解决该问题:
--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED
如果你使用的是 Java 命令行,可以这样运行你的程序:
java --add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED -jar your-program.jar
5.5 直接内存
直接内存:并不属于 JVM 中的内存结构,不由 JVM 进行管理,是虚拟机的系统内存(也就是操作系统的内存),常见于 NIO 操作时,用作数据缓冲区,直接内存的分配回收成本较高,但读写性能非常高
我们先来看一个文件复制的案例——将一个文件分别采用穿透 IO和 NIO 的方式复制到另一个文件夹(文件大小为 23.9 MB)
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.NIO.ByteBuffer;
import java.NIO.channels.FileChannel;
public class DirectMemoryDemo {
private static final String FROM = "F:\\Blog\\jvm\\06-JVM组成-你听过直接内存吗.mp4";
private static final String TO = "F:\\Blog\\jvm\\video\\06-JVM组成-你听过直接内存吗.mp4";
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
io();
NIO();
}
private static void NIO() {
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
FileChannel to = new FileOutputStream(TO).getChannel();
) {
ByteBuffer bb = ByteBuffer.allocateDirect(_1MB);
while (true) {
int len = from.read(bb);
if (len == -1) {
break;
}
bb.flip();
to.write(bb);
bb.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.err.println("NIO 用时:" + (end - start) / 1000_000.0 + "ms");
}
private static void io() {
long start = System.nanoTime();
try (FileInputStream from = new FileInputStream(FROM);
FileOutputStream to = new FileOutputStream(TO);
) {
byte[] buf = new byte[_1MB];
while (true) {
int len = from.read(buf);
if (len == -1) {
break;
}
to.write(buf, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("IO用时:" + (end - start) / 1000_000.0 + "ms");
}
}
代码运行后,可以看到 NIO 的效率比 传统 IO高很多(文件越大越明显)
我们来分析一下为什么 NIO 的效率比 传统 IO 高
常规 IO 的数据拷贝流程
Java 本身并不具备与磁盘文件直接交互的能力,Java 要与磁盘进行交互的话,需要调用操作系统提供的函数(也就是本地方法),这个过程涉及到了 CPU 运行状态的转换
首先会从用户态切换到内核态,切换到内核态后,由 CPU 去读取磁盘中的文件,将文件内容放到系统缓冲区中(如果文件较大时,不会一次性读取到系统缓冲区中,而是分批次读取)
但 Java 代码是不能直接在系统缓冲区中对文件内容进行操作的,所以 Java 会在堆中分配一块内存,然后将系统缓冲区中的文件内容复制到刚分配的堆内存,这个时候 CPU 的运行状态转换为用户态,然后调用 Java 中的输入输出流进行操作
Java 对文件内容进行操作后,将文件内容放回系统缓冲区,让 CPU 将系统缓冲区中的文件内容保存到磁盘中
由于操作流程中有不必要的数据复制操作,常规 IO 的效率不是很高
NIO 的数据拷贝流程
直接内存相当于在操作系统中划分出一块缓冲区,这块缓冲区 Java 代码可以访问,操作系统也可以访问,是一块双方共享的内存区域
数据加入到直接内存之后,进行磁盘文件读写操作的时候,Java 操作代码将变得十分方便,比传统的 io 操作少了从缓冲区中复制文件内容的操作,速度自然也能够提升不少
6. 类加载器
6.1 什么是类加载器,类加载器有哪些
类加载器主要用于装载字节码文件( *.class
文件)
JVM 只会运行二进制文件,类加载器的作用就是将字节码文件加载到 JVM 中,从而让 Java 程序能够启动
类加载器有四种:
- 启动类加载器:主要加载
JAVA_HOME/jre/lib
目录下的库扩展 - 拓展类加载器:主要加载
JAVA HOME/jre/lib/ext
目录中的类 - 应用类加载器:主要加载
classPath
下的类 - 自定义类加载器(了解即可):继承自应用类加载器,实现自定义类加载规则
6.2 什么是双亲委派模型
双亲委派模型:加载某一个类时,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果上级加载器没有加载该类,那么子加载器才会尝试加载该类
6.3 JVM 为什么要采用双亲委派机制
- 双亲委派机制可以避免某一个类被重复加载,当上级加载器已经加载该类后,则无需子加载器重复加载,保证类的唯一性
- 为了安全,保证类库 API 不会被修改
假如我们自己定义了一个 String 类,包名也是 java.lang
在编译阶段就失败了,错误信息如下:
软件包 ‘lang’ 存在于另一个模块中: java.base
根据双亲委派机制的规则,java.lang.String
由启动类加载器时加载,因为在核心 jre 库中有其相同名字的类文件,所以在编译阶段就报错了
双亲委派机制在一定程度上可以防止恶意篡改核心 API
库(毕竟你写的 String 类大概率没有官方写的好用。。。)
6.4 类装载的执行过程
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了 7 个阶段:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
其中验证、准备和解析这三个部分统称为连接(linking)
6.4.1 加载
加载的流程:
- 根据类的全名获取类的二进制数据流
- 解析类的二进制数据流到方法区(相当于将类的信息存入方法区)
- 创建
java.lang.Class
类实例,表示该类型,作为这个类的各种数据在方法区中的访问入口
上面可能说的很抽象,下面是一个例子
现在有一个 Person 类,Person 类被加载后,就会存储到运行时数据区的两块区域中,一块是方法区(也就是元空间),存储的是 Person 类的信息(比如 Person 类的构造函数、方法、字段等),主要存储的是类的结构;另一块区域是堆,在堆中会开辟一块空间存储类的 Class 对象
等到创建对象的时候,比如现在有两个对象,一个是张三,一个是李四,这两个对象都是基于堆中的 Person.class 创建的,每个对象的对象头都指向了堆中的 Person.class ,但是类中的具体数据(比如方法、构造函数、字段等)需要通过方法区中的 Person.class 才能获取
6.4.2 验证
验证类是否符合 JVM 规范,主要是做安全性检查
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
前面三项进行的都是格式检查,比如文件格式是否错误、语法是否错误、字节码是否合规等
符号引用验证怎么理解呢,具体来说,Class 文件会在常量池会通过字符串记录自己将要使用的其他类或者方法,并检查这些类和方法是否存在
6.4.3 准备
类变量:用 static 修饰的变量
该阶段主要是为类变量分配内存并为类变量设置初始值,分为三种情况:
- static 变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
- static 变量是被 final 关键字修饰的基本类型。字符串常量(String 类底层的数据结构是一个被 final关键字修饰的字符数组),值已确定,赋值在准备阶段完成
- static 变量是被 final 关键字修饰的引用类型,那么赋值也会在初始化阶段完成
6.4.4 解析
把类中的符号引用改为直接引用
比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法
6.4.5 初始化
对类的静态变量,静态代码块执行初始化操作,初始化的规则如下:
- 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类
- 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行
大家可以运行以下代码,观察控制台的输出结果来加深对初始化规则的理解
public class InitializeDemo {
public static void main(String[] args) {
// 1. 首次访问这个类的静态变量或静态方法时
System.out.println(Animal.number);
// 2. 子类初始化,如果父类还没初始化,会引发父类先初始化
System.out.println(Cat.sex);
// 3. 子类访问父类静态变量,只触发父类初始化
System.out.println(Cat.number);
}
}
class Animal {
static int number = 55;
static {
System.out.println("Animal 静态代码块...");
}
}
class Cat extends Animal {
static boolean sex = false;
static {
System.out.println("Cat 静态代码块...1");
}
static {
System.out.println("Cat 静态代码块...2");
}
}
6.4.6 使用
JVM 开始从入口方法开始执行用户的程序代码:
- 调用静态类成员信息(例如静态字段、静态方法)
- 使用 new 关键字为其创建对象实例
6.4.7 卸载
当用户程序代码执行完毕后,JVM 就会开始销毁创建的 Class 对象
7. 垃圾回收
7.1 对象什么时候可以被垃圾回收器回收
如果某个对象没有任何的引用指向它,那么这个对象现在就是垃圾,如果对象被定位成垃圾,就可能会被垃圾回收器回收
有两种方式来确定某个对象是不是垃圾
- 引用计数法
- 可达性分析算法
7.1.1 引用计数法
一个对象被引用了一次,该对象就会递增一次引用次数,如果这个对象的引用次数为 0 ,代表这个对象可回收
引用计数法比较简单,但存在一定的问题
当对象之间出现了循环引用的情况,引用计数法就会失效,出现内存泄漏问题
7.1.2 可达性分析算法
现在的虚拟机都是通过可达性分析算法来确定哪些内容是垃圾
上图中,X、Y 这两个节点是可回收的
Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
扫描堆中的对象,看是否能够沿着 GC Root 对象为起点的引用链找到该对象,找不到,表示则表示对象可以回收
那哪些对象可以作为 GC Root 呢,主要有以下四种
第一种:虚拟机栈(栈帧中的本地变量表)中引用的对象
第二种:方法区中类静态属性引用的对象
第三种:方法区中常量引用的对象
第四种:本地方法栈中 JNI(Java Native Interface)引用的对象(了解即可)
7.2 垃圾回收算法有哪些
垃圾回收算法主要有三个:
- 标记清除算法
- 复制算法
- 标记整理算法
7.2.1 标记清除算法(用的比较少)
标记清除算法将垃圾回收分为2个阶段,分别是标记和清除
- 根据可达性分析算法得出的垃圾进行标记
- 对这些标记为可回收的内容进行垃圾回收
从上图可以看出标记清楚算法的优点和缺点:
- 优点:标记和清除速度较快
- 缺点:碎片化较为严重,内存不连贯
7.2.2 标记整理法
标记整理法与标记清除法类似,但标记整理法解决了标记清除算法的碎片化的问题
因为标记整理法比标记清除法多了一步,移动存活对象在内存中的位置(将存活的对象都向内存的某一端移动),当然,这一步对效率有一定的影响
很多老年代的垃圾回收算法都是采用标记整理法
7.2.3 复制法
复制法的主要思路就是将存活的对象复制到另一块内存区域中,然后清空原来的内存区域,复制的过程中就解决了碎片的整理过程
一般年轻代的垃圾回收算法采用的就是复制法
复制法的优点和缺点:
- 优点:在垃圾对象较多的情况下,效率较高,而且垃圾清理后没有内存碎片
- 缺点:将内存空间一分为二,但 2 块内存空间在同一个时刻,只能使用一个,内存使用率较低
7.3 JVM 的分代回收
分代收集算法
在 Java 8 中,堆被分成两个区域:新生代和老年代(1 : 2)
而在新生代内部,又划分了三个区域:Eden区(伊旬园区)、S0区(from)、S1区(to)
Eden区 : S0区 : S1 区 = 8 : 1 : 1
7.3.1 分代收集算法-工作机制
- 新创建的对象,都会先分配到 Eden 区
- 当 Eden区 内存不足时,标记 Eden区 与 from区 的存活对象
- 将存活对象采用复制算法复制到 to 中,复制完毕后, Eden 区和 from 区的内存都得到释放
- 经过一段时间后 Eden 区的内存又不足,标记 Eden 区 和 to 区存活的对象,将存活的对象复制到 from 区
- 当幸存区的对象熬过几次回收(最多15次)后,将晋升到老年代(如果幸存区内存不足或对象较大会导致提前晋升)
from 区域和 to 区域的角色一直在不断地交换,可参考复制法的原理
7.3.2 MinorGC、Mixed GC、FullGC 有什么区别
名词解释
STW:Stop The World,暂停所有线程,等待垃圾回收完成
- Minor GC【Young GC】:发生在新生代的垃圾回收,暂停时间短(STW)
- Mixed GC:新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
- FulI GC:新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免
7.4 JVM 有哪些垃圾回收器
在 JVM 中,实现了多种垃圾收集器,包括:
- 串行垃圾收集器
- 并行垃圾收集器
- CMS(并发)垃圾收集器
- G1垃圾收集器
7.4.1 串行垃圾收集器
Serial 和 Serial Old 串行垃圾收集器,是指使用单线程进行垃圾回收,适合堆内存较小的情况(个人电脑),在企业开发中很少用
- Serial 作用于新生代,采用复制算法
- Serial Old 作用于老年代,采用标记-整理算法
串行垃圾回收器在进行垃圾回收时,只有一个线程在工作,并且 Java 应用中除了垃圾回收线程以外的所有线程都要暂停(STW),等待垃圾回收的完成
7.4.2 并行垃圾收集器
Parallel New 和 Parallel Old 是一个并行垃圾回收器,JDK8 默认使用此垃圾回收器
- Parallel New 作用于新生代,采用复制算法
- Parallel Old 作用于老年代,采用标记-整理算法
并行垃圾收集器在进行垃圾回收时,多个线程在工作,并且 Java 应用中除了垃圾回收线程以外的所有线程都要暂停(STW),等待垃圾回收的完成
7.4.3 CMS(并发)垃圾回收器
CMS,全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好,其最大特点是在进行垃圾回收时,应用仍然能正常运行
7.4.4 G1 垃圾收集器
G1 垃圾收集器的内容较多,且较难理解,此处只记录了简略部分,具体可参考视频教程:G1 垃圾回收器
G1 垃圾收集器
- 应用于新生代和老年代,在 JDK 9 之后默认使用 G1 垃圾回收器
- 划分成多个区域,每个区域都可以充当 eden,survivor,old,humongous,其中 humongous 专为大对象准备
- 采用复制算法
- 响应时间与吞吐量兼顾
- 分成三个阶段:新生代回收、并发标记、混合收集
- 如果并发失败(即回收速度赶不上创建新对象速度)会触发 FuIl GC
7.5 强引用、软引用、弱引用、虚引用的区别
强引用、软引用、弱引用、虚引用的区别
章节的内容较多,且较难理解,此处只记录了简略部分,具体可参考视频教程:强引用、软引用、弱引用、虚引用的区别
强引用:只有在所有 GC Roots 对象都没有通过【强引用】引用该对象的情况下,该对象才能被垃圾回收(也就是通过所有的 GC Root 对象都找不到该对象)
软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收
弱引用:仅有弱引用引用该对象,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关
方法释放直接内存
8. JVM实践
8.1 如何设置 JVM 的参数
主要有两种设置方法:
- war 包部署在 tomcat 中的设置
- jar 包部署在启动参数设置
8.1.1 war 包部署在 tomcat 中的设置
修改 TOMCAT HOME/bin/catalina.sh
文件(Linux 环境下是修改 catalina.sh
文件,Windows 环境下是修改 catalina.bat
文件
8.1.2 jar 包部署在启动参数设置
通常在 Linux 系统下直接加参数启动 SpringBoot 项目
nohup java -Xms512m -Xmx1024m -jar xxx.jar --spring.profiles.active=prod &
指令说明:
nohup
:这个命令的作用是让Java进程在后台运行,即使当前用户会话结束(例如,用户登出)也不会影响Java进程java
:这是Java虚拟机的命令,用于启动Java应用程序-Xms512m
:这个参数指定了JVM启动时的初始堆大小(Initial Heap Size),即JVM初始分配给堆的内存大小为512MB-Xmx1024m
:这个参数指定了JVM最大堆大小(Maximum Heap Size),即JVM可以分配给堆的最大内存大小为1024MB-jar xxx.jar
:这个参数指定了一个JAR文件,JVM将会从这个JAR文件中加载应用程序--spring.profiles.active=prod
:这是一个Java应用程序的启动参数,它告诉应用程序使用名为prod
的环境配置。在Spring框架中,这个参数通常用于指定不同的配置文件,如开发环境(dev)、测试环境(test)和生产环境(prod)&
:这个符号用于将当前命令放入后台执行。当你在命令行中输入这个命令后,它会立即返回命令行提示符,表明Java应用程序已经开始在后台运行
8.2 JVM 有哪些参数可以调优
对于 JVM 调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型
官网:Java HotSpot VM
- 设置堆空间大小
- 虚拟机栈的设置
- 年轻代中 Eden 区和两个 Survivor 区的大小比例
- 年轻代晋升老年代阈值
- 设置垃圾回收收集器
8.2.1 调整堆空间的大小
通常是设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把堆空间的最大大小、堆空间的初始大小设置为相同的值
不指定单位默认为字节;指定单位,按照指定的单位设置
堆空间设置多少合适?
- 堆空间的最大大小的默认值是物理内存的 1/4,初始大小是物理内存的 1/64
- 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生 STW,暂停用户线程
- 堆内存大肯定是好的,但也存在风险,假如发生了 Full GC,它会扫描整个堆空间,暂停用户线程的时间长
- 设置参考推荐:尽量大,但也要考察一下当前计算机其他程序的内存使用情况
8.2.2 虚拟机栈的设置
虚拟机栈的设置:每个线程默认会开启 1M 的内存,用于存放栈帧、调用参数、局部变量等,但一般 256K 就够用了
通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统
-Xss
8.2.3 年轻代中 Eden 区和两个 Survivor 区的大小比例
设置年轻代中 Eden 区和两个 Survivor 区的大小比例,该值如果不设置,则默认比例为 8:1:1
通过增大 Eden 区的大小来减少 GC 发生的次数,但有时我们发现,虽然次数减少了,但 Eden 区满的时候,由于占用的空间较大,导致释放缓慢,此时 STW 的时间较长,因此需要按照程序情况去调优
-XX:SurvivorRatio=8
-XX:SurvivorRatIO= n
可以理解成对于每一个 Survivor 区,Eden 区的大小是其 n 倍,所以 SurvivorRatio=3 的时候,实际上 Eden 区和两个 Survivor 区的大小比就是 3:1:1
8.2.4 年轻代晋升老年代的阈值
threshold:阈值
-XX:MaxTenuringThreshold=threshold
- 默认为 15
- 取值范围 0-15
8.2.5 设置垃圾回收收集器
可以通过设置并行垃圾回收收集器,通过增大吞吐量提高系统性能
JDK 8 默认使用的是并行垃圾收集器(Parallel)
8.3 JVM 调优的工具
命令工具:
- jps(Java Process Status):Java Development Kit(JDK)的一部分,用于显示当前系统中运行的 Java 进程信息
- jstack:查看 Java 进程内线程的堆栈信息
- jmap:用于生成堆转内存快照、查看内存使用情况
- jstat:JVM 统计监测工具
可视化工具:
- jconsole:用于对 JVM 的内存,线程,类的监控
- VisualVM:能够监控线程、内存情况
8.3.1 jps
jps 指令主要用于查看系统中正在运行的 Java 进程信息
运行以下代码
public class ToolDemo {
public static void main(String[] args) {
new Thread(() -> {
while (true) {
}
}, "t1").start();
new Thread(() -> {
while (true) {
}
}, "t2").start();
new Thread(() -> {
while (true) {
}
}, "t3").start();
}
}
然后在终端中输入 jps 指令,查看系统中正在运行的 Java 进程信息(前面的数字是 pid,也就是进程 id)
8.3.2 jstack
查看 Java 进程内线程的堆栈信息
jstack [option] <pid>
我们在终端中输入以下指令
jstack 22780
终端中输出的信息较多,我们查找与刚才运行的 t1、t2、t3 线程相关的信息
8.3.3 jmap
用于生成堆转内存快照、查看内存使用情况
jmap -heap pid
显示 Java 堆的信息
显示 Java 堆的信息,format=b 表示以 hprof 二进制格式转储 Java 堆的内存,file=<filename>
用于指定快照dump文件的文件名
jmap -dump:format=b,file=heap.hprof pid
- dump 文件是一个进程或系统在某一给定的时间的快照,比如在进程崩溃时,甚至是任何时候,我们都可以通过工具将系统或某进程的内存备份出来供调试分析用
- dump 文件中包含了程序运行的模块信息、线程信息、堆栈调用信息、异常信息等数据,方便系统技术人员进行错误排查
我们在终端中执行以下指令
jmap -heap 22780
如果你遇到了以下问题,是因为从 Java 11 开始,jmap 和其他 JDK 工具(如 jstack、jinfo 等)被迁移到了 jhsdb 工具集中。因此,在较新的 Java 版本中,你需要使用 jhsdb jmap
命令
Error: -heap option used
Cannot connect to core dump or remote debug server. Use jhsdb jmap instead
jhsdb jmap --heap --pid=22780
控制台中输出的信息较多,我们摘取某个片段来分析一下
Garbage-First (G1) GC with 10 thread(s) // 当前 JVM 使用的是 G1 垃圾回收器
Heap Configuration:
MinHeapFreeRatIO = 40 // 空闲堆空间的最小百分比
MaxHeapFreeRatIO = 70 // 空闲堆空间的最大百分比
MaxHeapSize = 4200595456 (4006.0MB) // 堆空间允许的最大值
NewSize = 1363144 (1.2999954223632812MB) // 新生代堆空间的默认值
MaxNewSize = 2518679552 (2402.0MB) // 新生代堆空间允许的最大值
OldSize = 5452592 (5.1999969482421875MB) // 老年代堆空间的默认值
NewRatIO = 2 // 新生代与老年代的堆空间比值,此处的2表示新生代:老年代=1:2
SurvivorRatIO = 8 // 两个 Survivor 区和 Eden 区的堆空间比值为8,表示S0:S1:Eden=1:1:8
MetaspaceSize = 22020096 (21.0MB) // 元空间的默认值
CompressedClassSpaceSize = 1073741824 (1024.0MB) // 压缩类使用空间大小
MaxMetaspaceSize = 17592186044415 MB // 元空间允许的最大值
G1HeapRegionSize = 2097152 (2.0MB) // 在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小
剩余信息可自行研读
我们再次在终端输入以下指令
jmap -dump:format=b,file=F:\Blog\jvm\heap.hprof 22780
可以看到,dump 文件已经生成了,那这个文件要怎么打开呢,后面会说到(会有专门的可视化工具打开该文件)
8.3.4 jstat
JVM 统计监测工具,可以用来显示垃圾回收信息、类加载信息、新生代统计信息等
jstat -gcutil pid
总结垃圾回收统计
jstat -gc pid
垃圾回收统计
我们在终端中输入以下指令
jstat -gcutil 22780
可以看到以下信息
我们在终端中输入以下指令
jstat -gc 22780
可以看到以下信息
8.3.5 jconsole
一个基于 jmx 的 GUI 性能监控工具,用于监控 JVM 的内存、线程、类
打开方式:Java 安装目录 的bin 目录下,双击启动 jconsole.exe
找到对应的进程,选择后点击连接按钮,然后点击不安全的连接
连接后可以看到概览,内存的使用情况,还能检测死锁
8.3.6 VisualVM
能够监控线程,内存情况,查看方法的 CPU 时间和内存中的对象,已被 GC 的对象,反向查看分配的堆栈
打开方式:Java 安装目录 的 bin 目录下,双击 jvisualvm.exe
文件启动 VisualVM
注意:在高版本的 JDK 中(JDK >= 9) 已经移除了该文件,如果高版本的 JDK 需要使用该工具,需要额外下载,下载地址:VisualVM: Download,下载完成后双击 bin 目录下的 visualvm.exe
文件启动 VisualVM
在 VisualVM 中打开 dump 文件
8.3.7 VisualVM 汉化
VisualVM 的界面默认是英文的,而且没有更改语言的选项,但是已经有前辈帮我们做好了 VisualVM 的汉化工作
VisualVM 汉化版的下载地址:VisualVM-汉化版-v2.1.8-1
但是该版本有点问题,直接双击 visualvm.exe
文件会报以下错误
因为该版本不是官方发布的可能会有点小问题(具体问题可能是无法检测到 JAVA_HOME 环境变量)
我们可以在 visualvm.exe
文件所在的目录下新建visualvm.bat
文件,文件内容如下
visualvm.exe --jdkhome %JAVA_HOME%
双击visualvm.bat
文件就可以启动 VisualVM 了
8.4 内存泄漏的排查思路
重点关注OutOfMemoryError:java heap space
排查步骤:
- 获取堆内存快照 dump 文件
- 使用 VisualVM 可视化工具打开 dump 文件(具体可参考本文的 8.3.6 VisualVM)
- 通过查看堆信息的情况,定位内存溢出问题可能是哪几行代码导致的
- 找到对应的源代码,阅读上下文的情况,修复内存泄露的问题
获取堆内存快照 dump 文件有两种方式
8.4.1 使用 jmap 命令获取运行中程序的 dump 文件
jmap -dump:format=b,file=heap.hprof pid
8.4.2 使用 vm 参数获取 dump 文件
有的情况是内存溢出之后程序则会直接中断,而 jmap 只能打印在运行中的程序,所以可以通过参数的方式生成 dump 文件
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=dump文件的保存路径
8.5 CPU 飚高的排查方案与解决思路
第一步:使用 top 命令查看 CPU 的使用情况
第二步:在 top 命令的输出信息中查看是哪一个进程 CPU占用率较高,并记录该进程的 pid
第三步:查看进程中的线程信息
ps H -eo pid,tid,%cpu | grep 某个进程的pid
指令解释:
ps H
:ps
是process status
的缩写,用于显示当前运行的进程。H
选项表示显示进程的线程信息-eo pid,tid,%cpu
:这是ps
命令的格式化输出选项,-e
表示选择所有进程,-o
后面跟着的是要显示的字段,这里指定了进程 ID(pid)、线程 ID(tid)和 CPU 使用率(%cpu)
第四步:根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号(先将十进制的线程 id 转换为十六进制的线程 id,接着在控制台找到有问题的线程,可以使用 printf "%x\n" id
指令来将十进制的线程 id 转换为十六进制的线程 id)
jstack 某个进程的pid
8.6 解决 CPU 飚高问题的简单案例
下面给出一个解决 CPU 飚高问题的简单示例
打包前先在 pom.xml
文件中添加以下打包插件,同时指定 mainClass
属性(如果是 SpringBoot
项目,pom.xml 文件中会自带一个打包插件,无需添加)
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>cn.edu.scau.ToolDemo</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
我们将 Java 程序打包成 jar 包(可以在 target 目录下找到该 jar 包)
接着进入 Linux 服务器的 /tmp
目录
cd /tmp
将 jar 文件上传到 /tmp
目录下,运行以下命令启动 jar 包
nohup java -jar jvm-1.0-SNAPSHOT.jar &
运行 jar 包后可以看到 CPU 的使用率瞬间达到了 100%
我们使用 top 命令查看 CPU 的使用情况,按下 C 键,让进程按照 CPU 的使用率从高到低排列(再次按下 C 键进程将按照 CPU 的使用率从低到高排列
可以看到 CPU 占用率排在第一位的是我们刚启动的 Java 程序
我们记录下该进程的 PID,然后按下 CTRL + C
键退出监控页面
接着输入以下指令查看进程中的线程信息
ps H -eo pid,tid,%cpu | grep 1741941
可以看到有三个线程的 CPU 占用率非常高
我们以线程 id 为1741965 的线程为例,定位到问题代码的源码行号
先将十进制的线程 id 转换为十六进制
printf "%x\n" 1741965
然后输入以下指令查看查看 Java 进程内线程的堆栈信息(注意:jstack 后面紧跟的是 PID)
jstack 1741941
然后根据这个十六进制的线程 id 定位到问题代码的源码行号
查看 ToolDemo.java
类的源代码,发现第 8 行代码是一个死循环
至此就找到了 CPU 飚高的原因
最后,记得杀掉该 Java 进程,让 CPU 恢复正常运转
sudo kill 1741941