5.1 Java虚拟机内存模型
Java虚拟机内存模型是Java程序运行的基础。JVM将其内存数据分为程序计数器,虚拟机栈,本地方法栈,Java堆和方法区等部分。
程序计数器:用于存放下一条运行的指令;
虚拟机栈和本地方法栈:用于存放函数调用堆栈信息;
Java堆:用于存放Java程序运行时所需的对象等数据;
方法区:用于存放程序的类元数据信息;
5.1.1 程序计数器
程序计数器是一块很小内存空间。由于Java是支持线程的语言,当线程数量超过CPU数量时,线程之间根据时间片轮询抢夺CPU资源。对于单核CPU而言,每一时刻,只能有一个线程在运行,而其他线程必须被切换出去。为此,每一个线程都必须用一个独立的程序计数器,用于记录下一条要运行的指令。各个线程之间的计数器互不影响,独立工作,是一块线程私有的内存空间。
如果当前线程正在执行一个Java方法,则程序计数器记录正在执行的Java字节码地址,如果当前线程正在执行一个Native方法,程序计数器为空。
5.1.2 Java虚拟机栈
Java虚拟机栈也是线程私有的内存空间,它和Java线程在同一时间创建,它保存方法的局部变量,部分结果,并参与方法的调用与返回。
Java虚拟机规范允许Java栈的大小是动态的或者是固定的。在Java虚拟机规范中,定义了两种异常与栈空间有关:StackOverflowError和OutOfMemoryError。如果线程在计算过程中,请求的栈深度大于最大可用的栈深度,则抛出StackOverflowError;如果Java栈可以动态扩展,而在扩展栈的过程中,没有足够的内存空间来支持栈的扩展,则抛出OutOfMemoryError。
在HotSpot虚拟机中,可以使用-Xss参数来设置栈的大小。栈的大小直接决定了函数调用的可达深度。
虚拟机栈在运行时使用一种叫做栈帧的数据结构保存上下文数据。在栈帧中,存放了方法的局部变量表,操作数栈,动态连接方法和返回地址等信息。每一个方法的调用都伴随着栈帧的入栈操作。相应地,方法的返回则表示栈帧的出栈操作。如果方法调用时,方法的参数和局部变量相对较多,那么栈帧中的局部变量表就会比较大,栈帧会膨胀以满足方法调用所需传递的信息。因此,单个方法调用所需的栈空间大小也会比较多。
注意:函数嵌套调用的次数由栈的大小决定。栈越大,函数嵌套调用次数越多。对一个函数而言,它的参数越多,内部局部变量越多,它的栈帧就越大,其嵌套调用次数就会越少。
在栈帧中,与性能调优关系最为密切的部分就是局部变量表。局部变量表用于存放方法的参数和方法内部的局部变量。局部变量表以“字”为单位进行内存的划分,一个字为32位长度。对于long和double型的变量,则占用2个字,其余类型使用1个字。在方法执行时,虚拟机使用局部变量表完成方法的传递,对于非static方法,虚拟机还会将当前对象(this)作为参数通过局部变量表传递给当前方法。
注意:使用jclasslib工具可以深入研究Class类文件的结构,有助于读者对Java语言更深入的了解。
局部变量表的字空间是可以重用的。因为在一个方法体内,局部变量的作用范围并不一定是整个方法体。
// 最大局部变量表容量:2+1=3
public void test1() {
{
long a = 0;
}
long b = 0;
}
// 最大局部变量表容量:2+2+1=3
public void test2() {
long a = 0;
long b = 0;
}
局部变量表的字,对系统GC也有一定影响。如果一个局部变量被保存在局部变量表中,那么GC根就能引用到这个局部变量所指向的内存空间,从而在GC时,无法回收这部分空间。
// GC无法回收,因为b还在局部变量表中
public static void test1() {
{
byte[] b = new byte[6*1024*1024];
}
System.gc();
System.out.println("first explict gc over");
}
// GC可以回收,因为赋值为null将销毁局部变量表中的数据
public static void test1() {
{
byte[] b = new byte[6*1024*1024];
b = null;
}
System.gc();
System.out.println("first explict gc over");
}
// GC可以回收,因为变量a复用了b的字,GC根无法找到b
public static void test1() {
{
byte[] b = new byte[6*1024*1024];
}
int a = 0;
System.gc();
System.out.println("first explict gc over");
}
// GC无法回收,因为变量a复用了c的字,b仍然存在
public static void test1() {
{
int c = 0;
byte[] b = new byte[6*1024*1024];
}
int a = 0; // 复用c的字
System.gc();
System.out.println("first explict gc over");
}
// GC可以回收,因为变量d复用了b的字
public static void test1() {
{
int c = 0;
byte[] b = new byte[6*1024*1024];
}
int a = 0; // 复用c的字
int d = 0; // 复用b的字
System.gc();
System.out.println("first explict gc over");
}
在这个函数体内,即使在变量b失效后,又未能定义足够多的局部变量来复用该变量所占的字,变量b仍会在该栈帧的局部变量表中。因此GC根可以引用到该内存块,阻碍了其回收过程。在这种环境下,手工对要释放的变量赋值为null,是一种有效的做法。
当方法一结束,该方法的栈帧就会被销毁,即栈帧中的局部变量表也被销毁。
注意:局部变量表中的字可能会影响GC回收。如果这个字没有被后续代码复用,那么它所引用的对象不会被GC释放。