您现在的位置是:首页 >学无止境 >JVM原理:JVM运行时内存模型(通俗易懂)网站首页学无止境
JVM原理:JVM运行时内存模型(通俗易懂)
目录
前言
做了几年开发,平时除了写代码造BUG和修复BUG之外,偶尔也会遇到反馈说程序较慢问题,要对程序性能排查与优化就得更深入学习,学习JVM可以帮助我们加深对JAVA的理解,让我们具备一定的性能排查与调优的能力,无非就是让程序别太卡或者别挂了,那挂了目前我遇到的主要是内存泄漏后导致OOM,或者内存分配不当,当机器内存不足时出发了Linux的保护机制,自动kill调占用内存最高的程序;所以我们要了解平时创建的对象、变量是如何存储的,这些知识点可以帮我们更好解决问题。
正文
文章中的JVM是以HotSpot为例:
JVM运行时数据区包括虚拟机栈、堆、方法区、程序计数器、本地方法栈。
虚拟机栈
虚拟机栈是线程私有的,也就是创建一个线程时,就会分配一个私有的栈,这个比较好理解,我们平时创建多线程时,每个方法里面的局部变量都是新的一份;
虚拟机栈维护了栈帧,每个方法都可以看作是一个栈帧。方法调用时会创建一个栈帧并且压栈,栈的特点是先进后出,比较符合我们程序的调用流程,比如A方法调用B方法,首先将创建栈帧A并压栈,当A方法中调用了B方法时创建栈帧B压栈,由于栈是先进后出,所以JVM在拿指令调用时,会先拿栈帧B进行调用。
自定义栈的大小
-Xss 512k
下面看下栈的结构:
局部变量表
局部变量表保存着方法中定义的局部变量,如基本数据类型、指向引用类型的地址指针、以及 returnAddress 类型。
局部变量表的数据随着栈帧的创建而存在,随着栈帧的销毁而销毁,这个比较好理解,每个方法的局部变量都是独立的,当方法调用完成后,局部变量就消失了,所以局部变量表是线程安全的。
栈是有空间大小的,所以当调用的方法较多时,会创建大量的栈帧,而栈帧又是占用内存空间,当创建栈帧时内存不足会导致stackoverflow栈溢出,栈溢出不会导致整个程序挂掉,但会导致当前线程挂掉。所以平常写递归要留意,不能出现死循环,不然最后就会报stackoverflow;
操作数栈
操作数栈也是栈结构,先入后出。操作数栈就是在程序计算指令执行前后用于保存临时数据的空间;
举个例子吧:
int a=1;
int b=1;
int c=a+b;
程序要执行以上的计算时
1、执行存值指令,将a=1放入操作数栈中
2、执行存值指令,将b=1放入操作数栈中
3、执行相加指令,出栈拿到a、b的值进行运算
4、将运算结果存入操作数栈中
5、执行赋值指令,将结果赋值给c,也就是存入局部变量表里
动态链接
我们编写Person类后,将其编译成class字节码文件,使用类加载器将其加载到内存中,此时会将类名、修饰符、变量名、方法名、方法返回值等类信息存入到常量池中,这里我们简单理解为维护了key-value表,如#01:0X71;
当调用方法时,会将指向方法地址的符号#01进行解析替换,此时指向方法地址的值就不在是#01,而是0X71了;所以动态链接会在程序运行时将间接地址引用转为直接地址引用;
方法返回地址
当一个返回调用结束后,返回值给上个方法,而且上个方法接收到返回值之后继续往下进行。而方法返回地址就是记录上个栈帧的位置,此时的栈帧出栈后,将返回结果存入下个栈帧的操作数栈中,然后执行赋值指令将值存入下个栈帧的局部变量表中;
这里我们知道会有两种返回方式,一种是程序正常退出,此时会返回值(如果有定义的话);一种是当前方法执行过程中抛出异常中断了方法,此时不会返回值;
本地方法栈
本地方法栈和虚拟机栈结构差不多,区别是虚拟机栈是为java方法(java写的代码)而设立的,而本地方法栈是为java代码中的native修饰的代码而设立的;并不是所有的虚拟机都有本地方法栈,如Sun HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。
本地方法存在的意义
本地方法会调用C语言或者C++类库,比如内核为了安全,要调度硬件时,不会轻易给你权限进行调度,你需要调用内核提供好的函数库接口,由它先来进行安全检查,再帮你进行调度。另一方面操作底层使用java代码难度较高,直接调用现成的库效率更高;
本地方法的调用
上图案例有点像调用Thread的start方法开辟新线程来执行Runnable的run方法过程;
虚拟机堆
当我们new一个对象时,会开虚拟机堆中开辟一块内存空间,引用类型和数组的数据都是存放在堆中。它是JVM中内存最大的一块空间,所有线程都可以访问它,所以它是共享,非线程安全的。
堆结构
我们程序中经常导致性能问题的地方就是堆了,由于它的空间很大,而程序长期运行必然会产生很多没有用的垃圾对象,所以JVM使用垃圾回收器对没有被引用的对象进行回收,垃圾回收过程会STW,用户线程会挂起,所以就必须减少STW的时间,垃圾回收算法后续文章会进行讲解;
由于堆空间很大,垃圾回收是需要对整个空间进行扫描的(Full GC),为了让垃圾回收得更快,这里将堆空间划分为年轻代和老年代,年轻代包括eden区、survivor0、survivor1区域;
Eden区
new对象时,会在Eden区开辟内存空间,当Eden区满了之后,会触发年轻代垃圾回收YGC
Survivor区域
这里有两块Survivor是因为YGC垃圾回收采用复制算法,将没有被垃圾回收的对象拷贝到另一块空间;
如Student对象刚开始在Eden区,当触发YGC之后,由于Student对象还被其它对象引用,所以不会进行回收。由于是采用复制算法,会将没有回收的对象迁移到Survivor中,当第二次YGC之后,Student对象还未被回收,将survivor0迁移到survivor1中。第三次YGC时,又将survivor1中没有被回收迁移到survivor0中。
那这些没有被垃圾回收的对象,一直占用着空间,如果占用较多时,YGC的频率就会特别高,所以这里引入了年龄的概念。每次YGC后,未被回收的将年龄+1,当年龄到达一定阈值时(默认15),迁移到老年代中,降低YGC频率。如果Survivor区满了,则直接进入老年代。
老年代Old区
老年代主要存放YGC过程中,一直没有被回收的对象。当老年代满了之后会触发FULL GC,此时老年代和年轻代都会进行垃圾回收,这个时间是比较久的,所以我们程序优化中要尽量减低FULL GC发生的频率;
常用参数指令
设置堆最小值:-Xms
设置堆最大值:-Xmx
设置年轻代大小:-Xmn
设置Eden:survivor0:survivor1比例:-XX:SurvivorRatio
晋升老年代的动态年龄: -XX:MaxTenuringThreshold
方法区
类编译后被加载到内存中后,其类修饰符、类名、父类信息、方法名称、变量名称等存入方法区的常量池中,方法区是各个线程共享的,JVM关闭时该区域空间就会被释放;
常量池
如下案例:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}
JVM翻译成指令后:
// ===========================================类的描述信息===============================================
Classfile /xx/xx/xx/xx/HelloWorld.class
Last modified 2021-10-12; size 569 bytes
MD5 checksum 7f4f0fe4b6e6d04ddaf30401a7b04f07
Compiled from "HelloWorld.java"
public class org.memory.jvm.t5.HelloWorld
minor version: 0
major version: 49
flags: ACC_PUBLIC, ACC_SUPER
// ===========================================常量池===============================================
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // org/memory/jvm/t5/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lorg/memory/jvm/t5/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 org/memory/jvm/t5/HelloWorld
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
// =======================================虚拟机中执行编译的方法===========================================
{
public org.memory.jvm.t5.HelloWorld();
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 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lorg/memory/jvm/t5/HelloWorld;
// main方法JVM指令码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
// main方法访问修饰符描述
flags: ACC_PUBLIC, ACC_STATIC
// main方法中的代码执行部分
// ===============================解释器读取下面的JVM指令解释并执行===================================
Code:
stack=2, locals=1, args_size=1
// 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
// 从常量池中符号地址为 #3 的地方加载常量 hello world
3: ldc #3 // String hello world
// 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
// main方法返回
8: return
// ==================================解释器读取上面的JVM指令解释并执行================================
// 行号映射表
LineNumberTable:
line 9: 0
line 10: 8
// 局部变量表
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
上面类经过编译成.class文件后,再然后将class文件反编译为JVM指令码后,我们可以看到常量池中记录了类的名称、方法名等符号引用,还有#23等字面量;
- 字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;如: #23 = Utf8 hello world
- 符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。如: #3 = String #23
运行时常量池
运行时常量池是当类被加载到内存时的版本,上述案例可以看到每个类都会存在常量池,所以当常量池被加载进内存,将数据放入运行时常量池之后,也是每个类都有一份,此时符号引用会被解析成直接引用;
如上述案例中的 #3 = String #23 会变成 #3 = String hello world
运行时常量池是一个统称,它包括了字符串常量池、类名称常量、方法名称常量、静态变量池、基础数据常量池等;
方法信息
存储方法要运行的指令、局部变量表、返回类型等信息
类信息
存储类的描述信息、枚举、接口、父类等信息
域信息
域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
JDK1.7前的方法区
此类版本的方法区实现叫永久代,它存在于JVM内存之内,堆内存之外,这块区域由于有空间大小限制,当JVM加载太多的Class类时,垃圾回空间跟不上存入空间时,会报会报内存溢出异常OutOfMemoryError:PermGen space。
下面命令可用来指定空间大小:
-
-XX:PermSize来设置永久代初始分配空间。
-
-XX:MaxPermSize来设定永久代最大可分配空间。
JDK1.7时的方法区
此版本将字符串常量和字符串常量池放到了堆中,其它还是在永久代中
JDK1.7后的方法区
此版本的方法区,由metaSpace实现,存在于本地内存中,也就是JVM外的内存空间,它受限与物理内存,当物理内存不足时,会内存溢出;
在这里插入图片描述
程序计数器
每个线程都有一个程序计数器用于记录当前线程要执行的指令地址;由于程序运行一般是多线程,单CPU数量少于线程数量时,就会存在并发,每个线程会获得CPU的执行权。如执行线程A时,由于CPU分配的时间片到了,此时将当前线程挂起,线程B获取CPU的使用权,当线程B执行完毕后或者时间片用完后,需要恢复线程A的执行,此时如果想CPU从上次执行点开始往下执行的话,就需要记录之前的指令;
程序计数器记录着当前线程要执行的下条执行,当CPU从程序计数器拿到指令的引用之后,需要将下条指令的引用地址维护进来。如果是调用native方法时,程序计数器的记录值就为空。
总结
JVM理论知识点很多,平常如果很少实操这些东西是很难真正懂的,中文社区的JVM文章五花八门,很多写的都不一样。这篇文章有些按自己的理解不一定准确,但是我感觉这些知识点有利于我们解决问题也就够了,如有错误望指出修改。
内存模型了解之后,我们知道了数据存哪里了,那内存只有那么大,而程序一直运行,会不断占用内存空间,程序又是如何保证内存一直能放得下数据,那就得学习垃圾回收了。