函数的调用过程与出入栈
函数调用的过程
线程执行的基本行为是函数调用,每次函数调用的数据都是通过Java栈传递的。Java栈与数据结构上的栈有类似的含义,它是一块先进后出的数据结构,只支持入栈和出栈两种操作。Java栈的主要内容是栈帧。每次函数调用都会有一个对应的栈帧被压入Java栈,每次函数调用结束(无论是正常返回或者抛出异常),都会有一个栈帧被弹出Java栈。
如图所示,函数1中调用函数2,函数2中调用函数3,函数3调用函数4。函数1被调用,栈帧1入栈;函数2被调用,栈帧2入栈;函数3被调用,栈帧3入栈;函数4被调用,栈帧4入栈;函数4调用完毕,栈帧4出栈;函数3调用完毕,栈帧3出栈,一直到函数1出栈。
每次函数调用都会生成对应的栈帧,从而占用一定的内存。由于HotSpot虚拟机并不区分本地方法栈和Java栈,栈内存大小由-Xss参数设定。关于本地方法栈和Java栈,在Java虚拟机规范中定义了两种异常。
- 线程的请求栈的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
public class StackDeepTest {
private static int count = 0;
public static void recursionCall() {
count ++;
recursionCall();
}
public static void main(String[] args) {
try {
recursionCall();
}catch(Throwable ex) {
System.out.println("调用了:"+count);
ex.printStackTrace();
}
}
}
使用-Xss128k参数,结果为“调用了:1089”,当使用-Xss256k参数,结果为“调用了:3546”。说明参数改大之后调用的次数能够明显增加。
- 虚拟机在扩展栈时无法申请到足够的内存时,将抛出OutOfMemoryError异常
public class StackOOMTest {
public static void main(String[] args) {
while(true) {
Thread th = new Thread(()->{
while(true) {}
});
th.start();
}
}
}
没有演示出来,执行有风险,导致了电脑卡死。在《深入理解Java虚拟机》中,给出了OOM异常。
栈帧的数据内容
方法调用在JVM中转换成的是字节码执行,字节码指令执行的数据结构就是栈帧(stack frame)。也就是在虚拟机栈中的栈元素。虚拟机会为每个方法分配一个栈帧,因为虚拟机栈是LIFO(后进先出)的,所以当前线程正在活动的栈帧,也就是栈顶的栈帧,JVM规范中称之为“CurrentFrame”,这个当前栈帧对应的方法就是“CurrentMethod”。字节码的执行操作,指的就是对当前栈帧数据结构进行的操作。
栈帧的数据结构主要分为四个部分:局部变量表、操作数栈、动态链接以及方法返回地址(包括正常调用和异常调用的完成结果)
局部变量表
局部变量表用于保存函数参数以及局部变量。局部变量表中的变量只在当前的函数调用中有效,当函数调用结束,随着函数栈帧的销毁,局部变量表也会随之销毁。局部变量表和操作数栈的容量在编译期就确定了,并通过相关方法的code属性保存及提供给栈帧使用。
栈帧中的局部变量标准的槽位是可以复用的,如果一个局部变量表过了其作用域,那么在其作用域之后声明的变量就很有可能复用过期的局部变量表的槽位,达到节省资源的目的。局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或者间接引用的对象都不会被垃圾回收。
public class StackVarTest {
private static final int CAPACITY = 6*1024*1024;
public static void test1() {
byte[] a = new byte[CAPACITY];
System.gc();
}
public static void test2() {
byte[] a = new byte[CAPACITY];
a=null;
System.gc();
}
public static void test3() {
{
byte[] a = new byte[CAPACITY];
}
System.gc();
}
public static void test4() {
{
byte[] a = new byte[CAPACITY];
}
int b = 10;
System.gc();
}
public static void test5() {
test1();
System.gc();
}
public static void main(String[] args) {
test3();
System.err.println("----------");
test4();
}
}
使用参数-XX:+PrintGC,结果显示如下。可以看到test3没有GC,test4有GC。test3在进行垃圾回收前,虽然a已经离开了作用域,但是变量a依然存在局部变量表中,并且也指向这块byte的数组,故数组不能被垃圾回收。test4声明的变量b复用了a的槽位,导致a不存在局部变量表,故可以被垃圾回收
[GC (System.gc()) 8140K->6784K(125952K), 0.0024535 secs]
[Full GC (System.gc()) 6784K->6672K(125952K), 0.0055990 secs]
----------
[GC (System.gc()) 13482K->6704K(125952K), 0.0005817 secs]
[Full GC (System.gc()) 6704K->528K(125952K), 0.0282544 secs]
操作数栈
和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。不同于程序计数器,Java虚拟机没有寄存器,程序计数器也无法被程序指令直接访问。Java虚拟机的指令是从操作数栈中而不是从寄存器中取得操作数的,因此它的运行方式是基于栈的而不是基于寄存器的。虽然指令也可以从其他地方取得操作数,比如从字节码流中跟随在操作码(代表指令的字节)之后的字节中或从常量池中,但是主要还是从操作数栈中获得操作数。
栈帧在刚创建的时候,操作数栈是空的。Java虚拟机提供一些字节码指令从局部变量表或者对象实例的字段中复制常量或者变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据以及把操作结果重新入栈。在调用方法时,操作数栈也用来准备调用方法的参数以及接受方法的返回结果。
动态链接
每个栈帧的内部都包含一个指向当前方法所在类型的运行时常量池的引用,以便对当前的方法的代码实现动态链接。在class文件里,一个方法若要调用其他方法,或者访问成员变量,则需要通过符号引用来表示,动态链接的作用就是将这些以符号引用表示的方法转换为实际方法的直接引用。类加载的过程中将要解析尚未被解析的符号引用,并且将对变量的访问转化访问这些变量的存储。加载阶段或第一次使用时转化为直接引用的(将变量的访问转化为访问这些变量的存储结构所在的运行时内存位置)就叫做静态解析。JVM的动态链接还支持运行期转化为直接引用。也可以叫做Late Binding,晚期绑定。
方法返回地址
方法正常返回会把返回值压入调用者的栈帧的操作数栈,PC计数器的值就会调整到方法调用指令后面的一条指令。这样使得当前的栈帧能够和调用者连接起来,并且让调用者的栈帧的操作数栈继续往下执行。
方法的异常调用完成,主要是JVM跑出的异常,如果异常没有被捕获主,或者遇到athrow字节码指令显示抛出,那么就一定不会有返回值返回给调用者。
栈上分配
栈上分配是Java虚拟机提供的一项优化技术,它的基本思想是:对那些线程私有的对象(指不能被其他线程访问到的对象),可以将他们打散分配到栈上,而不是分配在堆上。其好处是可以在函数调用完毕自行销毁,而不需要垃圾回收期介入,从而提高了系统的性能。
栈上分配对象的技术基础是进行逃逸分析。逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。p1是成员变量,该字段可能被任何线程访问,属于逃逸对象;p2是局部变量,并且没有被返回,因此它并未发生逃逸,对这种情况,对象就可能被分配在栈上,而不是堆上。
public class StackObjectTest {
static class Person{
public String name;
public int age;
}
private Person p1;
/**
* 逃逸对象
*/
public void alloc1() {
p1 = new Person();
p1.age=23;
p1.name="ss";
}
/**
* 非逃逸对象
*/
public static void alloc2() {
Person p2 = new Person();
p2.age=23;
p2.name="ff";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for(int i=0; i<1000000000; i++) {
alloc2();
}
long end = System.currentTimeMillis();
System.out.println(end-start);
}
}
对于大量零散的小对象,栈上分配提供了一种很好的对象优化分配策略,栈上分配速度快,并且可以有效避免垃圾回收带来的负面影响,但是和堆空间相比,栈空间小,因此对大对象不适合也不能在栈上分配。