微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

足足万字,字节架构师百万调优经验之作:JVM调优实战笔记上篇

本文主要介绍JVM虚拟机层面的性能调优方法。由于Java字节码是运行在JVM虚拟机上的,所以同样的字节码使用不同的JVM虚拟机参数运行,其性能表现可能各不一样。为了能使系统性能最优,就需要选择使用合适的JVM参数运行Java应用程序。

本文涉及的主要知识点有:

  • JVM内存模型结构
  • 与内存分配(尤其是堆分配)相关的JVM参数;
  • 垃圾回收器的种类及使用方法
  • 常用的JVM调优参数及其使用效果
  • 一个JVM调优实例。

Java虚拟机内存模型

Java虚拟机内存模型是Java程序运行的基础。为了能使Java应用程序正常运行,JVM虚拟机将其内存数据分为程序计数器、虚拟机栈、本地方法栈、Java堆和方法区几部分,如图5.1所示。

万字长文,字节大牛百万调优经验之作: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记录了递归的层次,这个没有出口的递归函数一定会导致栈溢出,程序在栈溢出时打印出栈的当前深度。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

认情况下,程序输出结果如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

如果系统需要支持更深的栈调用,则可以使用参数-Xss1M运行程序,从而扩大栈空间的最大值。此时,再次运行代码输出如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

可以看到,增加栈空间大小后,程序支持函数调用深度明显上升。

虚拟机栈在运行时使用一种叫作栈帧的数据结构保存上下文数据。在栈帧中,存放了方法的局部变量表、操作数栈、动态链接方法和返回地址等信息。每一个方法调用都伴随着栈帧的入栈操作。相应地,方法的返回则表示栈帧的出栈操作。如果方法调用时其参数和局部变量相对较多,那么栈帧中的局部变量表就会比较大,从而栈帧会膨胀,以满足方法调用所需传递的信息。因此,单个方法调用所需的栈空间也会比较大。

如图5.2所示为栈帧的基本结构。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

注意:函数嵌套调用次数由栈的大小决定。栈越大,函数嵌套调用次数越多。对于一个函数而言,它的参数越多,内部的局部变量就越多,它的栈帧就越大,其嵌套调用次数就会减少。

以下代码的递归函数recursion()定义了多个传入参数和局部变量,因此它的栈帧大小就会膨胀。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

同样使用参数-Xss1M运行程序,输出如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

可以看到,随着调用函数参数的增加和局部变量的增加,单次函数调用对栈空间的需求也会增加函数调用次数由无参时的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字。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

局部变量表中的字空间是可以重用的。因为在一个方法体内,局部变量的作用范围并不一定是整个方法体。观察下面这个类的两个方法的实现代码

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所示。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

局部变量表的字对系统GC也有一定影响。如果一个局部变量被保存在局部变量表中,那么GC根就能引用这个局部变量所指向的内存空间,从而在执行GC时无法回收这部分空间。这里用一个非常简单的示例来说明局部变量对GC的影响。

首先,尝试运行以下的test1()函数

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

以上代码定义了一个局部变量b,并且它的作用范围仅限于大括号中。在显式地进行GC调用时,变量b已经超过了它的作用范围,其对应的堆空间应该被回收。而事实上,这段代码的GC调用过程如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

很明显,显式地进行FullGC调用并没有能释放它所占用的堆空间。这是因为变量b仍在该栈帧的局部变量表中,因此GC根可以引用该内存块,阻碍了其回收过程。

假设该变量失效后,在这函数体内又未能定义足够多的局部变量来复用该变量所占的字,那么在整个函数体中,这块内存区域是不会被回收的。如果函数体内的后续操作非常费时或者又申请了较大的内存空间,则将会对系统性能造成较大的压力。在这种环境下,手工将要释放的变量赋值为null,是一种有效的做法。

以下代码显式地将变量b设置为null,帮助系统执行GC。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

代码的GC调用过程如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

