文章目录
java虚拟机(JVM)在java程序运行的过程中,会将它所管理的内存划分为若干个不同的数据区域,这些区域有的随着JVM的启动而创建,有的随着用户线程的启动和结束而建立和销毁。
JVM虚拟机
JVM虚拟机的组成有3个部分,其中最重要的就是 运行时数据区
- 类装载子系统(C++实现)
- 运行时数据区(JVM内存区域)
- 字节码执行引擎(C++实现)
java Math
的执行先由类装载子系统加载Math.class,将数据都放到运行时数据区,最终通过字节码执行引擎来执行程序代码。
JVM 内存模型 运行时数据区 Run-Time Data Areas
JDK8下JVM内存模型的组成如下
- 堆(Heap)
- 栈(Java Virtual Machine Stacks)
- 栈帧
- 本地方法栈:
- 方法区(元空间)(Method Area)
- 运行时常量池(Run-Time Constant Pool)
- 程序计数器(The pc Register)
堆和方法区是所有线程共享的,栈、程序计数器和本地方法栈都是每个线程独享的
堆 Heap
new出来的对象一般放在堆内,有时也会放在栈内
- 年轻代 (1/3)
- Eden (8/10), new出来的对象一般放在这里, 放满了会触发 minorgc(非fullgc)
- Survivor
- s0 (1/10)
- s1 (1/10)
- 老年代 (2/3)
GC大致过程
- Eden区满, 在Eden区, 从GCRoot出发, 找到所有有用的非垃圾对象, 复制到Survivor0区, 分代年龄+1, 其他的垃圾对象直接干掉
- Eden区再次满, 再次触发GC, 在Eden和Survivor0区, 从GCRoot出发, 找到所有有用的非垃圾对象, 复制到Survivor1区(放不下的放到老年代, 这个过程称为晋升(Promotion)), 分代年龄+1, 其他的垃圾对象直接干掉
- Eden区再次满, 再次触发GC, 在Eden和Survivor1区, 从GCRoot出发, 找到所有有用的非垃圾对象, 复制到Survivor0区(放不下的放到老年代), 分代年龄+1, 其他的垃圾对象直接干掉
- …
- 当分代年龄达到15(可设置但最大15(对象头空间大小限制))时, 对象会被复制到老年代, 如常量/静态变量/Spring的Bean等长期不释放的
- 当老年代放满了, 触发FullGC, 回收整个堆和方法区, 如果空间还是不够用, OOM
可以通过 jvisualvm 工具(需要安装VisualGC插件)来查看 list 的 while(true) 添加过程(sleep). 也可以使用阿里开源的 Arthas
class HeapTest {
byte[] array = new byte[1024 * 1000]; // 1000kb
public static void main(String[] args) {
try {
List<HeapTest> list = new LinkedList<>();
while (true) {
list.add(new HeapTest());
Thread.sleep(10);
}
} catch (Throwable cause) {
cause.printStackTrace();
}
}
}
STW - Stop The World
在 minorgc 和 fullgc 时会发生 STW, 会暂停用户的所有线程, 会影响性能, 暂停较长时间的话会感觉卡顿
JVM调优主要就是减少FullGC或者FullGC的执行时间, 因为其收集垃圾的时间会比较久
GC时为什么要STW? 因为如果不暂停用户线程, 那就有可能出现找出来的非垃圾对象被还在继续执行的线程结束后释放掉了, 即找出来的非垃圾对象变成了垃圾对象, 导致找的过程无意义了. 再加上标记非垃圾对象的过程比较复杂, 所以才索性直接STW, 方便省事儿了
在minor gc过程中对象挪动后,引用如何修改?
对象在堆内部挪动的过程其实是复制,原有区域对象还在,一般不直接清理,JVM内部清理过程只是将对象分配指针移动到区域的头位置即可,比如扫描s0区域,扫到gcroot引用的非垃圾对象是将这些对象复制到s1或老年代,最后扫描完了将s0区域的对象分配指针移动到区域的起始位置即可,s0区域之前对象并不直接清理,当有新对象分配了,原有区域里的对象也就被清除了。
minor gc在根扫描过程中会记录所有被扫描到的对象引用(在年轻代这些引用很少,因为大部分都是垃圾对象不会扫描到),如果引用的对象被复制到新地址了,最后会一并更新引用指向新地址。
这里面内部算法比较复杂,感兴趣可以参考R大的这篇文章:
栈 Java Virtual Machine Stacks
虚拟机栈是用于描述java方法执行的内存模型。
官方叫虚拟机栈,个人理解叫线程栈更合适。线程运行前,jvm会给每个线程创建一个独享的线程栈,主要存放该线程运行时的各方法的局部变量。我们常说的“堆内存、栈内存”中的“栈内存”指的便是虚拟机栈。
方法调用时,创建栈帧,并压入虚拟机栈;方法执行完毕,栈帧出栈并被销毁,释放内存。线程结束后,线程栈销毁,释放内存。
特点
虚拟机栈是线程隔离的,即每个线程都有自己独立的虚拟机栈。
虚拟机栈的StackOverflowError
若单个线程请求的栈深度大于虚拟机允许的深度,则会抛出StackOverflowError(栈溢出错误)。
JVM会为每个线程的虚拟机栈分配一定的内存大小(-Xss参数),因此虚拟机栈能够容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽,典型如一个无结束条件的递归函数调用。
虚拟机栈的OutOfMemoryError
不同于StackOverflowError,OutOfMemoryError指的是当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时抛出的异常。
JVM未提供设置整个虚拟机栈占用内存的配置参数。虚拟机栈的最大内存大致上等于“JVM进程能占用的最大内存(依赖于具体操作系统) - 最大堆内存 - 最大方法区内存 - 程序计数器内存(可以忽略不计) - JVM进程本身消耗内存”。当虚拟机栈能够使用的最大内存被耗尽后,便会抛出OutOfMemoryError,可以通过不断开启新的线程来模拟这种异常。
栈帧(Stack Frame)
栈帧存放于线程栈内。栈内的栈帧是先进后出的。线程启动后,每一个方法调用前,jvm会在该线程栈内压入为该方法创建的独享的一个栈帧,方法内的局部变量都放在该栈帧内,方法结束后栈帧弹出线程栈释放内存。
举例:程序启动,jvm给主线程分配线程栈,jvm给main方法创建栈帧并压入线程栈,当执行到调用另一个compute方法时,jvm为compute方法创建栈帧并压入线程栈(此时线程栈中有两个栈帧),待compute执行结束,对应栈帧弹出销毁,接着执行main方法中的内容,main方法执行结束后,对应栈帧弹出销毁,线程结束,对应线程栈销毁。
特别说明,嵌套调用会一直创建栈帧,最后占满栈帧空间,然后报 StackOverFlowError
栈帧主要包含如下结构
- 局部变量表
- 操作数栈
- 动态链接
- 方法出口
局部变量表
放局部变量的值, 并不会存放局部变量的符号. 类似数组(table), 索引0代表调用栈帧对应的该方法的对象 this, 索引1及之后代表的都是方法内部的局部变量了.
基本数据类型的局部变量直接把值存在局部变量表中, 引用类型的局部变量会把对象存在堆中, 然后在局部变量表中引用对应的地址
如 a=1, math=new Math(), 索引1代表a, 索引1对应的值是1, 索引2代表math, 索引2对应的值是存在堆中的Math实例的内存地址
操作数栈
在程序运行过程中用于临时存放数据的中转内存空间. 是一个栈, 先进后出
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
如上方法, 编译为class文件再转换为jvm汇编指令后, 在执行的时候, 1/2/3/10/30等数字都会压入弹出操作数栈
动态链接
动态链接的定义是在程序运行期间完成的将符号引用替换为直接引用. 程序启动时, 各种代码会加载到方法区, 每个方法都会有对应的内存地址, 动态链接就是把符号替换为对应的内存地址, 当前栈帧对应的方法的代码的直接地址就放在动态链接这块内存中
public static void main(String[] agrs) { // 类加载时解析(静态链接)会替换掉static/void/main等符号, 但是方法中的compute等并不会被解析, 而是在方法执行的时候才会动态解析
Math math = new Math();
math.compute(); // 都是常量池中的符号, 在类加载时不会解析, 而是在运行时解析, 解析出来的直接引用会放到动态链接这块内存中
System.out.println();
}
方法出口
该栈帧对应的方法执行完后, 需要回到调用该方法的代码的地方, 方法出口里面就保存了这些信息
程序计数器(Program Counter Register)
程序计数器是一个记录着当前线程所执行的字节码的行号指示器。执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址。由字节码执行引擎每执行完一行代码后负责修改。作用是cpu轮转后字节码指令能接着运行
JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。
JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。
每一条字节码指令都是一个原子操作, 不可分割, cpu时间片耗尽前一定能完成执行完某一条字节码指令
特点
- 线程隔离性,每个线程工作时都有属于自己的独立计数器。
- 执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址。
- 执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。
- 程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。
- 程序计数器,是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域。
本地方法栈(Native Method Stack)
java调用本地方法的时候也需要一定的内存空间, 但是内存空间的使用由调用的本地方法自行管理使用
本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出StackOverflowError和OutOfMemoryError异常。
不同的是,**本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法。**如何去服务native方法?native方法使用什么语言实现?怎么组织像栈帧这种为了服务方法的数据结构?虚拟机规范并未给出强制规定,因此不同的虚拟机实可以进行自由实现,我们常用的HotSpot虚拟机选择合并了虚拟机栈和本地方法栈。
方法区(Method Area)(元空间, 1.8之前叫永久带/持久带)
- 常量 (static且final)
- 静态变量 (static无final, 基本数据类型直接放在方法区, 引用类型的引用地址放在方法区)
- 类元信息(Klass): 类的所有信息以及每个方法的代码等, 是C++的数据结构, java无法直接查看, 提供给java开发人员的访问入口是位于堆内存中的对应的Class对象, 可以访问类的很多信息, 但是没有每个方法的代码
常量池(Constant Pool)
后面再补
运行时常量池(Run-Time Constant Pool)
后面再补
通过 javap -v Math.class
得到的JVM汇编代码中, Constant Pool 中的内容被载入到程序中后都存放在这里
Constant pool:
#1 = Methodref #5.#26 // java/lang/Object."<init>":()V
#2 = Class #27 // com/mrathena/Math
#3 = Methodref #2.#26 // com/mrathena/Math."<init>":()V
#4 = Methodref #2.#28 // com/mrathena/Math.computer:()I
#5 = Class #29 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Lcom/mrathena/Math;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 math
#18 = Utf8 computer
#19 = Utf8 ()I
#20 = Utf8 a
#21 = Utf8 I
#22 = Utf8 b
#23 = Utf8 c
#24 = Utf8 SourceFile
#25 = Utf8 Math.java
#26 = NameAndType #6:#7 // "<init>":()V
#27 = Utf8 com/mrathena/Math
#28 = NameAndType #18:#19 // computer:()I
#29 = Utf8 java/lang/Object
8大数据类型对象池
后面再补
字符串常量池
后面再补
JVM 参数设置
注意: jvm参数, -version, -X…, -XX…, X越多, 说明该参数越不稳定, 后续版本可能废除或修改
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示例
-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多
class StackOverflowTest {
static int count = 0;
static void redo() {
count++;
redo();
}
public static void main(String[] args) {
try {
redo();
} catch (Throwable cause) {
cause.printStackTrace();
System.out.println(count);
}
}
}
字节码 指令码
将 Math.class 反编译为 jvm专用的汇编指令码(非常规意义上的汇编语言),javap -c Math.class -> Math.txt
, javap -v Math.class -> Math.txt
javap -c Math.class -> Math.txt
Compiled from "Math.java"
public class com.mrathena.Math {
public com.mrathena.Math();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/mrathena/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method computer:()I
12: pop
13: return
public int computer();
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
}
javap -v Math.class -> Math.txt
Classfile /C:/mrathena/develop/workspace/idea/mrathena/test/target/classes/com/mrathena/Math.class
Last modified 2021-1-15; size 578 bytes
MD5 checksum 4bd311478d52edaf713c7a8df7f83d12
Compiled from "Math.java"
public class com.mrathena.Math
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#26 // java/lang/Object."<init>":()V
#2 = Class #27 // com/mrathena/Math
#3 = Methodref #2.#26 // com/mrathena/Math."<init>":()V
#4 = Methodref #2.#28 // com/mrathena/Math.computer:()I
#5 = Class #29 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Lcom/mrathena/Math;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 math
#18 = Utf8 computer
#19 = Utf8 ()I
#20 = Utf8 a
#21 = Utf8 I
#22 = Utf8 b
#23 = Utf8 c
#24 = Utf8 SourceFile
#25 = Utf8 Math.java
#26 = NameAndType #6:#7 // "<init>":()V
#27 = Utf8 com/mrathena/Math
#28 = NameAndType #18:#19 // computer:()I
#29 = Utf8 java/lang/Object
{
public com.mrathena.Math();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/mrathena/Math;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/mrathena/Math
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method computer:()I
12: pop
13: return
LineNumberTable:
line 6: 0
line 7: 8
line 8: 13
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 args [Ljava/lang/String;
8 6 1 math Lcom/mrathena/Math;
public int computer();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
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
LineNumberTable:
line 11: 0
line 12: 2
line 13: 4
line 14: 11
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lcom/mrathena/Math;
2 11 1 a I
4 9 2 b I
11 2 3 c I
}
SourceFile: "Math.java"
computer 简单方法说明
# java code
public int compute() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
# javap -c Math.class
# javap -v Math.class, -v比-c更详细, 常量池也会有
public int compute(); // 执行每一个方法时, jvm都会创建该方法对应的栈帧, 并压入该线程对应的线程栈
Code:
0: iconst_1 // 将int类型常量1压入操作数栈, 操作数栈是栈帧里面的主要部分之一, 在操作数栈里压入一个int类型的1
1: istore_1 // 将int类型值存入局部变量1, 局部变量1是a(0是this), 将int的1从操作数栈中出栈, 并放入局部变量表索引1的位置的内存中, 相当于a=1. 这两个jvm汇编指令就相当于执行了 int a = 1; 这行代码
2: iconst_2 // 将int类型常量2压入操作数栈
3: istore_2 // 将int类型值存入局部变量2, 局部变量2是b, 将int的2从操作数栈中出栈, 并放入局部变量表索引2的位置的内存中
4: iload_1 // 从局部变量1中装载int类型值, 从局部变量表索引1的位置加载其值, 即加载a的值, 并入栈到操作数栈
5: iload_2 // 从局部变量2中装载int类型值, 入栈到操作数栈, 此时操作数栈中有两个值, 分别是2和1
6: iadd // 执行int类型的加法, 从操作数栈中出栈2和1, 做加法操作, 将int结果3压入操作数栈
7: bipush 10 // 将一个8位带符号整数压入栈, 即把int的10压入操作数栈, 此时操作数栈中有两个值, 分别是10和3. 这里7->9是因为每个编号都代表一个内存地址, 后面的10也需要占一个内存也有内存地址, 这个内存地址的编号可以理解成就是8
9: imul // 执行int类型的乘法, 从操作数栈中出栈10和3, 做乘法操作, 将int结果30压入操作数栈
10: istroe_3 // 将int类型值存入局部变量3, 局部变量3是c, 将int30从操作数栈中出栈, 将c=30放入局部变量表索引3的位置的内存中
11: iload_3 // 从局部变量3中装载int类型值, 从局部变量表索引3的位置加载其值, 即加载c的值, 并入栈到操作数栈
12: ireturn // 从方法中返回int类型的数据, 将int30从操作数栈中出栈, 并将值返回到主线程