Java最大栈深度有多大?-从一道面试题开始学习JVM
一、问题:Java最大支持栈深度有多大?
1.分析
有JVM的内存结构我们可知:
- 随着线程栈的大小越大,能够支持越多的方法调用,也即是能够存储更多的栈帧;
- 局部变量表内容越多,那么栈帧就越大,栈深度就越小。
2.详解
从Java运行时数据区域我们知道,线程中的虚拟机栈结构如下:
每个栈帧包含:本地变量表,操作数栈,动态链接,返回地址等东西。也就是说栈调用深度越大,栈帧就越多,就越耗内存。
3、测试案例
1.1、测试线程栈大小对栈深度的影响
下面我们用一个测试例子来说明:
有如下递归方法:
public class StackTest { private int count = 0; public void recursiveCalls(String a){ count++; System.out.println("stack depth: " + count); recursiveCalls(a); } public void test(){ try { recursiveCalls("a"); } catch (Exception e) { System.out.println(e); } } public static void main(String[] args) { new StackTest().test(); } }
我们设置启动参数
-Xms256m -Xmx256m -Xmn128m -Xss256k
输出内容:
stack depth: 1556 Exception in thread "main" java.lang.StackOverflowError at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
可以发现,栈深度为1556的时候,就报 StackOverflowError了。
接下来我们调整-Xss线程栈大小为 512k,输出内容:
stack depth: 3249 Exception in thread "main" java.lang.StackOverflowError at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
发现栈深度变味了3249,说明了:
随着线程栈的大小越大,能够支持越多的方法调用,也即是能够存储更多的栈帧。
1.2、测试方法参数个对栈深度的影响
这里我们固定设置-Xss为256k。
我们知道此时的深度为:1556。
接下来我们给方法添加参数:
public class StackTest { private int count = 0; public void recursiveCalls(String a){ count++; System.out.println("stack depth: " + count); recursiveCalls(a); } public void test(){ try { recursiveCalls("a"); } catch (Exception e) { System.out.println(e); } } public static void main(String[] args) { new StackTest().test(); } }
为何要添加参数呢,因为添加参数之后,栈帧中的本地变量表就会增加内容,我们可以尝试使用以下命令查看下Class文件的汇编指令:
javap -v StackTest.class
可以发现recursiveCalls
方法的本地变量表的确增加了,对应方法的入参 a:
LocalVariableTable: Start Length Slot Name Signature 0 44 0 this Lcom/itzhai/jvm/stacks/StackTest; 0 44 1 a Ljava/lang/String;
这个时候我们在执行程序看看结果:
stack depth: 1318 Exception in thread "main" java.lang.StackOverflowError at java.nio.Buffer.<init>(Buffer.java:201)
可以发现,栈深度由原来的1556编程了1318。
可以得出结论:
局部变量表内容越多,那么栈帧就越大,栈深度就越小。
二、JVM体系
1. JDK,JRE,JVM的联系是啥?
JVM Java Virtual Machine
JDK Java Development Kit
JRE Java Runtime Environment
直接上官网上的介绍的图片,一目了然。
2. JVM的作用是啥?
JVM有2个特别有意思的特性,语言无关性和平台无关性。
- 语言无关性:是指实现了Java虚拟机规范的语言对可以在JVM上运行,如Groovy,和在大数据领域比较火的语言Scala,因为JVM最终运行的是class文件,只要最终的class文件复合规范就可以在JVM上运行。
- 平台无关性:是指安装在不同平台的JVM会把class文件解释为本地的机器指令,从而实现Write Once,Run Anywhere
3.JVM运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
Java虚拟机所管理的内存将会包括以下几个运行时数据区域
其中方法区和堆是所有线程共享的数据区程序计数器,虚拟机栈,本地方法栈是线程隔离的数据区,画一个逻辑图
3.1程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器
为什么要记录当前线程所执行的字节码的行号?直接执行完不就可以了吗?
因为代码是在线程中运行的,线程有可能被挂起。即CPU一会执行线程A,线程A还没有执行完被挂起了,接着执行线程B,最后又来执行线程A了,CPU得知道执行线程A的哪一部分指令,线程计数器会告诉CPU。
3.2虚拟机栈
虚拟机栈存储当前线程运行方法所需要的数据,指令,返回地址等。
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈道出栈的过程。
3.2.1、局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,其中存放的数据的类型是编译期可知的各种基本数据类型、对象引用(reference)和returnAddress类型(它指向了一条字节码指令的地址)。也即基本基本数据类型,则存在局部变量表中,如果是引用类型。如String,局部变量表中存的是引用,而实例在堆中。局部变量表所需的内存空间在编译期间完成分配,即在Java程序被编译成Class文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。来看一个例子:引用类型(new出来的对象)的数据如何存储的,
public int methodOne(int a, int b) { Object obj = new Object(); return a + b; }
假如methodOne方法调用methodTwo方法时, 虚拟机栈的情况如下:
当虚拟机栈无法再放下栈帧的时候,就会出现StackOverflowError。
拓展:
局部变量表的容量以变量槽(Slot)为最小单位。在虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小(允许其随着处理器、操作系统或虚拟机的不同而发生变化),一个Slot可以存放一个32位以内的数据类型:boolean、byte、char、short、int、float、reference和returnAddresss。reference是对象的引用类型,returnAddress是为字节指令服务的,它执行了一条字节码指令的地址。对于64位的数据类型(long和double),虚拟机会以高位在前的方式为其分配两个连续的Slot空间。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量,对于32位数据类型的变量,索引n代表第n个Slot,对于64位的,索引n代表第n和第n+1两个Slot。
在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
局部变量表中的Slot是可重用的,方法体中定义的变量,作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省空间,在某些情况下Slot的复用会直接影响到系统的而垃圾收集行为。
3.2.2、操作数栈
操作数栈又常被称为操作栈,操作数栈的最大深度也是在编译的时候就确定了。32位数据类型所占的栈容量为1, 64位数据类型所占的栈容量为2。
当一个方法开始执行时,它的操作栈是空的,在方法的执行过程中,会有各种字节码指令(比如:加操作、赋值元算等)向操作栈中写入和提取内容,也就是入栈和出栈操作。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。因此我们也称Java虚拟机是基于栈的,这点不同于Android虚拟机,Android虚拟机是基于寄存器的。
- 基于栈的指令集最主要的优点是可移植性强,主要的缺点是执行速度相对会慢些;
- 由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要的缺点是可移植性差。
接着解释一下操作数栈,还是比较容易理解的。假如Test.java中有如下方法,
public int getSum(int a, int b) { return a + b; }
反编译生成的Test.class文件,并输出到show.txt中
javap -v Test.class > show.txt
show.txt的内容如下,简单2个数相加都会用到栈,这个栈就是操作数栈。
public int getSum(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=3 0: iload_1 # 局部变量1压栈 1: iload_2 # 局部变量2压栈 2: iadd # 栈顶2个元素相加,计算结果压栈 3: ireturn LineNumberTable: line 12: 0
3.2.3、动态连接
每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
3.2.4、方法返回地址
当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出站,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
3.3本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈发挥的作用是非常相似的,他们之间的区别不过是:
- 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,
- 而本地方法栈则为虚拟机使用到的Native方法服务。
3.4堆
- 对于大多数应用来说,Java堆(Java Heap)是Java虚拟机锁管理的内存中最大的一块。
- Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建,在JVM中只有一个。
- 此内存区域的唯一目的就是存放对象实例以及数组(当然,数组引用是存放在Java栈中的),几乎所有的对象实例都在这里分配内存。
- 这部分空间也是Java垃圾收集器管理的主要区域。
3.5方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储以下信息(不仅限于):
- 已被虚拟机加载的类的信息(包括类的名称、方法信息、字段信息)
- 静态变量
- 常量
- 编译器编译后的代码。
在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。
在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。
在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为“永久代”,是因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移除了。
4.JVM堆内存模型
它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。
(1) 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的
(2) Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配
(3) TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。
(4) 所有新创建的Object 都将会存储在新生代Yong Generation中。如果Young Generation的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在Eden Space。
由颜色可以看出,jdk1.8之前,堆内存被分为新生代,老年代,永久代,jdk1.8及以后堆内存被分成了新生代和老年代和元空间,元空间可以理解为直接的物理内存。新生代的区域又分为eden区,s0区,s1区,默认比例是8:1:1,
5.JVM垃圾回收
GC (Garbage Collection)的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停
(1)对新生代的对象的收集称为minor GC;
(2)对旧生代的对象的收集称为Full GC;
(3)程序中主动调用System.gc()强制执行的GC为Full GC。
不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:
(1)强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)
(2)软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)
(3)弱引用:在GC时一定会被GC回收
(4)虚引用:由于虚引用只是用来得知对象是否被GC
参考文档
- https://www.cnblogs.com/dolphin0520/p/3613043.html
- https://blog.csdn.net/ns_code/article/details/17565503
- https://www.itzhai.com/articles/how-stack-frame-can-a-thread-hold.html
- https://www.itzhai.com/articles/how-java-runtime-data-area-works.html
- https://zhuanlan.zhihu.com/p/109794172
了解更多知识,关注我。