2007年4月30日星期一

关于Java栈与堆的思考

1. 栈(stack)与堆(heap)都是Java用来在Ram中存放数据的地方。与C++不同,Java自动管理栈和堆,程序员不能直接地设置栈或堆。

  2. 栈的优势是,存取速度比堆要快,仅次于直接位于CPU中的寄存器。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。另外,栈数据可以共享,详见第3点。堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。

  3. Java中的数据类型有两种。
  一种是基本类型(primitive types), 共有8种,即int, short, long, byte, float, double, boolean, char(注意,并没有string的基本类型)。这种类型的定义是通过诸如int a = 3; long b = 255L;的形式来定义的,称为自动变量。值得注意的是,自动变量存的是字面值,不是类的实例,即不是类的引用,这里并没有类的存在。如int a = 3; 这里的a是一个指向int类型的引用,指向3这个字面值。这些字面值的数据,由于大小可知,生存期可知(这些字面值固定定义在某个程序块里面,程序块退出后,字段值就消失了),出于追求速度的原因,就存在于栈中。
  另外,栈有一个很重要的特殊性,就是存在栈中的数据可以共享。假设我们同时定义

  int a = 3;
  int b = 3;

  编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将a指向3的地址。接着处理int b = 3;在创建完b的引用变量后,由于在栈中已经有3这个字面值,便将b直接指向3的地址。这样,就出现了a与b同时均指向3的情况。
  特别注意的是,这种字面值的引用与类对象的引用不同。假定两个类对象的引用同时指向一个对象,如果一个对象引用变量修改了这个对象的内部状态,那么另一个对象引用变量也即刻反映出这个变化。相反,通过字面值的引用来修改其值,不会导致另一个指向此字面值的引用的值也跟着改变的情况。如上例,我们定义完a与 b的值后,再令a=4;那么,b不会等于4,还是等于3。在编译器内部,遇到a=4;时,它就会重新搜索栈中是否有4的字面值,如果没有,重新开辟地址存放4的值;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。
  另一种是包装类数据,如Integer, String, Double等将相应的基本数据类型包装起来的类。这些类数据全部存在于堆中,Java用new()语句来显示地告诉编译器,在运行时才根据需要动态创建,因此比较灵活,但缺点是要占用更多的时间。

  4. String是一个特殊的包装类数据。即可以用String str = new String("abc");的形式来创建,也可以用String str = "abc";的形式来创建(作为对比,在JDK 5.0之前,你从未见过Integer i = 3;的表达式,因为类与字面值是不能通用的,除了String。而在JDK 5.0中,这种表达式是可以的!因为编译器在后台进行Integer i = new Integer(3)的转换)。前者是规范的类的创建过程,即在Java中,一切都是对象,而对象是类的实例,全部通过new()的形式来创建。Java 中的有些类,如DateFormat类,可以通过该类的getInstance()方法来返回一个新创建的类,似乎违反了此原则。其实不然。该类运用了单例模式来返回类的实例,只不过这个实例是在该类内部通过new()来创建的,而getInstance()向外部隐藏了此细节。那为什么在String str = "abc";中,并没有通过new()来创建实例,是不是违反了上述原则?其实没有。

  5. 关于String str = "abc"的内部工作。Java内部将此语句转化为以下几个步骤:
  (1)先定义一个名为str的对String类的对象引用变量:String str;
  (2)在栈中查找有没有存放值为"abc"的地址,如果没有,则开辟一个存放字面值为"abc"的地址,接着创建一个新的String类的对象o,并将o 的字符串值指向这个地址,而且在栈中这个地址旁边记下这个引用的对象o。如果已经有了值为"abc"的地址,则查找对象o,并返回o的地址。
  (3)将str指向对象o的地址。
  值得注意的是,一般String类中字符串值都是直接存值的。但像String str = "abc";这种场合下,其字符串值却是保存了一个指向存在栈中数据的引用!

  为了更好地说明这个问题,我们可以通过以下的几个代码进行验证。

  String str1 = "abc";
  String str2 = "abc";
  System.out.println(str1==str2); //true

  注意,我们这里并不用str1.equals(str2);的方式,因为这将比较两个字符串的值是否相等。==号,根据JDK的说明,只有在两个引用都指向了同一个对象时才返回真值。而我们在这里要看的是,str1与str2是否都指向了同一个对象。
  结果说明,JVM创建了两个引用str1和str2,但只创建了一个对象,而且两个引用都指向了这个对象。

  我们再来更进一步,将以上代码改成:

  String str1 = "abc";
  String str2 = "abc";
  str1 = "bcd";
  System.out.println(str1 + "," + str2); //bcd, abc
  System.out.println(str1==str2); //false

  这就是说,赋值的变化导致了类对象引用的变化,str1指向了另外一个新对象!而str2仍旧指向原来的对象。上例中,当我们将str1的值改为"bcd"时,JVM发现在栈中没有存放该值的地址,便开辟了这个地址,并创建了一个新的对象,其字符串的值指向这个地址。
  事实上,String类被设计成为不可改变(immutable)的类。如果你要改变其值,可以,但JVM在运行时根据新值悄悄创建了一个新对象,然后将这个对象的地址返回给原来类的引用。这个创建过程虽说是完全自动进行的,但它毕竟占用了更多的时间。在对时间要求比较敏感的环境中,会带有一定的不良影响。

  再修改原来代码:

  String str1 = "abc";
  String str2 = "abc";

  str1 = "bcd";

  String str3 = str1;
  System.out.println(str3); //bcd

  String str4 = "bcd";
  System.out.println(str1 == str4); //true

  str3 这个对象的引用直接指向str1所指向的对象(注意,str3并没有创建新对象)。当str1改完其值后,再创建一个String的引用str4,并指向因str1修改值而创建的新的对象。可以发现,这回str4也没有创建新的对象,从而再次实现栈中数据的共享。

  我们再接着看以下的代码。

  String str1 = new String("abc");
  String str2 = "abc";
  System.out.println(str1==str2); //false

  创建了两个引用。创建了两个对象。两个引用分别指向不同的两个对象。

  String str1 = "abc";
  String str2 = new String("abc");
  System.out.println(str1==str2); //false

  创建了两个引用。创建了两个对象。两个引用分别指向不同的两个对象。

  以上两段代码说明,只要是用new()来新建对象的,都会在堆中创建,而且其字符串是单独存值的,即使与栈中的数据相同,也不会与栈中的数据共享。

  6. 数据类型包装类的值不可修改。不仅仅是String类的值不可修改,所有的数据类型包装类都不能更改其内部的值。

  7. 结论与建议:

  (1)我们在使用诸如String str = "abc";的格式定义类时,总是想当然地认为,我们创建了String类的对象str。担心陷阱!对象可能并没有被创建!唯一可以肯定的是,指向 String类的引用被创建了。至于这个引用到底是否指向了一个新的对象,必须根据上下文来考虑,除非你通过new()方法来显要地创建一个新的对象。因此,更为准确的说法是,我们创建了一个指向String类的对象的引用变量str,这个对象引用变量指向了某个值为"abc"的String类。清醒地认识到这一点对排除程序中难以发现的bug是很有帮助的。

  (2)使用String str = "abc";的方式,可以在一定程度上提高程序的运行速度,因为JVM会自动根据栈中数据的实际情况来决定是否有必要创建新对象。而对于String str = new String("abc");的代码,则一概在堆中创建新对象,而不管其字符串值是否相等,是否有必要创建新对象,从而加重了程序的负担。这个思想应该是享元模式的思想,但JDK的内部在这里实现是否应用了这个模式,不得而知。

  (3)当比较包装类里面的数值是否相等时,用equals()方法;当测试两个包装类的引用是否指向同一个对象时,用==。

  (4)由于String类的immutable性质,当String变量需要经常变换其值时,应该考虑使用StringBuffer类,以提高程序效率。

2007年4月29日星期日

关注性能: 宏性能基准测试

文档选项
将此页作为电子邮件发送



拓展 Tomcat 应用
下载 IBM 开源 J2EE 应用服务器 WAS CE 新版本 V1.1




级别: 初级

Jack Shirazi (jack@JavaPerformanceTuning.com), 董事, JavaPerformanceTuning.com
Kirk Pepperdine (kirk@JavaPerformanceTuning.com), 首席技术官, JavaPerformanceTuning.com


2003 年 12 月 16 日

热衷于 Java 性能的 Jack Shirazi 和 Kirk Pepperdine ―― JavaPerformanceTuning.com 的董事和 CTO ―― 跟踪遍布 Internet 上的性能讨论,探究是什么在困扰着开发人员。在浏览 Usenet 新闻组 comp.lang.java 时,他们遇到了几个有意思的底层性能调整问题。在关注性能的这篇文章中,他们对字节码作了一些分析,检验并回答了其中的一些问题。
尽管没有专门针对 Java 性能的 Usenet 讨论组,但是有许多关于性能调整和优化的讨论。这些讨论中很大一部分基于从宏性能基准测试中得到的结果,所以在本月的专栏中我们也准备谈论有关宏基准测试(microbenchmarking)的好处和不足之处。

前置还是后置?

有一个问题特别引起我们注意:哪一种运算更快: i++ 还是 ++i ?在我们浏览过的几乎每一个论坛中都可以看到以不同的形式提出的这个问题。虽然这个问题很简单,但是看来没有一个绝对的答案。

首先介绍一下它们的区别, ++i 使用 前置增量运算符,而 i++ 使用 后置增量运算符。虽然它们都增加变量 i ,但是前置增量运算符在增量运算之前返回 i 的值,而后置增量运算符在增量运算之后返回值 i 。一个简单的测试程序展示了这种区别:

public class Test {
public static void main(String[] args) {
int pre = 1;
int post = 1;
System.out.println("++pre = " + (++pre));
System.out.println("post++ = " + (post++));
}
}



运行 Test 类生成以下的输出:

++pre = 2
post++ = 1







回页首




宏基准测试

难道不能试着反复运行每次运算,并观察哪一种运算有更快的运行时吗?简单的回答是能,但是危险在于宏基准测试并不总是测量您想要它们测量的内容。相当多的时候,即时(JIT)编译器的优化和变化掩盖了底层性能中所有可检测的差异。例如,一个这种测试显示第二个 i++ 运算比第一个 ++i 测试更快。但是改变测试顺序显示正好相反的结果!从这里我们只能得出测试方法有缺陷的结论。进一步的调查表明,这种令人困惑的结果来源于在第一次测试时发生的 HotSpot 优化。这些优化有双重效果:使第一次运行增加了额外的开销,并去掉了第二次运行时的解释成本。

宏基准测试的其他变化,如在发生了 JIT 启动成本后重复测试,在反复运行时只能给出不确定的结果。它可能告诉我们两种运算符在速度上没有区别,但是我们对此不能确定。

iinc 字节码运算符

Heinz Kabutx 博士在其新闻信 The Java Specialists Newsletter 中,问他的读者哪一个更快: i++ 、 ++i 还是 i+=1 ?在 Issue 64 中,他报告说有一位读者用一种简单的技术回答了他的问题:查看编译的字节码。事实上,他考察了四种增量语句:

++i;
i++;
i -= -1;
i += 1;



可以用 Java SDK 所带的反汇编程序 javap 很容易地分析编译的字节码。这四种增量语句的每一种得到的字节码都是 iinc 1 1 。

iinc 运算符有两个参数。第一个参数指定变量在 JVM 的局部变量表中的索引,第二个参数指定变量的增量值。

这是不是给了我们一个明确的回答?无论如何,如果不同的源代码编译为同样的字节码,那么在速度上没有区别,是不是?






回页首




运算符上下文

那么,如果代码片断都编译为同样的字节码,使用不同的运算符的意义何在?好,让我们回过头看前置增量符和后置增量符。关键的一点是什么时候访问变量。如果不访问变量,那么这些运算符之间就没有什么区别。语句 i++ 和 ++i 本身在功能上是一样的。不过,语句 j=i++ 和 j=++i 在功能上是 不一样的。我们需要分析在额外的赋值上下文中的字节码。考虑这两个类似的方法:

public static int preIncrement() {
int i = 0, j;
j = ++i;
return j;
}
public static int postIncrement() {
int i = 0, j;
j = i++;
return j;
}



反汇编 preIncrement() 和 postIncrement() 得到下面的字节码:

Method int preIncrement()
0 iconst_0
1 istore_0
2 iinc 0 1
5 iload_0
6 istore_1
7 iload_1
8 ireturn
Method int postIncrement()
0 iconst_0
1 istore_0
2 iload_0
3 iinc 0 1
6 istore_1
7 iload_1
8 ireturn



