实战 Java 虚拟机-高级篇
什么是 GraalVM
GraalVM 是 Oracle 官方推出的一款 **高性能JDK,**使用它享受比 OpenJDK 或者 OracleJDK 更好的性能。
- GraalVM 的官方网址:https://www.graalvm.org/
- 官方标语:Build faster, smaller, leaner applications.
- 更低的 CPU、内存使用率
- 官方标语:Build faster, smaller, leaner applications.
- 更低的CPU、内存使用率
- 更快的启动速度,无需预热即可获得最好的性能
- 更好的安全性、更小的可执行文件
- 支持多种框架Spring Boot、Micronaut、Helidon 和 Quarkus。多家云平台支持。
- 通过 Truffle框架运行 JS、Python、Ruby等其他语言。
GraalVM 的版本
GraalVM 分为社区版(Community Edition)和企业版(Enterprise Edition)。企业版相比较社区版,在性能上有更多的优化。
GraalVM 社区版环境搭建
GraalVM 的两种运行模式
JIT (Just-In-Time) 模式,即时编译模式
JIT 模式的处理方式与 Oracle JDK类似,满足两个特点:
- Write Once,Run Anywhere ->一次编写,到处运行。
- 预热之后,通过 内置的Graal即时编译器 优化热点代码,生成比 Hotspot JIT 更高性能的机器码。
AOT (Ahead-Of-Time)模式,提前编译模式
AOT 编译器通过源代码,为特定平台创建可执行文件。比如,在Windows下编译完成之后,会生成 exe文件。通过这种方式,达到启动之后获得最高性能的目的。但是不具备跨平台特性,不同平台使用需要单独编译。这种模式生成的文件称之为Native Image本地镜像。
官网:https://www.graalvm.org/latest/reference-manual/native-image/#prerequisites
GraalVM 模式和版本的性能对比
社区版的GraalVM 使用本地镜像模式性能不如 HotspotJVM 的 JIT 模式,但是企业版的性能相对会高很多
GraalVM 存在的问题
GraaIVM 的 AOT模式虽 然在启动速度、内存和CPU开销上非常有优势,但是使用这种技术会带来几个问题:
1、跨平台问题,在不同平台下运行需要编译多次。编译平台的依赖库等环境要与运行平台保持一致。
2、使用框架之后,编译本地镜像的时间比较长,同时也需要消耗 大量的CPU 和 内存。
3、AOT编译器 在编译时,需要知道运行时所有可访问的所有类。但是 Java 中有一些技术可以在运行时创建类,例如反射、动态代理等。这些技术在很多框架比如Spring中大量使用,所以框架需要 对AOT编译器进行适配解决类似的问题。
实战案例1:使用 SpringBoot3 搭建 GraalVM 环境
应用场景
GraalVM 企业级应用 - Serverless 架构
传统的系统架构中,服务器等基础设施的运维、安全、高可用等工作都需要企业自行完成,存在两个主要问题:
1、开销大,包括了人力的开销、机房建设的开销。
2、**资源浪费,面对一些突发的流量冲击,比如秒杀等活动,必须提前规划好容量准备好大量的服务器,**这些服务器在其他时候会处于闲置的状态,造成大量的浪费。
随着虚拟化技术、云原生技术的愈发成熟,云服务商提供了一套称为 Serverless无服务器化的 架构。企业无需进行服务器的任何配置和部署,完全由云服务商提供。比较 典型的有亚马逊 AWS、阿里云等 。
Serverless架构 - 函数计算
Serverless架构 中第一种常见的服务是函数计算(Function as a Service),将一个应用拆分成多个函数,每个函数会以事件驱动的方式触发。典型代表有 AWS 的 Lambda、阿里云的 FC。
函数计算主要应用场景有如下几种:
① 小程序、API服务 中的接口,此类接口的调用频率不高,使用常规的服务器架构容易产生资源浪费,使用 Serverless 就可以实现按需付费降低成本,同时支持自动伸缩能应对流量的突发情况。
② 大规模任务的处理,比如 音视频文件转码、审核等,可以利用事件机制当文件上传之后,自动触发对应的任务。
函数计算的计费标准中 包含CPU和内存使用量,所以使用 GraaIVM AOT模式 编译出来的本地镜像可以节省更多的成本。
函数计算的计费标准中 包含CPU和内存使用量,所以使用 GraaIVM AOT模式编译 出来的本地镜像可以节省更多的成本。
实战案例 2:将程序部署到阿里云函数计算
Serverlesss 架构 -Serverless 应用
函数计算的服务资源比较受限,比如 AWS 的Lambda 服务一般 无法支持超过15分钟的函数执行,所以云服务商提供了另外一套方案:基于容器的Serverless 应用,无需手动配置 K8s中 的 Pod、Service 等内容,只需选择镜像就可自动生成应用服务。
同样,Serverless应用 的计费标准中 包含CPU和内存使用量,所以 使用GraaIVM AOT模式 编译出来的本地镜像 可以节省更多的成本。
将程序部署到 阿里云 Serverless 应用
步骤:
1、在项目中编写 Dockerfile 文件。
2、使用服务器制作镜像,这一步会消耗大量的CPU和内存资源,同时 GraaIVM相关 的镜像服务器在国外,**建议使用阿里云的镜像服务器制作Docker镜像。**前两步同实战案例2
3、配置Serverless应用,选择容器镜像、CPU和内存。
4、绑定外网负载均衡并使用 Postman 进行测试。
参数优化和故障诊断
GraalVM 的内存参数
由于 GraalVM 是一款独立的 JDK,所以大部分 HotSpot 中的虚拟机参数都不适用。常用的参数参考:官方手册
- 社区版只能使用串行 垃圾回收器(Serial GC),使用串行垃圾回收器的默认 最大Java 堆大小 会设置为物理内存大小的 80%,调整方式为使用 -Xmx最大堆大小。如果希望在编译期就指定该大小,可以在编译时添加参数 -R:MaxHeapSize=最大堆大小。
- G1垃圾回收器只能在企业版中使用,开启方式为添加 --gC=G1参数,有效降低垃圾回收的延迟。
- 另外提供一个Epsilon GC,开启方式:–gc=epsilon,它不会产生任何的垃圾回收行为所以没有额外的内存、CPU开销。如果在公有云上运行的程序生命周期短暂不产生大量的对象,可以使用该垃圾回收器,以节省最大的资源。
- -XX:+PrintGC -XX:+VerboseGC 参数打印垃圾回收详细信息。
实战案例4:内存快照文件的获取
实战案例5:运行时数据的获取
总结
新一代的GC
垃圾回收器的技术演进
不同的垃圾回收器设计的目标是不同的,如下图所示:
Shenandoah GC
Shenandoah 是由 Red Hat 开发的一款低延迟的垃圾收集器,Shenandoah 并发执行大部分 GC 工作,包括并发的整理,堆大小对 STW的时间 基本没有影响。
Shenandoah 的使用方法
1、下载。Shenandoah 只包含在 Open JDK中,默认不包含在内需要单独构建,可以直接下载构建好的。
下载地址: https://builds.shipilev.net/openjdk-jdk-shenandoah/
选择方式如下:
ZGC
ZGC 是一种可扩展的低延迟垃圾回收器。ZGC 在垃圾回收过程中,STW 的时间不会超过一毫秒,适合需要低延迟的应用。支持 几百兆 到 16TB 的堆大小,堆大小对 STW 的时间基本没有影响。ZGC 降低了停顿时间,能降低接口的最大耗时,提升用户体验。但是吞吐量不佳,所以 如果Java服务 比较关注 QPS(每秒的查询次数)那么 G1是 比较不错的选择。
ZGC 的版本更选
OracleJDK 和 OpenJDK 中都支持 ZGC,阿里的 DragonWell 龙井JDK 也支持 ZGC 但属于其自行对 Open JDK 11 的 ZGC 进行优化的版本。
建议使用 JDK17 之后的版本,延迟较低同时无需手动配置并行线程数。
ZGC 在设计上做到了自适应,根据运行情况自动调整参数,让用户手动配置的参数最少化。
- 自动设置年轻代大小,无需设置 -Xmn参数。
- 自动晋升阈值(复制中存活多少次才搬运到老年代),无需设置 -XX:TenuringThreshold。
- JDK17 之后支持自动的并行线程数,无需设置 -XX:ConcGCThreads。
ZGC 的参数设置
ZGC 的调优
ZGC 中可以使用 Linux 的 Huge Page 大页技术优化性能,提升吞吐量、降低延迟。
注意:安装过程需要 root 权限,所以 ZGC 默认没有开启此功能。
操作步骤:
1、计算所需页数,Linuxx86 架构中大页大小为 2MB,根据所需堆内存的大小估算大页数量。比如堆空间需要 16G,预留 2G( JVM 需要额外的一些非堆空间),那么页数就是 18G / 2MB = 9216。
2、配置系统的大页池以具有所需的页数(需要root权限):
$ echo 9216 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
3、添加参数 -XX:+UseLargePages 启动程序进行测试
实战案例
内存不足时的垃圾回收测试
总结
揭秘 Java 工具
在 Java 的世界中,除了 Java 编写的业务系统之外,还有一类程序也需要 Java程序员 参与编写,这类程序就是 Java工具。
常见的 Java工具 有以下几类:
1、诊断类工具,如 Arthas、VisualVM 等。
2、开发类工具,如 ldea、Eclipse。
3、APM 应用性能监测工具,如 Skywalking、Zipkin 等。
4、热部署工具,如 Jrebel 等。
学习 Java 工具常用技术 Java Agent
Java Agent技术是 JDk 提供的用来编写 Java工具 的技术,使用这种技术生成一种特殊的 jar包,这种 jar包 可以让 Java程序 运行其中的代码。
Java Agent 技术的两种模式-静态加载模式
静态加载模式可以在程序启动的一开始就执行我们需要执行的代码,适合用 APM等性能监测系统 从一开始就监控程序的执行性能。静态加载模式需要在Java Agent的项目中编写一个 premain 的方法,并打包成jar包。
搭建 java agent静态加载模式的环境
maven 环境配置Maven重点学习笔记(包入门 2万字)
Java代码
import java.lang.instrument.Instrumentation;
public class AgentMain {
// premain 方法
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("premain执行了");
}
}
Java Agent技术的两种模式-动态加载模式
动态加载模式可以随时让 java agent代码执行,适用于 Arthas等诊断系统。动态加载模式需要在 Java Agent的项目中编写一个 agentmain 的方法,并打包成 jar包。
搭建 java agent 动态加载模式的环境
Java 代码
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.IOException;
public class AttachMain {
public static void main(String[] args) throws IOException, AttachNotSupportedException {
// 获取进程虚拟对象
VirtualMachine vm = VirtualMachine.attach("37632");
// 执行Java agent里面的 agentmain 方法
vm.loadAgent("D:/jvm/javaagent/itheima-agent/target/itheima-agent-1.0-SNAPSHOT-jar-with-dependencide");
}
}
实战案例
实战案例1:简化版的 Arthas
需求:
编写一个简化版的 Arthas程序,具备以下几个功能:
1、查看内存使用情况
2、生成堆内存快照
3、打印栈信息
4、打印类加载器
5、打印类的源码
6、打印方法执行的参数和耗时
该程序是一个 独立的Jar包,可以应用于 任何Java编写 的系统中具备以下特点:代码无侵入性、操作简单、性能高。
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import java.io.IOException;
public class AttachMain {
public static void main(String[] args) throws IOException, AttachNotSupportedException {
// 获取进程列表,让用户手动进行输入
// 1.执行 jps 命令,打印所有进程列表
Process jps = Runtime.getRuntime().exec("jps");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(jsp.getInputStream()));
try {
String line;
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
} finally {
if (bufferedReader != null) {
bufferedReader.close();
}
}
// 2. 输入进程 id
Scanner scanner = new Scanner(System.in);
String processId = scanner.next();
// 获取进程虚拟对象
VirtualMachine vm = VirtualMachine.attach("processId");
// 执行Java agent里面的 agentmain 方法
vm.loadAgent("D:/jvm/javaagent/itheima-agent/target/itheima-agent-1.0-SNAPSHOT-jar-with-dependencide");
}
}
获取运行时信息 - JMX 技术
JDK 从 1.5 开始提供了 Java Management Extensions(JMX) 技术,通过 Mbean 对象的写入和获取,实现:
- 运行时配置的获取和更改
- 应用程序运行信息的获取(线程栈、内存、类信息等)
获取类和类加载器的信息 - Instumentation 对象
Oracle官方手册: https://docs.oracle.com/javase/17/docs/api/java/lang/instrument/Instrumentation.html
打印类的源码
这里我们会使用 jd-core 依赖库 来完成,github地址:https://github.com/java-decompiler/id-core
import java.util.List;
public class MemoryCommand {
// 打印所有内存信息
public static void printMemory() {
List<MemoryPoolMXBean> memoryPoolMXBeans = ManagementFactory.getMemoryPoolMXBeans();
// 堆内存
memoryPoolMXBeans.stream().filter(x -> x.getType().equals(MemoryType.HEAP))
.forEach(x -> {
StringBuilder sb = new StringBuilder();
sb.append("name")
.append(x.getName())
.append(" used:")
.append(x.getUsage().getUsed() / 1024 / 1024)
.append("m")
.append(" committed:")
.append(x.getUsage().getUsed() / 1024 / 1024)
.append("m")
.append(" max:")
.append(x.getUsage().getUsed() / 1024 / 1024)
.append("m")
});
}
}
Spring AOP是不是也可以实现类似的功能呢?
打印方法执行的参数和耗时
打印方法执行的参数和耗时需要对原始类的方法进行增强,可以使用类似于 Spring AOP这类 面向切面编程 的方式,但是考虑到并非每个项目都使用了Spring 这些框架,所以我们选择的是最基础的字节码增强框架。字节码增强框架是在当前类的字节码信息中插入一部分字节码指令,从而起到增强的作用。
打印方法执行的参数和耗时 - ASM
ASM是一个通用的 Java字节码 操作和分析框架。它可用于直接以二进制形式修改现有类或动态生成类。ASM重点关注性能。让操作尽可能小且尽可能快,所以它非常适合在动态系统中使用。ASM的缺点是代码复杂。
官网:ASM的官方网址:https://asm.ow2.io/
操作步骤
示例
// ASM 入门案例,向每个方法添加一行字节码指令
public class ASMDemo {
public static void main(String[] args) throws IOException {
// 1.从本地读取一个字节码文件, byte[]
byte[] bytes = FileUtils.readFileToByteArray(new File("D\\jvm\\AttahMain.class"));
// 2.通过ASM修改字节码文件
// 将二进制数据转换成可以解析内容
ClassReader classReader = new ClassReader(bytes);
// 创建visitor对象,修改字节码信息
ClassWriter classWriter = new ClassWriter(0);
ClassVisitor classVisitor = new ClassVisitor(ASM7, new ClassWriter(0)) {
@Override
public MethodVisitor visitMethod(int access, String name, string descriptor, String signature, String[] exception) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exception);
// 返回自定义的MethodVisitor
MethodVisitor methodVisitor = new MethodVisitor(this.api.mv) {
// 修改字节码指令
@Override
public void visitCode() {
// 插入一行代码指令 ICOST_0
visitInsn(ICONST_)
}
}
return methodVisitor;
}
};
classReader.accept(classVisitor, 0);
// 将修改完的字节码信息写入文件中,进行替换
FileUtils.writeByteArrayToFile(new File("D\\jvm\\AttahMain.class"), classWriter.toByteArray());
}
}
打印方法执行的参数和耗时 - Byte Buddy
Byte Buddy 是一个代码生成和操作库,用于在 Java应用程序 运行时创建和修改 Java类,而无需编译器的帮助。Byte Buddy底层基于ASM,提供了非常方便的 APl。
Byte Buddy官网:https://bytehuddy.net/
// 使用 bytebyddy 增强类
new AgentBuilder.Default()
// 禁止byte buddy处理时修改类名
.disableClassFormatChanges() // AgentBuilder
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) // AgentBuilder.RedefinitionListenable.With
// 打印出错误日志
.with(new AgentBuilder.Listener.WithTransformationsOnly(AgentBuilder.Listener.StreamWriting
.toSystemOut())) // AgentBuilder
// 匹配哪些类
.type(ElementMatchers.named(className)) // AgentBuilder.Identified.Narrowable
// 增强,使用MyAdvice通知,对所有方法都进行增强
.transform(builder, typeDescription, classLoader, module, pretectionDomain) ->
builder.visit(Advice.to(MyAdvice.class).on(ELementMathcers.any())) // AgentBuilder.Identify
.installOn(inst);
class MyAdvice {
@Advice.OnMethodEnter
static long enter(@Advice.AllArguments Object[] ary) {
if (ary != null) {
for (int i = 0; i < ary.length; i++) {
System.out.println("Argument: " + i + " is " + ary[i]);
}
}
return System.nanoTime();
}
@Advice.OnMethodExit
static void exit(@Advice.Enter long value) {
System.out.println("耗时为:" + (System.nanoTime() - value) + "纳秒");
}
}
最后将整个 简化版的arthas进行打包,在服务器上进行测试。使用 maven-shade-plugin插件 可以将所有依赖打入同一个 jar包 中并指定 入口main方法。
实战案例 2:APM系统 的数据采集
Application performance monitor (APM) 系统
Application performance monitor(APM) 应用程序性能监控系统是采集运行程序的实时数据并使用可视化的方式展示,使用 APM 可以确保 系统可用性,优化服务性能 和 响应时间,持续改善用户体验。常用的 APM系统 有Apache Skywalking、Zipkin等。
Skywalking官方网站: https://skywalking.apache.org/
ByteBuddy 参数的传递
在 Java Agent 中如果需要传递参数到 ByteBuddy,可以采用如下的方式:
1、绑定 KeyValue,Key 是一个自定义注解,Value 是参数的值。
实战 Java虚拟机 - 原理篇
说服自己学习
栈上的数据存储
这里的内存占用,指的是堆上或者数组中内存分配的空间大小,栈上的实现更加复杂。
Java 中的 8大数据类型 在虚拟机中的实现:
案例:验证 boolean 在栈上的存储方式
栈中的数据要保存到堆上或者从堆中加载到栈上时怎么处理?
案例:验证 boolean 从栈保存到堆上只取最后一位
对象在堆上是如何存储的?
对象在堆中的内存布局
对象在堆中的内存布局-标记字段
标记字段相对比较复杂。在不同的对象状态(有无锁、是否处于垃圾回收的标记中)下存放的内容是不同的,同时在64位(又分为是否开启指针压缩)、32位虚拟机中的布局都不同。以 64位 开启指针压缩为例:
JOL 打印内存布局
JOL 是用于分析 JVM 中对象布局的一款专业工具。工具中使用 Unsafe、JVMTI 和 Serviceability Agent(SA) 等虚拟机技术来打印实际的对象内存布局。
使用方法:
Klass pointer元数据的指针指向方法区中保存的 lnstance Klass对象:
指针压缩
在 64位 的 Java虚拟机中,Klass Pointer 以及对象数据中的对象引用都需要占用 8个字节,为了减少这部分的内存使用量,64位 Java 虚拟机使用指针压缩技术,将堆中原本 8个字节的 指针压缩成4个字节,此功能默认开启
可以使用 -XX:-UseCompressedOops 关闭。
指针压缩的思想是将寻址的单位放大,比如原来按1字节去寻址,现在可以 按8字节寻址。如下图所示,原来按1去寻址,能拿到1字节开始的数据,现在按1去寻址,就可以拿到8个字节开始的数据。
这样将编号当成地址,就可以用更小的内存访问更多的数据。但是这样的做法有两个问题:
1、需要进行内存对齐,指的是将对象的内存占用填充至8字节的倍数。存在空间浪费(对于Hotspot来说不存在,即便不开启指针压缩,也需要进行内存对齐)
2、寻址大小仅仅能支持2的35次方个字节(32GB,如果超过32GB指针压缩会自动关闭)。不用压缩指针,应该是2的64次方=16EB,用了压缩指针就变成了8(字节)=2的3次方*2的32次方=2的35次方
案例:在 hsdb工具 中验证 klass pointer 正确性
内存对齐
内存对齐主要目的是为了解决并发情况下CPU缓存失效的问题:
内存对齐之后,同一个缓存行中不会出现不同对象的属性。在并发情况下,如果让A对象一个缓存行失效,是不会影响到B对象的缓存行的。
在 Hotspot 中,要求每个属性的偏移量 Offset(字段地址-起始地址)必须是字段长度的 N倍。比如下图中,Student类 中的 id属性类型 为long,那么偏移量就必须 是8的倍数。
内存对齐-字段重排列
如果不满足要求,**会尝试使用内存对齐,**通过在属性之间插入一块对齐区域达到目的。
如下图中,name字段 是引用占用8个字节(关闭了指针压缩),所以 Offset 必须是8的倍数,在age和name之间插入了4个字节的空白区域。
子类和父类的偏移量
总结
方法调用的原理
方法调用的本质是通过字节码指令的执行,能在栈上创建栈帧,并执行调用方法中的字节码执行。
以 invoke 开头的字节码指令的作用是执行方法的调用
在 JVM 中,一共有五个字节码指令可以执行方法调用:
1、**invokestatic:**调用静态方法
2、invokespecial: 调用 对象的private方法、构造方法,以及使用 super关键字 调用父类实例的方法、构造方法,以及所实现接口的默认方法。
3、**invokevirtual:**调用对象的 非private方法。
4、**invokeinterface:**调用接口对象的方法。
5、**invokedynamic:**用于调用动态方法,主要应用于 lambda 表达式中,机制极为复杂了解即可。
Invoke 方法的核心作用就是找到字节码指令并执行
Invoke指令执行时,需要找到方法区中 instanceKlass 中保存的方法相关的字节码信息。但是方法区中有很多类每一个类又包含很多个方法,怎么精确地定位到方法的位置呢?
静态绑定
1、编译期间,invoke指令 会携带一个参数符号引用,引用到常量池中的方法定义。方法定义中包含了类名+方法名+返回值+参数。
2、在方法第一次调用时,这些符号引用就会被替换成内存地址的直接引用,这种方式称之为静态绑定。静态绑定适用于处理静态方法、私有方法、或者使用 final 修饰的方法,因为这些方法不能被继承之后重写。
invokestatic
invokespecial
final 修饰的 invokevirtual
动态绑定
对于 非static、非private、非final的方法,有可能存在子类重写方法,那么就需要通过 动态绑定 来完成方法地址绑定的工作。比如在这段代码中,调用的其实是 Cat类对象的eat方法,但是编译完之后虚拟机指令中调用的是 Animal类的 eat方法。这就需要在运行过程中通过 动态绑定找到cat类的eat方法,这样就实现了多态。
动态绑定是基于 方法表 来完成的,invokevirtual 使用了虚方法表(vtable),invokeinterface 使用了接口方法表(itable),整体思路类似。所以接下来使用 invokevirtual 和 虚方法表 来解释整个过程。
每个类中都有一个虚方法表,本质上它是一个数组,记录了方法的地址。子类方法表中包含父类方法表中的所有方法;
子类如果重写了父类方法,则使用自己类中方法的地址进行替换。
- 产生 invokevirtual 调用时,先根据对象头中的类型指针找到方法区中 InstanceClass对象,获得虚方法表。再根据虚方法表找到对应的对方,获得方法的地址,最后调用方法。
总结
异常捕获的原理
在 Java 中,程序遇到异常时会向外抛出,此时可以使用 try-catch 捕获异常 的方式将异常捕获并继续让程序按程序员设计好的方式运行。比如如下代码:在 try代码块 中如果抛出了 Exception对象 或者 子类对象,则会进入 catch分支。异常捕获机制的实现,需要借助于编译时生成的异常表。
异常捕获的原理
异常表在编译期生成,存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
在 位置2到 4字节码指令 执行范围内,如果出现了 Exception对象 的异常或者子类对象异常,直接跳转到 位置7的指令。也就是 i = 2代码 位置。
程序运行中触发异常时,Java虚拟机 会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java虚拟机 会判断所抛出的异常和该条目想要捕获的异常是否匹配。
1、如果匹配,跳转到“跳转PC"对应的字节码位置。
2、如果遍历完都不能匹配,说明异常无法在当前方法执行时被捕获,此方法栈帧直接弹出,在上一层的栈帧中进行异常捕获的查询。
多个 catch分支情况下,异常表会从上往下遍历,先捕获 RuntimeException,如果捕获不了,再捕获 Exception。
同理,multi-catch 的写法也是一样的处理过程,多个 catch分支情况下,异常表会从上往下遍历,先捕获 RuntimeException,如果捕获不了,再捕获 lOException。
finally 的处理方式就相对比较复杂一点了,分为以下几个步骤:
1、finally 中的字节码指令会插入到 try 和 catch代码块中,保证在 try 和 catch 执行之后一定会执行 finally 中的代码。
2、如果抛出的异常范围超过了 Exception,比如 Error 或者 Throwable,此时也要执行 finally,所以异常表中增加了两个条目。覆盖了 try 和 catch 两段字节码指令的范围,any代表可以捕获所有种类的异常。
JIT 即时编译器
在 Java中,JIT即时编译器是一项用来提升应用程序代码执行效率的技术。字节码指令被 Java虚拟机 解释执行,如果有一些指令执行频率高,称之为 热点代码,这些字节码指令则被 JIT即时编译器 编译成机器码同时进行一些优化,最后保存在内存中,将来执行时直接读取就可以运行在计算机硬件上了。
在 HotSpot 中,有三款即时编译器,C1、C2 和 Graal,其中 Graal 在 GraalVM 章节中已经介绍过。****
C1 编译效率比 C2 快,但是优化效果不如 C2。所以 C1 适合优化一些执行时间较短的代码,C2 适合优化服务端程序中长期执行的代码。
JDK7 之后,采用了分层编译的方式,在 JVM 中 C1 和 C2 会一同发挥作用,分层编译将整个优化级别分成了 5个等级。
C1即时编译器 和 c2即时编译器 都有独立的线程去进行处理,内部会保存一个队列,队列中存放需要编译的任务。
一般即时编译器是针对方法级别来进行优化的,当然也有对循环进行优化的设计。
详细来看看 c1 和 c2 是如何进行协作的:
案例:测试 JIT 即时编译器的优化效果
import java.util.concurrent.TimeUnit;
// 执行 5轮 预测,每次持续 1 秒
@Warmup(interations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
// 执行一次测试
@Fork(value = a, jvmArgsAppend = {"-Xmslg", "-Xmx1g"})
// 显示平均时间,单位纳秒
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class MyJITBenchmark {
public int add(int a, int b) {
return a + b;
}
public int jitTest() {
int sum = 0;
for (int i = 0; i < 10000000; i++) {
sum = add(sum, 100);
}
return sum;
}
// 禁用 JIT
@Benchmark
@Fork(value=1, jvmArgsAppend={"-Xint"})
public void testNoJit(Blackhole blackhole) {
int i = jitTest();
blackhole.consume(i);
}
// 只使用 c1 1层
@Benchmark
@Fork(value = 1, jvmArgsAppend = {"-XX:TieredStopAtLevel=1"})
public void testC1(Blackhole blackhole) {
int i = jitTest();
blackhole.consume(i);
}
@Benchmark
public void testMethod(Blackhole blackhole) {
int i = jitTest();
blackhole.consume(i);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(MyJITBenchmark.class.getSimpleName())
.forks(1)
.build();
}
}
JIT 编译器 主要优化手段是 方法内联 和 逃逸分析。
方法内联(Method Inline):方法体中的字节码指令直接复制到调用方的字节码指令中,节省了创建栈帧的开销。
案例:使用JIT Watch工具查看方法内联的优化结果
需求:
1、安装 JIT Watch 工具,下载源码:https://github.com/AdoptOpenJDK/jitwatch/tree/1.4.2
2、使用资料中提供的脚本文件直接启动。
3、添加源代码目录,点击沙箱环境 RUN:
方法内联的限制
并不是所有的方法都可以内联,内联有一定的限制:
1、方法编译之后的字节码指令总大小 < 35字节,可以直接内联。。(通过 -XX:MaxlnlineSize=值 控制)
2、方法编译之后的字节码指令总大小 < 325字节,并且是一个热方法。。(通过 -XX:FreqlnlineSize=值 控制)
3、方法编译生成的机器码不能大于 1000字节。(通过-XX:InlineSmallCode = 值控制)
4、一个接口的实现必须小于 3 个,如果大于三个就不会发生内联。
案例:String 的 toUpperCase 方法性能优化
需求:
1、String 的 toUpperCase 为了适配很多种不同的语言导致方法编译出来的字节码特别大,通过编写一个方法只处理 a-z 的大写转换提升性能。
2、通过 JITWatch 观察方法内联的情况。
逃逸分析
逃逸分析指的是如果 JIT 发现在方法内创建的对象不会被外部引引用**,那么就可以采用锁消除、标量替换等方式进行优化。**
逃逸分析-锁消除
逃逸分析-标量替换
逃逸分析真正对性能优化比较大的方式是标量替换,在 Java 虚拟机中,对象中的基本数据类型称为标量,引用的其他对象称为聚合量。标量替换指的是如果方法中的对象不会逃逸,那么其中的标量就可以直接在栈上分配。
案例:逃逸分析的优化测试
JIT 优化的几点建议
根据 JIT 即时编器优化代码的特性,在编写代码时注意以下几个事项,可以让代码执行时拥有更好的性能:
1、尽量编写比较小的方法,让方法内联可以生效。
2、高频使用的代码,特别是第三方依赖库甚至是 JDK 中的,如果内容过度复杂是无法内联的,可以自行实现一个特定的优化版本。
3、注意下接口的实现数量,尽量不要超过2个,否则会影响内联的处理。
4、高频调用的方法中创建对象临时使用,尽量不要让对象逃逸。
垃圾回收器原理
G1 垃圾回收器原理
G1 垃圾回收器原理
G1 垃圾回收有两种方式:
- 1.年轻代回收(Young GC)
- 2、混合回收 (Mixed GC)
G1 垃圾回收器原理-年轻代回收
年轻代回收只扫描年轻代对象(Eden + Survivor),所以从 GC Root 到年轻代的对象或者年轻代对象引用了其他年轻代的对象都很容易扫描出来。
这里就存在一个问题,年轻代回收只扫描年轻代对象(Eden+Survivor),如果有老年代中的对象引用了年轻代中的对象,我们又如何知道呢?
方案1:从 GC Root开始,扫描所有对象,如果年轻代对象在引用链上,就标记为存活
方案2:维护一个详细的表,记录哪个对象被哪个老年代引用了。在年轻代中被引用的对象,不进行回收。
方案2 的第一次优化:只记录 Region 被哪些对象引用了。这种引用详情表称为 记忆集 RememberedSet(简称 RS 或 RSet):是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。扫描时将记忆集中的对象也加入到 GC Root中,就可以根据引用链判断哪些对象需要回收了。
方案2 的第二次优化:将所有区域中的内存按一定大小划分成很多个块,每个块进行编号。记忆集中只记录对块的引用关系。如果一个块中有多个对象,只需要引用一次,减少了内存开销。
G1 垃圾回收器原理-卡表(Card Table)
每一个 Region 都拥有一个自己的卡表,如果产生了跨代引用(老年代引用年轻代),此时这个 Region 对应的卡表上就会将字节内容进行修改,JDK8 源码中0代表被引用了称为脏卡。这样就可以标记出当前 Region 被老年代中的哪些部分引用了。那么要生成记忆集就比较简单了,只需要遍历整个卡表,找到所有脏卡。
G1 垃圾回收器原理- 写屏障
JVM 使用写屏障(Write Barrier)技术,在执行引用关系建立的代码时,可以在代码前和代码后插入一段指令,从而维护卡表。
记忆集中不会记录新生代到新生代的引用,同一个 Region 中的引用也不会记录。
G1 垃圾回收器原理-记忆集的生成流程
记忆集的生成流程分为以下几个步骤:
1、通过写屏障获得引用变更的信息。
2、将引用关系记录到卡表中,并记录到一个脏卡队列中。
3、JVM 中会由 Refinement 线程定期从脏卡队列中获取数据,生成记忆集。不直接写入记忆集的原因是避免过多线程并发访问记忆集。
总结
G1垃圾回收器原理-混合回收
G1垃圾回收器原理-初始标记
初始标记会暂停所有用户线程,只标记从 GC Root 可直达的对象,所以停顿时间不会太长。采用三色标记法进行标记 三色标记法在原有双色标记(黑也就是1代表存活,白 0 代表可回收)增加了一种灰色,采用队列的方式保存标记为灰色的对象。
**黑色:存活,**当前对象在 GC Root 引用链上,同时他引用的其他对象也都已经标记完成。
**灰色:待处理,**当前对象在 GC Root 引用链上,他引用的其他对象还未标记完成。
**白色:可回收,**不在 GC Root 引用链上。
接下来进入并发标记阶段,继续进行未完成的标记任务。此阶段和用户线程并发执行。
从灰色队列中获取 尚未完成标记的 对象B。标记 B 关联的 A 和 C 对象,由于 A对象 并未引用其他对象,可以直接标记成黑色,
而 B 也完成了所有引用对象的标记,也标记为黑色。C对象 有引用对象E,所以先标记成灰色。所以剩余 对象F就是 白色,可回收。
三色标记存在一个比较严重的问题,由于用户线程可能同时在修改对象的引用关系,就会出现错标的情况,比如:
这个案例中正常情况下,B 和 c 都会被标记成黑色。但是在 BC标记前,用户线程执行了B.c = nuLl;将 B 到 c 的引用去除了。同时执行了A.c = C;添加了 A 到 C 的引用。此时会出现错标的情况,C是白色可回收。
G1 为了解决这个问题,使用了 SATB技术(Snapshot At The Beginning,初始快照)。SATB技术 是这样处理的:
1、标记开始时创建一个快照,记录当前所有对象,标记过程中新生成的对象直接标记为黑色,
2、采用前置写屏障技术,在引用赋值前比如 B.c = nuLl 之前,将之前引用的 对象c 放入 SATB 待处理队列中。SATB队列 每个线程都有一个,最终会汇总到一个大的 SATB队列中。
G1 垃圾回收器原理 - 最终标记
最终标记会暂停所有用户线程,主要是为了处理 SATB 相关的对象标记。这一步中,将所有线程的 SATB队列 中剩余的数据合并到总的 SATB队列 中,然后逐一处理。
SATB队列 中的对象,默认按照存活处理,同时要处理他们引用的对象。SATB的缺点是在本轮清理时可能会将不存活的对象标记成存活对象,产生了一些所谓的浮动垃圾,等到下一轮清理时才能回收
练习题
G1 垃圾回收器原理-转移
转移的步骤如下:
1、根据最终标记的结果,可以计算出每一个区域的垃圾对象占用内存大小,根据停顿时间,选择转移效率最高(垃圾对象最多)的几个区域。
2、转移时先转移 GC Root直接引用的对象,然后再转移其他对象。
转移的步骤如下:
1、根据最终标记的结果,可以计算出每一个区域的垃圾对象占用内存大小,根据停顿时间,选择转移效率最高(垃圾对象最多)的几个区域。
2、转移时先转移 GC Root 直接引用的对象,然后再转移其他对象。
3、回收老的区域,如果外部有其他区域对象引用了转移对象,也需要重新设置引用关系。
ZGC 原理
什么是 ZGC?
G1 转移时需要停顿的主要原因
在转移时,能不能让用户线程和 GC 线程同时工作呢?考虑下面的问题:
转移完之后,需要将 A 对对象的引用更改为新对象的引用。但是在更改前,执行 A.c.count = 2,此时更改的是转移前对象中的属性
更改引用之后,A 引用了转移之后的对象,此时获取 A.c.count 发现属性值依然是 1。这样就产生了问题,所以 G1 为了解决问题,在转移过程中需要进行用户线程的停止。ZGC 和 Shenandoah 解决了这个问题,让转移过程也能够并发执行。
ZGC 的解决方案
在 ZGC 中,使用了读屏障 Load Barrier 技术,来实现转移后对象的获取。当获取一个对象引用时,会触发读后的屏障指令,如果对象指向的不是转移后的对象,用户线程会将引用指向转移后的对象。
着色指针 (Colored Pointers)
访问对象引l用时,使用的是对象的地址。在 64位虚拟机中,是8个字节可以表示接近无限的内存空间。所以一般内存中对象,高几位都是 0 没有使用。着色指针就是利用了这多余的几位,存储了状态信息。
着色指针将原来的 8字节 保存地址的指针拆分成了三部分:
1、最低的 44位,用于表示对象的地址,所以最多能表示 16TB 的内存空间。
2.,中间4位是颜色位,每一位只能存放 0 或者 1,并且同一时间只有其中一位是 1
正常应用程序使用8个字节去进行对象的访问,现在只使用了44位,不会产生问题吗?
应用程序使用的对象地址,**只是虚拟内存,操作系统会将虚拟内存转换成物理内存。**而 ZGC 通过操作系统更改了这层逻辑。所以不管颜色位变成多少,指针指向的都是同一个对象。
ZGC 的内存划分
在 ZGC 中,与 G1垃圾回收器一样将堆内存划分成很多个区域,这些内存区域被称之为 Zpage。
Zpage 分成 三类大中小,管控粒度比 G1 更细,这样更容易去控制停顿时间。
小区域:2M,只能保存 256KB 内的对象。
中区域:32M,保存 256KB-4M 的对象。
大区域:只保存一个大于 4M 的对象。
初始标记阶段
并发标记阶段
遍历所有对象,标记可以到达的每一个对象是否存活**,用户线程使用读屏障,如果发现对象没有完成标记也会帮忙进行标记**
并发处理阶段
选择需要转移的 Zpage,并创建转移表,用于记录转移前对象和转移后对象地址,
转移开始阶段
转移 GC Root 直接关联的对象,不转移的对象 remapped值设置成1,避免重复进行判断。转移之后将两个对象的地址记入转移映射表
并发转移阶段
将剩余对象转移到新的 ZPage 中,转移之后将两个对象的地址记入转移映射表。
转移完之后,转移前的 Zpage 就可以清空了,转移表需要保留下来。
此时,如果用户线程访问4 对象引用的5对象,会通过读屏障,将4 对5 的引用进行重置,修改为对5 的引用,同时将 remap 标记为 1 代表已经重新映射完成
并发转移阶段结束之后,这一轮的垃圾回收就结束了,但其实并没有完成所有指针的重映射工作,这个工作会放到下一阶段,与下一阶段的标记阶段一起完成(因为都需要遍历整个对象图)。
第二次垃圾回收的初始标记阶段
第二次垃圾回收的初始标记阶段,沿着 GC Root 标记对象。
并发转移阶段 并发问题
分代 ZGC 的设计
在 JDK21 之后,ZGC 设计了年轻代和老年代,这样可以让大部分对象在年轻代回收,减少老年代的扫描次数,同样可以提升一定的性能。同时,年轻代和老年代的垃圾回收可以并行执行。
分代 ZGC 的设计
ZGC 核心技术
1、着色指针(Colored Pointers)
着色指针将原来的 8字节保存地址的指针拆分成了三部分,不仅能保存对象的地址,还可以保存当前对象所属的
状态。 不支持32位系统、不支持指针压缩
2、读屏障(Load Barrier)
在获取对象引I用判断对象所属状态,如果所属状态和当前 GC阶段 的颜色状态不一致,由用户线程完成本阶段的
工作。会损失一部分的性能,大约在5%~10%之间。
ShenandoahGC 原理
ShenandoahGC 的设计
ShenandoahGC 和 ZGC 不同,ShenandoahGC 很多是使用了 G1源代码改造而成,所以在很多算法、数据结构的定义上,与 G1 十分相像,而 ZGC 是完全重新开发的一套内容。
1、ShenandoahGC 的区域定义与 G1 是一样的。
2、没有着色指针,通过修改对象头的设计来完成并发转移过程的实现。
3**、ShenandoahGC 有两个版本,1.0版本存在于 JDK8 和 JDK11 中,后续的 JDK 版本中均使用2.0版本。**
ShenandoahGC 的设计-1.0版本
如果转移阶段未完成,此时转移前的对象和转移后的对象都会存活。如果用户去访问数据,需要使用转移后的数据。ShenandoahGC 使用了读前屏障,根据对象的前向指针来获取到转移后的对象并读取。
写入数据时,也会使用写前屏障,判断 Mark Word 中的 GC 状态,如果 GC 状态为0证明没有处于 GC过程中,直接写入,如果不为0则根据 GC状态值确认当前处于垃圾回收的哪个阶段,让用户线程执行垃圾回收相关的任务。
ShenandoahGc的设计 - 2.0版本
1.0版本的缺点:
1、对象内存大大增加,每个对象都需要增加8个字节的前向指针,基本上会占用5%-10%的空间。
2、读屏障中加入了复杂的指令,影响使用效率。
2.0版本 优化了前向指针的位置,仅转移阶段将其放入了Mark Word中。
ShenandoahGC 的执行流程
并发转移阶段 并发问题
如果用户线程在帮忙转移时,ShenandoahGC 线程也发现这个对象需要复制,那么就会去尝试写入前向指针,使用了类似 CAS 的方式来实现,只有一个线程能成功修改,其他线程会放弃转移的操作。
标签:Java,进阶,对象,虚拟机,引用,内存,方法,字节 From: https://blog.csdn.net/2301_79083000/article/details/143055939