JVM性能调优
本文是按照自己的理解进行笔记总结,如有不正确的地方,还望大佬多多指点纠正,勿喷。
1、JDK体系结构与跨平台特性介绍
2、JVM内存模型深度剖析
3、从jvisualvm来研究下对象内存流转模型
4、讲透Gc Root与STW机制
5、日均百万级订单交易系统JVM参数如何设置
6、JVM参数设置通用模型
1. JDK的体系结构

JDK: JDK提供了编译、运行Java程序所需的各种资源和工具;包括Java编译器,Java运行时环境【JRE】;开发工具包括编译工具(javac.exe) 打包工具(jar.exe)等。
JRE: 即JAVA运行时环境,JVM就是包括在JRE中,以及常用的JAVA类库等;
SDK: SDK是基于JDK进行扩展的,是解决企业级开发的工具包。如JSP、JDBC、EJB等就是由SDK提供的 ;
JVM(Java Virtual Machine),Java虚拟机,可以看做是一台抽象化的计算机,它有一套完整的体系架构,包括处理器、堆栈 、寄存器等。
在运行时环境,JVM会将Java字节码解释成机器码。机器码和平台相关的(不同硬件环境、不同操作系统,产生的机器码不同),所以JVM在不同平台有不同的实现。目前JDK默认使用的实现是Hotspot VM。
2. Java语言的跨平台特性

一次编译,到处执行(Write Once ,Run Anywhere)。 用Java创建的可执行二进制程序,能够不加改变的运行于多个平台。从软件层面屏蔽不同操作系统底层硬件与指令上的区别
其实跨平台就是JVM来做的,我们在下载JDK的时候,会让我们选择不同的操作系统按照相应的JDK就是为了对应不同的操作系统。
3.JVM整体结构及内存模型
官方文档参考:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.htmI#jvms-2.5.4
3.1 内存模型
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为个不同的数据区。这些区域有各自的用途,以及创建和销毁事件。
JVM用来存储加载的类信息、常量、静态变量、编译后的代码等数据。

3.1.1 PC寄存器(线程私有)
PC寄存器,也叫程序计数器。JVM支持多个线程同时运行,每个线程都有自己的程序计数器,也是自己独有的内存空间。他存储当前正在运行或者马上要运行的那句代码的内存位置。倘若当前执行的是JVM方法,则该寄存器中保存当前执行指令的地址;倘若执行的是native方法,则PC寄存器为空。
程序计数器是可以变动的,每执行一行代码,那个字节码执行引擎会马上去修改程序计数器。他为什么可以修改呢?我们知道我们的代码Math.class最终是加载到方法区的。但是他在方法区呈现的是一些元素信息,不是一个文件,是字节码引擎他来执行那个Math.class的代码。那你执行到哪个位置他肯定知道。
这个内存区域是唯一一个在虚拟机中没有规定任何OutOfMemoryError情况的区域。
java虚拟机为什么要设置一个这压根的计数器?
多线程。我正在执行这个代码,忽然被优先级更高的线程把cpu抢占起来了,那么当前这个线程就要挂起,挂起完将来肯定是要修复的,如果要恢复需要告诉cpu上次没有执行的位置开始执行。这个时候就是需要根据程序计数器里面的值知道执行到哪一行了。
3.1.2 虚拟机栈(线程私有)
栈里面是存局部变量的。每个线程有一个私有的栈,随着线程的创建而创建。栈里面存放着一种叫做“栈帧”的东西,每个方法在执行的时候会创建一个栈帧,存储了局部变量表(基本数据类型和对象引用),操作数栈,动态连接,方法出口等信息。
每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。
(方法中的局部变量的空间可以进行释放)
通常所说的栈,一般是指虚拟机栈中的局部变量表部分。局部变量表所需的内存在编译期间完成分配。
栈的大小可以固定也可以动态扩展,当扩展到无法申请足够的内存,则OutOfMemoryError。
当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误
演示栈帧:
package ding;
public class Math {
public static final int initData = 666;
public static People people = new People();
public int compute(){
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
}
}
当我们的线程一开始运行main方法,马上分配一块自己专属的线程栈,只要线程开始运行main方法,会在这一大块线程栈里面给自己的mian方法分配一块自己专属的地方,放main方法自己的局部变量,比如math。然后compute一运行又会给compute分配栈内存区域,用来放compute的局部变量。整个JVM内部他给每一个方法都会分配一块专属的内存空间,我们把这块空间叫做栈内存空间。


那这个里面的栈与数据结构的栈(先进后出)有什么不一样的吗?
这个栈就是数据结构里面的栈,是一样的,这个下面是栈底,上面是栈顶,就是mian在栈底,compute在栈顶,出的时候就是compute先出,然后main再出。
为什么用数据结构里面的栈存储内存空间呢?
比如这个compute方法他是后调用的,但是他会先执行完,然后释放空间。
如果是递归的话在内存里面也不是套娃,也是一个一个往上,有很多个compute,他是往栈顶一直走,不是套娃。

栈帧内部还是比较复杂的,除了放局部变量表之外,还有一块是操作数栈、动态链接、方法出口。
1. 局部变量表
就是放一些局部变量的
2. 操作数栈
看这个就需要看字节码文件
我们先来看字节码文件。

然后进行反汇编。打开终端