现在我们 可以 看到这两种方法间的区别: preIncrement() 返回 1,而 postIncrement() 返回 0 。让我们分析字节码,更好地理解这种区别。首先,我们将解释在反汇编的代码中可以看到的不同字节码运算符。

字节码运算:i=0

iconst_0 运算符将整数 iconst_0 推到堆栈上。要完全理解这一点,请记住 JVM 模拟一个基于堆栈的 CPU(如果您以前没接触过堆栈,请参阅 java.util.Stack 类文档)。JVM 在需要以后对某些东西进行操作时,先将它们推到堆栈中,在准备对它们进行操作时弹出它们。

在 Java 语言中有几种不同的数据类型,对于不同的数据类型有不同的字节码运算符。对于某些特定的优化,值 -1、0、1、2、3、4 和 5 都有专门的字节码。如果我们不是处理这些值,那么编译器会生成 bipush 字节码运算,将一个特定的整数推到堆栈上(例如,如果方法的第一条语句是 int i = -2 ,那么第一个字节码将会 bipush -2 )。

下一条语句 istore_0 看上去可能像另一个处理整数 -1 到 5 的特殊字节码,但是事实上,这次 _0 指向一个到局部变量表的索引。JVM 维护一个局部于方法的变量表,字节码 istore 在堆栈的顶部弹出这个值,并将这个值储存到局部变量表中。在这里我们用的是 istore_0 ,所以这个值储存在表的索引 0 处。

所有这些解释针对的是“ i=0 ”的Java 字节码,它被转换为字节码:

0 iconst_0
1 istore_0



更多的字节码运算

现在我们知道了堆栈和局部变量表,我们可以更快地讨论其他字节码。正如我们前面说的,字节码 iinc 0 1 在局部变量表索引 0 处增量值 1, iload_0 将局部变量表索引 0 处的值推到椎栈中,而 ireturn 从堆栈中弹出这个值,并将它推到调用方法的操作数堆栈上。下面的表 1 概括了字节码。

表 1. 字节码

字节码 描述
iconst_0 将 0 推到堆栈中
iconst_1 将 1 推到堆栈中
istore_0 从堆栈中弹出这个值,并将它存储到局部变量表的索引 0 处
istore_1 从堆栈中弹出这个值,并将它存储到局部变量表的索引 1 处
iload_0 将局部变量表索引 0 处的值推到堆栈中
iload_1 将局部变量表索引 1 处的值推到堆栈中
iadd 从操作数堆栈中弹出两个整数并让它们相加。将得到的整数推回堆栈中
iinc 0 1 局部变量表索引 0 处的变量加 1
ireturn 从堆栈中弹出值并将它推到调用方法的操作数栈中。退出方法


比较方法

现在,让我们再看一下这些反汇编的字节码。我们将用 lvar 表示局部变量表,就像它是一个 Java 数组,并对字节码加上注释:

Method int preIncrement()
0 iconst_0 //push 0 onto the stack
1 istore_0 //pop 0 from the stack and store it at lvar[0], i.e. lvar[0]=0
2 iinc 0 1 //lvar[0] = lvar[0]+1 which means that now lvar[0]=1
5 iload_0 //push lvar[0] onto the stack, i.e. push 1
6 istore_1 //pop the stack (value at top is 1) and store at it lvar[1], i.e. lvar[1]=1
7 iload_1 //push lvar[1] onto the stack, i.e. push 1
8 ireturn //pop the stack (value at top is 1) to the invoking method i.e. return 1
Method int postIncrement()
0 iconst_0 //push 0 onto the stack
1 istore_0 //pop 0 from the stack and store it at lvar[0], i.e. lvar[0]=0
2 iload_0 //push lvar[0] onto the stack, i.e. push 0
3 iinc 0 1 //lvar[0] = lvar[0]+1 which means that now lvar[0]=1
6 istore_1 //pop the stack (value at top is 0) and store at it lvar[1], i.e. lvar[1]=0
7 iload_1 //push lvar[1] onto the stack, i.e. push 0
8 ireturn //pop the stack (value at top is 0) to the invoking method i.e. return 0



现在,希望您能更清楚地了解所发生的事情,以及方法之间的一些功能差别。惟一的差别是两个方法的第三个和第四个字节码交换了。注释的字节码清楚表明,在 postIncrement() 方法中, iinc 运算完全是多余的,因为从这一点起,不再使用被更新的局部变量元素 lvar[0] 。对于这个特定的方法,一个优化 JIT 编译程序可以完全去掉这种字节码运算。所以在这种特定情形中, postIncrement() 方法可能有比 preIncrement() 操作更少的字节码运算,从而使它更加高效。但是在大多数使用后置增量运算符的情况下,增量运算是不能优化的。






回页首




那么谁更快呢?

我们学到了什么?是的,如果语句只有 ++i 和 i++ ,那么它们之间没有区别。只有在存在额外的赋值时,编译的字节码才会有区别。

在赋值的上下文中,比较前置增量运算符或者后置增量运算符的使用有可能得到不同的运行时。但是使用哪种运算的功能结果都不太可能是一样的。记住,在我们这里的例子里,方法实际上返回不同的值,它取决于我们是使用前置增量运算符还是后置增量运算符。在一个普通程序中,其中一种变化可能会成为一个缺陷。






回页首




结束语

在过去,我们可以根据一组运算的语言表达对它们的成本进行测量。这是因为这些运算到底层运行时环境的转换总是静态的,这在 Java 运行时中是不成立的。Java 运行时可以动态优化运行的代码,这是一种特别强大的功能。尽管这种功能还没有使我们完全不能进行宏性能基准测试,但是它导致我们在使用这种技术时需要更加当心。

关注性能: 压力负载

优化大师 Jack Shirazi 和 Kirk Pepperdine 分别是 JavaPerformanceTuning.com 的董事和首席技术官,他们从事全球 Internet 上的性能问题讨论。在 TheServerSide.com 留言板上最近提出了一些关于压力测试和负载测试的问题。Jack 和 Kirk 详细探讨了这一主题,并讨论了正确的工具如何导致结果产生巨大的差别。
TheServerSide.com 讨论板通常是相当活跃的,所以这个月我们驻步于此以了解在性能世界中发生了什么事情。讨论板的名字就是 TheServerSide,所以在这里讨论的性能集中于 J2EE 系统是很正常的。当然,这是一个相当广泛的题目,因为它包含了 Java 平台中的几乎所有内容――连 J2ME 系统常常也是 J2EE 系统的客户机,所以有时您甚至可以遇到关于优化 J2ME 系统的问题。

压力测试和负载测试

在性能列表中最常问的问题是:“是否有一种工具可以帮助我对 J2EE 应用程序进行压力测试?” 在回答这个问题之前,让我们问一问自己:压力测试是什么,为什么这些开发人员需要它?(我相信你们中相当一部分人曾经遇到一定要在昨天完成测试这种让您感到压力的情况,但是我们在这里说的不是这个)。压力测试是为了发现在什么条件下您的应用程序的性能会变得不可接受。这通过改变应用程序的输入以对应用程序施加越来越大的负载并测量在这些不同的输入时性能的改变来实现的。这种操作也称为负载测试,但是负载测试通常描述一种特定类型的压力测试――增加用户数量以对应用程序进行压力测试。

对应用程序进行压力测试最简单的方法是手工改变输入(客户机数量、需求大小、请求的频率、请求的混合程度,等等)并描绘性能的变化。对于一些应用程序,您需要做的就是这些。但是如果有许多输入,或者需要在大的范围内改变输入,那么就可能需要一个自动化的工具。另外,在手工测试中,如果想在进行一些改变后重新测试应用程序,可能很难精确地重复一组测试。如果是让多个用户测试您的应用程序,那么几乎不可能一致性地运行手工测试,除非您有很多失业的朋友,否则扩大测试应用程序的用户数量是非常困难的。






回页首




没有一刀切的方案

不幸的是,没有一种通用的压力测试工具,因为每一个应用程序所接受的输入以及对它们进行处理的方式都是不同的。但是对于许多 J2EE 应用程序来说,从客户机到达服务器的通信使用的是 HTTP 协议。幸运的是,有许多负载测试工具可以以一种可控制和重复的方式模拟 HTTP 上的用户活动。它们包括免费工具如 Apache JMeter、The Grinder 以及 PushToText,和相当昂贵的工具如 Mercury Astraload。一般来说一分钱一分货――工具越贵,它能做的事情就越多。为了了解它们的差别,我们首先来看最基本的负载测试工具能做些什么。

如果您想构建自己的负载测试工具,那么您会首先编写一个对每一个模拟客户机运行一个线程的程序。每一个线程需要与服务器通信,可能使用 java.net.URL 类。这种方法使您得到基本的 HTTP 客户机模拟,它可以执行 GET 和 PUT。每个线程需要做的就是发送 HTTP 请求、收集回复、等待一些时间(模拟“考虑时间”),再重复。这一组行动可以相当容易地抽象到一个单独的配置文件中。很快,您就得到一个基本的负载测试工具。您可能需要增加一些配置选项以确定运行多少个线程(模拟的客户机)以及它们是同时开始还是慢慢增加负载。当然,您需要对与服务器的交互计时,因为这是您要测试的核心内容。






回页首




如果这么简单……

那么,对于处理扩展的交互(即一个请求取决于上一个请求的结果)如何呢?对于处理 cookies 如何呢?cookies 对于许多面向会话的 J2EE 系统是必不可少的。改变数据输入呢?如果 J2EE 应用程序客户机需要处理一些 JavaScript 以进入下一次通信呢?在收集了响应时间数据后,如何对它进行分析?其他类型的监视,如 CPU 时间、网络使用、堆大小、分页活动或者数据库活动呢?

像这样和其他的功能,如用于记录浏览器会话并将它们加入到测试脚本中的工具,是高端负载测试工具与基本工具的差别所在。如何为自己选择正确的工具呢?当然,这取决于您的需要、您的计划和您的预算。最重要的是,您需要使用可以正确地模拟您的应用程序要求的客户浏览器功能的工具。具备了基本功能后,可以考虑工具的生产率。一般来说,包含的分析工具越多,可以记录的性能数据类型越多,您可以达到的生产率就越高――您愿意付的钱也就越多。顶级的负载测试程序可以模拟多个浏览器,与大多数应用服务器集成,收集多个服务器主机的性能数据(包括操作系统、JVM 和数据库统计数字),生成可以在以后用高级的分析工具分析的数据集。另一方面,低端负载测试程序是免费的。在那些预算有限的日子里,“免费”的意义是不言自明的。

图 1 展示了免费的负载测试程序 Apache JMeter,它显示了一个自动记录的脚本。



图 1. JMeter 显示一个自动记录的脚本






回页首




丰富的功能

我们已经看过了压力测试工具的基本功能,还适合增加什么附加功能呢?不同的负载测试工具的区别在什么地方呢?当然,您最主要的要求是这个工具必须可以模拟您的应用程序客户机。如果您的应用程序使用一些不常见的浏览器功能组合或者其他非标准客户机技术,那么就排除了相当一部分候选者。除此之外,还有其他功能使负载测试的生产率更高。对于决定适合于自己项目的负载测试工具,下面的列表是一个有用的出发点:

