万字长文,字节大牛百万调优经验之作:JVM 调优实战笔记
程序计数器用于存放下一条运行的指令;虚拟机栈和本地方法栈用于存放函数调用的堆栈信息;Java 堆用于存放 Java 程序运行时所需的对象等数据;方法区用于存放程序的类元数据信息。
程序计数器
程序计数器(ProgramCounterRegister)是一块很小的内存空间。由于 Java 是支持线程的语言,当线程数量超过 CPU 数量时,线程之间根据时间片轮询抢夺 CPU 资源。对于单核 CPU 而言,每一时刻只能有一个线程在运行,而其他线程必须被切换出去。为此,每一个线程都必须用一个独立的程序计数器,用于记录下一条要运行的指令。各个线程之间的计数器互不影响,独立工作,是一块线程私有的内存空间。
如果当前线程正在执行一个 Java 方法,则程序计数器记录正在执行的 Java 字节码地址;如果当前线程正在执行一个 Native 方法,则程序计数器为空。
Java 虚拟机栈
Java 虚拟机栈也是线程私有的内存空间,它和 Java 线程在同一时间创建,它保存方法的局部变量和部分结果,并参与方法的调用和返回。
Java 虚拟机规范允许 Java 栈的大小是动态的或者是固定的。在 Java 虚拟机规范中定义了两种异常与栈空间有关,分别是 StackOverflowError 和 OutOfMemoryError。线程在计算过程中,如果请求的栈深度大于最大可用的栈深度,则抛出 StackOverflowError;如果 Java 栈可以动态扩展,而在扩展栈的过程中没有足够的内存空间来支持栈的扩展,则抛出 OutOfMemoryError。
在 HotSpot 虚拟机中,可以使用-Xss 参数来设置栈的大小。栈的大小直接决定了函数调用的可达深度。
以下代码演示了一个递归调用的应用。计数器 count 记录了递归的层次,这个没有出口的递归函数一定会导致栈溢出,程序在栈溢出时打印出栈的当前深度。
默认情况下,程序输出结果如下:
如果系统需要支持更深的栈调用,则可以使用参数-Xss1M 运行程序,从而扩大栈空间的最大值。此时,再次运行代码,输出如下:
可以看到,增加栈空间大小后,程序支持的函数调用深度明显上升。
虚拟机栈在运行时使用一种叫作栈帧的数据结构保存上下文数据。在栈帧中,存放了方法的局部变量表、操作数栈、动态链接方法和返回地址等信息。每一
个方法的调用都伴随着栈帧的入栈操作。相应地,方法的返回则表示栈帧的出栈操作。如果方法调用时其参数和局部变量相对较多,那么栈帧中的局部变量表就会比较大,从而栈帧会膨胀,以满足方法调用所需传递的信息。因此,单个方法调用所需的栈空间也会比较大。
如图 5.2 所示为栈帧的基本结构。
注意:函数嵌套调用的次数由栈的大小决定。栈越大,函数嵌套调用的次数越多。对于一个函数而言,它的参数越多,内部的局部变量就越多,它的栈帧就越大,其嵌套调用的次数就会减少。
以下代码的递归函数 recursion()定义了多个传入参数和局部变量,因此它的栈帧大小就会膨胀。
同样使用参数-Xss1M 运行程序,输出如下:
可以看到,随着调用函数参数的增加和局部变量的增加,单次函数调用对栈空间的需求也会增加(函数调用次数由无参时的 40042 下降到 23578)。
在栈帧中,与性能调优关系最为密切的就是局部变量表。局部变量表用于存放方法的参数和方法内部的局部变量。局部变量表以“字”为单位进行内存的划分,一个字为 32 位长度。对于 long 和 double 型的变量则占用 2 个字,其余类型占用 1 个字。在方法执行时,虚拟机使用局部变量表完成方法的传递,对于非 static 方法,虚拟机还会将当前对象(this)作为参数通过局部变量表传递给当前方法。
使用 jclasslib 工具可以查看 class 文件中每个方法所分配的最大局部变量表的容量。jclasslib 工具是开源软件,它可以用于查看 class 文件的结构,包括常量池、接口、属性和方法,还可以用于查看方法的字节码,帮助读者对 class 文件做较为深入的研究。目前,该工具可以
在
http://sourceforge.net/projects/jclasslib/files/jclasslib上下载。
注意:使用 JClassLib 工具可以深入研究 class 类文件的结构,有助于读者对 Java 语言做更深入的了解。
使用 JClassLib 打开上例中的 TestStack2.class 文件,可以看到 recursion()方法,将其展开后查看 Code 属性,在 Code 属性的 Misc 页面,可以看到当前方法的最大局部变量表容量。如图 5.3 所示,可以看到,TestStack2.recursion()方法的最大栈容量为 13。因为该方法有 3 个 long 型参数,并在方法体内又定义了 3 个 long 型变量,共占 12 字,外加 this 变量作为参数,故最大的局部变量表为 13 字。
局部变量表中的字空间是可以重用的。因为在一个方法体内,局部变量的作用范围并不一定是整个方法体。观察下面这个类的两个方法的实现代码:
publicclassTestWordReuse{publicvoidtest1(){
{
longa=0;}
longb=0;}
publicvoidtest2(){longa=0;
longb=0;}
}
在 test1()中,变量 a 的作用域只限于用于最近的大括号中,故在变量 b 定义时,变量 a 已经没有意义,变量 b 完全可以重用变量 a 所在的空间,其最大局部变量表容量只需 2+1=3 字。而在 test2()方法中,同样定义了 a、b 两个变量,但是它们的作用范围相同,不存在重用的可能,其最大局部变量表容量需要 2+2+1=5 字。
通过 JClassLib 工具查看 test1()和 test2()方法的最大局部变量,如图 5.4 所示。
局部变量表的字对系统 GC 也有一定影响。如果一个局部变量被保存在局部变量表中,那么 GC 根就能引用这个局部变量所指向的内存空间,从而在执行 GC 时无法回收这部分空间。这里用一个非常简单的示例来说明局部变量对 GC 的影响。
首先,尝试运行以下的 test1()函数:
以上代码定义了一个局部变量 b,并且它的作用范围仅限于大括号中。在显式地进行 GC 调用时,变量 b 已经超过了它的作用范围,其对应的堆空间应该被回收。而事实上,这段代码的 GC 调用过程如下:
很明显,显式地进行 FullGC 调用并没有能释放它所占用的堆空间。这是因为变量 b 仍在该栈帧的局部变量表中,因此 GC 根可以引用该内存块,阻碍了其回收过程。
假设该变量失效后,在这个函数体内又未能定义足够多的局部变量来复用该变量所占的字,那么在整个函数体中,这块内存区域是不会被回收的。如果函数体内的后续操作非常费时或者又申请了较大的内存空间,则将会对系统性能造成较大的压力。在这种环境下,手工将要释放的变量赋值为 null,是一种有效的做法。
以下代码显式地将变量 b 设置为 null,帮助系统执行 GC。
代码的 GC 调用过程如下:
可以看到,显式地进行 FullGC 操作顺利地回收了变量 b 所占的内存块。
在实际开发中,遇到上述情况的可能性并不大。因为在多数情况下,如果后续仍然需要进行大量的操作,那么极有可能会声明新的局部变量,从而复用变量 b 的字,使 b 所占的内存空间可以被 GC 回收。以下代码演示了这种可能:
该段代码的 GC 调用过程如下:
很明显,变量 b 由于 a 的作用被回收了。
同理,读者可以再阅读以下两个函数。函数 test4()由于在变量 b 之前定义了变量 c,故作用域外的变量 a 复用了变量 c 的字。变量 b 依然保留,因此 GC 操作无法回收变量 b 的空间。而在函数 test5()中,由于后续又定义了变量 a 和变量 d,恰好复用了变量 c 和变量 b 的字,故 GC 操作可以顺利回收变量 b 所占的空间。
//GC 无法回收 byte 数组,因为变量 a 复用了 c 的字,b 仍然存在 publicstaticvoidtest4(){
{int c=0;
byte[]b=newbyte[612041024];}
int a=0; //复用 c 的字
System.gc();
System.out.println("firstexplictgcover");
}
publicstaticvoidtest5(){ //GC 可以回收 byte 数组,因为变量 d 复用了 b 的字
{
int c=0;
byte[]b=newbyte[612041024];
}
int a=0; //复用 c 的字
int d=0; //复用 b 的字
System.gc();
System.out.println("firstexplictgcover");
}
在方法体内,变量 b 所在的字是否被复用,或者变量 b 是否被手工设置为 null,当方法一结束,该方法的栈帧就会被销毁,即栈帧中的局部变量表也被销毁,变量 b 就会被自然回收。
publicstaticvoidmain(Stringargs[]){
test1();
System.gc(); //总是可以回收 b,因为上层函数的栈帧已经销毁
System.out.println("secondexplictgcover");
}
以上代码先调用了 test1(),虽然在 test1()中变量 b 无法回收,但是当 test1()方法一结束,其栈帧被销毁,那么方法体外的 GC 就能顺利回收变量 b 了。以上代码的 GC 调用过程如下:
[GC271K->151K(5056K),0.0014619 secs]
[FullGC151K->151K(5056K),0.0108332 secs]
[FullGC7375K->7375K(12284K),0.0097149 secs]
first explict gc over
[FullGC7394K->151K(13320K),0.0081832 secs]
second explict gc over
可以看到,方法体内的 GC 操作没能回收内存,但在 test1()方法体外的 GC 操作成功回收了变量 b。
注意:局部变量表中的字可能会影响 GC 回收。如果这个字没有被后续代码复用,那么它所引用的对象不会被 GC 释放。
本地方法栈
=====
本地方法栈和 Java 虚拟机栈的功能很相似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈用于管理本地方法的调用。本地方法并不是用 Java 实现的,而是使用 C 语言实现的。在 SUN 的 HotSpot 虚拟机中,不区分本地方法栈和虚拟机栈,因此和虚拟机栈一样,它也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
Java 堆
Java 堆可以说是 Java 运行时内存中最为重要的部分,几乎所有的对象和数组都是在堆中分配空间的。Java 堆分为新生代和老年代两个部分。新生代用于存放刚刚产生的对象和年轻的对象,如果对象一直没有被回收,生存得足够长,则该对象就会被移入老年代。
新生代又可进一步细分为 eden、survivorspace0(s0 或者 fromspace)和 survivorspace1(s1 或者 tospace)。eden 意为伊甸园,即对象的出生地,大部分对象刚刚建立时,通常会存放在这里。s0 和 s1 为 survivor 空间,直译为幸存者,也就是说存放其中的对象至少经历了一次垃圾回收并得以幸存。如果在幸存区的对象到了指定年龄仍未被回收,则有机会进入老年代(tenured)。
注意:堆空间可以简单地分为新生代和老年代。新生代用于存放刚产生的新对象,老年代则存放年长的对象(存在的时间较长,经过垃圾回收的次数较多的对象)。
评论