运行时数据区
1. 内存和线程
Java的内存布局规定了Java在运行过程中内存的申请、分配、管理的策略,保证了JVM高效稳定的运行。JVM中关于内存的管理主要涉及的就是运行时数据区,如下所示:
其中有些部分是线程独有的,如虚拟机栈、本地方法栈和程序计数器;而方法区或是元空间、堆是进程所有的,即所有线程所共享的。对于JVM来说,由于堆通常占据了最大的内存空间,因此针对于JVM的优化实际上就是对于堆空间的优化,还有一小部分是对方法区的优化。
计算机中的多个进程并发的执行,同时进程中的多个线程也可以并发执行,其中进程中的每个线程和操作系统的本地线程是直接映射的。本地线程会随着Java线程的创建而创建,最后会随着Java线程的结束而终止。当在Java程序中创建好线程后,实际上是操作系统负责将所有的线程和本地线程相互映射,然后将本地线程调度到可用的CPU上。一旦本地线程初始化完成,它就会调用Java线程中的run()
。
此外,JVM中的系统级线程主要包括:
- 虚拟机线程:它需要JVM到达安全点才会出现,这种此案成的执行类型包括”stop-the-world”的垃圾收集、线程栈收集、线程挂起和偏向锁撤销
- 周期任务线程:它是时间周期事件的体现,一般用于周期性操作的调度执行
- GC线程:JVM中不同的垃圾收集机制提供了不同的支持
- 编译线程:它负责在运行时间字节码编译到本地代码
- 信号调度线程:它用于接收信号并发送给JVM,在它内部通过调用适当的方法进行处理
2. 程序计数器
JVM中的程序计数器(Program Counter Register,PC Register)和计算机中的PC寄存器的原理是类似的,它负责存放线程和指令的现场信息,即指向下一条指令的地址,执行引擎会根据地址来执行相应的指令。PC Register具有如下的特点;
- 只占用很小的内存空间,但运行速度最快
- 线程私有,它的生命周期和所属的线程是一致的,JVM使用它进行并发执行的线程之间的切换
- 任何时间一个线程只有一个方法在执行,即当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址,如果是正在执行的native方法,则是undefined
- 它是程序控制流的指示器,Java中的分支、循环、跳转、异常处理、线程恢复都需要程序计数器来完成
- 字节码解释器工作时也依赖程序计数器来选择下一条需要执行的字节码指令
- JVM中唯一一个没有规定任何OOM异常的区域
下面我们通过一个demo来看一下程序计数器在JVM的运行期间是如何起作用的,假设此时程序如下所示:
当我们编译结束后,使用javap - v xxx.class
命令或是jclasslib插件查看程序所对应的字节码信息时,ByteCode部分包含两部分的信息:指令地址和相应的指令。程序计数器中保存的就是指令地址,执行引擎根据指令地址来取指令执行。
3. 虚拟机栈
3.1概念
JVM为了做到真正的跨平台性,实现了底层硬件之间的解耦,采用了栈的思想来设计。这样的做法可以实现跨平台、指令集小。但由于不依赖于寄存器,不足之处在于性能下降,执行相同的操作需要更多的指令。
栈和堆的区别:
- 栈是运行时单位,堆是存储的单位。栈解决程序的运行问题,即程序如何执行,堆处理数据的存储问题,即数据应该如何存放
- 对象主要方法堆空间中,它在运行时数据区中所占比例较大
- 栈空间中存放基本数据类型的局部变量,以及引用类型对象的引用,即对象在堆空间中的地址
Java虚拟机栈(Java Virtual Machine Stack)是线程私有的,每个线程在创建时都会创键一个属于自己的虚拟机栈,栈内部保存一个个的栈帧(Stack Frame),其中每个栈帧都对应一个Java方法。它主要负责Java程序的运行,它保存方法的局部变量和部分结果,并参与方法的调用的返回。
栈是一种后进先出的结构,因此,当方法被调用时入栈,当方法调用结束后出栈,它就不存在垃圾回收问题。
2. 栈内存溢出
栈内存可以分为虚拟机栈(VM Stack)和本地方法栈(Native Method Stack),除了它们分别用于执行Java方法(字节码)和本地方法,其余部分原理是类似的(以虚拟机栈为例说明)。Java虚拟机栈是线程私有的,当线程中方法被调度时,虚拟机会创建用于保存局部变量表、操作数栈、动态连接和方法出口等信息的栈帧(Stack Frame)。
具体来说,当线程执行某个方法时,JVM会创建栈帧并压栈,此时刚压栈的栈帧就成为了当前栈帧。如果该方法进行递归调用时,JVM每次都会将保存了当前方法数据的栈帧压栈,每次栈帧中的数据都是对当前方法数据的一份拷贝。如果递归的次数足够多,多到栈中栈帧所使用的内存超出了栈内存的最大容量,此时JVM就会抛出StackOverflowError。
下面我们下一个不断的递归调用自己的方法,然后执行该程序:
public class StackOverflowErrorDemo {
private static int stackLength = 0;
public static void main(String[] args) {
StackOverflowErrorDemo demo = new StackOverflowErrorDemo();
try {
demo.pusStack();
} catch (Throwable e){
System.out.println("stack length is: " + demo.stackLength);
throw e;
}
}
public void pusStack(){
stackLength++;
pusStack();
}
}
运行程序很快就会抛出异常,异常信息如下所示。从输出信息中发现,出现问题的地方就是程序中递归调用方法自身的地方。
stack length is: 20315
Exception in thread "main" java.lang.StackOverflowError
at OutOfMemoryErrorDemo.StackOverflowErrorDemo.pusStack(StackOverflowErrorDemo.java:16)
at OutOfMemoryErrorDemo.StackOverflowErrorDemo.pusStack(StackOverflowErrorDemo.java:16)
at OutOfMemoryErrorDemo.StackOverflowErrorDemo.pusStack(StackOverflowErrorDemo.java:16)
......
总之,不论是因为栈帧太大还是栈内存太小,当新的栈帧内存无法被分配时,JVM就会抛出StackOverFlowError。通常栈内存可以通过设置-Xss
参数来改变大小。
如果Java虚拟机栈是允许动态扩展的,并且在尝试扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那么JVM会抛出OOM异常。
3.3 存储结构和原理
前面说到虚拟机栈是线程私有的,不同线程中所包含的栈帧是不允许相互引用的。线程正在执行的每个方法都对应于栈中的一个栈帧。栈帧中保存了方法在执行过程中的各种数据信息,当方法被执行时,方法对应的栈帧入栈;当方法执行结束后,栈帧出栈。因此,在每个时间点上,虚拟机栈中只会有一个活动的栈帧(栈顶栈帧,当前栈帧),它对应于当前正在执行的方法(当前方法)。
执行引擎运行的所有字节码指令只针对于当前栈帧进行操作。如果当前方法调用了其他方法,方法返回之际,当前栈帧会返回此方法的执行结果给前一个栈帧,然后虚拟机会丢弃当前栈帧,使得前一个栈帧重新称为栈顶栈帧。
Java方法有两种返回函数的方式:
- 正常的返回,使用return指令
- 抛出异常
但不管使用使用哪一种方式,方法执行结束后都会弹出当前栈顶栈帧。
3.4 内部结构
每个栈帧都存储了如下的内容:
- 局部变量表(Local Variable)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)
- 方法返回地址(Return Adress)
- 一些附加信息
4. 局部变量表
局部变量表也被称为局部变量数组或本地变量表,它的形式为数字数组,主要用于存储方法参数和定义在方法体内部的局部变量,这些数据类型包括各种基本数据类型、对象引用和returnAddress类型。它所需的容量大小是在编译期确定下来的,并保存在方法的Code属性maximum local variables数据项中,在方法执行期间它的大小是不会改变的。
局部变量表是与栈调优最为相关的部分,方法执行时,JVM通过局部变量来完成方法的传递。而且它也是垃圾回收的根节点,只要被局部变量表中直接或是间接引用的对象都不会被回收。
局部变量表的大小也影响了方法的递归调用,当一个方法的参数和局部变量越多时,它对应的局部变量表就会越大,栈帧也会越大,那么执行该方法所需的栈内存空间就越大,最后可递归调用的次数就越小。
而且局部变量表中的变量只在当前方法执行中有用。在方法执行时,JVM通过使用局部变量表来完成参数值到参数变量列表的传递过程;当方法调用结束后,随着栈帧的销毁,栈帧中的局部变量表也就不复存在了。
例如在之前的例子中,我们通过jclasslib打开mian()
的局部变量表,可以看到表中的元素和方法中的变量是一一对应的。局部变量表中的信息包含变量的长度、表中的起始索引、名字和相应的描述信息。
4.1 变量槽Slot
局部变量表中参数值的存放总是以局部变量数组的索引0开始,到数组的长度 - 1 终止,其中局部变量表最基本的存储单元就是Slot。槽中可存放编译期可知的各种基本数据类型、引用类型和returnAddress类型的变量,除了long和double类型的变量占两个槽之外,其他的变量都只占一个槽。
- byte、short、char、float在存储前都被转换为int,boolean用0表示false,用非0表示true
- long和double占两个槽
JVM会为局部变量表中的每个槽分配一个起始索引,通过索引就可以访问局部变量表中指定的变量值。从上面的demo中可以看出,当调用一个方法时,方法的参数列表和方法体中的的变量会按序的插入槽中。如果当前栈帧是构造方法或是实例方法对应的栈帧,那么该对象引用this也会被插入到槽中,并且起始索引就是0,其他的依然接着按序插入。
此外,栈帧中的局部变量表中的Slot是被重复利用的,如果一个变量过了其作用域,那么在其作用域之后声明的局部变量就可能重用它所占的slot。例如:
private void test() {
int a = 0;
{
int b = 0;
b = a+1;
}
//变量c使用之前以及经销毁的变量b占据的slot位置
int c = a+1;
}
4.2 静态变量 VS局部变量
变量按照它在类中声明的位置可分为:
成员变量:使用前都会被赋予默认初始值
- static修饰:它属于类变量,在类加载的linking阶段会为类变量赋初始值,在初始化阶段会为类变量显式的赋值
- 不被static修饰
- 局部变量:使用前需显式的进行赋值
5. 操作数栈
每个独立的栈帧除了虚拟机栈之外,还包含一个后进先出的操作数栈(表达式栈)。每个操作数栈都有一个明确的表示栈深度的数值,其所需的最大深度在编译期确定,保存在方法的code属性的max_stack值中。栈中的数据可以是任意的数据类型,而且不同类型数据在栈中所占的大小是不同的:
- 32bit的类型数据占用一个栈单位深度
- 64bit的类型数据占用两个栈单位深度
由于栈本身的结构特点,当程序想要取栈中的数据时只能通过入栈和出栈操作进行获取。
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。它会随着栈帧的创建而创建,而且在创建的一开始为空,随着方法的执行,栈中的数据会随着发生变化。它在方法的执行过程中用于根据字节码指令往栈中写入或是提取数据,基本操作就是入栈和出栈:
- 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出,使用它们后再把结果压栈
- 涉及操作数栈的常用操作有:复制、交换、求和等
此外,如果被调用的方法带有返回值,那么返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令地址。
操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段再次验证。
下面我们通过一个例子来看一下操作数栈在方法的执行过程中是如何发生变化的,假设此时只是做一个两数相加的操作,代码如下所示:
public class StackDemo {
public static void main(String[] args) {
int a = 2;
int b = 3;
int res = a + b;
System.out.println(res);
}
}
编译结束后通过jclasslib查看字节码指令:
代码对应的字节码指令为:
0 iconst_2
1 istore_1
2 iconst_3
3 istore_2
4 iload_1
5 iload_2
6 iadd
7 istore_3
8 return
其中的iconst
指令表示变量的定义,istore
表示入栈操作,iload
表示出栈操作。下面我们通过图示的方法具体的来看一下栈发生的变化。
而实际中局部变量表的存放和图示的有所不同,这里只是为解释方便所画。实际中,局部变量表的内容是:
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
2 7 1 a I
4 5 2 b I
8 1 3 res I
3.4.5 栈顶缓存技术
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作所需的入栈和出栈指令通常更多,这就意味着将需要更多的指令分派次数和内存读写次数。频繁的读写内存必然会影响执行速度,因此,为了解决这个问题,HotSpot虚拟机中引入了栈顶缓存技术。它就是将栈顶元素全部缓存到物理CPU的寄存器中,以此来降低对内存的读写次数,从而提升程序的执行效率。
6. 动态链接
6.1 概念
Java程序经过编译得到字节码文中后,所有的变量和方法引用都被表示为符号引用(Symbolic Reference)保存在字节码文件的常量池中。当某个方法调用另一个方法时,就是通过常量池指向该方法的符号引用完成的。虚拟机栈中的每一个栈帧内部都包含一个指向运行时常量池或该栈帧所属方法的引用,包含这个引用的目的是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。因此,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
假设当前代码为:
public class ConstantPoolDemo {
public static int number = 1;
public static void main(String[] args) {
int res = number + 1;
System.out.println(res);
}
}
经过编译得到的字节码文件信息如下所示:
下面分析以下字节码指令和运行时常量池中所包含的信息:
0: getstatic #7
指令首先获取定义的静态常量number,此时的符号引用为#7
。因此,我们需从运行时常量池中找到它指向的位置#7 Fieldref
,它是一个字段引用,又指向了#8、#9
#8 Class #10
它表示我们定义的类,同时指向到#10
,即定义的ConstantPoolDemo类- 返回去我们再看
#9 NameAndType
,表示此时变量为int型。同时指向了#12
和#13
,#12
就表示变量的类型为int #13
此时已经走到了6: getstatic
,#13
指向了#14
和#15
,#14
指向了#16
,#15
指向了#17
和#18
都表示调用打印流PrintStream中println()
的过程
6.2 方法的调用
- 静态链接:当一个字节码文件被装载进JVM时,如果被调用的目标方法在编译期可知,且运行期保持不变时,将调用方法的符号引用你转换为直接引用的过程称为静态链接
- 动态链接:如果被调用的方法在编译器无法被确定下来,只能在运行期进行转换操作,由于这种方式具有动态性,所以将其称为动态链接
JVM中将符号引用转换为调用方法的直接引用方法和方法的绑定机制有关,对应的方法的绑定机制为早期绑定(Early Binding)和晚期绑定(Late Binding):
- 早期绑定:指调用的方法如果编译期可知,且运行期保持不变时,即可将这个方法与所属的类型就行绑定。这样由于明确了被调用的目标方法,因此可以使用静态链接的方式将符号引用装换为直接引用
- 晚期绑定:如果被调用的方法只能在运行期根据实际的类型进行绑定相关的方法的话,这种方式就是晚期绑定
7. 方法返回地址
方法返回地址(Return Address)存放的就是调用该方法的程序计数器值,由3.3中的内容可知,无论程序因何种方式退出,在方法退出后都会返回到该方法被调用的位置。当程序正常退出时,调用者的程序计数器中的值作为返回地址,即调用该方法的下一条指令的地址。而当因为异常退出时,返回地址需要通过异常表确定,栈帧中一般不会保存这部分信息。
正常完成退出和因异常而退出的区别在于:因为异常退出的不会给它的上层调用者产生任何的返回值。
方法的退出本质上就是对应栈帧出栈的过程,此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置程序计数器值等,让调用者方法继续执行。
具体来说,一个方法开始执行后,只有两种方式可以退出:
- 执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,称为正常完成出口
- 方法在正常调用完成之后究竟使用哪一个返回指令还需要根据方法的返回值的实际数据类型确定
- 字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int)、lreturn、freturn、dreturn、areturn和return(声明为void的方法、实例初始化方法、类或接口的初始化方法)。
- 方法的执行过程中遇到了异常,并且该异常没有在方法内进行处理,即只要在方法的一场表中没有搜索到匹配的异常处理器,就会导致方法退出,称为异常完成出口
8. 附加信息
栈帧中还允许携带一些与JVM实现相关的附加信息,例如,对程序调试提供支持的信息等。
9. 本地方法栈
一个本地方法就是Java程序调用非Java代码的接口,它的实现不是由Java语言实现。当Java程序需要和外间需要交互时,就可以选择使用本地方法。
public class IHaveNatives {
public native void Native1(int x);
native static public long Native2();
native synchronized private float Native3(Object o);
native void Native4(int[] array) throws Exception;
}
本地方法栈(Native Method Stack)用于本地方法的调用,线程私有,它允许被实现成固定大小或是可动态扩展的内存大小。程序使用本地方法栈的具体做法是:在本地方法栈中登记native方法,在执行引擎执行时加载本地方法库。当某个线程调用一个本地方法时,它就进入了一个全新的且不受虚拟机限制的世界,它和虚拟机拥有同样的权限。
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
- 甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存堆中分配任意数量的内存
但并不是所有的JVM都支持本地方法。HotSpot直接将本地方法栈和虚拟机栈合二为一。