可以看到,显式地进行FullGC操作顺利地回收了变量b所占的内存块。

在实际开发中,遇到上述情况的可能性并不大。因为在多数情况下,如果后续仍然需要进行大量的操作,那么极有可能会声明新的局部变量,从而复用变量b的字,使b所占的内存空间可以被GC回收。以下代码演示了这种可能:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

该段代码的GC调用过程如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

很明显,变量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所示。

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

为了方便读者更好地理解对象在内存中的分配方式,可以结合以下这个简单的示例,初步了解对象在堆中的分布。

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”运行这段代码输出结果如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

首先,在示例代码中注释掉显示GC的这一行代码。由程序的输出结果可以看到,在多次进行内存分配的过程中,触发了一次新生代GC。在这次GC调用中,原本分配在eden段的变量b1被移动到from空间段(s0)。最后分配的8MB内存被分配到eden新生代。如果执行程序中的FullGC操作,则堆的信息如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

可以看到,在执行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”运行以上代码,部分输出结果如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

从加粗的部分可知,每当常量池饱和时,执行FullGC总能顺利回收常量池中的数据,确保程序稳定、持续地运行。

与常量池的回收相比,类的元数据回收稍微复杂一些。作为演示,这里需要使用Javassist类库动态生成大量类,观察GC对类元数据的回收情况。

用于演示的动态类的父类如下(生成的动态类均为其子类):

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

动态类生成代码如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

以上代码在运行时将会动态生成大量JavaBeanObject类的子类,并为所有动态类均生成一份实例。但实例的生命周期仅限为for循环的一次循环。也就是说,当下一个循环开始时,上一个循环体中生成的类及其实例都应该被视为垃圾而被回收。使用JVM参数“-XX:PermSize=2M-XX:MaxPermSize=4M-XX:+PrintGCDetails”运行以上代码,程序在运行后不久便抛出异常而结束,其最后的输出结果如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

可以看到,持久代已经饱和,并抛出
java.lang.OutOfMemoryError:PermGenspace异常显示持久代溢出。FullGC在这种情况下不能回收类的元数据。

而在现有的软件开发项目中,cglib和Javassist等动态字节码生成工具已经得到了非常普遍的使用。当系统中需要生成大量动态类时,对持久代的压力显然会比较大,不支持类元数据的回收显然是不合理的。幸好,HotSpot虚拟机也并非上例中显示的那样完全无视对类元数据的回收。只要虚拟机确认这个类信息没有并不会再被使用时,也会对类的元数据进行回收。

事实上,如果虚拟机确认该类的所有实例已经被回收,并且加载该类的ClassLoader已经被回收,GC就有可能回收该类型。

一个最简单的ClassLoader实现如下:

public class MyClassLoader extends ClassLoader{
}

使用Javassist生成大量动态类,并尝试回收这些动态类的元数据,代码如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

与前一个例子相比,以上代码引入了一个自定义的ClassLoader,并使用该ClassLoader加载所有的动态类。每个ClassLoader加载10个动态类后,其引用的cl变量便被设置为null,使得虚拟机可以回收这个ClassLoader的实例。

依然使用JVM参数“-XX:PermSize=2M-XX:MaxPermSize=4M-XX:+PrintGCDetails”运行这段代码。可以看到,这段代码已经不会抛出OutOfMemoryError异常,可以持久稳定地运行。其最后几行的输出结果如下:

万字长文,字节大牛百万调优经验之作:JVM调优实战笔记“上篇”

很明显,只要ClassLoader被回收,在执行FullGC时,永久区中的类的元数据是完全有可能被回收的。这种方法可以很好地与一些动态字节码生成库结合使用,以确保永久区的稳定。

注意:如果HotSpot虚拟机确认某一个类信息不会被使用,也会将其回收。回收的基本条件是所有该类的实例被回收,并且装载该类的ClassLoader被回收。

由于文章字数限制,我把全文分成两篇文章分享,朋友们可以关注我哦~

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