下面这个相当于是JVM的汇编语言。
Compiled from "Math.java"
public class ding.Math {
public static final int initData;
public static ding.People people;
public ding.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int compute();
Code:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class ding/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_1
12: invokevirtual #5 // Method compute:()I
15: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
18: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
21: ldc #7 // String test
23: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
26: return
static {};
Code:
0: new #9 // class ding/People
3: dup
4: invokespecial #10 // Method ding/People."<init>":()V
7: putstatic #11 // Field people:Lding/People;
10: return
}
JVM的指令手册可以参考:https://blog.youkuaiyun.com/weixin_44991304/article/details/120057916
我们首先来看compute方法,在这个方法里面的第一句话:
0: iconst_1这句话的意思就是将int类型常量1压入操作数栈。
这个前4行就是把变量a,b进行赋值。
就是先把1放到操作数栈里面,然后是a在局部变量表里面,然后把操作数栈里面的1赋值给局部变量表里面的a。接着b也是这样的操作。
4、5行就是把1、2放到操作数栈里面。
6就是把操作数栈里面的数据出栈加一起等于3,把3入栈到操作数栈里面。
7是把10压到操作数栈里面。
9是相加
操作数栈就是我们操作数在程序运行过程中要操作的一块临时的一块中转存放的内存空间。
3. 动态链接
动态链接就是把符号引用转换成直接引用。
静态链接就是程序在加载的过程中。
动态链接就是程序在运行的过程中,符号引用转换成这个方法对应的代码的这些个直接地址,在内存里面的地址。
4. 方法出口
你这个方法执行完要回到main方法中去。那我是怎么知道呢?当在调用这个math.compute的时候实际上就把这个main方法运行时什么时候回来继续执行都放在了这个方法出口这块。意思就是根据这个方法出口知道要返回到main方法里面去继续执行。
5. main方法的局部变量表
他的局部变量表放的时math,而这个math时new的一个对象。一般来说是放到堆里面的。但其实是有一个math放到局部变量表的。他里面放的是堆里面给他分配的内存地址。
3.1.3 栈和堆的关系
其实这个时候栈和堆的关系就已经出来了。栈里面放的都是一些局部变量。而堆里面放的是值,栈里面放的都是堆里面对应值的内存地址。
3.1.4 方法区(线程共享)
方法区也是所有线程共享的。主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。
运行时常量池主要放到方法区,方法区=常量+静态变量+类信息
比如在上面那个Math类里面有一句话:public static People people = new People();这句话中user会放到方法区里面也会放到堆里面,堆里面是对象,是值,方法区里面放的是堆里面对应的内存地址。这个时候方法区与堆的关系也出来了。
这个区域的内存回收目标主要针对常量池的回收和对类型的卸载。
当方法区无法满足内存分配需求时,则抛出OutOfMemoryError异常。
在HotSpot虚拟机中,用永久代来实现方法区,将GC分代收集扩展至方法区,但是这样容易遇到内存溢出的问题。
JDK1.7中,已经把放在永久代的字符串常量池移到堆中。
JDK1.8撤销永久代,引入元空间。
3.1.5 本地方法栈
和虚拟机栈类似,主要为虚拟机使用到的Native方法服务。
比如new Thread().start();就是本地方法。

点击start进入

再进去就是一个native方法。

3.1.6 堆

静态池、缓存、spring容器里面的对象最终可能会跑到老年代里面。
public class HeapOutOfMemoryErrorTest {
byte[] arr = new byte[1024 * 1000];//1M
public static void main(String[] args) throws InterruptedException {
ArrayList<HeapTest> list = new ArrayList<>();
while (true) {
list.add(new HeapTest());
}
}
}
cmd运行下面这句话
jvisualvm

没有VisualGC界面,可以使用插件,下载即可,重启
启动程序,查看程序的运行过程:

如果老年代放满了会发生什么?
会先触发full gc,他回收的是整个堆与方法区。然后如果都回收不掉了会触发OOM,内存溢出。
在触发full gc的时候会执行STW。其实minor gc的时候也会触发,但是STW的时间会很短很短。
JVM为什么要设置STW机制
gc过程就是在找一些非垃圾对象。比如现在发生full gc了。然后那边在找对象,当这一条链条找完了。正准备找其他的局部变量、静态变量要找,那我们这个线程可以继续区执行,执行完突然结束了,意味着这些局部变量出栈了,早就被释放掉了,意味着math指向堆的指针就没有了。那这个时候之前找到的那个链条就变成垃圾对象了。如果使用STW,在一些关键核心的地方那个线程别变化,别执行。


3.2 JVM整体结构及内存模型

补充一个问题:
在minor gc过程中对象挪动后,引用如何修改?
对象在堆内部挪动的过程其实是复制,原有区域对象还在,一般不直接清理,JVM内部清理过程只是将对象分配指针移动到区域的头位置即可,比如扫描s0区域,扫到gcroot引用的非垃圾对象是将这些对象复制到s1或老年代,最后扫描完了将s0区域的对象分配指针移动到区域的起始位置即可,s0区域之前对象并不直接清理,当有新对象分配了,原有区域里的对象也就被清除了。
minor gc在根扫描过程中会记录所有被扫描到的对象引用(在年轻代这些引用很少,因为大部分都是垃圾对象不会扫描到),如果引用的对象被复制到新地址了,最后会一并更新引用指向新地址。
4. JVM内存参数设值

Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar
-Xss:每个线程的栈大小
-Xms:设置堆的初始可用大小,默认物理内存的1/64
-Xmx:设置堆的最大可用大小,默认物理内存的1/4
-Xmn:新生代大小
-XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。
-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。
这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。
由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
StackOverflowError示例:
// JVM设置 -Xss128k(默认1M)
public class StackOverflowTest {
static int count = 0;
static void redo() {
count++;
redo();
}
public static void main(String[] args) {
try {
redo();
} catch (Throwable t) {
t.printStackTrace();
System.out.println(count);
}
}
}
运行结果:
java.lang.StackOverflowError
at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:12)
at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)
at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)
......
结论:
-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多
2080

被折叠的 条评论
为什么被折叠?



