本文主要介绍JVM虚拟机层面的性能调优方法。由于Java字节码是运行在JVM虚拟机上的,所以同样的字节码使用不同的JVM虚拟机参数运行,其性能表现可能各不一样。为了能使系统性能最优,就需要选择使用合适的JVM参数运行Java应用程序。
Java虚拟机内存模型
Java虚拟机内存模型是Java程序运行的基础。为了能使Java应用程序正常运行,JVM虚拟机将其内存数据分为程序计数器、虚拟机栈、本地方法栈、Java堆和方法区几部分,如图5.1所示。
程序计数器用于存放下一条运行的指令;虚拟机栈和本地方法栈用于存放函数调用的堆栈信息;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[6*1204*1024];}
int a=0; //复用c的字
System.gc();
System.out.println("firstexplictgcover");
}
publicstaticvoidtest5(){ //GC可以回收byte数组,因为变量d复用了b的字
{
int c=0;
byte[]b=newbyte[6*1204*1024];
}
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)。
注意:堆空间可以简单地分为新生代和老年代。新生代用于存放刚产生的新对象,老年代则存放年长的对象(存在的时间较长,经过垃圾回收的次数较多的对象)。
堆空间的基本结构如图5.5所示。
为了方便读者更好地理解对象在内存中的分配方式,可以结合以下这个简单的示例,初步了解对象在堆中的分布。
publicclassTestHeapGC{
publicstaticvoidmain(Stringargs[]){
byte[] b1=new byte[1024*1024/2];
byte[] b2=newbyte[1024*1024*8];
b2=null;
b2=newbyte[1024*1024*8] ;//进行一次新生代GC调用
//System.gc();
}
}
使用JVM参数“-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=15-Xms40M-Xmx40M-Xmn20M”运行这段代码,输出结果如下:
首先,在示例代码中注释掉显示GC的这一行代码。由程序的输出结果可以看到,在多次进行内存分配的过程中,触发了一次新生代GC。在这次GC调用中,原本分配在eden段的变量b1被移动到from空间段(s0)。最后分配的8MB内存被分配到eden新生代。如果执行程序中的FullGC操作,则堆的信息如下:
可以看到,在执行FullGC操作之后,新生代空间被清空,未被回收的对象全部被移入老年代(tenured)。
JVM所使用的GC操作方式JVM调优的重点之一,这部分内容将在后续章节中详细介绍。
方法区也是JVM内存区中非常重要的一块内存区域。与堆空间类似,它也是被JVM中所有的线程共享的。方法区主要保存的信息是类的元数据。
方法区中最为重要的是类的类型信息、常量池、域信息和方法信息。类型信息包括类的完整名称、父类的完整名称、类型修饰符(public/protected/private)和类型的直接接口类表;常量池包括这个类方法、域等信息所引用的常量信息;域信息包括域名称、域类型和域修饰符;方法信息包括方法名称、返回类型、方法参数、方法修饰符、方法字节码、操作数栈和方法栈帧的局部变量区大小及异常表。总之,方法区内保存的信息大部分来自于class文件,是Java应用程序运行必不可少的重要数据。
在HotSpot虚拟机中,方法区也称之为永久区,是一块独立于Java堆的内存空间。虽然叫作永久区,但是在永久区中的对象同样也是可以被GC回收的,只是GC的表现和Java堆空间略有不同。对永久区GC的回收,通常主要从两个方面分析:一是GC对永久区常量池的回收;二是永久区对类元数据的回收。
注意:方法区也可称为永久区,主要存放常量及类的定义信息。
HotSpot虚拟机对常量池的回收策略是很明确的。只要常量池中的常量没有被任何地方引用,就可以被回收。下面的代码生成了大量的String对象,并将其加入常量池。String.intern()方法的含义是:如果常量池中已经存在当前String,则返回池中的对象;如果常量池中不存在当前String对象,则先将String加入常量池,并返回池中的对象引用。
因此,以下代码会不停地将String对象加入常量池,导致永久区饱和。如果GC不能回收永久区的这些常量数据,那么就会抛出OutOfMemory错误。
@Test
public void permGenGC(){
for (int i=0; i < Integer.MAX_VALUE; i++){
String t=String.valueOf(i).intern(); //加入常量池
}
}
使用JVM参数“-XX:PermSize=2M-XX:MaxPermSize=4M-XX:+PrintGCDetails”运行以上代码,部分输出结果如下:
从加粗的部分可知,每当常量池饱和时,执行FullGC总能顺利回收常量池中的数据,确保程序稳定、持续地运行。
与常量池的回收相比,类的元数据回收稍微复杂一些。作为演示,这里需要使用Javassist类库动态生成大量类,观察GC对类元数据的回收情况。
用于演示的动态类的父类如下(生成的动态类均为其子类):
动态类生成的代码如下:
以上代码在运行时将会动态生成大量JavaBeanObject类的子类,并为所有动态类均生成一份实例。但实例的生命周期仅限为for循环的一次循环。也就是说,当下一个循环开始时,上一个循环体中生成的类及其实例都应该被视为垃圾而被回收。使用JVM参数“-XX:PermSize=2M-XX:MaxPermSize=4M-XX:+PrintGCDetails”运行以上代码,程序在运行后不久便抛出异常而结束,其最后的输出结果如下:
可以看到,持久代已经饱和,并抛出
java.lang.OutOfMemoryError:PermGenspace异常显示持久代溢出。FullGC在这种情况下不能回收类的元数据。
而在现有的软件开发项目中,CGLIB和Javassist等动态字节码生成工具已经得到了非常普遍的使用。当系统中需要生成大量动态类时,对持久代的压力显然会比较大,不支持类元数据的回收显然是不合理的。幸好,HotSpot虚拟机也并非上例中显示的那样完全无视对类元数据的回收。只要虚拟机确认这个类信息没有并不会再被使用时,也会对类的元数据进行回收。
事实上,如果虚拟机确认该类的所有实例已经被回收,并且加载该类的ClassLoader已经被回收,GC就有可能回收该类型。
一个最简单的ClassLoader实现如下:
public class MyClassLoader extends ClassLoader{
}
使用Javassist生成大量动态类,并尝试回收这些动态类的元数据,代码如下:
与前一个例子相比,以上代码引入了一个自定义的ClassLoader,并使用该ClassLoader加载所有的动态类。每个ClassLoader加载10个动态类后,其引用的cl变量便被设置为null,使得虚拟机可以回收这个ClassLoader的实例。
依然使用JVM参数“-XX:PermSize=2M-XX:MaxPermSize=4M-XX:+PrintGCDetails”运行这段代码。可以看到,这段代码已经不会抛出OutOfMemoryError异常,可以持久稳定地运行。其最后几行的输出结果如下:
很明显,只要ClassLoader被回收,在执行FullGC时,永久区中的类的元数据是完全有可能被回收的。这种方法可以很好地与一些动态字节码生成库结合使用,以确保永久区的稳定。
注意:如果HotSpot虚拟机确认某一个类信息不会被使用,也会将其回收。回收的基本条件是所有该类的实例被回收,并且装载该类的ClassLoader被回收。
由于文章字数限制,我把全文分成三篇文章分享,朋友们可以关注我哦~