.java-> 编译->.class(字节码文件)-> 解释-> 机器码
字节码解释成机器码是实时进行的,从而导致每次执行时都需要解释,这也是 java 性能不如 c/c++ 的原因之一。这样做是为了实现跨平台
即时编译(JIT, just-in-time):将解释出来的机器码保存到内存中,再次执行时直接调用
JVM 组成:
- 类加载器:负责加载字节码文件
- 运行时数据区:JVM 管理的内存
- 执行引擎:包含即时编译器、解释器、垃圾回收器等,将字节码文件中的指令解释成机器码,同时使用即时编译器优化性能
- 本地接口:调用本地已经编译的方法(native 方法)
字节码文件的组成
- 基础信息
- 常量池
- 字段
- 方法
- 属性
魔数
即文件头。Java 中称为魔数,其他文件称为文件头
1.2 之后,大版本号=主版本号-44;如 50 对应 jdk1.6
常量池
用于节省空间;索引从 1 开始
package org.example.local;
public class Test {
private void test() {
String a = "abc";
String abc = "abc";
}
}
对于上面的类文件,常量池中保存了类名、方法名、变量名、父类名(Object)、包名 等等,还包括隐藏的 init 方法、this 变量。
- 为什么常量池中即保存了字面量 a、abc,还保存了一个值,跳转到字面量 abc?
符号引用
通过编号引用到常量池的过程
方法
操作数栈:临时存放数据的地方
局部变量表:存放方法中局部变量的位置
FinalShell 服务器连接工具
jclasslib 字节码查看工具,IDEA 有相应插件
常见字节码指令
iconst_ 将 int 类型的值 i(i [-1,5])放到操作数栈中, 等价于 bipush <i>
(无范围限制)
istore_ 弹出操作数栈的数,放到局部变量表中。n 为局部变量表索引,从 0 开始
iload_ 取出局部变量表中的数,压入操作数栈。局部变量表中值不会清空
iinc by 将局部变量表中的数增加 i
iadd
代码分析:下面 2 种自增方式,哪种效率更高?
i++;
i += 1;
++ 与 +=对应的字节码指令都是 iinc
,因此效率相同
常用命令
javap 查看用法
javap -v 输出附加信息
javap -version 查看版本
jar -xvf 解压 jar 包
Arthas 线上监控诊断
进入后输入应用序号即可查看应用信息
dashboard - 当前系统的实时数据面板
- dashboard -i 2000 -n 3 每 2 秒刷新一次,共刷新 3 次
dump - dump 已加载类的 byte code 到特定目录
jad - 反编译指定已加载类的源码
IDEA http 请求插件
类的生命周期
生命周期
加载
根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息
不同渠道包括:本地文件,动态代理生成,网络传输
jdk 自带 hsdb 工具
启动:java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
- jar 包中有多个启动类时,需要手动指定
jps:查看所有 java 程序的进程 id 及类名
连接
验证:验证内容是否满足《Java 虚拟机规范》
准备:给静态变量赋初值
- 比如 int 0,boolean false
解析:将常量池中的符号引用替换成指向内存的直接引用
初始化
执行静态代码块中的代码,并为静态变量赋值
执行字节码文件中 clinit 部分的字节码指令(cl 即 class)
clinit 不出现的情况
-
无静态代码块且无静态变量赋值语句
-
有静态变量,但是没有赋值语句
-
静态变量使用 final 关键字修饰
- 有 final 关键字,但是用静态代码块赋值有没有 clinit?
jvm 启动参数 -xx:+TraceClassLoading
:打印出加载并初始化的类
代码分析:a,b 最终结果分别是多少?
public class Test3 {
static {
b = 2;
}
private static int a = 1;
private static int b = 1;
static {
a = 2;
}
}
最终 a=2,b=1。静态变量的赋值是在初始化阶段完成的,且执行顺序是从上到下。静态代码块跟直接赋值都赋值语句,都是从上到下执行
初始化场景
-
访问一个类的静态变量或者静态方法
- 注意:final 修饰并且等号右边是常量时不会触发初始化
-
调用 Class.forName(String className)
- 另一个重载方法通过参数控制是否执行初始化
-
new 类对象
-
执行 main 方法的当前类
代码分析:下面两段代码输出结果分别是什么?
DACBCB
直接访问父类的静态变量,不会触发子类的初始化
子类的初始化 clinit 调用之前,会先调用父类的 clinit 初始化方法
public static void main(String[] args) {
// 0;如果先调用 new Son(),则结果为1
System.out.println(Son.a);
// 0
System.out.println(Parent.a);
}
public class Parent {
static int a = 0;
{
a = 1;
}
}
public class Son extends Parent {
static {
a = 2;
}
}
创建数组时不会导致初始化
下面代码会将初始化放到 clinit 方法中,即赋值语句会出现在 clinit 中
public static final int a = Integer.valueOf(1);
使用
卸载
1
类加载器
相关应用
- SPI 机制
- 类的热部署
- Tomcat 类的隔离
- 类的双亲委派机制,怎样打破双亲委派机制
- 自定义类加载器
- 使用 Arthas 实现不停机更新
分类
按实现可以分类 Java 代码实现和虚拟机底层实现
类加载器的设计,jdk8 和 8 之后的版本差别较大。
jdk8 及之前的版本中默认有以下几种类加载器:
-
启动类加载器 BootstrapClassLoader:加载 Java 中最核心的类
- 默认加载 Java 安装目录 /jre/lib 下的类文件,比如 rt.jar tools.jar resources.jar
- 由于是底层实现的,因此在 Java 代码中诸如通过 getClassLoader 的方式获取不到加载器对象,获取时结果是 null
-
扩展类加载器 sum.misc.Launcher$ExtClassLoader:允许扩展 Java 中比较通用的类
- 默认加载 Java 安装目录 /jre/lib/ext 下的类文件
-
应用程序类加载器 sun.misc.Launcher$AppClassLoader:加载应用使用的类
- 加载的类包含应用程序中用户写的类及第三方 jar 中的类
- 也会加载 /jre/lib 和 /jre/lib/ext 中的类
- 怎样通过启动类加载器去加载用户类文件?
-
打成 jar 包放到 /jre/lib 下,交给启动类加载器去加载
- 不推荐,因为有命名检查,当不符合规范时不会被加载
-
使用 JVM 参数扩展
-Xbootclasspath/a:jar
包目录 /jar 包名进行扩展
- 怎样通过扩展类加载器去加载用户类文件?
-
放入 /jre/lib/ext 下,不推荐
-
使用
-Djava.ext.dirs=jar包目录
- 这种方式会覆盖原始目录,可以用
;(windows):(macos/linux)
追加原始目录
- 这种方式会覆盖原始目录,可以用
双亲委派机制
作用
- 保证类加载的安全性:通过双亲委派机制,避免恶意代码替换 JDK 中的核心类库,确保核心类库的完整性和安全性
- 避免重复加载:双亲委派机制可以避免一个类被多次加载
加载流程
- 当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载
-
每个类加载器都有一个父类加载器,在类加载的过程中,类加载器都会先检查自己是否已经加载了该类,如果已经加载了则直接返回;否则将加载请求委派给父类加载器,父类再检查并委派给父类
-
当类加载器没有父类时,则检查自己是否加载过该类,加载过则返回;没加载过则由自己来尝试加载,检查类路径是否在自己的加载目录中;在则进行加载,不在则原路向下找到子加载器,交给子类加载器加载
-
3 种类加载器的优先级关系自上到下为:启动类加载器,扩展类加载器,应用程序类加载器
- 优先级关系并非继承关系,而是通过成员变量 parent 来找到父加载器
- 扩展类加载器的 parent=null,因为启动类加载器对象不在 Java 堆中
问题:
-
所有类都会交给所有类加载器来加载一遍吗?为什么 /jre/lib 中的类同时被启动类加载器和应用程序类加载器加载了?
- 这是因为 /jre/lib 也出现在应用程序类加载器路径中吗?
-
自己写一个
java.lang.String
类,能被加载吗- 应用程序类加载器把
java.lang.String
交给启动类加载器,启动类加载器发现已经加载过位于 rt.jar 中的java.lang.String
类,则返回 rt.jar 中的,并不会加载用户自己编写的
- 应用程序类加载器把
-
加载一个不存在的类会怎么样
- 会抛出
java.lang.ClassNotFoundException
异常
- 会抛出
-
为什么只有父类加载器,但是被称为“双亲委派机制”?
- parent 翻译过来为“双亲”
如何打破双亲委派机制
三种方式
- 自定义类加载器
- 线程上下文类加载器
- Osgi 框架的类加载器
自定义类加载器
自定义类加载器并重写 loadClass 方法,去除双亲委派机制的代码
Tomcat 通过这种方式实现应用之间类隔离
- 一个 Tomcat 程序中可以运行多个 web 应用,如果两个 web 应用中出现了相同限定名的类,不打破双亲委派机制,将导致只有其中一个能被正常加载
- Tomcat 使用了自定义类加载器来实现应用之间类的隔离,第一个应用都会有一个独立的类加载器来加载对应的类,且不会走双亲委派机制
自定义类加载器默认 parent 为 AppClassLoader
问题:
-
两个自定义类加载器加载相同限定名的类,会冲突吗
- 不会冲突。在同一个 Java 虚拟机中,只有相同类加载器 + 相同类限定名,都会被认为是同一个类
-
怎样指定一个类只被自定义类加载器加载,而不会被其他类加载器先加载?
线程上下文类加载器
利用上下文类加载器加载类,比如 JDBC 和 JNDI
SPI 机制:Service Provider Interface,JDK 内置的一种服务提供发现机制
工作原理
- 在 classpath 路径下的 META-INF/services 文件夹中,以接口的全限定名来命名文件,文件内容为该接口的实现
- 使用 ServiceLoader 加载实现类
JDBC 中由启动类加载器加载核心类库中的 DriverManager,初始化 DriverManager 时,通过 SPI 机制加载 jar 包中的数据库驱动。数据库驱动中的类应该由应用程序类加载器来加载,应用程序类加载器是通过线程上下文 Thread.currentThread().getContextClassLoader()
来获取
JDBC 加载数据库驱动的过程中实际上没有打破双亲委派机制
- JDBC 只是在 DriverManager 加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制
Osgi 框架的类加载器
历史上 Osgi 框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载
运行时数据区
划分
- 虚拟机栈
- 本地方法栈
- 程序计数器
- 堆
- 方法区
前三者线程不共享,后两者线程共享;程序计数器不会发生内存溢出
Java 虚拟机栈
- 采用栈的数据结构来管理方法调用中的基本数据,每个方法的调用使用一个栈帧来保存
栈帧组成
- 局部变量表:运行过程中存放所有的局部变量
- 操作数栈:执行指令过程中存放临时数据
- 帧数据:动态链接,方法出口,异常表的引用
局部变量表
- 栈帧中的局部变量表是一个数组,数组中每个位置称之为槽(slot),long 和 double 类型占用两个槽,其他类型占用一个槽
属性说明
-
Nr.:变量序号,从 0 开始
-
起始 PC:变量从第几行字节码指令开始可以被使用
- 只有完成赋值后才能被使用,在赋值指令的下一行开始可以被使用
-
长度:可被使用的字节码指令行数
- 赋值指令的下一行为能被使用的第一行
-
序号
- 槽的起始编号
-
名字
保存内容
-
包括:实例方法的 this 对象,方法参数,方法体中声明的局部变量
-
局部变量表中的槽是可复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用
- 起始 PC 和长度的作用可以体现在这里
操作数栈
桢数据
动态链接
- 保存了编号到运行时常量池的内存地址的映射关系。当前类的字节码指令引用了基类的属性或方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址
方法出口
- 存储此方法出口的地址。方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址
异常表
- 代码中异常的处理信息,包含了 try 代码块和 catch 代码块执行后跳转到的字节码指令位置
修改 + 栈大小 -Xss栈大小
,默认单位字节(使用字节作为单位时,必须为 1024 的位数,否则启动会报错)
- 如
-Xss1m
- 也可以使用
-XX:ThreadStackSize=1024
来配置
HotSpot JVM 对栈大小的最大值和最小值有要求,Windows(64 位)下限 180k,上限 1024m
- 超出这个范围时会不生效
本地方法栈
- 在 HotSpot 虚拟机中,Java 虚拟机栈和本地方法栈实现上使用了同一个栈空间
堆
内存占用
-
userd 已使用,max 当前最大内存,total 可分配最大内存
- 并不是 used=max=total 时才会内存溢出
-
默认下系统内存占比为 max=1/4,total=1/64
- 设置初始 total
-Xmssize
,如-Xms1m
;必须大于 1MB - 设置 max
-Xmxsize
;必须大于 2MB
- 设置初始 total
arthas 中的 heap 内存使用了 JMX 技术中的内存获取方式,这种方式与垃圾回收器有关,计算的是可分配对象的内存,而不是整个内存。因此看起来跟参数设置的不完全相同
方法区
组成
- 类的元信息:保存了所有类的信息
- 运行时常量池:保存了字节码文件中的常量池内容
- 字符串常量池:保存了字符串常量
-
存储每个类的基本信息(元信息),一般称之为 InstanceKlass 对象。在类的加载阶段完成
-
方法区可以包括:基本信息,常量池,字段,方法,虚方法表
- 常量池、方法会单独使用一块内存来存储,而非使用方法区存储;方法区中只保存了引用
- 虚方法表是实现多态的基础
-
方法区是《Java 虚拟机规范》中设计的虚拟概念,每款 Java 虚拟机在实现上都可能不同
字符串常量池
- 存储在代码中定义的常量字符串内容
字符串对象相加,底层是通过 StringBuider 来实现,相加的结果创建了对象,存放到堆中;如果对字符串常量相加,编译器会去掉加号,直接把结果存到字符串常量池中
String a = "1";
String b = "2";
System.out.println(a + b == "12"); // false
System.out.println(a + "2" == "12"); // false
System.out.println("1" + "2" == "12"); // true
- java.lang.String#interrn() 会把字符串放到常量池中,并返回引用
JDK7 及之后由于字符串常量池在堆上,所以 intern() 方法会把第一次遇到的字符串引用(而非字面量)放到字符串常量池中;而 JDK 6 及以前,intern() 方法是把堆中的字符串复制到常量池中,再返回引用
- 对于
java
之类的字符串,在程序启动过程中就已经放到了字符串常量池中 - 对于 JDK8,由于程序启动过程中已经把
java
字面量存到了字符串常量池中,因此s2.intern()
返回的是字符串常量池中java
的引用,而s2
是堆中的引用,因此二者不等;但是s1.intern()
返回的是字符串常量池中的引用,此引用指向堆中的thing123
,与s1
相等
// JDK6 false false
// JDK8 true false
String s1 = new StringBuilder().append("think").append("123").toString();
System.out.println(s1.intern() == s1);
String s2 = new StringBuilder().append("ja").append("va").toString();
System.out.println(s2.intern() == s2);
直接内存
直接内存不属于 Java 运行时的内存区域。在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决 2 个问题
- Java 堆中对象不再使用时会回收,而回收会影响对象的创建和使用
- IO 操作先把文件读入直接内存(缓冲区),再复制到 Java 推中。如果直接从直接内存中读取,可减少复制的开销
垃圾回收
- 线程不共享的部分(程序计数器,虚拟机栈,本地方法栈),伴随着线程的创建而创建,线程的销毁而销毁。方法的栈帧在执行完方法后就会自动弹出栈并释放掉对应的内存
方法区垃圾回收
同时满足以下 3 个条件时,类才可以被卸载
- 此类的所有实例对象都已经被回收,堆中不存在任何实例对象及子类对象
- 加载该类的类加载器已经被回收
- 该类对应的 java.lang.Class 对象没有在任何地方被引用
手动触发垃圾回收:System.gc()
调用后不一定会立即执行回收,仅仅是向 Java 虚拟机发送一个垃圾回收的请求,是否执行由 Java 虚拟机自行判断
-
用户编写的类由应用程序类加载器来加载,此加载器对象不会被回收,因此一般情况下用户编写的类不会被回收
-
也有会出现回收的情况,比如 OSGi、JSP 的热部署等应用场景中
- 每个 JSP 文件对应一个唯一的类加载器,当一个 JSP 文件被修改了,就直接卸载其类加载器;重新创建类加载器,重新加载 JSP 文件
堆回收
- 对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,就不允许回收
堆回收
判断堆上的对象有没有被引用
引用计数法
为每个对象维护一个引用计数器,对象被引用时 +1,取消引用时-1。为 0 时则没有被引用
缺点:
- 要维护计数器,对性能有一定影响
- 无法解决循环引用的问题
可达性算法
将对象分为两类:垃圾回收的根对象(GC Root,正常情况不可被回收)和普通对象,对象与对象之间存在引用关系
GC Root 对象分类:
-
线程 Thread 对象
- 引用了线程栈帧中的方法参数、局部变量等
-
系统类加载器加载的 java.lang.Class 对象
-
监视器对象,用来保存同步锁 synchronized 关键字持有的对象
-
本地方法调用时使用的全局对象
HotSpot 虚拟机选择可达性算法来判断对象引用
五种对象引用
强引用
- 可达性算法中描述的对象引用,一般指的是强引用,即 GCRoot 对象对普通对象有引用关系
软引用
- 相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当和程序内存不足时,就会将软引用中的数据进行回收
- JDK1.2 之后提供了 SoftReference 类来实现软引用
- 软引用常用于缓存中
使用过程
-
将对象用软引用包装起来
byte[] bytes = new byte[1024]; SoftReference<byte[]> softReference = new SoftReference<>(bytes);
-
内存不足时,虚拟机尝试进行垃圾回收
-
垃圾回收仍不能解决内存不足的问题,进一步回收软引用中的对象
-
如果内存依然不足,招聘 OutOfMemory 异常
弱引用
- 弱引用包含的对象在垃圾回收时,不管内存够不够都会直接回收
- JDK1.2 之后提供了 WeakReference 类来实现弱引用
- 弱引用主要在 ThreadLocal 中使用
- 弱引用对象本身也可以使用引用队列来回收
虚引用
- 又叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象
- 常规开发中不会使用,唯一用途是当对象被垃圾回收器回收时可以接收到对应的通知
- JDK1.2 之后提供了 PhantomReference 类来实现虚引用
- 直接内存为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现
终结器引用
-
常规开发中不会使用
-
对象需要被回收时,终结器引用会关联对象并放置在 Finalizer 类中的引用队列中,稍后由一条由 FinalizerThread 线程从队列中获取对象,并执行对象的 finalize 方法;在对象第二次被回收时,该对象才真正被回收
- 第一次回收:用终结器引用关联对象
- 第二次回收:真正回收
-
在 finalize 方法中可以再次将自身对象使用强引用关联上,从而实现“自救”不被回收
- finalize 方法的注释写明了,对象的 finalize 方法只会被调用一次
垃圾回收算法
工作:
- 找到内存中存活的对象
- 释放不再存活的对象,使程序能再次利用这部分空间
垃圾回收算法分类
- 标记-清除算法
- 复制算法
- 标记-整理算法
- 分代 GC
标记-清除算法
- 标记阶段:将所有存活的对象进行标记
使用可达性算法,从 GC Root 开始通过引用链遍历出所有存活对象
- 清除阶段:从内存中删除没有被标记,也就是非存活对象
优点:
- 实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可
缺点
- 碎片化问题:在对象被删除后,内存中会出现很多细小的可用内存单元
- 分配速度慢:由于内存碎片的存在,需要维护一个空闲链表,极有可能每次需要遍历到链表的最后才能获得合适的内存空间
复制算法
核心思想
- 准备两块空间 From 和 To,每次在对象分配阶段,只能使用其中一块(From)
- 在 GC 阶段,将 From 中存活对象复制到 To
- 结束阶段,将 From 和 To 名字互换
优点
- 吞吐量高
- 不会发生碎片化
缺点
- 内存使用率低
标记-整理算法
核心思想
- 标记阶段:将所有存活的对象进行标记
- 整理阶段:将存活对象移动到堆的一端
优点
- 内存使用率高
- 不会发生碎片化
缺点
- 整理阶段效率不高
分代 GC
核心思想
- 将堆内存区域划分为年轻代(Young)、老年代(Old),年轻代又分为 Eden、Surviror,Survivor 又分为 S0、S1(即复制算法的 From 和 To)
- 新创建的对象会放入年轻代的 Eden 区,当 Eden 区满后,触发 Young GC(Minor GC),回收 Eden 区和 From,存活的对象放入 To
- 新创建的对象年龄为 0,每次 Minor GC 后存活的对象年龄 +1;当年龄达到阈值(上限 15),将对象移动到老年代
- MinorGC 后如果 To 区空间不足,对象会直接进入老年代
- 当老年代空间不足,会触发 Full GC
- Full GC 后仍然空间不足,则会抛出 OutOfMemoryError
为什么分代 GC 算法要把堆分成年轻代和老年代
- 系统中的大部分对象存活时间都比较短,老年代存放长期存活的对象
- 新生代和老年代可以使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除、标记-整理算法
- 分代的设计中允许只回收新生代,能避免每次都对整个堆进行回收,可有效减少 STW 时间
- 可以通过调整年轻代、老年代比例来适应不同类型的程序,提高内存的利用率和性能
垃圾回收器
分类
-
G1:同时适用于年轻代和老年代
-
年轻代垃圾回收器
- Serial
- ParNew
- Parallel Scavenge
-
老年代垃圾回收器
- CMS
- Serial Old
- Parallel Old
Serial
- 是一种单线程串行回收年轻代的垃圾回收器
- 适用于 Java 编写的客户端程序或者硬件配置有限的场景
SerialOld
- 是 Serial 垃圾回收器的老年代版,采用单线程串行回收
- 使用标记-整理算法
- 与 Serial 垃圾回收器搭配使用,或者在 CMS 特殊情况下使用
ParNew
- 本质是对 Serial 在多 CPU 下的优化,使用多线程进行垃圾回收
- 适用于 JDK8 及之前的版本中,与 CMS 老年代垃圾回收器搭配使用
CMS(Concurrent Mark Sweep)
- CMS 垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间
- 使用标记清除算法
- 适用于大型互联网系统中用户请求数据量大、占频率高的场景
执行步骤
- 初始标记,用极短的时间标记出 GC Roots 能直接关联到的对象
- 并发标记,标记所有对象,用户线程不需要暂停
- 重新标记,由于并发标记阶段有些对象会发生变化,存在错标、漏标等情况,需要重新标记
缺点
- 使用了标记-清除算法,在垃圾回收结束后会出现大量内存碎片,CMS 会在 Full GC 时进行碎片的整理,从而导致用户线程暂停
- 无法处理在并发整理过程中产生的“浮动垃圾”,不能做到完全的垃圾回收
- 如果老年代内存不足无法分配对象,CMS 就会退化成 Serial Old 单线程回收老年代
Parallel Scavenge
- JDK8 默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量
- 具备自动调整堆内存大小的特点
- 允许手动设置最大暂停时间和吞吐量;Oracle 官方建议在使用 Parallel Scavenge+Parallel Old 组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小
- 适用于后台任务,不需要与用户交互并且容易产生大量的对象
Parallel Old
- Parallel Scavenge 的老年代版本,利用多线程并发收集
- 使用标记整理算法
- 适用于与 Parallel Scavenge 搭配使用
G1(Garbage first)
- 是 JDK9 之后默认的垃圾回收器
- 支持巨大的堆空间回收,并有较高的吞吐量
- 支持多 CPU 并行垃圾回收
- 允许用户设置最大暂停时间
G1 的整个堆会被划分成多个大小相等的区域,称之为区 Region。区域不要求是连续的。Region 的大小可以通过堆空间大小/2048 得到,也可以通过参数指定。Region size 必须是 2 的指数幂,取值范围从 1M 到 32M
G1 垃圾回收有两种方式:
- 年轻代回收(Young GC)
- 混合回收(Mixed GC),同时回收年轻代和老年代
流程
-
新创建的对象会存放在 Eden 区;当年轻代区较多(默认 Eden+Survivor 超过所有 Region 的 60%),无法分配对象时需要执行 Young GC
-
标记 Eden 和 Survivor 区域中的存活对象,根据配置的最大暂停时间,选择某些区域将存活对象复制到一个新的 Survivor 区中,清空这些区域
- 在 Young GC 的过程中记录每次垃圾回收时 Eden 区和 Survivor 区的平均耗时,根据配置的最大暂停时间,从而计算出本次最多回收多少个 Region 区域
-
后续 Young GC 时与之前相同,只不过 Survivor 区中的存活对象会被搬到另一个 Survivor 区
-
当某个存活对象年龄达到阈值,将放入老年代
-
如果对象大小超过 Region 的一半,会直接放入到老年代,这类老年代被称为 Humongous 区;对象过大时可横跨多个 Region
-
多次回收之后会出现很多老年代区,当占有率达到总堆阈值(默认 45%)会触发混合回收 MixedGC,回收所有年轻代和部分老年代的对象以及大对象区
- 采用复制算法
- 对老年牮清理会选择存活度最低(存活对象数量最少,还是存活对象占用空间最少?)的区域来进行回收,从而保证回收效率最高
-
如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现 Full GC,采用单线程执行标记-整理算法,此时会导致用户线程的暂停,所以尽量保证堆内存有多余的空间
内存泄漏
- 如果一个对象不再使用,但是依然在 GC Root 的引用链上,就不会被垃圾回收器回收,这种情况就称之为内存泄漏
- 内存泄漏绝大多数情况是由堆内存泄漏引起的
- 持续的内存泄漏会导致内存溢出
内存监控
top
默认根据 CPU 使用率倒序排序
- RES 常驻内存,包含了共享内存
- SHA 共享内存
- M 根据内存倒序排序
VisualVM
- 在 Oracle JDK6~8 中发布,Oracle 9 之后不在 JDK 安装目录中,需单独下载
- IDEA 插件:VisualVM Launcher,可快速启动本地的 VisualVM
Arthas
tunnel:管理所有需要监控的程序
使用步骤:
- 在 Spring Boot 程序中添加 arthas 的依赖(仅支持 Spring Boot2),在配置文件中添加 tunnel 服务端的地址
- 部署 tunnel 服务端程序并启动
- 启动 Java 程序
- 打开 tunnel 的服务端页面,查看进程列表,并选择进程进行 arthas 操作
Prometheus+Grafana
- 企业运维常用的监控方案,其中 Prometheus 用来采集系统或者应用的数据,同时具备告警功能;Grafana 将采集的数据以可视化的方式展示出来
内存泄漏的场景
Map 的 key 选取不当
- 用作 key 的对象没重写 equals、hashCode 方法时,预期 put 操作是覆盖原 key,实际上是添加一个新 node;且 map 始终不会释放
内部类引用外部类
- Demo1
非静态的内部类默认持有外部类的引用,如果有地方引用了这个非静态内部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类;Inner 对象中包含一个 this,即外部类实例
将内部类改成静态,则不会持有外部类的引用,GC 后内存中也不会有外部类实例
- Demo2
匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者
双大括号创建的是匿名内部类
ThreadLocal 使用不当
如果是手动创建的线程,就算没调用 ThreadLocal#remove 方法也不会产生内存泄漏,因为线程被回收时,ThreadLocal 同样会被回收;
但是如果使用线程池,线程得不到回收,就可能出现内存泄漏
解决方案:线程执行完,调用 ThreadLocal#remove 清理数据
String#intern 方法将大量字符串放到常量池中
字符串常量池中的数据,当内存不足时也会被回收;但是如果大量对象不能被回收,就会导致内存泄漏
通过静态字段保存对象
如果大量数据在静态变量中被长期引用,数据就得不到释放,长期积累下来就可能导致内存泄漏
解决方案:
- 尽量减少将对象长时间保存在静态变量中,对象不再使用时将对象删除
- 使用缓存时,设置过期时间
资源没有正常关闭
连接和流这些资源会占用内存,如果使用后没有关闭,这部分内存可能出现内存泄漏(不一定)
解决方案:
- 关闭不再使用的资源
- 使用 try-with-resources 自动关闭资源
总结
上面的几种场景,直接原因是更多的对象没被回收,根本原因是最外层对象没有回收;比预期有更多的对象没有被回收,从而更容易导致内存溢出
问题
-
用静态内部类来实现单例模式,有内存泄漏的风险吗
-
上述案例中的 ThreadLocal 导致内存泄漏,并没有每次都新创建 ThreadLocal 对象,为什么还会导致内存泄漏?
-
资源没有正常关闭,为什么可能导致内存泄漏?
- 是因为没执行 close 方法来清理可能导致内存泄漏的数据吗?
诊断
内存快照
- JVM 参数
-XX:+HeapDumpOnOutOfMemoryError 发生内存溢出时自动生成 hprof 内存快照文件
-XX:HeapDumpPath= 指定 hprof 文件输出路径
-XX:+HeapDumpBeforeFullGC 在 FullGC 之前生成内存快照
导出运行中系统的内存快照(注意只需要导出标记为存活的对象)
-
通过 JDK 自带的 jmap 命令导出
- jmap -dump:live,format=b,file=文件路径和文件名 进程 id
-
通过 arthas 的 heapdump 命令导出
- heapdump --live 文件路径和文件名
MAT
- 打开 hprof 文件分析内存泄漏原因
原理
MAT 提供了支配树(Dominator Tree)的对象图,支配树展示的是对象实例间的支配关系。在对象引用图中,所有指向对象 B 的路径都经过对象 A,则认为对象 A 支配对象 B
- 支配树对象本身占用的空间称为浅堆
- 支配树对象的子树就是被该对象支配的内容,这些内容组成了对象的深堆;深堆的大小表示该对象如果被回收,能释放多大的内存空间
- MAT 根据支配树,从叶子节点向根节点遍历,如果发现深堆的大小占整个堆内存的比例超过阈值,就将其标记成内存泄漏的“嫌疑对象”
内存溢出
场景
分页查询导致的内存溢出
- 原因
- 分页参数 pageSize 未作限制
- 分页接口并发过高
解决方案
- 限制最大 pageSize
- 减小分页对象,减少查询不必要的字段
- 接口限流
Mybatis foreach 参数过多
原因
- foreach 拼接 sql 时,会在内存中创建对象,既有大量字符串,还会有一个很大的参数 map
解决方案
- 限制 foreach 参数个数
导出大文件
Excel 文件导出如果使用 POI 的 XSSFWorkbook,在大数据量的情况下全占用大量内存
解决方案:
- 使用 easy excel,其对内存进行了大量优化
ThreadLocal 的不当使用
在拦截器 preHandle 方法中使用 ThreadLocal 存储数据,在 postHandle 中调用 remove。当处理器方法抛出异常,postHandle 方法就不会调用,导致了内存泄漏
解决方案:
- 将 remove 方法移动到 afterCompletion 中,就一定会被调用
使用线程池处理异步任务
存在问题:
- 若线程池最大线程数过大,或者任务队列过大,并发时会创建大量线程,或者队列中保存大量数据
- 任务没有持久化,一旦走线程池的拒绝策略或者服务宕机,就会丢失任务
解决方案:
- 合理设置线程池参数,指定拒绝策略
- 持久化异步任务
- 使用消息队列
定位问题的方案
离线:jmap+MAT
在线:arthas,或者 btrace
GC 调优
核心目标
避免由垃圾回收引起的程序性能下降
核心
- 通用 JVM 参数设置
- 特定垃圾回收器的 JVM 参数设置
- 解决频繁 FullGC 引起的性能问题
核心指标
吞吐量
- 业务吞吐量
- 垃圾回收吞吐量
延迟
内存使用量
满足吞吐量和延迟的情况下,内存使用越小越好
发现问题
Jstat
JDK 自带的监控工具
-
jstat -gc 进程 id 统计时间间隔(ms) 统计次数
-
内存单位是 KB
-
提示 "Could not attach to..." 时,可能要切换用户
-
CCS 代表 "Compressed Class Space" 压缩类空间,Java 8 及以后版本中引入的一个新的内存区域
- 是 Metaspace 的一个子区域。当类被加载到 JVM 中时,它们的元数据首先会被放入 Compressed Class Space。这个区域的特点是,它使用了指针压缩技术,使得在 32 位 JVM 中可以引用更多的内存。当 Compressed Class Space 用完时,类的元数据会被移动到 Metaspace 的其他区域。
-
VisualVM
VisualVM 中提供了一款 Visual Tool 插件,实时监控 Java 进程的堆内存结构、堆内存变化趋势以及垃圾回收时间的变化趋势,同时还可以监控对象晋升的直方图
Prometheus+Grafana
企业运维常用的监控方案
GC 日志
JDK8 及以下:-XX:+PrintGCDetails -Xloggc:文件名
JDK9 及以上:-Xlog:gc*:file=文件名
GC Viewer
将 GC 日志转换成可视化图表的工具
使用方法:java -jar gcviewer.jar 日志文件.log
GCeasy
业界首款使用 AI 机器学习技术进行在线 GC 分析和诊断的工具
基础 JVM 参数设置
推荐设置参数
-Xmx 最大堆内存
-Xms 初始堆内存,建议将-Xms 设置的和-Xmx 一样大,好处:
- 运行时性能更好,避免堆的扩容
- 可用性问题,避免扩容时才发现内存不足从而导致内存分配失败
- 启动速度更快;如果初始堆太小,Java 应用程序会变得很慢,因为 JVM 被迫频繁执行垃圾回收
-XX:MaxMetaspaceSize 最大元空间大小
-XX:MetaspaceSize 元空间到达这个值后会触发 FullGC,后续什么时候再触发,JVM 会自行计算
- 如果设置和 MaxMetaspaceSize 一样大,就不会 FullGC,但是对象也无法回收
-Xss 虚拟机栈的大小
- 可适当调小以节省空间;推荐值为 256k-1m 之间
不建议手动设置的参数
-Xmn 年轻代的大小。默认为整个堆的 1/3。尽量让对象只存放在年轻代,不进入老年代
- 实际场景中需要大量测试才能得到合理值
- G1 垃圾回收器尽量不要设置该值,因为 G1 会动态调整年轻代的大小
-XX:SurvivorRatio 伊甸园区和幸存者区的大小比例,默认为 8
-XX:MaxTenuringThreashold 最大晋升阈值,年龄大于此值后,会进入老年代
其他参数
-XX:+DisableExplicitGC 禁止在代码中显式调用 System.gc()
- 调用时不生效
-XX:+HeapDumpOnOutOfMemoryError 发生内存溢出时,生动生成 hprof 内存快照文件
- -XX:HeapDumpPath= 指定 hprof 文件的输出路径
打印 GC 日志
JDK8 及之前:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
JDK9 及之后:-Xlog:gc*file=文件路径
接口响应时间很长问题定位
Arthas trace
可以展示整个方法的调用路径以及每一个方法的执行耗时
trace 类名 方法名
- --skipJDKMethod false 输出 JDK 核心包中的方法及耗时
- 'cost > 毫秒值' 只会显示耗时超过的方法
- -n 数值 最多显示 n 次调用
- 所有监控结束之后,输入 stop 结束监控,重置 arthas 增强的对象
Arthas watch
监控方法获得更为详细的方法信息
watch 类名 方法名 '{params, returnObj}' '#cost > 毫秒值' -x 2
- '{params, returnObj}' 打印参数和返回值
- -x 2 打印的结果中如果有嵌套(比如对象里有属性),最多展示 2 层;允许设置的最大值为 4
Arthas profile
生成性能监控的火焰图;火焰图中一般绿色部分 Java 中栈顶上比较平的部分,可能是性能的瓶颈
profile start 开始监控方法执行性能
profile stop --format html 以 HTML 生成火焰图
获取方法执行时长
接口入口、出口记录时间,求差值
Arthas trace 命令
有性能损耗
jmeter 测试
JMH 测试
可设置预热,从而合理利用 JIT 获取真实性能
GraalVM
介绍
GraalVm 是 Oracle 官方推出的一款高性能 JDK
- 更低的 CPU、内存使用率
- 更快的启动速度,无需预热即可获得最好的性能
- 更好的安全性,更小的可执行文件
两种运行模式
JIT 模式
与 Oracle JDK 类似,满足两个特点
-
一次编写,到处运行
-
预热之后,通过内置的 Graal 即时编译器优化热点代码,生成比 HotSpot JIT 更高性能的机器码
- -XX:-UseJVMCICompiler 关闭 Graal 编译器
AOT 模式
AOT(Ahead-Of-Time)模式,提前编译模式
通过源代码,为特定平台创建可执行文件。比如,在 Windows 下编译完成之后会生成.exe 文件。通过这种方式,达到启动之后获得最高性能的目的
这种模式生成的文件称之为 Native Image 本地镜像。其不具备跨平台特性
使用方法:
-
安装依赖库
-
制作本地镜像
- native-image 类名
-
运行本地镜像可执行文件
优缺点
存在的问题
-
跨平台问题
-
在不同平台下要编译多次
-
编译平台的依赖库要与运行平台保持一致,不一致时容易导致编译后的程序不能运行
- 很容易不一致
-
-
使用框架时,编译本地镜像时间比较长,同时需要消耗大量 CPU 和内存
- 编译时间可能长达几十分钟,内存可能需要 8g
-
AOT 编译器在编译时,需要知道运行时所有可访问的类,但是 Java 中的反射、动态代理 可以在运行时创建类。因此需要对 AOT 编译器进行适配
解决方案
- 使用公有云的 Docker 等容器化平台进行在线编译,确保编译与运行环境一致;同时解决编译资源问题
- 使用 SpringBoot3 等整合了 GraalVM AOT 模式的框架版本
适用场景
- 对性能要求比较高的场景
- 公有云的部分服务是按照 CPU 和内存使用量来计费,使用 GraalVM 可以有效降低费用
新一代 GC
Shenandoah
Red Hat 开发的一款低延迟的垃圾收集器。Shenandoah 并发执行大部分 GC 工作,包括并发的整理,因此堆大小对 STW 的时间基本没有影响
- 没有引入 Oracle JDK 中,只能在 Open JDK 中使用
使用方法
-
下载
- Shenandoah 只包含在 OpenJDK 中,且默认不包含,需要单独构建;可直接下载构建好的
-
配置 OpenJDK 环境变量
-
添加参数,并运行 Java 程序
- -XX:+UseShenandoahGC 开启 Shenandoah GC
- -Xlog:gc 打印 GC 日志
JMX
获取 Java 运行时的实时数据
- ManagementFactory 获取相应对象
ZGC
一种可扩展的低延迟垃圾回收器。ZGC 在垃圾回收过程中,STW 的时间不会超过 1ms,适合需要低延迟的应用。支持几百兆到 16TB 的堆大小,堆大小对 STW 的时间基本没有影响
ZGC 降低了停顿时间,能降低接口最大耗时,但吞吐量不佳
- 在 JDK14-15 时才发布第一个正式版本
使用方法
- OracleJDK 和 OpenJDK 中都支持 ZGC,阿里的 DragonWell 龙井 JDK 也支持 ZGC,但属于其自行对 OpenJDK11 的 ZGC 进行优化的版本
Java Agent
Java 工具
常见类型
- 诊断类工具,如 Arthas,VisualVM
- 开发类,如 IDEA,Eclipse
- APM 应用性能监测,如 Skywalking,Zipkin
- 热部署,如 Jrebel
Java Agent 介绍
是 JDK 提供的用来编写 Java 工具的技术,使用这种技术能生成特殊的 jar 包,被 Java 程序所调用
其有两种模式:静态加载模式和动态加载模式
静态加载模式
在程序启动时执行代码,适合于 APM 性能监测系统从一开始就监控程序的执行性能
用法:在 Java Agent 项目中编写一个 premain 方法,并打成 jar 包
public static void premain(String agentArgs, Instrumentation inst)
启动 java 程序时,指定 agent
java -javaagent:agent.jar -jar test.jar
premain 方法会在主线程中执行,并且是在 main 方法之前执行
- 可以指定多个 agent
Demo
-
创建 maven 项目,添加 maven-assembly 插件(用于打成 Java Agent 的 jar 包)
-
-
编写类和 premain 方法
-
编写 MANIFEST.MF 文件
- 用于描述 Java Agent 的配置属性,比如使用哪一个类的 premain 方法
-
使用 maven-assembly-plugin 打包
-
运行 Java 程序,指定 Java Agent
动态加载模式
可以随时执行,适用于 Arthas 等诊断系统
用法
在 Java Agent 项目中编写一个 agentmain 方法,并打成 jar 包
public static void agentmain(String agentArgs, Instrumentation inst)
接下来使用以下代码来让 Java Agent 在指定 Java 进程中执行
// 动态连接到指定Java进程
VirtualMachine vm = VirtualMachine.attach("processId");
// 加载java agent
vm.loadAgent("agent.jar");
agentmain 方法会在独立线程中执行
Demo
- 创建 maven 项目,添加 maven-assembly 插件(用于打成 Java Agent 的 jar 包)
- 编写类和 agentmain 方法
- 编写 MANIFEST.MF 文件
- 使用 maven-assembly-plugin 打包
- 编写 main 方法,动态连接到运行中的 java 程序
简易版 Arthas
执行 jps 命令并获取结果
Process jps = Runtime.getRuntime().exec("jps");
BufferdReader br = new BufferdReader(new InputStreamReader(jps.getInputStream()));
获取运行时信息-JMX
通过 Mbean 对象的写入和获取,可实现
- 运行时配置的获取和更改
- 运行时线程栈、内存、类信息的获取
获取内存信息
ManagementFactory.getMemoryPoolMXBeans()
ASM
- ASM 是一个通用的 Java 字节码操作和分析框架,可直接以二进制形式修改现有类或者动态生成类
- ASM 重点关注性能,让操作尽可能快
- 缺点是代码复杂
ByteBuddy
ByteBuddy 是一个代码生成和操作库,用于在 Java 应用程序运行时创建和修改 Java 类,而无需编译器的帮助。ButeBuddy 底层基于 ASM,提供了非常方便的 API
Java 虚拟机中的基本数据类型
占用槽位
每个数组元素(slot 槽)空间大小:
- 32 位虚拟机为 32 位,4 个字节
- 64 位虚拟机为 32 位,8 个字节
对于 long,double 类型,由于编译时就要确定占用操作数栈的槽位数,因此为了兼容跨平台,不管在 32 位还是 64 位虚拟机中都是使用 2 个槽位,一定程度上造成了内存空间的浪费。
在 Java 虚拟机中,栈上 boolean 类型保存方式与 int 类型相同,值为 1 代表 true,0 代表 false
- 赋值的字节码指令分别为 iconst1,iconst0
- byte,short 同样是当成 int 类型来存储
对象在堆上的存储
内存布局
- 标记字段、元数据指针、数组长度均占 4/8 个字节
- 对象头使用了小端存储,打印的地址要反着读
标记字段(Mark Word)
- 分代年龄只使用 4bit,因此年龄不能超过 15
JOL 打印内存布局
JOL 是用于分析 JVM 中对象布局的工具,其中使用 Unsafe、JVMTI 和 Serviceability Agent(SA) 等虚拟机技术来打印对象实际的内存布局
元数据的指针(Klass pointer)
Klass pointer 元数据的指针指向方法区中保存的 InstanceKlass 对象
指针压缩
在 64 位 Java 虚拟机中,Klass pointer 以及对象数据中的对象引用都需要占用 8 个字节。JVM 认为对象引用数据远达不到 2^64 的量级,因此为了减小这部分的内存使用量,64 位 Java 虚拟机使用了指针压缩技术,将堆中原本 8 个字段的指针压缩成 4 个字节,此功能默认开启
- 可使用 -XX:-UseCompressedOops 关闭
原理:将寻址的单位放大,比如原来按 1 字节去寻址,现在可以按 8 字段寻址,这样将编号当成地址,元数据指针就可以用更小的内存访问更多的数据
带来的问题
-
需要进行内存对齐,将对象的内存占用填充到 8 字节的倍数,存在空间浪费
- 对于 Hotspot 来说不存在这个问题,因为即使不开启指针压缩,也需要进行内存对齐
-
寻址大小仅仅能支持 2^35 次方个字节(32GB)
-
是否开启压缩指针的寻址大小计算
- 不用压缩指针,元数据指针大小为 8 字节,寻址大小为 2^64=16EB
- 用了压缩指针,元数据指针大小为 4 字节,每一位对应 8 字节空间,寻址大小为 8 * 2^32 = 2 ^ 35
-
如果内存超过 32GB,压缩指针会自动关闭
-
内存对齐
主要是为了解决并发情况下 CPU 缓存失效的问题,从而高效读取数据
- 字段重排列
在 Hotspot 中,要求每个属性的偏移量 Offset 必须是字段长度的 N 倍,从而避免一个字段分布在不同缓存行中降低性能
如果不能通过重排列的方式来实现,就会在字段之间产生内存填充来达到效果
- 子类和父类的偏移量
子类会继承处父类的属性,属性的偏移量和父类是一致的;
在父类中,对象类型重排列后一定在基本类型之后;
在子类中,父类的对象类型仍会在子类所有类型之前,而不会重排列到子类基本类型之后
方法调用的原理
方法调用的本质是通过字节码指令的执行,在栈上创建栈帧,并执行调用方法中的字节码
- Invoke 方法的核心就是找到字节码指令并执行
- 通过静态绑定或者动态绑定,找到具体要执行的方法
静态绑定
编译期间,invoke 指令会携带一个参数符号引用,引用到常量池中的方法定义。方法定义包含了 类名 + 方法名 + 返回值 + 参数。
在方法第一次调用时,这些符号引用就会被替换成内存地址的直接引用。
静态绑定适用于处理静态方法、私有方法,或者用 final 修饰的方法,因为这些方法不能被继承后重写
- 即 invokestatic,invokespecial,final 修饰的 invoke virtual
动态绑定
对于非 static、非 private、非 final 的方法,有可能存在子类重写方法,此时需要通过动态绑定来完成方法地址绑定的工作
动态绑定基于方法表来完成,invokevirtual 使用了虚方法表(vtable),invokeinterface 使用了接口方法表(itable)
每个类中都有一个虚方法表,本质是一个数组,记录了方法的地址。子类方法表中包含父类方法表中的所有方法,如果子类重写父类方法,则使用自己类中方法地址进行替换
invokevirtual 调用时,先根据 对象头 中的类型指针找到方法中 InstanceClass 对象获得虚方法表,再根据虚方法表获得方法的地址,最后调用方法
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于