模拟您的客户机
首要要求一定是负载测试程序能够处理您的应用程序所使用的功能和协议。
运行多个模拟的客户机
这是负载测试程序最基本的功能,它有助于确定哪些是负载测试程序以及哪些不是(一些框架试图伪装成负载测试程序)。
脚本化执行并能编辑脚本
如果不能编写客户机与服务器之间交互的脚本,那么就不能处理除最简单的客户机之外的任务东西。编辑脚本的能力是最基本的――小的改变不应该要求您重新生成脚本。
支持会话
负载测试程序如果不支持会话或者 cookies,那么就不算是真正的负载测试程序,并且不能对大多数 J2EE 应用程序进行负载测试。
可配置的用户数量
测试程序应该可以让您指定每个脚本由多少个模拟用户运行,包括让您随时间改变模拟用户的数量,因为许多负载测试应该从小的用户数量开始,并慢慢增加到更多的用户数量。
报告成功、错误和失败
每一个脚本都必须定义一个方法来识别成功的交互以及失败和错误模式(错误完全不会有页面返回,而失败可能在页面上得到错误的数据)。
页面显示
如果负载测试程序可以让您检查一些发送给模拟用户的页面,这会很有用,这样您就可以确保测试工作是正确进行的。
导出结果
您常常希望可以用不同的工具来分析测试结果,这些工具包括电子表格和可以处理数据的自定义脚本。虽然许多负载测试工具包括大量的分析功能,但是导出数据的能力使您在以任意的方式分析和编目数据方面具有更大的灵活性。
考虑时间
真实世界的用户不会在收到一页后立即请求另一页――一般在查看一页和下一页之间会有延迟。 考虑时间这个标准术语表示在脚本中加入延迟以更真实地模拟用户行为。大多数负载测试程序支持根据统计分布随机生成考虑时间。
客户机从列表中选择数据
用户一般不会使用同样的一组数据,每位用户通常与服务器进行不同的交互。模拟用户应该也这样做,如果在交互的关键点,脚本可以从一组数据中选择数据,则可以更容易地让您的模拟用户表现出使用不同数据的行为。
从手工执行的会话记录脚本
相对于编写脚本,用浏览器手工运行会话并记录这个会话然后再编辑会容易得多。
JavaScript
一些应用程序大量使用 JavaScript 并且需要模拟客户机支持它。不过,使用客户端 JavaScript 可能会增加对测试系统上系统资源的需求。
分析工具
测量性能只是成功的一半。另一半是分析性能数据。谁能比编写测试工具的人能更好地开发这种分析工具呢?是的,至少理论是这样。无论如何,您的工具箱提供的分析工具越多,您就能做得越好。
测量服务器端统计数字
基本负载测试程序测量客户机/服务器交互中基于客户机的响应时间。如果同时收集其他统计数字,如 CPU 使用情况和页面错误率就更好了。您得到的统计数字越多,您用负载测试系统可以做的就越多。如果有这种数据,那么就可以做一些有用的工作,如查看服务器负载上下文中的客户机响应时间和吞吐量统计。






回页首




结束语

用任何一种工具可以完成的工作常常受到人的技能、知识和想像力的局限。在描述用负载测试工具查看什么内容的时候,我们也展示了使用这种工具的各种可能性。现在,您可以运用您的想像力去开拓更多的可能性。




参考资料

您可以参阅本文在 developerWorks 全球站点上的 英文原文.


阅读 Jack Shirazi 和 Kirk Pepperdine 所写的的全部“ 关注性能系列 ”。



Java Performance Tuning站点包含上千个性能调优提示和技巧。



“ review of stress testing tools”这篇文章比较了几个免费工具和商业工具。



从 PushToTest 的首席执行官 Frank Cohen 所写的 performance testing SOAP-based applications一文( developerWorks,2001 年 11 月)学习相关技巧。



教程“ IBM Web performance tools”( developerWorks,2002 年 12 月)提供了一些很好的实用建议。



学习如何 stress test your software without stressing out your testers( developerWorks,2001 年 2 月)。



Web 服务是分布式计算的核心,它们之间的交互通常是难于测试的。在“ Stress testing Web services”这篇文章( developerWorks, 2003 年 8 月)中,Chris Wilkinson 表明了压力测试是发现代码缺陷的高效方法,但是其前题条件是这些是有效设计的压力系统。



“ Proofing Web applications for performance and scalability” ( developerWorks,2001 年 6 月)是一个为负载测试开发脚本框架的案例分析。



Apache JMeter是一个免费的负载/回归测试工具。



The Grinder是一个免费的工具,它可以用一个图形控制台应用程序来协调测试脚本在多台计算机上的活动。



有关 Web 站点测试和站点管理工具的信息请参阅 Software Q/A Test Resource Center。



在 developerWorksJava 技术专区 可以找到关于 Java 编程各个方面的数百篇文章。





作者简介



