在上一篇中,介绍了JVM组件中的类加载器,以及相关的双亲委派机制。这一篇主要介绍运行时的数据区域
JVM架构图:
JDK1.8后的内存结构:
(图片来源:https://github.com/Seazean/JavaNote)
而在运行时数据区域中,根据线程是否共享可以进行分类:
- 线程不共享:程序计数器,本地方法栈,Java虚拟机栈。
- 线程共享:堆,方法区。
1、程序计数器
1.1、概述
简称PC寄存器,用于存储当前线程正在执行的指令的地址或者下一条即将执行的指令的地址。在Java虚拟机中,每个线程都有自己独立的程序计数器,它是线程私有的,不会被线程切换所影响。
它记录了当前线程正在执行的字节码指令的地址。当线程执行一般方法时,程序计数器记录的是正在执行的虚拟机字节码指令的地址,当线程执行的是本地方法(源码中被native关键字修饰的方法)时,程序计数器的值为空(Undefined)。
程序计数器的作用有以下几点:
- 线程切换恢复: 当线程切换回来时,虚拟机通过程序计数器来确定线程上次执行到的位置,从而继续执行。(例如我现在有A,B两个线程并发执行某个方法,该方法有10条指令,A线程首先获得了执行权,在执行到第4条指令时CPU的时间分片结束,B线程获得到了执行权,从第1条指令开始执行,等待CPU时间分片再次结束,假设A线程获得了执行权,就从第4条指令继续执行。)
- 指令定位: 程序计数器指示了当前正在执行的虚拟机指令的地址,帮助虚拟机准确定位下一条需要执行的指令。
- 异常处理: 虚拟机使用程序计数器来记录异常处理代码的起始地址,以便异常处理完成后能够继续执行原来的代码。
- 线程间通信: 在多线程环境下,程序计数器也可以用于线程间通信,例如实现轻量级的线程协作机制。
1.2、案例
例如有如下的一段代码
public class Demo1 {public static void main(String[] args) {int i = 0;if (i ==0){i--;}i++;}
}
它的字节码指令是
0 iconst_0
1 istore_1
2 iload_1
3 ifne 9 (+6)
6 iinc 1 by -1
9 iinc 1 by 1
12 return
其中每一行开头处的0,1等代表偏移量,在字节码或者内存中,偏移量表示了某个数据项相对于起始地址的偏移量,以字节为单位。
在加载阶段,虚拟机将字节码的指令读取到内存后,会将偏移量转换为内存地址:
代码的执行过程中,程序计数器会记录下一行字节码指令的地址,执行完当前指令,虚拟机的执行引擎会根据程序计数器执行下一条指令。
2、栈
首先明确一个概念:栈区别于队列,是一种先进后出的数据结构,类似于弹夹,先压入的子弹最后打出,后压入的子弹最先打出。
并且在多线程环境下,栈之间是相互独立的,这一点在JUC并发编程篇中做过验证。
在JVM中,栈又是由三部分组成:
- 局部变量表:存放运行时的所有局部变量
- 操作数栈:用于存放执行过程中的临时数据
- 帧数据:包含动态链接,方法出口,异常表引用等
2.1、局部变量表
我现在有一段代码
public class Demo2 {public static void main(String[] args) {int i = 10;long j = 20;}
}
编译后通过jclasslib查看:
表头的含义:
- Nr.:代表当前元素的编号,在案例中0代表args,1代表i,2代表j。
- 起始PC:表示该局部变量的作用域的起始位置,即该局部变量在方法中有效的起始位置:
这段字节码大致的含义是:
0 bipush 10: 这条字节码将整数10推送到操作数栈顶。bipush指令用于将一个字节(-128到127之间的整数)推送到操作数栈顶。
2 istore_1: 将操作数栈顶的整数值(之前推送的10)存储到索引为1的本地变量中。istore_1指令将整数值存储到本地变量表中索引为1的位置。
3 ldc2_w #2 <20>: 将一个常量(在常量池中的索引为2的项,可能是一个long或double类型的常量)推送到操作数栈顶。
6 lstore_2: 将操作数栈顶的long类型常量值(之前推送的常量)存储到索引为2的本地变量中。lstore_2指令将long类型的值存储到本地变量表中索引为2的位置。
7 return: 从当前方法返回,没有返回值。return指令用于从当前方法返回,结束方法的执行。
由此可知,当i变量经过了1,2两步后,才算赋值完成,所以i的作用域是从3开始。j同理。
- 长度:表示该局部变量的作用域的长度,即该局部变量在方法中有效的长度。
- 序号:表示该局部变量在局部变量表中的索引位置。局部变量表是按索引顺序存储局部变量的,索引从0开始递增。
而在实例方法中(区别于被static关键字修饰的静态方法),序号为0的位置会存放一个this。代表调用该方法的对象:
public class Demo2 {public static void main(String[] args) {}public void test1(){int i = 10;long j = 20;}
}
如果是带有参数的方法,方法的参数也是会存放在局部变量表中的,例如main方法的args参数,在第一个案例中就有所体现。
例如在某个实例方法中,有两个参数,并且有两个局部变量,那么在局部变量表中就会有5个元素。
局部变量表中的序号也是能复用的:
public class Demo2 {public static void main(String[] args) {}public void test1(int k,int m){{int a = 1;int b = 2;}{int c = 1;}int i = 0;long j = 1;}
}
上面的案例,在0号索引处存放了this,然后将参数k,m放在了1,2号索引处,第一个代码块中的a,b放在3,4号索引处。
然后执行第二个代码块,a,b的作用范围已经结束了。就会把c放在原先a的3索引的位置。
最后执行给i,j赋值的语句,c的作用范围也结束了,就会把i放在原先c的3索引位置,j方法原先b的4索引位置。
2.2、操作数栈
操作数栈的深度是在编译期就提前确定的:
2.3、帧数据
2.3.1、动态链接
动态链接是指在方法调用时,JVM需要确定被调用方法的实际地址或者说是方法在内存中的具体位置。由于Java是一种面向对象的语言,方法调用可能涉及到多态性,即被调用方法的具体实现可能在运行时才能确定。
动态链接会有以下的步骤:
-
查找方法: 当一个方法被调用时,JVM需要查找该方法的具体实现。首先,它会根据方法调用指令中的符号引用(Symbolic Reference)去找到对应的类和方法,这个过程叫做解析。
-
解析: 解析阶段会将符号引用解析为直接引用(Direct Reference),即找到被调用方法在内存中的具体位置。这个过程可能会涉及到类加载、链接等步骤。
-
绑定: 绑定是将方法调用指令与被调用方法的具体实现关联起来的过程。动态绑定是在运行时根据对象的实际类型来确定方法的具体实现。这种机制允许在程序运行时实现多态性。
简单来说,动态链接表现在编译期无法确定,只能在运行期间将符号引用转换为直接引用。(编译和链接阶段,函数调用只是一个符号引用,不包含实际的地址。)
与之相对的是静态链接,在编译阶段,所有的函数调用在链接时就被确定为了直接引用,所有的库函数以及其他被调用的函数的代码都会被复制到可执行文件中。(可执行文件在运行时不再依赖外部的库,因为所有的依赖关系在编译时已经被解决了。)
一般的场景是,如果没有依赖外部的库或动态链接库,是一个独立的执行文件,则是静态链接。如果你的程序需要使用系统提供的共享库或第三方库,则是动态链接。
2.3.2、异常表
异常表是一种数据结构,用于管理和处理Java程序中的异常。异常表存储在方法的字节码中,并由JVM在方法执行期间使用。
public class Demo1 {public static void main(String[] args) {int i = 0;try {i = 1;} catch (Exception e) {i = 2;}}
}
对应的字节码指令:
0 iconst_0
1 istore_1
2 iconst_1
3 istore_1
4 goto 10 (+6) -- 如果没有发生异常,就直接跳到第十步。
7 astore_2
8 iconst_2
9 istore_1
10 return
对应的异常表
其中起始PC和结束PC就是try...catch块的作用范围,跳转PC为出现异常时执行的代码,捕获类型为捕获何种异常,在案例中是所有Exception类型的。
在栈中,是可能存在内存溢出问题的,通常的原因是递归没有正确设置退出条件,导致栈溢出。
public class Demo1 {static int count = 0;public static void main(String[] args) {test1();}private static void test1() {System.out.println(count++);test1();}
}
在执行了大约9800次的时候发生了栈溢出(StackOverflowError)。
栈的大小是可以通过JVM参数进行设置的,如果没有设置栈的大小,JVM也会创建一个默认大小的栈,其大小取决于不同的操作系统。
如果需要手动修改栈的大小,可以通过JVM参数:-Xss栈大小 实现:
例如我将其设置成为了512M:
如果局部变量过多,操作数栈深度过大也会影响栈内存的大小。
3、堆
堆内存是用于存储对象实例的内存区域,是 Java 程序中最主要的内存区域之一。堆内存由 JVM 在运行时动态分配和管理,用于存储所有通过New关键字创建的对象实例以及数组对象。
3.1、对象实例
栈中的局部变量表,可以存放堆上对象的引用:
同时堆的内存也会存在溢出现象(OutOfMemoryError):
public class Demo1 {public static void main(String[] args) throws InterruptedException, IOException {ArrayList<Object> objects = new ArrayList<Object>();while (true){objects.add(new byte[1024 * 1024 * 100]);}}
}
我们也可以通过arthas工具的dashboard命令进行堆内存使用情况的查看:
- used:代表当前已使用的内存。
- total:代表虚拟机分配的可用堆内存。
- max:是java虚拟机可以使用的最大堆内存。
简单来说,当used大于等于total时,total会扩容,但是最大不能超过max。
我们通过在上面的案例的循环中加上
while (true){System.in.read();objects.add(new byte[1024 * 1024 * 100]);
// Thread.sleep(1000);}
验证一下,当执行了两次循环后发生了扩容:
堆内存的大小也是可以通过JVM命令去设置的,如果没有设置,max默认是系统最大运行内存的1/4,total是1/64。
修改total的命令是:-Xms,修改max的命令是:-Xmx 其中Xms必须大于1M,Xmx必须大于2M,建议将Xms和Xmx设置成相同的值。
3.2、字符串常量池
字符串常量池用于存储代码中定义的常量字符串。在JDK1.8中,字符串常量池不位于方法区中,而是在堆中(运行时常量池位于直接内存的元空间中)。
例如我现在有以下的代码:
public class Demo2 {public static void main(String[] args) {String a = "1";String b = "2";String c = "12";String d = a + b;System.out.println(c == d);}
}
最终运行的结果是什么?答案是false,通过分析字节码指令,其原因在于,当我们执行String d = a + b 时,在字节码的层面是创建一个StringBuilder的对象,创建的对象会被放在堆内存中。
而c变量的值12是放在字符串常量池中的,所以指向的不是同一个地址(c指向的是字符串常量池中的12,d指向的是堆中的12),使用 == 判断的结果是false。
修改一下上面的案例:
public class Demo3 {public static void main(String[] args) {String a = "1";String b = "2";String c = "12";String d = "1" + "2";System.out.println(c == d);}
}
运行结果是true,执行 String d = "1" + "2";时不会产生新的对象,而是从字符串常量池中找到c变量的12。
3.3、静态变量
在JDK1.8后,静态变量存放在堆中,静态变量是属于类的,而不是属于类的实例,因此它们只会在类被加载时被初始化,并且在整个应用程序的生命周期内存在,直到应用程序结束或者类被卸载。
4、本地内存
4.1、方法区
用于存储类信息、常量、静态变量和即时编译器编译后的代码等数据。
主要包含了:
-
类信息存储: 方法区主要用于存储加载的类信息,包括类的结构信息、字段信息、方法信息、父类信息、接口信息等。每个加载的类都有对应的 Class 对象在方法区中存储。
-
常量池: 方法区包含了常量池(Constant Pool),用于存储类中的常量信息,如字符串常量、基本类型常量、符号引用等。常量池在类加载时被创建,包括编译时生成的常量和运行时生成的常量。
-
静态变量: 方法区还存储了类的静态变量,即被static修饰的类级别的变量。这些变量在类加载时被初始化,并在整个应用程序的生命周期内保持不变。
-
即时编译器产生的代码: 方法区还用于存储即时编译器(Just-In-Time Compiler,JIT)编译后的本地机器代码,这些代码用于提高 Java 程序的执行效率。
-
运行时常量池: 除了类加载时的常量池,方法区还包含了运行时常量池,它是在类加载完成后在方法区中动态生成的,用于存储运行时解析的常量信息。
在JDK1.8之后,方法区中的永久代(Permanent Generation)被元数据区(Metaspace)所取代。
复习一下,在类的生命周期的加载阶段,类加载器加载完成后,JVM会将读取到的字节码信息保存到内存的方法区中,生成一个InstanceKlass对象,保存类的基本信息。
方法中的静态常量池,连接阶段后,会将符号引用改变成直接引用。(连接阶段中的解析阶段,会将常量池中的符号引用替换成直接引用)。
上面提到过栈和堆都有可能存在内存溢出的问题,而方法区同样可能会内存溢出:
- 在JDK1.7及以前的版本中,方法区位于堆中的永久代空间。
- 在JDK1.8及以后的版本中,方法区位于元空间中,和堆一样是独立的空间。(本地内存)
这样就造成了,在JDK1.7以前的版本,方法区大小受限于堆的大小,而之后的版本,方法区的大小则取决于操作系统的直接内存大小。
同样也可以使用-XX:MaxMetaspaceSize= 命令分配元空间的大小。
4.2、直接内存
是一种在 Java 中进行内存分配和管理的机制,它不同于传统的 Java 堆内存和栈内存。直接内存并不是由 JVM 直接管理的,而是由操作系统管理的一块内存区域。
主要用于提高IO的效率,优势在于它可以通过操作系统的零拷贝技术来实现高效的数据传输。在进行 I/O 操作或者进行大规模数据处理时,直接内存能够直接与操作系统进行交互,避免了数据的多次复制和拷贝,从而提高了系统的性能和效率。
NIO在读写文件时,会将其放入直接内存,并且在堆上维护对直接内存地址的引用。
如果需要创建直接内存,可以使用:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
而直接内存和堆,栈,方法区一样,同样会存在内存溢出的问题:
如果需要手动调整直接内存大小,可以通过JVM命令-XX:MaxDirectMemorySize = 大小
补充:
运行时常量池和常量池表:
- 运行时常量池是每个类或接口的一部分,用于存储编译时生成的字面量常量和符号引用。除了字符串常量外,运行时常量池还包含其他类型的常量,如整数常量、浮点数常量等。运行时常量池是类加载过程中的一部分,在类加载后会被存储在方法区(JDK 8 及之前)或元空间(JDK 8 及之后)中。
- 常量池表是 class 文件中的一部分,用于存储编译时生成的常量信息。它包含了类或接口中的所有常量,包括字符串常量、符号引用、方法名、字段名等。常量池表中的每个常量都有一个索引,可以通过索引来访问常量池中的具体内容。运行时常量池实际上是常量池表在运行时被加载到内存中的形式之一。
常量池表在类加载后成为运行时常量池。