加群联系作者vx:xiaoda0423
仓库地址:https://webvueblog.github.io/JavaPlusDoc/
https://1024bat.cn/
在高性能硬件上部署程序,目前主要有两种方式:
通过 64 位 JDK 来使用大内存;
使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。
使用 64 位 JDK 管理大内存
堆内存变大后,虽然垃圾收集的频率减少了,但每次垃圾回收的时间变长。 如果堆内存为 14 G,那么每次 Full GC 将长达数十秒。如果 Full GC 频繁发生,那么对于一个网站来说是无法忍受的。
对于用户交互性强、对停顿时间敏感的系统,可以给 Java 虚拟机分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,至少要低到不会影响用户使用。
可能面临的问题:
内存回收导致的长时间停顿;
现阶段,64 位 JDK 的性能普遍比 32 位 JDK 低;
需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生超过 10GB 的 Dump 文件),哪怕产生了快照也几乎无法进行分析;
相同程序在 64 位 JDK 消耗的内存一般比 32 位 JDK 大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。
使用 32 位 JVM 建立逻辑集群
在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口, 然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。
考虑到在一台物理机器上建立逻辑集群的目的仅仅是为了尽可能利用硬件资源,并不需要关心状态保留、热转移之类的高可用性能需求, 也不需要保证每个虚拟机进程有绝对的均衡负载,因此使用无 Session 复制的亲合式集群是一个不错的选择。 我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据 SessionID 分配) 将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可。
可能遇到的问题:
尽量避免节点竞争全局资源,如磁盘竞争,各个节点如果同时访问某个磁盘文件的话,很可能导致 IO 异常;
很难高效利用资源池,如连接池,一般都是在节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余;
各个节点受到 32 位的内存限制;
大量使用本地缓存的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点都有一份缓存,这时候可以考虑把本地缓存改成集中式缓存。
调优案例分析与实战
场景描述
一个小型系统,使用 32 位 JDK,4G 内存,测试期间发现服务端不定时抛出内存溢出异常。 加入 -XX:+HeapDumpOnOutOfMemoryError(添加这个参数后,堆内存溢出时就会输出异常日志), 但再次发生内存溢出时,没有生成相关异常日志。
分析
在 32 位 JDK 上,1.6G 分配给堆,还有一部分分配给 JVM 的其他内存,直接内存最大也只能在剩余的 0.4G 空间中分出一部分, 如果使用了 NIO,JVM 会在 JVM 内存之外分配内存空间,那么就要小心“直接内存”不足时发生内存溢出异常了。
直接内存的回收过程
直接内存虽然不是 JVM 内存空间,但它的垃圾回收也由 JVM 负责。
垃圾收集进行时,虚拟机虽然会对直接内存进行回收, 但是直接内存却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收, 它只能等老年代满了后 Full GC,然后“顺便”帮它清理掉内存的废弃对象。 否则只能一直等到抛出内存溢出异常时,先 catch 掉,再在 catch 块里大喊 “System.gc()
”。 要是虚拟机还是不听,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。
JVM 的“无关性”
谈论 JVM 的无关性,主要有以下两个:
平台无关性:任何操作系统都能运行 Java 代码
语言无关性: JVM 能运行除 Java 以外的其他代码
Java 源代码首先需要使用 Javac 编译器编译成 .class 文件,然后由 JVM 执行 .class 文件,从而程序开始运行。
JVM 只认识 .class 文件,它不关心是何种语言生成了 .class 文件,只要 .class 文件符合 JVM 的规范就能运行。 目前已经有 JRuby、Jython、Scala 等语言能够在 JVM 上运行。它们有各自的语法规则,不过它们的编译器 都能将各自的源码编译成符合 JVM 规范的 .class 文件,从而能够借助 JVM 运行它们。
Java 语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的, 因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更加强大。 因此,有一些 Java 语言本身无法有效支持的语言特性,不代表字节码本身无法有效支持。
Class 文件结构
Class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的 0/1。Class 文件 中的所有内容被分为两种类型:无符号数、表。
无符号数 无符号数表示 Class 文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8 分别代表 1/2/4/8 字节的无符号数。
表 由多个无符号数或者其他表作为数据项构成的复合数据类型。
Class 文件具体由以下几个构成:
魔数
版本信息
常量池
访问标志
类索引、父类索引、接口索引集合
字段表集合
方法表集合
属性表集合
魔数
Class 文件的头 4 个字节称为魔数,用来表示这个 Class 文件的类型。
Class 文件的魔数是用 16 进制表示的“CAFE BABE”,是不是很具有浪漫色彩?
魔数相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在 Class 文件中标识文件类型比较合适。
版本信息
紧接着魔数的 4 个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪个版本的 JDK。
高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。
常量池
版本信息之后就是常量池,常量池中存放两种类型的常量:
字面值常量
字面值常量就是我们在程序中定义的字符串、被 final 修饰的值。
符号引用
符号引用就是我们定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符。
常量池的特点
常量池中常量数量不固定,因此常量池开头放置一个 u2 类型的无符号数,用来存储当前常量池的容量。
常量池的每一项常量都是一个表,表开始的第一位是一个 u1 类型的标志位(tag),代表当前这个常量属于哪种常量类型。
常量池中常量类型
类型 |
tag |
描述 |
---|---|---|
CONSTANT_utf8_info |
1 |
UTF-8 编码的字符串 |
CONSTANT_Integer_info |
3 |
整型字面量 |
CONSTANT_Float_info |
4 |
浮点型字面量 |
CONSTANT_Long_info |
5 |
长整型字面量 |
CONSTANT_Double_info |
6 |
双精度浮点型字面量 |
CONSTANT_Class_info |
7 |
类或接口的符号引用 |
CONSTANT_String_info |
8 |
字符串类型字面量 |
CONSTANT_Fieldref_info |
9 |
字段的符号引用 |
CONSTANT_Methodref_info |
10 |
类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info |
11 |
接口中方法的符号引用 |
CONSTANT_NameAndType_info |
12 |
字段或方法的符号引用 |
CONSTANT_MethodHandle_info |
15 |
表示方法句柄 |
CONSTANT_MethodType_info |
16 |
标识方法类型 |
CONSTANT_InvokeDynamic_info |
18 |
表示一个动态方法调用点 |
对于 CONSTANT_Class_info(此类型的常量代表一个类或者接口的符号引用),它的二维表结构如下:
类型 |
名称 |
数量 |
---|---|---|
u1 |
tag |
1 |
u2 |
name_index |
1 |
tag 是标志位,用于区分常量类型;name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量代表这个类(或接口)的全限定名,这里 name_index 值若为 0x0002,也即是指向了常量池中的第二项常量。
CONSTANT_Utf8_info 型常量的结构如下:
类型 |
名称 |
数量 |
---|---|---|
u1 |
tag |
1 |
u2 |
length |
1 |
u1 |
bytes |
length |
tag 是当前常量的类型;length 表示这个字符串的长度;bytes 是这个字符串的内容(采用缩略的 UTF8 编码)
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否被 abstract/final 修饰。
类索引、父类索引、接口索引集合
类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
由于 Java 不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。一个类可能实现了多个接口,因此用接口索引集合来描述。这个集合第一项为 u2 类型的数据,表示索引表的容量,接下来就是接口的名字索引。
类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过该常量总的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。
字段表集合
字段表集合存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。
每一个字段表只表示一个成员变量,本类中的所有成员变量构成了字段表集合。字段表结构如下:
类型 |
名称 |
数量 |
说明 |
---|---|---|---|
u2 |
access_flags |
1 |
字段的访问标志,与类稍有不同 |
u2 |
name_index |
1 |
字段名字的索引 |
u2 |
descriptor_index |
1 |
描述符,用于描述字段的数据类型。 基本数据类型用大写字母表示; 对象类型用“L 对象类型的全限定名”表示。 |
u2 |
attributes_count |
1 |
属性表集合的长度 |
u2 |
attributes |
attributes_count |
属性表集合,用于存放属性的额外信息,如属性的值。 |
字段表集合中不会出现从父类(或接口)中继承而来的字段,但有可能出现原本 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
方法表集合
方法表结构与属性表类似。
volatile 关键字 和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT 标志。
方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令。
属性表集合
每个属性对应一张属性表,属性表的结构如下:
类型 |
名称 |
数量 |
---|---|---|
u2 |
attribute_name_index |
1 |
u4 |
attribute_length |
1 |
u1 |
info |
attribute_length |
结构:
方法区(Metaspace)
堆(Heap)
虚拟机栈(Java Stack)
本地方法栈(Native Stack)
程序计数器(PC Register)
2. 方法区(Metaspace)
原理:
存储类的结构信息,如常量池、字段、方法数据。
JDK 8 之前叫永久代(PermGen),易爆
OutOfMemoryError: PermGen space
。JDK 8 后使用 Metaspace,改为使用本地内存。
案例(元空间 OOM):
import javassist.ClassPool;
public class MetaspaceOOM {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
for (int i = 0; ; i++) {
Class<?> c = pool.makeClass("MetaspaceOOM" + i).toClass();
}
}
}
启动参数:
-XX:MaxMetaspaceSize=32m
3. 堆(Heap)
原理:
所有对象实例分配在堆内。
GC 的主要回收区域。
新生代 + 老年代。
案例(堆 OOM):
import java.util.ArrayList;
import java.util.List;
public class HeapOOM {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1_000_000]); // 1MB
}
}
}
启动参数:
-Xms20m -Xmx20m
4. 虚拟机栈(Stack)
原理:
每个线程私有,方法调用时创建栈帧。
栈深过大导致
StackOverflowError
。
案例:
public class StackOverflow {
static int depth = 0;
public static void recursive() {
depth++;
recursive();
}
public static void main(String[] args) {
try {
recursive();
} catch (Throwable e) {
System.out.println("栈深度: " + depth);
}
}
}
参数设置:
-Xss256k
5. 本地方法栈(Native Stack)
原理:
调用 native 方法时使用,容易被 JNI 滥用造成 OOM。
多数 JVM 将其与虚拟机栈合并管理。
6. 程序计数器(PC Register)
原理:
每个线程私有,记录当前执行指令的地址。
几乎不会导致 OOM 或异常。
7. GC 原理 + 分代模型
分代设计:
新生代(Young) :Eden + Survivor(S0/S1),大部分对象快速回收。
老年代(Old) :长生命周期对象。
新生代案例(创建大量短生命周期对象):
public class YoungGCExample {
public static void main(String[] args) {
for (int i = 0; i < 5000; i++) {
byte[] buffer = new byte[1024 * 100]; // 100KB
}
}
}
参数配置:
-Xms64m -Xmx64m -Xmn32m -XX:+PrintGCDetails
8. 内存分配与调优参数
参数 |
含义 |
---|---|
-Xms / |
初始/最大堆内存 |
-XX:NewRatio |
老年代:新生代 比例(如 2 表示老年代是新生代的 2 倍) |
-XX:SurvivorRatio |
Eden 与 Survivor 的比例(如 8 表示 Eden:S0:S1 = 8:1:1) |
-XX:MaxMetaspaceSize |
元空间最大值(防止类加载过多爆内存) |
案例配置组合:
-Xms128m -Xmx128m -Xmn64m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:MaxMetaspaceSize=64m -XX:+PrintGCDetails
9. GC 案例对比(使用不同 GC)
# Parallel GC(默认 JDK8)
-XX:+UseParallelGC
# CMS
-XX:+UseConcMarkSweepGC
# G1
-XX:+UseG1GC
搭配上面代码,观察 GC log
输出差异。
10. 线程调优
原理:
线程数不合理会导致 CPU 上下文切换频繁。
死锁、线程阻塞、资源竞争需要定位。
案例(死锁):
public class DeadlockDemo {
public static void main(String[] args) {
Object a = new Object();
Object b = new Object();
new Thread(() -> {
synchronized (a) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (b) {}
}
}).start();
new Thread(() -> {
synchronized (b) {
synchronized (a) {}
}
}).start();
}
}
工具:
jstack <pid> # 查看线程栈
top -Hp <pid> # Linux 查看线程 CPU
🛠 推荐组合使用工具
工具 |
用途 |
---|---|
jstat -gc <pid> |
GC 次数、内存占用 |
jmap -dump:format=b,file=heap.hprof <pid> |
导出堆转储文件 |
jstack |
查看线程栈 |
VisualVM |
图形化分析 CPU、内存、线程 |
Arthas |
线上诊断神器(推荐!) |
已将各类 JVM 调优实战案例整合为 Java JVM 调优工具箱
,你可以通过命令行参数启动对应案例,例如:
java -Xms128m -Xmx128m -XX:+PrintGCDetails -cp . JVMTuningToolbox heapOOM
包含的功能有:
heapOOM
:堆内存溢出模拟metaspaceOOM
:元空间溢出模拟(需引入javassist
)stackOverflow
:栈溢出模拟youngGC
:新生代 GC 模拟deadlock
:线程死锁模拟
/*
* Java JVM 调优工具箱
* 包含:
* 1. 各类内存溢出模拟
* 2. GC 日志分析入口
* 3. 死锁与线程调试模拟
* 4. 启动参数建议与说明
*/
public class JVMTuningToolbox {
public static void main(String[] args) throws Exception {
switch (args[0]) {
case"heapOOM":
heapOOM();
break;
case"metaspaceOOM":
metaspaceOOM();
break;
case"stackOverflow":
stackOverflow();
break;
case"youngGC":
youngGC();
break;
case"deadlock":
deadlock();
break;
default:
System.out.println("Usage: [heapOOM | metaspaceOOM | stackOverflow | youngGC | deadlock]");
}
}
// 模拟堆内存溢出
public static void heapOOM() {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 1MB
}
}
// 模拟元空间溢出
public static void metaspaceOOM() throws Exception {
ClassPool pool = ClassPool.getDefault();
for (int i = 0; ; i++) {
CtClass cc = pool.makeClass("OOM" + i);
cc.toClass();
}
}
// 模拟栈溢出
static int depth = 0;
public static void stackOverflow() {
try {
recursive();
} catch (Throwable e) {
System.out.println("Stack depth: " + depth);
}
}
public static void recursive() {
depth++;
recursive();
}
// 模拟新生代垃圾回收
public static void youngGC() {
for (int i = 0; i < 10000; i++) {
byte[] data = new byte[1024 * 100];
}
}
// 模拟死锁
public static void deadlock() {
Object a = new Object();
Object b = new Object();
new Thread(() -> {
synchronized (a) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (b) {
System.out.println("Thread 1 done");
}
}
}).start();
new Thread(() -> {
synchronized (b) {
synchronized (a) {
System.out.println("Thread 2 done");
}
}
}).start();
}
}
/* 推荐启动参数:
-Xms128m -Xmx128m -Xmn64m -XX:NewRatio=2 -XX:SurvivorRatio=8 \
-XX:+PrintGCDetails -XX:+UseG1GC -XX:MaxMetaspaceSize=64m
工具建议:
- jstack <pid> 查看死锁
- jmap -dump 查看堆快照
- jstat -gc <pid> 查看 GC 活动
- VisualVM / Arthas 可用于深入诊断
*/