Jack Shirazi 是 JavaPerformanceTuning.com的董事,也是 Java Performance Tuning(O'Reilly)一书的作者。除了对于性能调优的关注,Jack 还开发了智能代理技术。可以通过 jack@JavaPerformanceTuning.com与 Jack 联系。




Kirk Pepperdine 是 JavaPerformanceTuning.com的首席技术官,最近15年,他主要致力于对象技术和性能调优。Kirk 还是 ANT Developer's Handbook一书的合著者之一。可以通过 kirk@JavaPerformanceTuning.com与 Kirk 联系。

关注性能: 改进您的开发过程

文档选项
将此页作为电子邮件发送



拓展 Tomcat 应用
下载 IBM 开源 J2EE 应用服务器 WAS CE 新版本 V1.1




级别: 初级

Jack Shirazi (jack@JavaPerformanceTuning.com), 董事, JavaPerformanceTuning.com
Kirk Pepperdine (kirk@JavaPerformanceTuning.com), CTO, JavaPerformanceTuning.com


2003 年 9 月 07 日

性能是Java 平台屡屡受到指责的一个方面。然而,Java 平台的巨大成功也使得对性能问题作一番严肃的调查研究颇有必要。在这个新专栏中,无畏的优化大师Jack Shirazi 和 Kirk Pepperdine,分别是 JavaPerformanceTuning.com 的董事和 CTO,他们在整个Internet 上推行性能大讨论,展开他们所碰到的问题并加以澄清。本月,他们来到 JavaRanch,讨论有关编译速度、异常以及堆长度调优等方面的话题。
上个月,我们在 JavaRanch 的 Big Moose Saloon 板块上花了大量的时间,以便查看 JavaRanch 的生手会提出什么样的性能方面的疑问。后来发现,大部分问题都是关于 J2SE 和开发过程的――提出的问题主要是关于 Java 语言、核心类以及如何改进他们的开发过程。

编译速度

您是否曾发现您的编译阶段很慢?是不是 javac 所花的时间太长?那么试试 Jikes 编译器吧,在创建 .class 文件时,它会加入额外的“动力”。这就是新兴的 Jikes,它拥有完整的 Java 源支持。(可能会引起 VerifyError ,不支持所有的 javac 选项,字节码可能不像所说的那么好,而且性能也可能受到影响。因此,在使用之前,请务必阅读使用手册。)
所以说,在 JavaRanch 上对 Jikes 的讨论不像我们自制的广告那么直接,但是有的读者也明确指出,Jikes Java 编译器是设计用来加快编译速度的。知道这一点很有用,尤其是对于那些需要编译很多文件的项目更是如此。不过要清楚,虽然 Jikes 有助于加快开发进程,但是对于最后的编译,最好还是使用与在生产中将要使用的 JVM 一起提供的那个编译器。不同的 JVM 版本会有不同的情况,所以当使用来自不同 JVM 的编译器时就可能引发问题。






回页首




异常开销很大

是的,异常开销很大。那么,这是不是就意味着您不该使用异常?当然不是。但是,何时应该使用异常,何时又不应该使用异常呢?不幸的是,答案不是一下子就说得清的。

我们要说的是,您不必放弃已经学到的好的 try-catch 编程习惯,但是使用异常时可能会遇到麻烦,创建异常就是一个例子。当创建一个异常时,需要收集一个栈跟踪(stack track),这个栈跟踪用于描述异常是在何处创建的。还记得当代码中抛出一个意料之外的异常时,您所看到的输出来的栈跟踪吗?像下面这个:

Exception in thread "main" my.corp.DidntExpectThisException
at T.noExceptionsHere(T.java:13)
at T.main(T.java:7)



构建这些栈跟踪时需要为运行时栈做一份快照,正是这一部分开销很大。运行时栈不是为有效的异常创建而设计的,而是设计用来让运行时尽可能快地运行。入栈,出栈,入栈,出栈。让这样的工作顺利完成,而没有任何不必要的延迟。但是,当需要创建一个 Exception 时,JVM 不得不说:“先别动,我想就您现在的样子存一份快照,所以暂时停止入栈和出栈操作,笑着等我拍完快照吧。”栈跟踪不只包含运行时栈中的一两个元素,而是包含这个栈中的每一个元素,从栈顶到栈底,还有行号和一切应有的东西。如果在一个深度为20的栈中创建了异常,那么就别指望只记录顶部的几个栈元素了――您得完完整整地记录下所有20个元素。从 main 或 Thread.run (在栈底)到栈顶,记录整个栈。

因此,创建异常这一部分开销很大。从技术上讲,栈跟踪快照是在本地方法 Throwable.fillInStackTrace() 中发生的,这个方法又是从 Throwable contructor 那里调用的。但是这并没有什么影响――如果您创建一个 Exception ,就得付出代价。好在捕获异常开销不大,因此可以使用 try-catch 将核心内容包起来。您也可以在方法定义中定义 throws 子句,这样对性能不会造成什么损失,例如:

public Blah myMethod(Foo x) throws SomeBarException {
....



从技术上讲,您甚至可以随意地抛出异常,而不用花费很大的代价。招致性能损失的并不是 throw 操作——尽管在没有预先创建异常的情况下就抛出异常是有点不寻常。真正要花代价的是创建异常。

try {
doThings();
if (true)
throw new SomeException(); //cos my program runs too fast
}
catch(SomeException e) {
doMoreThings();
}




幸运的是,好的编程习惯已教会我们,不应该不管三七二十一就抛出异常。异常是为异常的情况而设计的,使用时也应该牢记这一原则。但是,万一您不想遵从好的编程习惯,Java 语言就会让您知道,那样做可以让您的程序运行得更快,从而鼓励您去那样做。

Jack 和 Kirk 说过的一个词:“performance guys”

我们都理解为什么性能在计算中很重要。硬件和软件开发者一开始就很关注性能,从试图更快地找出敌方加密密钥以拯救生命的 Alan Turing ,到通过巨型计算机来提供美与平衡的 Seymour Cray,再到深蓝(Deep Blue )用以与 Garry Kasparov 脑中的计算引擎相抗衡的完美的运算能力。尽管我们努力探求自己所开发程序的最佳性能,但我们还是没有注意到,在很多情况下我们的环境已经为性能作了调优。我们每个月写这些提示的目的就是帮助您对特定的性能问题每天都有不同的关注。







回页首




最大堆长度

在我们访问过的所有讨论组中,有关 JVM 堆的问题不断冒出。在 JavaRanch 上有一次讨论就是以“最大堆长度设置应该是怎样的?”这一基本问题开始的。在深入研究之前,让我们先复习一下 Java 运行时中内存管理的基础知识。

JVM 有一片它自己管理的内存空间。对象存活(或消亡)所在的那部分空间就叫做 堆空间。对象在堆空间中创建,又由 JVM 垃圾收集器在不同的时机围绕着堆空间对其进行迁移。例如,当对堆进行碎片整理(或者紧缩)时,便需要移动对象。对象在堆中也会消亡。一个死去的对象也就是应用程序再也不能访问的对象。JVM 垃圾收集器寻找这些死去的对象,并回收这些对象所占用的空间,以便让这些空间能为新的对象所用。如果垃圾收集器无法进一步通过回收死去的对象来释放出空间,那么就说这个堆已 满。

一个已满的堆会引发问题。如果堆是满的,而应用程序又试图创建更多的对象,JVM 就会向底层操作系统请求更多的内存。如果 JVM 得不到更多的内存,那么分配一个新对象的这一操作就会抛出 OutOfMemoryError 异常。除非应用程序极其完善,否则那就意味着该应用程序要崩溃。

那么,对此我们能做点什么呢?大多数 JVM 都有一个可选的参数,可用于指定堆所能达到的最大长度。如果堆已经达到了这个长度,JVM 就不能再向操作系统请求更多的内存。在 Sun 和 IBM 最近提供的 JVM 中,该参数可通过 -Xmx 选项指定。更老版本的 JVM 使用的是一个 -mx 选项,现在大多数 JVM 还能理解这个选项。应用服务器拥有它们自己的配置参数,可用于指定最大堆长度,这些参数通常是通过 -Xmx 参数指定的。如果没有显式地使用 -Xmx 参数,JVM 有一个默认的最大堆长度,当然这个默认值是特定于供应商和版本的。Sun 1.4 JVM 提供的最大堆长度的默认值是 64 兆字节。

那么,为了达到最佳性能,最大堆长度应该为多少呢?您可能会认为“越大越好”,因为这样的话就可以避开 out-of-memory 错误,并且可以尽量多地为应用程序分配所需的内存。然而,事实证明,如果堆太大的话可能会产生大问题,这是由操作系统的工作方式所致的。现代操作系统有两种内存模式,一种是 实(real)内存,一种是 虚拟(virtual)内存。虚拟内存可以制造出一种假象,让人认为拥有比实内存更多的内存,这是通过使用交换文件(swap file)中的磁盘空间补充实内存来办到的,在这里交换文件充当的是一种额外(overflow)内存。操作系统可以调出当前使用不多的页,将它们放在磁盘中,直到需要时才重新调回内存,这样便腾出了实内存(暂时地)以供他用。通过这种方式,可用的内存便表现得比实内存更大,从而允许更多或者更大的进程得以运行。相应的代价就是那些在磁盘中的页在需要时不得不重新调回内存,这样就降慢了速度。毕竟磁盘的速度比起内存来要慢得多。

如果您允许堆比系统的实内存(您机器上的物理内存)还要大的话,那么这个堆就要分页。分页本身没什么问题――毕竟,只是那些不经常使用的页才要被分派到磁盘中。但是,当遇到垃圾收集的时候,由于要对整个堆进行扫描,所有那些很少使用的页又要返回到实内存中,而其他的页则需要被移出实内存,送到磁盘上去,以便为那些老的页腾出空间。这是一个恶性循环,因为被移出到磁盘的页本身在堆中很可能使用得不多,作为垃圾收集的一部分,垃圾收集器要扫描这些页。其结果就是,比起真正要做的有用的事来,您需要花费更多的时间来将页移进和移出内存。

垃圾收集常常是一个应用程序的瓶颈所在。但是,如果您还要让堆大到令操作系统不得不频繁地使用分页技术以便 JVM 能执行垃圾收集,那么其结果就是一次又一次缓慢的调页动作,从而让应用程序慢如蠕动。因此,务必确保最大堆长度 小于可用的系统 RAM,要为需要同时运行的其他进程考虑,尽量防止这种调页灾难的发生。




参考资料

您可以参阅本文在 developerWorks 全球站点上的 英文原文.


访问 JavaRanch 并在 Big Moose Saloon 板块共享您的有关性能的疑问。



查看 Java 性能调优 Web 站点,以获取大量的提示以及推荐读物。



Jack Shirazi 的书 Java Performance Tuning (O'Reilly & Associates,2003年1月)包含了更多像这样的提示。



下载免费的 Jikes 编译器以加快您的编译速度。



要获得有关应用程序性能调优的案例研究,请查看 “ 优化您的 Java 应用程序的性能”( developerWorks,2002年6月)。



Distinguished Engineer Robert Berry 对 IBM 在 Java 性能领域中所作出的工作给出了深入的分析( developerWorks,2001年12月)。



在 developerWorks Java 技术专区 可以找到关于 Java 编程各个方面的数百篇文章。





作者简介



Jack Shirazi 是 JavaPerformanceTuning.com 的董事,也是 Java Performance Tuning (O'Reilly)一书的作者。Jack 很早就使用 Java了,最近几年,他主要是为财政部门就 Java 性能方面提供咨询。可以通过 jack@JavaPerformanceTuning.com 与 Jack 联系。




Kirk Pepperdine 是 JavaPerformanceTuning.com 的首席技术官,最近15年,他主要致力于对象技术和性能调优。Kirk 还是 ANT Developer's Handbook 一书的合著者之一。可以通过 kirk@JavaPerformanceTuning.com 与 Kirk 联系。

关注性能: 引用对象

优化大师 Jack Shirazi 和 Kirk Pepperdine, 分别是 JavaPerformanceTuning.com 的董事和 CTO, 就他们本专栏中遇到的关于性能方面的问题,在 Internet 上进行广泛的探讨和研究。这个月,他们将目光投向 Java 游戏 Web 站点,去看看游戏开发者是怎样发现和解决垃圾收集过程中应用程序不能释放对象时出现的问题。
如果您认为 Java 游戏开发人员是 Java 编程世界的一级方程式赛车手,那么您就会明白为什么他们会如此地重视程序的性能。 游戏开发人员几乎每天都要面对的性能问题,往往超过了一般程序员考虑问题的范围。哪里可以找到这些特殊的开发人员呢?Java 游戏社区就是一个好去处(参见 参考资料)。 虽然在这个站点可能没有很多关于服务器端的应用,但是我们依然可以从中受益,看看这些“惜比特如金”的游戏开发人员每天所面对的,我们往往能从中得到宝贵的经验。让我们开始游戏吧!

对象泄漏

游戏程序员跟其他程序员一样――他们也需要理解 Java 运行时环境的一些微妙之处,比如垃圾收集。垃圾收集可能是使您感到难于理解的较难的概念之一, 因为它并不能总是毫无遗漏地解决 Java 运行时环境中堆管理的问题。似乎有很多类似这样的讨论,它的开头或结尾写着:“我的问题是关于垃圾收集”。

假如您正面遭遇内存耗尽(out-of-memory)的错误。于是您使用检测工具想要找到问题所在,但这是徒劳的。您很容易想到另外一个比较可信的原因:这是 Java 虚拟机堆管理的问题,而不会认为这是您自己的程序的缘故。但是,正如 Java 游戏社区的资深专家不止一次地解释的,Java 虚拟机并不存在任何被证实的对象泄漏问题。实践证明,垃圾收集器一般能够精确地判断哪些对象可被收集,并且重新收回它们的内存空间给 Java 虚拟机。所以,如果您遇到了内存耗尽的错误,那么这完全可能是由您的程序造成的,也就是说您的程序中存在着“无意识的对象保留(unintentional object retention)”。

内存泄漏与无意识的对象保留

内存泄漏和无意识的对象保留的区别是什么呢?对于用 Java 语言编写的程序来说,确实没有区别。两者都是指在您的程序中存在一些对象引用,但实际上您并不需要引用这些对象。一个典型的例子是向一个集合中加入一些对象以便以后使用它们,但是您却忘了在使用完以后从集合中删除这些对象。因为集合可以无限制地扩大,并且从来不会变小,所以当您在集合中加入了太多的对象(或者是有很多的对象被集合中的元素所引用)时,您就会因为堆的空间被填满而导致内存耗尽的错误。垃圾收集器不能收集这些您认为已经用完的对象,因为对于垃圾收集器来说,应用程序仍然可以通过这个集合在任何时候访问这些对象,所以这些对象是不可能被当作垃圾的。

对于没有垃圾收集的语言来说,例如 C++ ,内存泄漏和无意识的对象保留是有区别的。C++ 程序跟 Java 程序一样,可能产生无意识的对象保留。但是 C++ 程序中存在真正的内存泄漏,即应用程序无法访问一些对象以至于被这些对象使用的内存无法释放且返还给系统。令人欣慰的是,在 Java 程序中,这种内存泄漏是不可能出现的。所以,我们更喜欢用“无意识的对象保留”来表示这个令 Java 程序员抓破头皮的内存问题。这样,我们就能区别于其他使用没有垃圾收集语言的程序员。

跟踪被保留的对象

那么当发现了无意识的对象保留该怎么办呢?首先,需要确定哪些对象是被无意保留的,并且需要找到究竟是哪些对象在引用它们。然后必须安排好 应该在哪里释放它们。最容易的方法是使用能够对堆产生快照的检测工具来标识这些对象,比较堆的快照中对象的数目,跟踪这些对象,找到引用这些对象的对象,然后强制进行垃圾收集。有了这样一个检测器,接下来的工作相对而言就比较简单了:

等待直到系统达到一个稳定的状态,这个状态下大多数新产生的对象都是暂时的,符合被收集的条件;这种状态一般在程序所有的初始化工作都完成了之后。
强制进行一次垃圾收集,并且对此时的堆做一份对象快照。
进行任何可以产生无意地保留的对象的操作。
再强制进行一次垃圾收集,然后对系统堆中的对象做第二次对象快照。
比较两次快照,看看哪些对象的被引用数量比第一次快照时增加了。因为您在快照之前强制进行了垃圾收集,那么剩下的对象都应该是被应用程序所引用的对象,并且通过比较两次快照我们可以准确地找出那些被程序保留的、新产生的对象。
根据您对应用程序本身的理解,并且根据对两次快照的比较,判断出哪些对象是被无意保留的。
跟踪这些对象的引用链,找出究竟是哪些对象在引用这些无意地保留的对象,直到您找到了那个根对象,它就是产生问题的根源。





回页首




显式地赋空(nulling)变量

一谈到垃圾收集这个主题,总会涉及到这样一个吸引人的讨论,即显式地赋空变量是否有助于程序的性能。赋空变量是指简单地将 null 值显式地赋值给这个变量,相对于让该变量的引用失去其作用域。



清单 1. 局部作用域
public static String scopingExample(String string) {
StringBuffer sb = new StringBuffer();
sb.append("hello ").append(string);
sb.append(", nice to see you!");
return sb.toString();
}



当该方法执行时,运行时栈保留了一个对 StringBuffer 对象的引用,这个对象是在程序的第一行产生的。在这个方法的整个执行期间,栈保存的这个对象引用将会防止该对象被当作垃圾。当这个方法执行完毕,变量 sb 也就失去了它的作用域,相应地运行时栈就会删除对该 StringBuffer 对象的引用。于是不再有对该 StringBuffer 对象的引用,现在它就可以被当作垃圾收集了。栈删除引用的操作就等于在该方法结束时将 null 值赋给变量 sb。

错误的作用域

既然 Java 虚拟机可以执行等价于赋空的操作,那么显式地赋空变量还有什么用呢?对于在正确的作用域中的变量来说,显式地赋空变量的确没用。但是让我们来看看另外一个版本的 scopingExample 方法,这一次我们将把变量 sb 放在一个错误的作用域中。



清单 2. 静态作用域
static StringBuffer sb = new StringBuffer();
public static String scopingExample(String string) {
sb = new StringBuffer();
sb.append("hello ").append(string);
sb.append(", nice to see you!");
return sb.toString();
}



现在 sb 是一个静态变量,所以只要它所在的类还装载在 Java 虚拟机中,它也将一直存在。该方法执行一次,一个新的 StringBuffer 将被创建并且被 sb 变量引用。在这种情况下,sb 变量以前引用的 StringBuffer 对象将会死亡,成为垃圾收集的对象。也就是说,这个死亡的 StringBuffer 对象被程序保留的时间比它实际需要保留的时间长得多――如果再也没有对该 scopingExample 方法的调用,它将会永远保留下去。

一个有问题的例子

即使如此,显式地赋空变量能够提高性能吗?我们会发现我们很难相信一个对象会或多或少对程序的性能产生很大影响,直到我看到了一个在 Java Games 的 Sun 工程师给出的一个例子,这个例子包含了一个不幸的大型对象。



清单 3. 仍在静态作用域中的对象
private static Object bigObject;
public static void test(int size) {
long startTime = System.currentTimeMillis();
long numObjects = 0;
while (true) {
//bigObject = null; //explicit nulling
//SizableObject could simply be a large array, e.g. byte[]
//In the JavaGaming discussion it was a BufferedImage
bigObject = new SizableObject(size);
long endTime = System.currentTimeMillis();
++numObjects;
// We print stats for every two seconds
if (endTime - startTime >= 2000) {
System.out.println("Objects created per 2 seconds = " + numObjects);
startTime = endTime;
numObjects = 0;
}
}
}



这个例子有个简单的循环,创建一个大型对象并且将它赋给同一个变量,每隔两秒钟报告一次所创建的对象个数。现在的 Java 虚拟机采用 generational 垃圾收集机制,新的对象创建之后放在一个内存空间(取名 Eden)内,然后将那些在第一次垃圾收集以后仍然保留的对象转移到另外一个内存空间。在 Eden,即创建新对象时所在的新一代空间中,收集对象要比在“老一代”空间中快得多。但是如果 Eden 空间已经满了,没有空间可供分配,那么就必须把 Eden 中的对象转移到老一代空间中,腾出空间来给新创建的对象。如果没有显式地赋空变量,而且所创建的对象足够大,那么 Eden 就会填满,并且垃圾收集器就不能收集当前所引用的这个大型对象。所产生的后果是,这个大型对象被转移到“老一代空间”,并且要花更多的时间来收集它。

通过显式地赋空变量,Eden 就能在新对象创建之前获得自由空间,这样垃圾收集就会更快。实际上,在显式赋空的情况下,该循环在两秒钟内创建的对象个数是没有显式赋空时的5倍――但是仅当您选择创建的对象要足够大而可以填满 Eden 时才是如此, 在 Windows 环境、Java虚拟机 1.4 的默认配置下大概需要 500KB。那就是一行赋空操作产生的 5 倍的性能差距。但是请注意这个性能差别产生的原因是变量的作用域不正确,这正是赋空操作发挥作用的地方,并且是因为所创建的对象非常大。更加深入的讨论请参见“赋空变量和垃圾收集”这篇文章。(参见 参考资料)。

最佳实践

这是一个有趣的例子,但是值得强调的是,最佳实践是正确地设置变量的作用域,而不要显式地赋空它们。虽然显式赋空变量一般应该没有影响,但总有一些反面的例子证明这样做会对性能产生巨大的负面影响。例如,迭代地或者递归地赋空集合内的元素使得这些集合中的对象能够满足垃圾收集的条件,实际上是增加了系统的开销而不是帮助垃圾收集。请记住这是个有意弄错作用域的例子,其实质是一个无意识的对象保留的例子。

关注性能: 谈论垃圾

文档选项

将此页作为电子邮件发送');
//-->

将此页作为电子邮件发送
拓展 Tomcat 应用
下载 IBM 开源 J2EE 应用服务器 WAS CE 新版本 V1.1
级别: 初级
Jack Shirazi (jack@JavaPerformanceTuning.com), 董事, JavaPerformanceTuning.comKirk Pepperdine (kirk@JavaPerformanceTuning.com), 首席技术官, JavaPerformanceTuning.com
2004 年 5 月 01 日
您的应用程序是否经常出现 out-of-memory 错误?用户是否感受到响应时间有些不稳定?应用程序是否在相当长的时间内变得没有响应?应用程序的性能是否显得迟缓了?如果对任何一个问题的回答是肯定的,那么您很可能遇到了垃圾收集的问题了。先别进行优化,且听听 JavaPerformanceTuning.com 的 Jack Shirazi 和 Kirk Pepperdine 来解释如何识别垃圾收集问题,并由此帮助您回答这个问题:您知道垃圾收集器在干什么吗?
许多开发人员认为,内存管理至多是开发业务逻辑的主要任务之外的一项不重要的工作 —— 直到业务逻辑不能像预期的或者测试时那样执行得好。出现这种情况时,就需要知道哪里出错了及其原因,这意味着要理解应用程序如何与底层计算资源(特别是内存)进行交互。理解应用程序如何利用内存的最好方式是观察垃圾收集器的行动。
为什么我的应用程序不连贯了?
Java 虚拟机中最大的一个性能问题是应用程序线程与同时运行的 GC 的互斥性。垃圾收集器要完成其工作,需要在一段时间内防止所有其他线程访问它正在处理的堆空间(内存)。按 GC 的术语,这段时间称为“stop-the-world”,并且,正如其名字所表明的,在垃圾收集器努力工作时,应用程序有一个急刹车。幸运的是,这种暂停通常是很短的,很难察觉到,但是很容易想像,如果应用程序在随机的时刻出现随机且较长时间的暂停,对应用程序的响应性和吞吐能力会有破坏性的影响。
不过 GC 只是应用程序出现不连贯和停顿的一个原因。那么如何确定 GC 对产生这些问题是否负有责任呢?要回答这个问题,我们需要测量垃圾收集器的工作强度,并当在系统中进行改变时继续这些测量,以定量地确定所做的改变是否有所期望的效果。
我需要多少内存?
普遍接受的信念是,在系统中添加内存将解决许多性能问题。虽然这个原则对于 JVM 来说经常是正确的,但是太多好东西可能对性能是有害的。因此技巧在于 Java 应用程序需要多少内存就给它多少,但是绝不多给。问题是,应用程序需要多少内存?对于应用程序不连贯的情况,我们需要观察垃圾收集行为以了解看它做的是否比所需要的更多。这些观察将告诉我们所做的改变是否有所期望的效果。
回页首
测量 GC 的活动
生成 GC 日志的标准方式是使用 -verbose:gc 旗标,设置这个旗标后,垃圾收集器会在每次运行时生成它所做的事情的汇总,一般是写入到控制台(通过标准输出或者标准错误)。许多 VM 支持一个允许 verbose GC 输出转向到一个文件的选项。例如,在 Sun 的 1.4 JVM 中,可以使用开关 -Xloggc:filename 将 GC 输出写到文件中。对于 HP JVM,要使用 -Xverbosegc=file 开关。在本文中,我们将分析 Sun 1.4.2 和 IBM 1.4.1 JVM 捕获的 verbose GC 输出。
使用这个方法监视内存使用的一个最大好处是它对应用程序的性能的影响很小。不幸的是,这个解决方案并不完美,因为这些日志文件可能变得特别大,而维护它们可能需要重新启动 JVM。尽管如此,这种技术在生产环境中仍然是可行的,因为它可以帮助诊断只在这种环境中才列出的性能问题。
更深入观察 GC
-verbose:gc 旗标生成的输出根据 JVM 厂商而不同,不同的垃圾收集器选项会报告特定于该实现的信息。例如,由 IBM JVM 生成的输出比由 Sun JVM 生成的输出冗长得多,而 Sun 的输出更适合于由工具读取。就是说,每一个 GC 日志传达基本信息 —— 使用了多少内存、恢复了多少内存、GC 周期用了多少时间,以及在收集期间是否采取了其他行动。从这些基本测量中,我们可以推断出有助于更好地理解所发生的事情的细节。我们要计算的统计如下所示:
考虑的运行时的持续时间
收集总数
收集频率
收集所用最长时间
收集所用总时间
收集所用平均时间
收集的平均间隔
分配的字节总数
每次收集每秒分配的字节数
恢复的字节总数
每次收集每秒恢复的字节总数
理解了暂停时间,我们就可以理解 GC 对应用程序不响应是否负有部分或者全部责任了。一种实现这一任务的方法是将详细(verbose) GC 日志中的 GC 活动与系统采集的其他日志(如 Web 服务器日志中的请求 backlog)相对应。几乎可以肯定最长的 GC 暂停会导致整个系统响应可感觉的下降,所以知道什么时候响应下降是很有用的,这样就可以将 GC 活动与应用程序吞吐相关联起来。
另一种可能的竞争因素是堆内存分配和恢复的比率,称为 churn。产生大量立即释放的对象的应用程序通常会受到 churn 的拖累。更高的 churn 比率对垃圾收集器加以很大压力,创造了更多的内存资源竞争,这又可导致更长的暂停或者可怕的 OutOfMemoryError 。
了解应用程序是否遭遇这些问题的一个方法是测量所考虑的整个运行时期间 GC 所占用的总时间。有了这种计算,我们就可以了解 GC 做的是否比它所应该做的更多。让我们推导出进行这种判断所需要的公式。
回页首
Sun GC 日志记录
清单 1 是由 Sun 1.4.2_03 JVM 以 -Xloggc:filename 运行默认的标记-清除收集器所生成的日志记录的例子。可以看到,日志项非常精确地记录了每次所做的事情。 清单 1. 使用 -Xloggc:filename 旗标的 GC 日志记录
69.713: [GC 11536K->11044K(12016K), 0.0032621 secs]
69.717: [Full GC 11044K->5143K(12016K), 0.1429698 secs]
69.865: [GC 5958K->5338K(11628K), 0.0021492 secs]
69.872: [GC 6169K->5418K(11628K), 0.0021718 secs]
69.878: [GC 6248K->5588K(11628K), 0.0029761 secs]
69.886: [GC 6404K->5657K(11628K), 0.0017877 secs]
首先注意到的可能是每一项日志记录是写在一组方括号内的。其他 GC 算法,如并发收集器, 可能将一些值分解为更细的信息。如果是这种情况,这些被分解的值会由包围在嵌入的一组方括号中的细节所替代,这使工具可以更容易地处理详细 GC 输出。
我们的研究首先从分析清单 1 中标记为 69.713 的记录开始。这个标记是 JVM 开始后的秒数和毫秒数的时间戳。在这个例子中,JVM 在这个 GC 周期开始之前运行了 69.713 秒。从左到右的字段为:执行的收集的类型、GC 之前的堆使用、总的堆能力和 GC 事件的持续时间。从这个描述中我们可以看出第一个 GC 事件是一个小的收集。在 GC 开始之前,使用了 11536 Kb 的堆空间。在完成时,使用了 11044 Kb,堆能力为 12016 Kb,而整个收集用了 .0032621 秒。下一个事件,一个完全的 GC,在 69.717 秒时或者上一个小 GC 事件之后 0.003 秒时开始。注意,如果将小 GC 事件的持续时间加到其开始时间上,就会看到它在完全的 GC 开始之前不到 1毫秒结束。因此我们可以得出结论:小收集没有恢复足够的空间,这种失败触发了完全的 GC。对应用程序来说,这像是一个持续了 0.1462319 秒的事件。让我们继续确定如何计算其他值。
GC 日志记录的参数确定
我们通过确定每个 GC 日志记录中的值的参数来开始分析:
R(n) = T(n): [ HB->HE(HC), D]
n
清单中记录的索引,1 是第一个,m 是最后一个
R(n)
GC 记录
T(n)
第 n 个 GC 发生的时间
HB
GC 之前堆的数量
HE
GC 之后使用的堆数量
HC
堆空间的总量
D
GC 周期的持续时间
有了这些定义,我们现在可以推导出用于计算前面描述的值的公式。
回页首
基本值
我们要计算的第一个值是日志所覆盖的运行时整个持续时间。如果要考虑每一项记录,那么就要分析最后一项记录的时间戳。因为清单 1 只表示全部日志记录的一部分,我们需要从最后一项中提取出第一个时间戳。尽管对这个例子来说,这个数字足够精确,但是为了绝对准确,需要加上最后 GC 的持续时间。其原因是时间戳是在 GC 开始时记录的,而记录表示在记录了时间戳以后发生的事情。
剩余值是取记录中相应的值的总和计算的。值得注意的是恢复的字节可以通过分析记录中测量的关系而计算,而分配的字节可以通过分析前后记录测量之间的关系计算。例如,如果考虑在时间戳 69.872 和 69.878 之间发现的记录对,可以用第一个记录中 GC 之后占用的内存数量减去第二个记录在 GC 之前占用的字节数量计算在新的一代(generation)中分配的字节数量: 6248 Kb - 5418 Kb = 830 Kb 。下面表 1 展示了其他值的公式。
要找出最长的 GC 暂停,我们只需要查看持续时间并寻找 D(n) (记录 n 的持续时间)的最大值。
表 1. 统计公式
统计
计算(时间单位调整为秒)
运行时持续时间
RT = (T(M) + D(M)) - T(1)
小收集的总数
TMC = Sum( R(n)) 其中 GC(n) = GC
完全收集的总数
TFC = Sum( R(n)) 其中 GC(n) = Full
收集频率(小收集)
CFM = TMC / RT
收集频率(完全)
CFF = TFC / RT
收集的时间(最长的小收集)
MAX(D(n)) for all n 其中 GC(n) = GC
收集的时间(最长的完全收集)
MAX(D(n)) for all n 其中 GC(n) = Full
小收集的时间(总数)
TTMC = Sum(D(n)) for all n 其中 GC(n) = GC
完全收集的时间(总数)
TTFC Sum(D(n)) for all n 其中 GC(n) = Full
收集的时间(总数)
TTC = TTMC + TTFC
小收集的时间(平均)
ATMC = TTMC / RT
完全收集的时间(平均)
ATFC = TTFC / RT
收集的时间(平均)
ATC = TTC / RT
收集间隔(平均)
Sum( T(n+1) - T(n)) / (TMC + TFC) for all n
分配的字节(总数)
TBA = Sum(HB(n+1) - HE(n)) 对于所有 n
分配的字节(每秒)
TBA / RT
分配的字节(每次收集)
TBA / (TMC + TFC)
小收集恢复的字节(总数)
BRM = Sum(HB(n) - HE(n)) 其中 GC(n) = GC
完全收集恢复的字节(总数)
BRF = Sum(HB(n) - HE(n)) 其中 GC(n) = Full
恢复的字节(总数)
BRT = BRM + BRF
恢复的字节(每次小收集)
BRPM = BRM / TMC
恢复的字节(每次完全收集)
BRPF = BRF / TMF
恢复的字节(小收集每秒)
BRP = BRM / TTMC
恢复的字节(完全收集每秒)
BRF = BRF / TTFC
可以从公式中看出,我们经常需要分别考虑完全 GC 和小 GC。小 GC 与完全 GC 有根本性的不同,一般来说前者至少比后者要快一个数量级。我们可以通过快速分析清单 1 看出这一点 —— 最长的小收集比完全收集快 50 倍。
下面表 2 显示对清单 1 中的值使用表 1 中的公式的结果。
表 2. Sun GC 日志分析
统计
计算(时间单位调整为秒)
运行时持续时间
(69.886 + 0.0017877) - 69.713 = 0.1747877
小收集总数
5
完全收集总数
1
收集频率(小收集)
5 / 0.1747877 = 28.6 per second
收集频率(完全)
1 / 0.1747877 = 5.27 per second
收集时间(最长的小收集)
0.0032621
收集时间(最长的完全收集)
0.1429698
小收集的时间(总数)
0.0123469
完全收集的时间(总数)
0.1429698
收集的时间(总数)
0.1553167
小收集的时间(平均)
7.1%
完全收集的时间(平均)
81.8%
收集的时间(平均)
88.9%
收集间隔(平均)
.173/5=0.0346
分配的字节(总数)
3292
分配的字节(每秒)
18834 Kb/second
分配的字节(每次收集)
549 Kb
小收集恢复的字节(总数)
3270 Kb
完全收集恢复的字节(总数)
5901 Kb
恢复的字节(总数)
5901 + 3270 = 9171 Kb
恢复的字节(每次小收集)
3270/5 = 654
恢复的字节(每次完全收集)
5901/1 = 5901
恢复的字节(小收集每秒)
3270/0.0123469 = 264843 Kb/second
恢复的字节(完全收集每秒)
5901/0.1429698 = 41274K/second
表 2 包含从这些看来简单的日志中推算出的大量信息。取决于所关注的问题,可能不需要计算所有这些值,因为其中一些值比另一些更有用。对于应用程序长时间不响应的情况,要关注的是 GC 持续时间和计数。
回页首
IBM GC 日志记录
与 Sun 日志不同,IBM 日志特别冗长。即使这样,仍然需要一个指导以完全理解所提供的信息。清单 2 是这种 verbose:gc 日志文件的一部分。 清单 2. IBM JVM verbose:gc 输出





= 32), weak 0, final 2, phantom 0>













= 32), weak 0, final 0, phantom 0>





= 32), weak 0, final 18, phantom 0>

清单 2 中有三项 GC 日志记录。我将不会提供完全的说明,而是推荐一篇由 Sam Borman 所写的很好的文章“Sensible Sanitation”(请参阅 参考资料)。对于我们的目的,需要与像对 Sun JVM 的日志那样推算出同样类型的信息。好的方面是有一些计算结果已经是现成的了。例如,如果分析 AF[31] (事件 31 分配失败),将会看到 GC 之间的间隔、恢复的内存数量、事件的持续时间。我们可以根据这些数字计算其他所需要的值。
回页首
这些数字有什么意义
如何看待这些数字取决于所要得到的结果。在许多服务器应用程序中,它归结为缩短暂停时间,这又归结为减少所发生的完全收集的持续时间和次数。下个月,我们将探讨如何用这些信息调优曾经受这个问题困扰的一个真实应用程序。

关注性能: 确定更改的风险

2004 年 8 月 30 日
在性能调优时,不可避免地会在应用程序中产生一些 bug,这些 bug 可能会让团队无法继续前进,而且可能显著地影响项目的进度。如果计划很紧(它们什么时候不紧呢?),那么性能调优工作很有可能会使项目落后、延期甚至取消。幸运的是,软件度量(software metrics)可以提供帮助。问题是:如何管理一个合理的时间框架,使系统摆脱已知的瓶颈?假定您理解改进性能需要的所有更改,那么该问题的答案取决于及时进行更改的能力。当代码中遇到未预料到的问题时,在工作过程中,必需进行的改变或者需要考虑的改变的数量常常会不断增加。您也许认为这是一项不太可能完成的任务,您是对的——精确地计划任何形式的代码重构实际上是不可能的,除非有某种可以对风险进行评估的方法。幸运的是,软件度量(software metrics)可以为您提供帮助。
源代码的辩论分析
编写代码时会下留您的指纹。例如,一些开发人员可能决定将所有实例变量声明为 public,而另一些开发人员则可能选择将它们声明为 private。如果搜索代码,并统计遇到多少个声明为 private 、 protected 、 package 和 public 的实例变量,那么您就测量、或者说 度量(metric)了各种选择的流行程度(prevalence )。
假定您已经确定,某一给定应用程序中,声明为 public 的实例变量的流行度为 20%。那么对于代码而言,这个数字说明了什么呢?不幸的是,如果没有其他一些信息,这个数字说明不了什么。不过,也许您已经知道,已经成功部署了的应用程序 一般 只有不到 10% 的实例变量声明为 public 。有了这条额外信息,您现在就可以说,该应用程序 也许有 太多的 public 实例变量了。
那么,在使用像“一般”和“也许有”这样的短语时,您想要表达的是什么呢?在讨论度量时(它是一种人口统计),不能采用绝对的方式。度量值可能表明一种趋势或者模式,但是统计只能提供相互关系,却无法成为因果关系的证据。换句话说,可能有一个 100% 的实例变量都声明为 public 的成功项目,但是,如果标准定在 10% 左右,那么这种声明就是一种最例外的情况。
回页首
耦合的度量
对软件度量有了更好的理解后,让我们继续讨论它们如何帮助评估重新构建代码的风险。因为重新构建实际上是维护编码的一种形式,维护中遇到的那些麻烦事在重新构建时同样会遇到。在重新构建之后,最常见的随机 bug 是 不当耦合。
从最基本形式方面,可以将耦合看成是一个对象需要的所有引用。通过使用 Java 编译器,您可以非常容易地看到耦合的结果。选择一个应用程序、清除其类目录、然后选定一个源代码文件并用命令行编译它。您会看到,在目标目录中出现一组类文件。您可能会说“这又如何?”,假定每一个类都表示原来的类所依赖的一个实体,那么其中任何一个类的改变都会在原来的类中产生一个 bug。
您应该已经理解了编译所涉及的问题,现在,就让我们用一组示例结果来集中讨论耦合。假定有一个简单的系统,它包含 5 个类:A、B、C、D 和 E。表 1 显示了试图编译一个类的结果。
表 1. 编译一个类的结果
要编译的类
被编译的其他类
传入耦合
传出耦合
不稳定因素
A
B、C、D、E
0
4
1
B
C、D、E
1
3
0.75
C
-
2
0
0
D
E
3
1
0.25
E
D
3
1
0.25
首先让我们看一下该表的前两列,来查看一个具体情况——类 C。尽管其他类依赖于类 C 的存在,但是它不依赖于任何其他类。这个结果表明 C 是完全自包含的,并且在应用程序的类依赖图中表示为叶节点。
A 的情况更有趣一些,因为它依赖于系统中的所有其他类,同时没有类依赖于它。除了不依赖于类 A,B 与 A 有同样的依赖特性。D 和 E 彼此依赖,这种依赖关系称为 循环依赖。图 1 表现了可以用来描述表 1 中的信息的耦合图。 图 1. 耦合图
该图显示了三种不同类型的耦合——直接、间接和循环。A 直接依赖于 B,并间接地依赖于 C、D 和 E。正如已经介绍的,D 和 E 存在循环依赖。
判断依赖关系
如果某个类依赖于其他类,那么就称它对那个类具有 传入耦合 关系。传入耦合的统计数要回答的问题是:有多少个类依赖于我?如果分析编译 B、C、D 和 E 的结果 (如表 1 所示),那么在 “被编译的其他类” 栏中,您不会找到任何一个包含 A 的项。因此,该编译器表明了 A 没有传入耦合。
与传入耦合相反, 传出耦合定义为特定类所依赖的那些类。它要回答的问题是:我依赖于多少个类?与传入耦合的情况相同,只要统计在可以编译特定类之前需要编译的类,就可以得到答案。例如,编译 A 会使每一个其他类都编译。我们可以看到,在表 1 中,A 没有直接依赖关系,却有三个间接依赖关系。
量化不稳定性
不稳定因素是传出依赖与所有依赖之间的比率(即传出/[传出 + 传入])。该度量值的范围是从 0 到 1,得分 1 表示最不稳定,得分 0 表示最稳定。得分所测量的是另一个类的改变会对正讨论的类造成不稳定的可能性。因此,某个类的得分为 1 表明应用程序任何地方的改变都会影响该类。这个术语可能有些令人迷惑,所以我们将从不同的角度分析它。
考虑表 1 所示的系统。类 A 的不稳定因素为 1,这意味着系统中的任何改变都可能使 A 不稳定。而且,它的传入耦合数为 0,这表明该类的改变不会反过来影响应用程序的其他部分。另一方面,类 C 的不稳定因素为 0,这意味着系统中的改变对它的实现没有任何影响。不过,它的传入耦合数表明,它的改变很有可能会使应用程序的其他部分不稳定 (或者产生 bug)。换句话说,改变 C 可能会在 B 和/或者 A 中产生 bug。因为 C 和 B 密切相关,所以在 B 中预计也会出现 bug,但是因为 A 和 C 的关系更远 (检查时可能看不出来),在 A 中出现 bug 则会让人感到有些意外。
霰弹式修改(Shotgun surgery)是用来描述进行更改时,在应用程序中随机出现几处 bug 这种情况的术语。通常,当一个对象不恰当地违反了抽象界限或者向其他类开放其内部状态时,就会出现这种未预料到的 bug。这些是不当 (或者紧密) 耦合的例子。
恰当耦合的例子
如果编写过一定数量的 Java 代码,那么您很可能遇到过 java.io.Serializable ,这是序列化框架的标志性接口。序列化的一个有趣的地方在于,即使在编写应用程序之前已经编写好框架,该框架仍然有效!更有意思的是,框架经历了您可能从不曾注意的改变。
双重分派 (Double dispatch) 是将序列化代码与应用程序代码分离的设计模式。它的工作原理是这样的:如果将一个对象编写到 ObjectOutputStream ,那么它会回调 (用它自己作为参数) 原来的对象。它这样做是在说“我不知道您的内部状态,因此我把编写内部状态的责任回派给您”。然后,该对象将其实例变量编写给流。
用这种方法来回分派可以使 ObjectOutputStream 与域对象合作完成序列化过程,同时还可以维持松散的耦合。接口 java.io.Serializable 使这成为可能,它将序列化与域解耦。
回页首
耦合:好的、坏的和丑的
让我们回到 图 1,查看 D 与 E 之间存在的循环依赖。如果耦合的目标是维护依赖的单向性,那么由于彼此依赖,D 与 E 违背了这一原则。在某些条件下,这种违背是可以接受的。当同一组件/包中的两个或者多个对象相互合作来实现某些目标时,这种依赖关系会变好,这也是关系变好的最常见的一种情况。我们希望类羞于开放自己、但是勇于命令其他类。所以,如果 D 告诉 E 做某些事,而 E 增加了一些信息并告诉 D 继续这项任务,那么这种依赖性是可以接受的。但是我们可以做得更好!
在 Java 编程中,我们有一个管理一组依赖关系的很好的机制:接口。如果我们引入接口 F,让 D 依赖于它,并让 E 实现它,那么它会打破这两个类之间的循环依赖性。引入这种接口的效果如图 2 所示。图 2. 打破循环依赖性
现在。F 可能需要引用 D,但是这种依赖性并不是那么地不可接受,因为它没有将 E 的表示或者实现绑定到 D 或者相反。让我们重新分析进行这种重新构建之后,不稳定因素发生了怎样的改变。
表 2. 引入接口的耦合效果
要编译的类
被编译的其他类
传入耦合
传出耦合
不稳定因素
A
B、C、D、F
0
4
1
B
C、D、F
1
3
0.75
C
-
2
0
0
D
F
2
1
.33
E
F
0
1
1
F
-
4
0
0
从度量中可以看到,改变 A、B、C 或者 D 现在不再会影响 E 的实现。而且,改变 F 的实现会对所有依赖它的对象的稳定性产生可怕影响。最后,改变 E 现在也不会对整个框架造成影响。
序列化
让我们重提序列化,并试着为示例增加一些真实性。假定在类 A、B、C 和 D 中实现了序列化框架,并且接口 F 是 java.io.Serializable 。同时还假定 E 是域对象。在这种情况下,上面所做的分析依然成立。序列化过程的改变不会影响域的稳定性。也就是说, java.io.Serializable 中的改变对所有事物(包括类)都会产生戏剧性的影响。最后,我们可以对 E 做任何事 (除了没有实现接口这一显然步骤之外),而它对序列化框架没有任何影响。
坏的耦合
坏的依赖(或增加了代码脆弱性的依赖)是指危及封装或者倒转依赖方向的依赖。例如,考虑当两个或者更多对象开始彼此询问内部状态、而这些对象又不在同一包或组件中时可能出现的后果。在这种情况下,询问者可能问被询问者某些实例变量的值 (通过 getXxx 调用)。在某些情况下,该值可以作为其他计算的基础。而在另一些情况下,该值可能会改变。不管是哪种情况,被调用的对象都将被解除责任,而且在后一种情况下,它甚至不能维持其内部状态。
考虑代码片段 obj.getX().getY() 。在该实例中,坏的耦合 (对 getX 的调用) 在将对象的内部状态传递给第三者时变成了丑耦合。在这种情况下,对 Y 的改变会立刻成为 obj 中的 bug。如果这个链足够长或者足够复杂,那么将出现想像不到的各种 bug。
这种形式的耦合的坏处在于,只是假定了调用者对所请求的状态的责任。要对请求的状态负责,就必须了解与该状态相关的所有业务规则。此外,如果排除了包含对象的责任,那么该责任很有可能会被分派到整个应用程序。不管是业务规则、数据发生了改变,还是表示发生了改变,都会迫使您搜索源代码,并按新规则重新实现代码。
考虑以下这种情况,银行决定摆脱讨厌的出纳,想让所有顾客学会他们的业务规则。现在,如果银行需要改变规则,那么它必须搜索人群,找出其顾客,并重新教给他们新的规则。如果他们错过一位顾客,并且这位顾客决定使用这项服务,那么就会出现一些问题。这听起来很荒谬,但在很多代码中,程序员认为从系统中取消 “出纳员”是好主意。
什么时候坏的耦合是 OK 的
说坏的耦合是好的是不对的,但是有时坏的耦合是可以容忍的。最常见的例子是 GUI 代码。通常,不能将普通度量应用于用户界面代码。
因为性能原因,有时也会需要使用坏的耦合。在这种情况下,应当小心记录做出该决定的原因,以帮助以后使用该代码的用户了解决定不按好的实践编写代码的原因。和平时一样,要对未证实的或者不成熟的优化有所了解。
回页首
结束语
如果在开始性能调优过程时,就对代码集的复杂性和耦合进行了测量,那么您可以感受在规定的时间框架内完成手头任务的风险。这些数字可以鼓励您继续、促使您要求更多时间完成任务,或者做出取消工作的决定。不管是哪种情况,在着手改进性能时,软件度量都会提供更多的内幕信息。

关注性能: 边缘剖析

文档选项

将此页作为电子邮件发送');
//-->

将此页作为电子邮件发送
拓展 Tomcat 应用
下载 IBM 开源 J2EE 应用服务器 WAS CE 新版本 V1.1
级别: 初级
Jack Shirazi (jack@JavaPerformanceTuning.com), 董事, JavaPerformanceTuning.comKirk Pepperdine (kirk@JavaPerformanceTuning.com), 首席技术官, JavaPerformanceTuning.com
2004 年 9 月 01 日
调优的并不总是速度,有时候需要调整应用程序的其他方面,如果应用程序需要调优,要做的第一件事通常是使用剖析程序监控应用程序。但是,剖析并不总是可行的,有时候原因可能很可笑。 关注性能的本期文章中, Jack 和 Kirk 讲述了他们最近经历的一件事:他们奉命剖析一个胖客户机,事实上它是如此庞大,根本没有为剖析程序留下空间。
我们从来还没有遇到过调优应用程序内存占用的问题。通常,我们看到的和内存有关的调优要求都涉及到降低垃圾收集的开销,理想情况下可以通过调整堆的大小或者改变垃圾收集算法来解决,如果不行的话,还可以采用各种技术减少内存中的对象。但是,有时候无论分配和垃圾收集的效率如何,应用程序都要占用很多的内存。
减肥中心之旅
最近,我们奉命降低一个胖客户机的内存占用。虽然“胖客户机”一词通常表示普通的 GUI 客户应用程序,而这个客户机却是几近肥胖症。这个客户机在 Windows 平台上运行,处理大小的极限是 2 GB。去掉可执行地址空间和引入各种 JNI 产品所需要的其他空间之后,该应用程序能够使用的最大堆大小大约是 1.2 GB 或者 1.3 GB。不幸的是,某些用户因为要向该应用程序灌输大量的数据,以至于占用的空间常常接近这个极限。最明显的调优方案是转移到 Unix 机器上,但是因为不切实际而被排除掉了——客户更愿意让这个应用程序减肥。
于是,我们的任务就定下来了。对这个胖客户机进行剖析,看看到底是什么占用了这些空间。然后对这些对象减肥,为以后的扩展或者更大的数据量留下空间。我们认为这事很容易。可能要花点时间,因为减少对象的数量通常不能一蹴而就,但是这么大的堆, 肯定有很多赘肉能够割掉。我们这样想。
通常的过程
我们开始了通常的内存占用缩减过程:建立测试环境、规定可再现的测试、启动剖析程序、运行测试、分析数据、查找调优的机会。时间不断流逝,我们一直忙个不停……或者说我们是这样认为的。现在,我们进入了“运行测试”阶段,剖析程序趴下了。于是我们再次尝试。又死掉了。我们改变了剖析程序的配置,将开销减到最少,再次尝试。又死掉了。根本就没留下足够容纳剖析程序完全运行的 JVM 堆空间,更不用说生成任何有用的剖析数据了。而我们使用的是一种上等的商业剖析程序,一般是很可靠的,所以我们很吃惊。
试一次,再试一次
没关系,海里有数不清的鱼,现在也有数不清的剖析程序(关于剖析程序的最新评述,请参阅 Resources)。又是一天,又使用了一个剖析程序,怎么样呢?不幸的是,测试过程是惊人的相似。和一号剖析程序差不多在同一点上,二号剖析程序又让 JVM 崩溃了。和一号剖析程序一样,它甚至可以做更多的配置,重新配置,降低开销,去掉更多的数据。但它还是和一号剖析程序一样,也崩溃了。糟糕的是,三号剖析程序也没有什么不同。
巧妙的剖析程序
但是,四号剖析程序有了微妙的变化。对存活对象的快照进行内存分析(忽略对象的创建和垃圾收集,只观察某一点上存活对象的快照),在请求进行快照之前,四号剖析程序根本没有增加 JVM 的开销。成功了!我们的测试第一次在剖析程序运行的时候通过了需要拍摄快照的那个点。我们很高兴。然后我们激活了快照,于是 JVM 崩溃了。
我们又尝试了一次,但是这个剖析程序生成快照需要太多的额外空间。根本无法工作。我们又回到了起点!尽管还有半打商业剖析程序可供尝试,但结果是显然的。应该做一些横向思考了。
具有讽刺意味的是,我们的问题正在于剖析程序本身的复杂性。我们需要某种简单的东西。当然,简单并不意味着开销低,但是既然那些复杂的剖析程序令我们失望,不妨试一试。于是我们开始扫描开放源代码剖析程序。重新开始
我们首先寻找那些看来是用于内存分析的剖析程序。一号开发源代码内存剖析程序看起来绝对简单,也许过于简单了。输出结果用处不大,只有一个类列表和每个类的对象个数。但无论如何这也算是一个不错的起点。它崩溃了。我们陷入了重走老路的担忧。二号开放源代码剖析程序甚至比一号还简单,虽然它实际上给出了更详细的信息:每个对象都有堆占转储记录,说明对象的大小和所属的类。和其他剖析程序一样,我们用较低的配置尝试,于是可以看到堆转储逐渐增大——大致就是堆的大小,然后,我们看到的是一个 1 GB 的输出文件。我们尝试了它,它击溃了虚拟机。但它确实让我们看到了部分堆转储。
在处理像这种很大的因素时,必须能够判断所需资源的数量级和要花费的时间。转存 1 GB 的文件可能要花很多时间。如果没有考虑到一个操作可能花费多长的时间,您可能错误地认为进程被挂起了,而实际上它仍然在运行,只是要花费转储 1 GB 格式化文本所需要的时间。这个开放源代码剖析程序正在工作,但是第一次测试时我们忽视了给它足够的时间。更遭的是,第一次还没有结束的时候,我们又迫使它进行第二次转储,结果造成了崩溃。所幸的是,我们认识到问题在我们自己而不是剖析程序,有了较多的认识之后,我们再次进行了尝试并取得了成功。heapprofile 剖析程序
那么,到底哪个剖析程序成功了呢?它就是 Matthias Ernst 编写的“heapprofile”。它仅用了一页 C 代码,使用 Java Virtual Machine Profiler Interface (JVMPI) 把堆转存成最简单的格式。甚至还要自己编译,网站上(请参阅 Resources)没有提供预编译的可执行文件。这种简单性正是我们在这个问题里所需要的。没有任何开销。除了绝对必要的之外,没有使用堆或者 JNI 资源。程序运行的时候它什么也不做,当我需要堆转储的时候,它仅仅遍历一次堆,直接将每个对象的大小和类转存到一个文件,没有在内存中创建任何结构,正是这种结构让其他所有剖析程序击溃了 JVM。
当然,事情还没有完。现在我们需要分析结果数据,使用它确定应用程序所用的对象。幸运的是,输出格式很容易解析。一旦找到了造成问题的对象,我们还需要找到分配这些对象的地方。为了降低开销,我们采用重新编译这几个类的简单策略,在构造函数中放上栈跟踪程序,Jack 的著作(请参阅 Resources)中详细描述了这种技术。这种简单的技术需要在构造函数中创建(而不是抛出)异常。异常中包含分配地点的栈踪迹。然后可以将所有对象的这些栈列成表格。因为多数栈都是相同的,标识调用栈以及链接到每个栈的相关实例个数需要存储的数据并不很多(最多几千个字符串)。 简单而丑陋
这都是些简单的技术,但并没有很高的生产率。我们更愿意使用功能完备的剖析程序输出数据,尤其是因为它们提供的数据更便于分析。我们本来希望从堆的根开始,向下跟踪较大的节,直到发现大量引用堆的对象,但是我们没有选择这个方法。
和通常使用调优技术相比,这次使用的技术比较简陋。但最终我们发现了一些完全不需要的对象,使用一些类的不同实现可以完全消除它们;另一些必需的对象也可以苗条一点,或者压缩到一起,减少其空间需求。对象缩减通常都是如此,胖客户机减肥也没有一定之规。和人类一样,让 Java 应用程序节食也是很困难的事情。也和节食一样,去掉身上多余的脂肪往往比您所想的要花费更长时间。令人遗憾的是,虽然我们从这个胖客户机上刮掉了两百兆字节,但它仍然没有瘦到足以容纳“真正的”内存剖析程序的运行。结束语
我们曾经在 Unix 讨论组看到这样一个问题 —— “Unix 大师们使用什么编辑文本?”,随后的讨论纷纷开始鼓吹 vi、Emacs 等。但毫无疑问,正确的答案应该是“Unix 大师使用任何能用的工具编辑文本。” Java 平台拥有一些非常杰出的剖析程序。但最终,调优应用程序必须分析数据,无论用何种方法,您必须拿到这些数据。
参考资料
您可以参阅本文在 developerWorks 全球站点上的 英文原文
正确使用剖析程序并不像您想象的那么简单。这也是作者提供这些实践培训教程的原因之一,它讨论了能够使用哪些剖析程序,以及这些剖析程序的用法。要获得更多信息,请参阅 JavaPerformanceTuning.com 的 培训课程页面
请阅读 Java Application Performance Management 工具专栏,其中包括一个商业剖析程序列表。
了解 heapprofile剖析程序的更多情况。
Dr. Heinz Kabutz 介绍了如何从程序中的任何地方 使用 Java 代码跟踪栈的使用
完整的 剖析程序列表,包括开放源代码项目和商业产品。
从作者的网站上查阅 性能剖析技巧
请阅读 Java Performance Tuning, 2nd Edition (O'Reilly 2003,Jack Shirazi 著)一书中的性能调优章节。
Performance Inspector是一套用于 Linux 的性能管理工具。
这些 培训课程利用第一手的调优经验向您分析各种剖析程序及其用法。
关于 JVMPI的这篇文章详细介绍了 Java 平台的剖析工具接口。
WebSphere Studio 在开发环境中集成了 J2EE profiling
developerWorksJava 技术专区 中,可以找到各种关于 Java 技术的文章。
Developer Bookstore提供了丰富的技术书籍,其中包括上百本 和 Java 有关的书籍
作者简介
Jack Shirazi 是 JavaPerformanceTuning.com的董事,也是 Java Performance Tuning, 2nd Edition (O'Reilly) 一书的作者。
Kirk Pepperdine 是 Java Performance Tuning.com 的首席技术官,15 年来,他一直专注于对象技术和性能调优。Kirk 是 Ant Developer's Handbook (MacMillan) 一书的合著者之一。

关注性能: 调优垃圾收集

将 100 MB 的垃圾打包成 50 MB 的包
文档选项

将此页作为电子邮件发送');
//-->

将此页作为电子邮件发送
拓展 Tomcat 应用
下载 IBM 开源 J2EE 应用服务器 WAS CE 新版本 V1.1
级别: 初级
Jack Shirazi (jack@JavaPerformanceTuning.com), 董事, JavaPerformanceTuning.comKirk Pepperdine (kirk@JavaPerformanceTuning.com), CTO, JavaPerformanceTuning.com
2004 年 7 月 30 日
如果您是当前写网志(blogging)狂热者中的一员,则可能听说过 Blog-City,这是由苏格兰的一家小公司 Blog-City Ltd. 拥有和运营的网志站点。当一些意料之外的性能问题突然出现时,Java 性能专家 Jack Shirazi 和 Kirk Pepperdine 被邀请帮助进行 Blog-City 的技术调整。他们的检测工作因为受硬件约束和整个项目所使用的通信通道(IRC、ftp 和 偶尔的电子邮件)的限制而变得复杂。
随着网志作为公共日记的流行,网志主机迅速地增长。所以对于 Blog-City 的人来说,非常清楚他们的站点需要发展和提高。为了满足其增长的需要,该公司最近刚刚推出了 Blog-City version 2.0。正像经常出现的情况那样,当新的应用程序转入运行阶段时,由于各种原因,其性能无法完全满足期望的要求,突然出现随机的长时间应用程序被挂起的现象还不是最坏的情况。
在其核心,Blog-City 依靠 Blue Dragon Servlet 引擎(CFML 引擎)和数据库。令人惊讶的是,所有这些软件都宿主在运行 Red Hat Linux 的相当老的 P3 机器上。这台机器具有单个硬盘和 512MB 内存,这对于过去的负载来说是足够强大的,但它正在承受不断增长的负载。Blog-City 的运作方式很成功,但其资源限制却成了其成功路上的绊脚石。尽管如此,这就是未来还要继续使用一段时间的所有硬件。
问题定义
整个过程的第一步是确定突然出现应用程序减慢的原因。首先我们怀疑的对象是垃圾收集。正如我们在 本专栏的上月文章 中所论述的那样,确定垃圾收集和内存利用问题是否对应用程序产生负面影响的最容易的方式是,设置 -verbose:gc JVM 选项,并检查日志输出。因此我们重新启动应用程序,打开冗长的垃圾收集日志选项,然后耐心地等待应用程序的性能降低。我们的耐心换来的是非常详细的垃圾收集日志文件。
从对日志文件的最初分析中看,在这一应用程序中垃圾收集的瓶颈是显而易见的。种种迹象包括垃圾收集的频率、持续时间和总体效率都已表明这一点。高于普通垃圾收集频率的常见原因是,堆的大小刚好足以适应所有当前正在使用的运行对象,无法适应新的正被创建的对象。虽然应用程序消耗大量堆可能有许多原因,但主要原因可能是没有足够内存而导致垃圾收集器运行,因为它设法满足当前需要。换句话说,应用程序试图分配新对象,但失败了,如果失败的话,将触发垃圾收集程序。如果垃圾收集失败而无法恢复足够内存,它将迫使另一个花费更大的垃圾收集程序发生。即使 GC 恢复了足够的空间来满足瞬间需求,可以肯定的是,在应用程序程序另一次分配失败,触发另一个 GC 之前,时间不会很长。因此,应该关注重复扫描空闲堆空间的无效任务,而不是服务于应用程序的 JVM。
应用程序逐步消耗所有可用的堆空间可能有许多原因,但如果有更多内存的话,临时解决方案就是配置更大的堆。假设应用程序没有内存泄漏(或者也就是我们常说的“无意识地保留对象”),它将找到一个“自然”级别的堆消耗,在这个级别中,GC 将能够很适应地得到维持(除非对象创建的速度过快,以至 GC 总是处于赛跑状态)。在这种情况下,以及无意识地保留对象的情况下,我们需要对应用程序做一些变动,以便获得某些改进。
回页首
如果仅仅是这样,那就太简单了
遗憾的是,我们必须面对严酷的现实因素——正在运行的机器只有 512 MB 内存。更糟的是,我们必须与数据库和其他运行在机器中的进程共享该空间。要完整理解这一点为什么至关重要,首先您必须明确理解垃圾收集的基本知识,以及它如何与底层操作系统进行交互。
虚拟内存不再是灵丹妙药
操作系统已经使用虚拟内存许多年了。正如您所知道的,虚拟内存使操作系统的内存看起来比实际的内存要多,这允许计算机运行那些所需内存比可用物理内存更大的程序,不使用内存的应用程序部分将保存在磁盘上。为了进一步简化,操作系统同时按页管理内存。页通常包含 512 字节到 8 KB,所有页的组合就组成了一个虚拟地址空间。操作系统维持一个页表,用于告诉操作系统如何映射虚拟地址到物理地址。当应用程序要求某个内存位置的内容时,操作系统(或硬件)将识别包含虚拟地址的页面。然后确定该页面是否在内存中,如果不在,将会报告 页面错误。但是有许多种方式来处理页面错误,最终的结果是,页面必须从磁盘载入到内存中。这样应用程序就可以访问到有效虚拟地址的内容。
如果相关对象总是在内存的同一页面上聚合,那么 GC 的连续工作很可能出现困难。但是现实世界中,相关对象很少(如果有的话)出现聚合现象。实际结果是,依靠虚拟内存的系统将导致操作系统将页从内存中换入和换出,因为它标记然后废弃堆空间,而当聚合现象发生时,GC 将很多时间花在等待页面从磁盘换入而不是实际恢复内存上。因此,应用程序正在等待 GC,而 GC 正在等待磁盘,其间未完成任何真正的工作。由于本系统只有一个磁盘,并且它还需要支持数据库,因此我们在解决问题时处于两难境地。一方面,我们需要增加内存数量,这样我们可以减少 GC 的频率,但另一方面,我们还需要确保数据库的完好运行,而数据库也是内存的消耗大户。因此,我们需要了解应用程序所需的最小内存数量。
正如我们在上月看到的,在冗长的 GC 日志中这一信息可以很容易得到,无需为这一信息而扫描整个日志,我们使用免费的 JTune 工具(请参阅 参考资料)来解释冗长的 GC 日志。图 1 显示了经过垃圾收集之后的内存利用情况,其中我们将 -Xmx 设置为 256 MB。 图 1. 垃圾收集之后的内存利用情况
回页首
分析 verbose:gc 输出
在图 1 中,蓝色部分表示部分 GC。橙色区域表示完整的 GC,而粉色矩形表示两个完整 GC 在它们之间少于一毫秒之内已经发生的堆利用情况。从结果中我们看到,平均每 0.257 秒有 12,823 次清除。总共有 345 次完整的垃圾收集和 44 次紧挨着的垃圾收集。完整垃圾收集的平均持续时间是 7.303 秒,结果表明有 9.36% 的运行时间花费在垃圾收集程序上。虽然这个值偏高,它仍然保持在 10% 的正常水平之内。因此,在本例中,GC 是系统的繁重负担但还没有达到严重的地步。真正的问题是存在内存泄漏,这一点可以从总体上堆利用率不断增长的趋势看出来。
即使内存泄漏消耗了 50 MB 内存,它也应该是经过很长一段时间后才发生,这使得内存泄漏在较短的测试中很少会引人注意。内存泄漏的实际结果是,它把 JVM 的内存消耗推动到某个点,在该点它强迫 JVM (从而强迫操作系统)消耗内存,它强迫启动分页。图 2 就证明了这一点。注意正好在 55,000 秒标记之后,每一 GC 周期的持续时间中内存消耗突然地持续增加。图 2. GC 持续时间
如您所想,由于垃圾收集的阻塞将导致系统只有更少的时间来分配给用户线程,因此用户响应开始增加。在日志的过去 10,000 秒中,我们看到每次完全收集(总共 15 次)花费时间超过了 30 秒,平均持续时间大约 70 秒 —— 这导致超过 10% 的处理时间分配给完全 GC。部分收集(这里刚好超过了 1000 次)无法正常工作,平均每次请求耗时 1.24 秒,远高于以前 11,800 次清除中的平均 0.25 秒。
分代收集
本文不涉及太深的细节(请参阅 参考资料,获取分代 GC 的详细描述),分代堆空间产生了“年轻”和“年老”对象,它们位于分开的堆空间中。在本配置中,年轻和年老分代空间可以通过不同的 GC 算法和策略来维持,以提高 GC 的整体性能。
一种这样的策略是,进一步将年轻分代划分为创建空间,称为 Eden,以及残存(survivor)空间,用于幸存一个或者多个收集的年轻对象。如果在 Eden 中有足够的内存来适应新对象创建的话,这一般能工作正常。如果不是这种情况,那么对象可以在年老对象空间中创建。同样,如果残存空间足够的话,那么对象将移入年老分代空间。我们将使用这些事实来帮助调优遇到的问题。
回页首
减少完全收集的次数
Blog-City 所碰到的难题是在某一随机点出现长的暂停时间。一旦应用程序启动出现问题,不重新启动机器的话,就无法返回跟踪。由于长时间暂停的现象直接与长的 GC 相关,我们考虑如果将对象保持在年轻分代来减少完全 GC 的次数。由于完全 GC 的代价如此之大,在年轻分代收集更多对象能够得到更短的暂停时间。要完成这一任务,我们调整了一些垃圾收集参数,包括 残存比率(survivor ratio)和 期限阈值(tenuring threshold)。
残存比率用于设置与年轻分代空间总体大小相关的残存空间的大小。如果残存比率设置为 8(Intel 的默认值),那么每一残存空间将是 Eden 空间的 1/8 大小。另一种考察它的方式是,年轻分代将该 Eden 空间划分为 10 个相同大小的值,该 Eden 将分配其中的 8 个,每一个残存空间的大小为 1。
我们的假设是,通过减少残存比率,我们可以减少由于残存空间中空间的缺乏,对象过早地被提升为年老分代的几率。另一种方法是增加期限阈值,这样的话,对象在提升之前将需要保留更多的 GC 事件。本着这个想法,Blog-City 将设置更改为 -XX:SurvivorRatio=4 ,然后重新启动。
选择低暂停时间的垃圾收集算法
由于这次技术调优的目标之一是减少暂停时间,我们决定抛弃默认的单线程、标记清扫的垃圾收集程序。我们选择通过标志 XX:+UseParallelGC 来采用并行拷贝收集程序。同样,有关实际算法的细节可以在 Resources中找到,这里需要提一下的是,这一标志调用了一个多线程收集程序。线程的数量设置为 CPU 的数量。基于这一事实,了解为什么单线程并行拷贝垃圾收集程序要比传统的标记清扫算法工作更好是很困难的,但是从实际观察中可以体会到提供了某些性能上的优势。
图 3 和图 4 的输出展示了使用标志 -XX:SurvivorRatio=4 +XX:+UseParallelGC -server -Xmx256M 运行时的结果。 图 3. 新配置下的内存使用情况
结果图表显示了明显的不同。虽然仍然有一个内存漏洞。内存消耗的总数相比前一个图已经是大大降低了。GC 持续时间的快速比较揭示了年轻分代和年老分代的总体 GC 持续时间的明显减少。图 4. 新配置下的 GC 持续时间
有意的、无意的对象保持
由于应用程序是依靠内存的,跟踪内存泄漏并消除它们已经变得越来越重要。在本例中,用于支持缓存策略的组件决定了主要漏洞的来源。从最后的内存分析情况来看(图 3),虽然消除了主要内存泄漏,我们可以看到仍有另一个“低级别的”的漏洞,但这个漏洞比较小,因此它在下一版本发布之前可以忽略。
回页首
结束语
本文提出了许多挑战。首先,我们正在调优一个现实中的应用程序,这意味着更改会受到很多限制。第二个挑战是,这项任务是使用 IRC 聊天室远程操控的。聊天室不提供任何级别或质量的相互通信,而通信在这种类型的任务中往往是必需的。在本例中,团队已经习惯了聊天室的真实性,并能通过这种真实性毫无任何阻碍地工作着。
最后也是最困难的挑战是我们受硬件的限制。由于多种原因,我们不可能为系统添加新硬件。其中最大的问题是系统中物理内存的数量,而 JVM 和 MySQL 需要大量的内存。但是,通过系统地逐一应用许多更改,并度量它们对系统产生的影响,我们可以逐步地改进总体系统性能。

www.kutie.com.cn www.kutie.cn

酷贴网 紧张测试中,还差最后一道工序 5.15 发布 敬请期待

我的简介

服务器端软件的开发,系统架构