JVM - 运行时数据区 内存结构 详解
Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会销毁。其他数据区域是每个线程。每个线程的数据区域在线程创建时创建,在线程退出时销毁。关于运行时数据区可以用以下图形来表示:下面我们一一来解析这几个区方法区方法区用于存储已被虚拟机加载的类信息、常量、静态变量、动态生成的类等元数据。是各个线程共享的内存区域。我们通
Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会销毁。其他数据区域是每个线程。每个线程的数据区域在线程创建时创建,在线程退出时销毁。关于运行时数据区可以用以下图形来表示:
下面我们一一来解析这几个区
方法区
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、动态生成的类等元数据。是各个线程共享的内存区域。我们通过ClassLoader加载的Class对象是存放在堆区的,不是方法区。
永久代(持久代)
永久代(也称持久代) 是Hotspot虚拟机特有的概念,是方法区的一种实现,别的JVM都没有这个东西。
在Java 6中,方法区中包含的数据,除了JIT编译生成的代码存放在native memory的CodeCache区域,其他都存放在永久代;
在Java 7中,Symbol的存储从PermGen移动到了native memory,并且把静态变量从instanceKlass末尾(位于PermGen内)移动到了java.lang.Class对象的末尾(位于普通Java heap内);
方法区与永久代关系
方法区是JVM的一种概念,在具体的HotSpot上,使用永久代来实现方法区,J9和JRockit虚拟机是没有永久代这个概念的,因此,永久代是一种实现,方法区是一个标准。可以使用如下参数来调节永久代的大小:
-XX:PermSize
方法区初始大小
-XX:MaxPermSize
方法区最大大小
超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError: PermGen
元空间
jdk1.8以后,用元空间来实现方法区。元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。可以通过以下参数来指定元空间的大小:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
元空间的组成
1. Klass Metaspace
Klass Metaspace就是用来存klass的,klass是我们熟知的class文件在jvm里的运行时数据结构,不过有点要提的是我们看到的类似A.class其实是存在heap里的,是java.lang.Class的一个对象实例。这块内存是紧接着Heap的,和我们之前的perm一样,这块内存大小可通过-XX:CompressedClassSpaceSize参数来控制,这个参数前面提到了默认是1G,但是这块内存也可以没有,假如没有开启压缩指针就不会有这块内存,这种情况下klass都会存在NoKlass Metaspace里,另外如果我们把-Xmx设置大于32G的话,其实也是没有这块内存的,因为会这么大内存会关闭压缩指针开关。还有就是这块内存最多只会存在一块。
2. NoKlass Metaspace
NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool等,这块内存是由多块内存组合起来的,所以可以认为是不连续的内存块组成的。这块内存是必须的,虽然叫做NoKlass Metaspace,但是也其实可以存klass的内容,上面已经提到了对应场景。
为什么移除永久代?
- 它的大小是在启动时固定好的——很难进行调优。-XX:MaxPermSize,设置成多少好呢?
- HotSpot的内部类型也是Java对象:它可能会在Full GC中被移动,同时它对应用不透明,且是非强类型的,难以跟踪调试,还需要存储元数据的元数据信息(meta-metadata)。
- 简化Full GC:每一个回收器有专门的元数据迭代器。
- 可以在GC不进行暂停的情况下并发地释放类数据。
- 使得原来受限于持久代的一些改进未来有可能实现。
Metaspace调优
使用-XX:MaxMetaspaceSize参数可以设置元空间的最大值,默认是没有上限的,也就是说你的系统内存上限是多少它就是多少。-XX:MetaspaceSize选项指定的是元空间的初始大小,如果没有指定的话,元空间会根据应用程序运行时的需要动态地调整大小。
MaxMetaspaceSize的调优
- -XX:MaxMetaspaceSize={unlimited}
- 元空间的大小受限于你机器的内存
- 限制类的元数据使用的内存大小,以免出现虚拟内存切换以及本地内存分配失败。如果怀疑有类加载器出现泄露,应当使用这个参数;32位机器上,如果地址空间可能会被耗尽,也应当设置这个参数。
- 元空间的初始大小是21M——这是GC的初始的高水位线,超过这个大小会进行Full GC来进行类的回收。
- 如果启动后GC过于频繁,请将该值设置得大一些
- 可以设置成和持久代一样的大小,以便推迟GC的执行时间
CompressedClassSpaceSize的调优
- 只有当-XX:+UseCompressedClassPointers开启了才有效
- -XX:CompressedClassSpaceSize=1G
- 由于这个大小在启动的时候就固定了的,因此最好设置得大点。
- 没有使用到的话不要进行设置
- JVM后续可能会让这个区可以动态的增长。不需要是连续的区域,只要从基地址可达就行;可能会将更多的类元信息放回到元空间中;未来会基于PredictedLoadedClassCount的值来自动的设置该空间的大小
正如前面提到了,Metaspace VM管理Metaspace空间的增长。但有时你会想通过在命令行显示的设置参数-XX:MaxMetaspaceSize来限制Metaspace空间的增长。默认情况下,-XX:MaxMetaspaceSize并没有限制,因此,在技术上,Metaspace的尺寸可以增长到交换空间,而你的本地内存分配将会失败。
每次垃圾收集之后,Metaspace VM会自动的调整high watermark,推迟下一次对Metaspace的垃圾收集。
这两个参数,-XX:MinMetaspaceFreeRatio和-XX:MaxMetaspaceFreeRatio,类似于GC的FreeRatio参数,可以放在命令行。
Metaspace可以使用的工具
针对Metaspace,JDK自带的一些工具做了修改来展示Metaspace的信息:
- jmap -clstats :打印类加载器的统计信息(取代了在JDK8之前打印类加载器信息的permstat)。
- jstat -gc :Metaspace的信息也会被打印出来。
- jcmd GC.class_stats:这是一个新的诊断命令,可以使用户连接到存活的JVM,转储Java类元数据的详细统计。
虚拟机栈
栈是运行时的单位,而堆是存储的单位。Java虚拟机栈(Java virtual Machine stack) ,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame) ,对应着一次次的Java方法调用。
栈的基本结构
- 每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在。
- 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
- 栈帧是一个内存区块,是一个数据集,保存了方法的局部变量、部分结果,并参与方法的调用和返回。
栈运行原理
- JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈,执行方法时入栈,执行结束时出栈。
- 执行引擎运行的字节码指令只针对当前栈帧。
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
- 如果当前方法调用了其它方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用
return
指令;另一种是抛出异常(异常没有被 catch 处理)。不管使用哪种方式,都会导致栈帧被弹出。
栈帧的内部结构
每个栈帧存储着:
- 局部变量表(Local Variables)。
- 操作数栈(Operand Stack)或表达式栈。
- 动态链接(Dynamic Linking)或指向运行时常量池的方法引用。
- 方法返回地址(Return Address)或方法正常退出或者异常退出的定义。
- 一些附加信息。
栈帧 - 局部变量表
是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译为 Class 文件时,就在方法的Code属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。
请看下面一段代码
public class TestStack {
private String test(long a, String b) {
byte[] bytes = new byte[6 * 1024 * 1024];
String str = a + b;
System.gc();
return str;
}
}
javac -g TestStack.java先编译,然后执行javap -c -l -p -v TestStack 输出返汇编信息:
- Code: 表明保存在方法的Code属性中。
- flags: ACC_PRIVATE: 代表私有方法。
- stack=3, locals=6, args_size=3
stack : 操作数栈的深度。
locals : 占用的槽的大小,long,double占2个,其余指针变量和int等占1个。
args_size: 方法参数,这里多了一个this,所以是3。
下面是具体的局部变量表存储的信息:
Start : 表示从哪个字节码偏移量作用域开始生效。
Length: 表示 到哪个字节码偏移量 作用域结束。
比如:this变量,从0开始到32结束
表示整this在个方法作用域有效。
在比如: bytes变量: 从6开始到26结束
6的上一个指令是4,刚好就是把分配好的bytes[] bytes压入操作数栈,从6开始生效。
26 时:执行gc把bytes销毁了,作用域结束。
Slot : 槽信息
this : 占了第0个槽 ,大小为1 (引用变量占1)。
a : 占了第1到2个槽【下面是从3开始算了】,大小为2(long变量占2)。
以此类推…
Signature : 变量类型签名
以上就是局部变量表 的 全部存储信息。
slot的重复利用
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public Class SlotTest{
public test(){
int a = 0;
{
int b = 0;
b = a + 1;
}
}
// 变量c使用之已经销毁的变量b占据slot
int c = a + 1;
}
栈帧 - 操作数栈
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间。当一个方法刚开始执行的时候,操作数栈是空的,在方法执行的过程中字节码指令会往操作数栈内写入和取出元素。
看一下代码
public static void main(java.lang.String[]) throws java.io.IOException;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1 //栈深度最大为3,3个变量槽
0: iconst_1 //常量1压入栈
1: istore_1 //栈顶元素出栈存入变量槽1
2: iconst_2 //常量2压入栈
3: istore_2 //栈顶元素出栈存入变量槽2
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
//调用静态方法main
7: iload_1 //将变量槽1中值压入栈
8: iload_2 //将变量槽2中值压入栈
9: iadd //从栈顶弹出俩个元素相加并且压入栈
10: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
//调用虚方法
13: return //返回
可以看出,在方法的执行过程中会有各种的字节码指令往操作数栈中写入和读出元素。而且操作数栈中的元素的数据类型必须和字节码指令操作的数据的数据类型相匹配,例如istore_2对int类型操作,而如果此时的栈顶元素是long占用俩个变量槽,那么后面的指令操作肯定都会出错。在类加载的时候,检验阶段会进行验证。
栈帧 - 动态链接
先说下静态链接
在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用转化为可确定的直接引用,不会延迟到运行期再去完成,这也就是Java中的静态链接。
比如有如下代码
class StaticClass {
public static void call() {
}
}
public class Main {
public static void main(String[] args) {
StaticClass.call();
}
}
javap反编译后
public class debug_jdk8.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Methodref #14.#15 // debug_jdk8/StaticClass.call:()V
#3 = Class #16 // debug_jdk8/Main
#4 = Class #17 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 Main.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Class #18 // debug_jdk8/StaticClass
#15 = NameAndType #19:#6 // call:()V
#16 = Utf8 debug_jdk8/Main
#17 = Utf8 java/lang/Object
#18 = Utf8 debug_jdk8/StaticClass
#19 = Utf8 call
{
public debug_jdk8.Main();
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 8: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: invokestatic #2 // Method debug_jdk8/StaticClass.call:()V
3: return
LineNumberTable:
line 10: 0
line 11: 3
}
我们发现 StaticClass.call(); 被编译器翻译成 invokestatic #2 。我们看看JVM是如何处理这条指令的:
- 指令中的#2指的是StaticCall类的常量池中第2个常量表的索引项,我们观察Constant pool 的#2是 : debug_jdk8/StaticClass.call:()V
#2 = Methodref #14.#15
#14 = Class #18 // debug_jdk8/StaticClass
#15 = NameAndType #19:#6 // call:()V
#15 又是由#19和#6
#19 = Utf8 call
#6 = Utf8 ()V
- 紧接着JVM会加载、链接和初始化StaticClass类;
- 然后在StaticClass类所在的方法区中找到call()方法的直接地址,并将这个直接地址记录到StaticCall类的常量池索引为15的常量表中。 这个过程叫常量池解析 ,以后再次调用StaticClass.call()时,将直接找到call方法的字节码;
- 完成了StaticClass类常量池索引项15的常量表的解析之后,JVM就可以调用call()方法,并开始解释执行call()方法中的指令了
通过上面的过程,我们发现经过常量池解析之后,JVM就能够确定要调用的call()方法具体在内存的什么位置上了。实际上, 这个信息在编译阶段就已经在StaticClass类的常量池中记录了下来。这种在编译阶段就能够确定调用哪个方法的方式, 我们叫做静态绑定机制 。 除了被static 修饰的静态方法,所有被private 修饰的私有方法、被final 修饰的禁止子类覆盖的方法都会被编译成invokestatic指令。 另外所有类的初始化方法和会被编译成invokespecial指令。JVM会采用静态绑定机制来顺利的调用这些方法。
动态链接
符号引用在运行期间转化为直接引用,这种转化为动态链接。比如我们的多态,有下面代码
class A {
public void s(){}
}
class B extends A {
@Override
public void s() {
super.s();
}
}
public class Main {
public static void main(String[] args) {
A a = new B();
a.s();
}
}
反编译为
public class debug_jdk8.Main {
public debug_jdk8.Main();
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 debug_jdk8/B
3: dup
4: invokespecial #3 // Method debug_jdk8/B."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method debug_jdk8/A.s:()V
12: return
}
invokevirtual 指令: 调用虚方法,运行期动态查找的过程。
栈帧 - 方法返回地址
当一个方法开始执行以后,只有两种方法可以退出当前方法:
- 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,
调用者的PC计数器可以作为返回地址
。 - 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),
返回地址要通过异常处理器表来确定
。
当方法返回时,可能进行3个操作:
恢复
上层方法的局部变量表和操作数栈- 把返回值
压入
调用者调用者栈帧的操作数栈 调整
PC 计数器的值以指向方法调用指令后面的一条指令
本地方法栈
本地方法栈与虚拟机栈差不多,只是本地方法栈是服务于native方法的。
程序计数器
在介绍java的计数器前,我先来讲一下CPU中的程序计数器。
CPU中的程序计数器(PC)
CPU中的PC是一个大小为一个字的存储设备(寄存器),在任何时候,PC中存储的都是内存地址(是不是有点像指针?),而CPU就根据PC中的内存地址,到相应的内存取出指令然后执行并且在更新PC的值。在计算机通电后这个过程会一直不断的反复进行。计算机的核心也在于此。这个过程我会在字节码执行引擎那部分深入去介绍一下。
JVM程序计数器
在CPU中PC是一个物理设备,而java中PC则是一个一块比较小的内存空间,它是当前线程字节码执行的行号指示器。在java的概念模型中,字节码解释器就是通过改变这个计数器中的值来选取下一条执行的字节码指令的,它的程序控制流的指示器,分支,线程恢复等功能都依赖于这个计数器。
我们知道多线程的实现是多个线程轮流占用CPU而实现的,而在线程切换的时候就需要保存当前线程的执行状态,这样在这个线程重新占用CPU的时候才能恢复到之前的状态,而在JVM状态的保存是依赖于PC实现的,所以PC是线程所私有的内存区域,这个区域也是java运行时数据区域唯一不会发生OOM的区域。
最后一个就是我们的重中之重 堆区,请看我下篇文章分析!
强烈推荐一个java进阶技术博客
更多推荐
所有评论(0)