目 录Table of Contents
1 前言 7
2 编码性能规范 7
2.1 线程同步规则 7
2.1.1 规则描述 7
2.1.2 案例研究 8
2.2 字符串使用规则 10
2.2.1 规则描述 10
2.2.2 案例研究 10
2.3 临时对象创建规则 17
2.3.1 规则描述 17
2.3.2 案例研究 17
2.4 集合类使用规则 22
2.4.1 规则描述 23
2.4.2 案例研究 23
2.5 IO读写规则 26
2.5.1 规则描述 26
2.5.2 案例研究 26
2.6 数组、集合操作规则 28
2.6.1 规则描述 29
2.6.2 案例研究 29
2.7 内存泄漏防范规则 30
2.7.1 规则描述 30
2.7.2 案例研究 31
3 设计性能规范 32
3.1 事件派发线程使用规则 32
3.1.1 规则描述 32
3.1.2 案例研究 32
3.2 界面组件设计规则 35
3.2.1 规则描述 38
3.2.2 案例研究 38
3.3 业务流程设计规则 41
3.3.1 规则描述 42
3.3.2 案例研究 42
3.4 界面响应设计规则 45
3.4.1 规则描述 45
3.4.2 案例研究 46
3.5 系统抗负载能力设计规则 46
3.5.1 规则描述 47
3.5.2 案例研究 47
3.6 多线程设计规则 48
3.6.1 规则描述 49
3.6.2 案例研究 49
4 附录A:安装盘压缩 52
4.1 背景介绍 52
4.2 Pack200压缩格式介绍 52
4.3 7z压缩格式介绍 52
5 附录B:性能测试专题 53
5.1 背景介绍 53
5.2 常用的java性能测试工具 54
5.2.1 Profiler工具介绍 54
5.2.2 Visualgc工具介绍 55
5.2.3 GC日志分析工具介绍 55
5.2.4 Windows性能检视器介绍 56
5.3 如何分析性能测试数据 57
5.3.1 检查测试数据的真实性 57
5.3.2 通过Excel对测试数据进行处理 58
5.3.3 分析测试数据的关注点 58
5.3.4 对测试项进行理论评估和公式推导 58
6 参考文献 59
表目录 List of Tables
表2 JTextArea组件和UltralEdit程序打开文件时的内存增量情况 39
表3 自己开发的文本组件和JTextArea组件的性能对比数据 40
图目录 List of Figures
Java编程性能规范
范 围Scope:
本规范规定了在基于程序性能考虑的情况下,java语言的系统编码和系统设计规则。
本规范适用于使用Java语言编程的部门和产品。
简 介Brief introduction:
本规范从如何提高java系统性能的角度,给出了系统设计和编码时的重要关注点。在本规范中,针对每一个关注点,都从概述、规则描述和案例研究三个方面来展开描述。本规范中的规则描述都是从实际的优化案例和技术研究中提炼出来的,可以供java编程人员在系统设计和编码时作为意见参考。值得一提的是,本文中列举了大量的实际优化案例(基于JDK1.4环境),这些案例对于java系统的性能优化有着比较高的借鉴价值,可以供开发人员在性能优化时参考。
关键词Key words:java,性能优化
引用文件:
下列文件中的条款通过本规范的引用而成为本规范的条款。凡是注日期的引用文件,其随后所有的修改单(不包括勘误的内容)或修订版均不适用于本规范,然而,鼓励根据本规范达成协议的各方研究是否可使用这些文件的最新版本。凡是不注日期的引用文件,其最新版本适用于本规范。
序号No. | 文件编号Doc No. | 文件名称 Doc Title |
1 | ||
2 | ||
术语和定义Term&Definition:<对本文所用术语进行说明,要求提供每个术语的英文全名和中文解释。List all Terms in this document, full spelling of the abbreviation and Chinese explanation should be provided.>
缩略语Abbreviations | 英文全名 Full spelling | 中文解释 Chinese explanation |
前言
性能是软件产品开发中需要关注的一个重要质量属性。在我们产品实现的各个环节,都可能引入不同程度的性能问题,包括从最初的软件构架选择、到软件的详细设计,再到软件的编码实现等等。但在本规范中,主要内容是针对java系统,给出一些在软件详细设计和编码实现过程中需要注意的规则和建议。
即使我们在产品开发的各个环节都考虑了性能质量属性,可能还是不够的。随着系统业务功能的不断增多,用户要求的不断提高,或者为了提高产品的可用性和竞争力,我们很多情况下还是需要对已开发的产品进行一次集中式的专门的性能优化工作。本规范中所提供的大量实际优化案例,可供这种专门的性能优化工作进行参考。
但是在性能优化工作中,我们也需警惕"过早优化"的问题。我们的基本指导策略还是首先让系统运行起来,再考虑怎么让它变得更快。一般只有在我们证实某部分代码的确存在一个性能瓶颈的时候,才应进行优化。除非用专门的工具分析瓶颈,否则很有可能是在浪费自己的时间。另外,性能优化的隐含代价会使我们的代码变得难于理解和维护,这一点也是需要权衡和关注的。
编码性能规范
线程同步规则
关于线程同步的处理,一般在编码过程中存在以下几个问题:
- 在需要进行多线程同步处理的地方,没有进行同步处理;
- 本是在单线程(如派发线程)中运行的代码,加了不必要的同步;
- 本来已经是在同步方法里运行的代码,又加了不必要的二次同步;
- 在可以用线程局部变量来规避同步问题的地方,直接使用了synchronized来进行同步;
- 本来只需要对方法里一小段代码进行同步的地方,直接使用synchronized对整个方法进行了同步;
从性能角度来讲,并不是所有的同步方法都影响性能。如果同步方法调用的频率足够少,则加同步和不加同步,对性能影响不是很大,甚至可以忽略。我们在编写代码时,需要对一些重点的地方关注同步问题。
规则描述
建议1.1.1.1:对于可能被其它代码频繁调用的方法,需要关注同步问题
建议1.1.1.2:对于常用工具类的方法,需要关注同步问题
建议1.1.1.3:对于不能确认被其它代码如何调用的方法,需要关注同步问题
在代码编写过程中,很容易犯同步问题的错误,不恰当的使用同步机制。我们在编写代码(重点是:可能被频繁调用的方法、作为常用工具类的方法、不能确认被其它代码如何调用的方法)时,需要重点关注以下同步问题:
- 避免不必要的同步。如明确只是在事件派发线程中调用的方法,就不必要加同步;在单线程环境下,就尽量不要用HashTable、Vector等类,而采用HashMap、ArrayList类;在多线程环境下,如果只是局部代码需要涉及同步问题,可以通过Collections.synchronizedCollection(…)来对ArrayList等集合类实现同步。
- 避免进行二次同步。仔细检查被频繁调用的热点代码,对于那些已经是在同步方法里运行的代码,就尽量不必要再进行二次同步。测试证明,多次同步比一次同步更影响性能。
- 在有些地方,可以用线程局部变量来规避同步。如对于那些作为临时缓冲区的成员变量,在多线程环境下就可以采用线程局部变量来实现。
- 尽量缩小同步的代码范围。建议尽量采用同步代码块的方式来进行同步,而不要直接对整个方法加synchronized进行同步。
总的来说,我们没必要对所有代码都关注同步问题,在处理同步问题的性能优化时还需要保证代码的可读性和可维护性。所以对于进入维护阶段的代码,我们不要盲目的进行同步问题的优化,以免引入一些新的问题。
案例研究
JDK中使用线程局部变量ThreadLocal来规避同步
在JDK的Integer类的public static String toString(int i)方法中,使用了线程局部变量perThreadBuffer来保存char数组,供构建String对象时使用。其代码如下:
...... // Per-thread buffer for string/stringbuffer conversion private static ThreadLocal perThreadBuffer = new ThreadLocal() { protected synchronized Object initialValue() { return new char[12]; } }; public static String toString(int i) { switch(i) { case Integer.MIN_VALUE: return "-2147483648"; case -3: return "-3"; case -2: return "-2"; case -1: return "-1"; case 0: return "0"; case 1: return "1"; case 2: return "2"; case 3: return "3"; case 4: return "4"; case 5: return "5"; case 6: return "6"; case 7: return "7"; case 8: return "8"; case 9: return "9"; case 10: return "10"; } char[] buf = (char[])(perThreadBuffer.get()); int charPos = getChars(i, buf); return new String(buf, charPos, 12 - charPos); } ...... |
- JDK中使用线程局部变量的示例
关于以上代码,如果不使用线程局部变量的话,一般做法会是首先在Integer类中定义一个char数组的静态成员变量,然后直接将toString(…)方法加上synchronized关键字来进行同步处理。但这种做法我们可以想象,当Integer的toString(…)方法被频繁调用的话,对性能影响是非常大的。
改用线程局部变量后,可以为每个调用的线程单独维护一个char数组,并且保证每个线程只使用自己的char数组变量,从而也就不存在需要同步的问题了,因此性能就比直接使用synchronized来进行同步要好很多。其实在JDK的源代码中,Integer.java、Long.java、Charset.java、StringCoding.java等类中都用到了线程局部变量。
JDK中使用代码块同步来代替直接对方法进行同步
在JDK的源代码中,很多地方都是使用代码块同步,而不是直接对整个方法进行同步的。直接对方法进行同步的一个主要问题在于:当一个线程对对象的一个同步方法进行调用时,会阻止其它线程对对象其它同步方法的调用,而不管对象的这些同步方法之间是否具有相关性。以下是一个java.util.Timer.java中同步代码块的例子:
……
private TaskQueue queue = new TaskQueue();
……
public void cancel() {
synchronized(queue) {
thread.newTasksMayBeScheduled = false;
queue.clear();
queue.notify(); // In case queue was already empty.
}
}
……
- JDK中同步代码块的示例
字符串使用规则
在我们实际开发的代码中,有很大一部分代码都是在做各种字符串操作。经常用到的字符串操作包括:字符串连接、字符串比较、字符串大小写转换、字符串切分、字符串查找匹配等。我们在编码过程中存在的问题是:往往对于一种字符串操作,有多种处理方式可供选择,而对于程序中的那些热点方法,如果选择了不恰当的字符串处理方式,将会对程序性能产生较大的影响。
规则描述
规则1.2.1.1:对于常量字符串,不要通过new方式来创建
规则1.2.1.2:对于常量字符串之间的拼接,请使用"+";对于字符串变量(不能在编译期间确定其具体值的字符串对象)之间的拼接,请使用StringBuffer;在JDK1.5或更新的版本中,若字符串拼接发生在单线程环境,可以使用StringBuilder
建议1.2.1.3:在使用StringBuffer进行字符串操作时,请尽量设定初始容量大小;也尽量避免通过String/CharSequence对象来构建StringBuffer对象
规则1.2.1.4:当查找字符串时,如果不需要支持正则表达式请使用indexOf(…)实现查找;当需要支持正则表达式时,如果需要频繁的进行查找匹配,请直接使用正则表达式工具类实现查找
建议1.2.1.5:对于简单的字符串分割,请尽量使用自己定义的公用方法或StringTokenizer
建议1.2.1.6:当需要对报文等文本字符串进行分析处理时,请加强检视,注意算法实现的优化
案例研究
不恰当的字符串创建方式
在java语言中,对于字符串常量,虚拟机会通过常量池机制确保其只有一个实例。常量池中既包括了字符串常量,也包括关于类、方法、接口等中的常量。当应用程序要创建一个字符串常量的实例时,虚拟机首先会在常量池中查找,看是否该字符串实例已经存在,如果存在则直接返回该字符串实例,否则新建一个实例返回。我们说常量是可以在编译期就能被确定的,所以通过new方法创建的字符串不属于常量。关于字符串常量的特性,可以通过以下代码做一个测试:
String s0="abcd";
String s1="abcd";
String s2=new String("abcd");
String s3=new String("abcd");
System.out.println( s0==s1 ); //true
System.out.println( s2==s3 ); //false
System.out.println( s0==s2 ); //false
System.out.println( "============================" );
s2=s2.intern(); //把常量池中"abcd"的引用赋给s2
System.out.println( s0==s2 ); //true
输出结果为:
true
false
false
============================
true
- 字符串常量测试代码
通过上面测试代码的输出结果可以了解两点:
- 对于字符串常量,不要通过new方法来进行创建,因为这样可能会导致创建多个不必要的实例。
- 字符串对象有一个intern()方法,调用该方法后,如果常量池中没有该字符串常量,则会促使虚拟机创建一个新字符串对象实例,并保存到常量池中;如果常量池中含有该字符串常量,则直接从常量池中返回该字符串常量实例。
不恰当的字符串拼接方式
在java中,字符串拼接方式常用的有两种,一是通过"+"号进行拼接,另外一种是通过StringBuffer进行拼接。这两种拼接方式都有自己特定的适用场合。一般规则是对于字符串常量之间的拼接,请使用"+";对于字符串变量(不能在编译期间确定其具体值的字符串对象),请使用StringBuffer。另外,当使用StringBuffer进行字符串拼接时,请尽量指定合适的初始容量大小。以下代码是对字符串常量拼接的一个测试代码,通过"+"号来实现字符串常量拼接,可以达到共享实例的目的:
……
private static String constStr1="abcde";
private static String constStr2="fghi";
private final static String constStr3="abcde";
private final static String constStr4="fghi";
public static void main(String[] args) {
String str0="abcdefghi";
String str1="abcde";
String str2="fghi";
final String str_1="abcde";
final String str_2="fghi";
String str3="abcde"+"fghi";
String str4=str1+str2;
String str_4=str_1+str_2;
String str5=constStr1+constStr2;
String str6=constStr3+constStr4;
System.out.println(str0==str3); //true,直接通过常量相加,可以共享实例
System.out.println(str0==str4); //false,通过引用对常量进行相加,将会得到一个新的字符串变量
System.out.println(str0==str_4); //true,通过final引用对常量相加,可以共享实例
System.out.println(str0==str5); //false,没有加final,还是会得到一个新的字符串变量
System.out.println(str0==str6); //true,成员变量加了final后,才可以当作常量来使用
}
……
以上代码输出结果为:
true
false
true
false
true
- 通过"+"号实现字符串常量拼接的测试代码
最可怕的不恰当的字符串拼接方式,是在for循环中使用"+"来进行字符串对象拼接,类似如下代码:
for(int i = 0 ; i < 1024*1024; i++ )
{
str += "XXX" ;
}
- 错误的字符串拼接方式
运行上面代码的结果是:将导致整个操作系统CPU占有率长时间达到100%,操作系统长时间(应该在5分钟以上)几乎处于不响应状态。具体原因很简单,每次"+"号操作后,都会生成一个新的临时字符串对象,随着循环的深入,创建的临时字符串对象越来越大,执行起来就会越来越困难。如果将上述代码改为StringBuffer来实现拼接,可以看到程序能够正常运行。
不恰当的字符串查找匹配
在java中,进行字符串查找匹配时一般有三种实现方式:第一种是调用String对象的indexOf(String str)方法;第二种是调用String对象的matches(String regex)方法;第三种是直接使用正则表达式工具类(包括Pattern类、Matcher类)来实现匹配。这三种实现方式的各自特点如下:
- indexOf(String str)方法运行速度最快,效率最高,但不支持正则表达式。
- matches(String regex)方法性能最差,但支持正则表达式,使用起来简单(该方法性能差的原因是每调用一次时,就重新对正则表达式编译了一次,新建了一个Pattern对象出来,而不是重复利用同一个Pattern对象)。
- 直接使用正则表达式工具类来实现匹配,可以支持正则表达式,在频繁操作下性能比matches(String regex)方法要好很多。
以下是对三种查找匹配实现方式的性能测试代码:
String s0="abcdefghjkilmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
String s1="890ABCDE";
Pattern p = Pattern.compile(s1);
Matcher m = p.matcher(s0);
int loop=1000000;
long start=System.currentTimeMillis();
for(int i=0;i<loop;i++)
{
s0.indexOf(s1); //通过String对象的indexOf(String str)方法实现查找匹配
}
long end=System.currentTimeMillis();
long time1=end-start;
start=System.currentTimeMillis();
for(int i=0;i<loop;i++)
{
s0.matches(s1); //通过String对象的matches(String str)方法实现查找匹配
}
end=System.currentTimeMillis();
long time2=end-start;
start=System.currentTimeMillis();
for(int i=0;i<loop;i++)
{
m.matches(); //通过正则表达式工具类实现查找匹配
}
end=System.currentTimeMillis();
long time3=end-start;
System.out.println("time1:"+time1);
System.out.println("time2:"+time2);
System.out.println("time3:"+time3);
上述代码某次执行时的输出结果:
time1:187
time2:1844
time3:219
- 不同查找匹配方式实现的测试代码
不恰当的字符串分割
在我们实际开发中,常常需要对字符串进行分割,如将"abc def ghi"按空格分割成三个字符串然后放到一个String数组中。在java中,一般有三种方法可以实现字符串的分割:
- 利用String对象提供的split(String regex)方法实现分割;
- 利用StringTokenizer对象实现字符串分割;
- 自己开发代码实现字符串分割;
对于以上三种分割方法,其运行效率差别是很大的。采用split(String regex)方法性能最差,但由于使用了正则表达式,功能性最强;采用StringTokenizer对象分割字符串,性能可以接受,功能也较强;采用自己开发代码实现字符串分割,速度最快,但不具备通用性。下面是对三种方法的一个测试代码:
……
//通过String对象的split(String regex)方法实现字符串分割
public static String[] testSplit1(String str)
{
return str.split(",");
}
//通过StringTokenizer对象实现字符串分割
public static String[] testSplit2(String str)
{
ArrayList list=new ArrayList();
StringTokenizer st = new StringTokenizer(str,",");
while(st.hasMoreTokens())
{
list.add(st.nextToken());
}
String[] obj=(String[])list.toArray(new String[0]);
return obj;
}
//通过自己开发代码实现字符串分割
public static String[] testSplit3(String str)
{
int fromIndex=0;
int index0=0;
int signLen=",".length();
int strLen=str.length();
index0=str.indexOf(",",fromIndex);
if(index0==-1)
{
return new String[]{str};
}
ArrayList list=new ArrayList();
String subStr=str.substring(fromIndex,index0);
if(!subStr.equals(""))
{
list.add(subStr);
}
fromIndex=index0+1;
while(fromIndex<strLen-1)
{
index0=str.indexOf(",",fromIndex);
if(index0==-1)
{
list.add(str.substring(fromIndex));
break;
}
String subStr1=str.substring(fromIndex,index0);
if(!subStr1.equals(""))
{
list.add(subStr1);
}
fromIndex=index0+signLen;
}
return (String[])list.toArray(new String[0]);
}
……
- 字符串分割的三种算法实现
经过测试,假设要分割的字符串是"aaaa,bbbb,cccc,dddd,eeee,ffff",对上述三种算法各调用1000次后,所花的时间大概如下表:
实现算法
所花时间(单位:毫秒)
采用split(String regex)方法进行分割
141
采用StringTokenizer进行分割
46
采用自定义方法进行分割
16
- 三种字符串分割算法的性能对比表
根据上表的性能测试结果可知,采用split(String regex)方法进行分割字符串性能是很低的。所以在实际代码开发中,如果需要频繁对字符串进行分割的话,最好不要采用String对象的split(…)方法进行字符串分割,一个比较好的选择是自己写一个公用的字符串分割方法。
不高效的字符串处理算法
在实际开发中,会遇到需要对报文等文本字符串进行分析处理的问题,对于不同的开发人员,实现文本字符串分析处理的算法会存在较大差异,有的实现会比较高效,有的实现看起来比较简单但性能却很差。以下是一个字符串处理算法优化案例:
//优化前的方法实现
private String extractPingReportBody(String strPingReport)
{
String intialMessage = strPingReport;
int index = intialMessage.toUpperCase().indexOf(PING_MESSAGE_FORMAT);
if(0 <= index)
{
intialMessage = intialMessage.substring(index);//创建了一个新的子串
index = intialMessage.indexOf(DOUBLE_LINE_SEPARATOR);
if(0 <= index)
{
//又创建了一个新的子串
intialMessage = intialMessage.substring(index + DOUBLE_LINE_SEPARATOR.length());
index = intialMessage.indexOf(DOUBLE_LINE_SEPARATOR);
if(0 <= index)
{
intialMessage = intialMessage.substring(0, index); //又创建了一个新的子串
return intialMessage;
}
else
{
......
}
}
else
{
......
}
}
else
{
......
}
return strPingReport;
}
//优化后的方法实现
private String extractPingReportBody(String strPingReport)
{
int index0 = strPingReport.toUpperCase().indexOf(PING_MESSAGE_FORMAT);
if(index0<0)
{
......
}
int index1= strPingReport.indexOf(DOUBLE_LINE_SEPARATOR,index0);//不再创建子串
if(index1<0)
{
......
}
else
{
//不再创建子串
int dex2=strPingReport.indexOf(DOUBLE_LINE_SEPARATOR,index1
+ DOUBLE_LINE_SEPARATOR.length());
if(index2>=0)
{
return strPingReport.substring(index1,index2);
}
else
{
......
}
}
return strPingReport;
}
- 字符串处理算法优化实例
总结字符串处理的优化案例,一般不高效的字符串处理算法表现为:
- 使用StringBuffer时,没有给其合适的初始容量大小;
- 在多分支处理流程中,没有按需创建对象;
- 字符串方法使用不正确。如以下代码片断,就是由于indexOf方法使用不正确导致了错误的做法:
错误的做法:
intialMessage = intialMessage.substring(index);
index = intialMessage.indexOf(DOUBLE_LINE_SEPARATOR);
好的做法:
index= intialMessage.indexOf(DOUBLE_LINE_SEPARATOR,index);
临时对象创建规则
在java语言中,或者说在面向对象语言中,对象的创建是既耗时间,又占用内存。如果在系统运行过程中创建了大量不必要的临时对象,对性能的影响是比较大的。因此如何避免创建不必要的临时对象,对系统性能的提升有着重要作用。在实际代码开发中,创建了不必要的临时对象的原因一般可分为以下几种:
- 业务处理流程不够精简,增加了不必要的中间环节;
- 提前创建对象,而不是按需创建对象;
- 在for、while等循环里面创建对象,而不是在外面创建对象,使对象没有重用;
- 对于高频度使用的对象,没有进行优化处理给以重用;
规则描述
建议1.3.1.1:在实现业务处理流程的过程中,需要考虑临时对象引起的性能问题,精简业务处理流程,减少不必要的中间环节
建议1.3.1.2:对象的创建应尽量按需创建,而不是提前创建
建议1.3.1.3:对象的创建应尽量在for、while等循环外面创建,在循环里面进行重用
建议1.3.1.4:对于高频度使用的对象,需要进行单独优化处理给以重用
案例研究
跟踪消息体码流保存的处理流程不够精简
在某OM系统中,有一个对跟踪消息体码流进行保存的处理流程。在该处理流程中,主机会频繁的向OM系统上报跟踪消息,OM系统需要对所有这些上报消息实时进行保存,以便用户后续根据消息分析和定位问题。在优化前,跟踪消息体的保存处理流程如下图所示:
- 优化前的跟踪消息体保存处理流程
从上面的处理流程可以看出,每保存一条消息体码流,就新建了一个ByteBuffer对象和一个byte数组对象,这是不必要的浪费,当跟踪消息上报速度很大时,这种ByteBuffer和byte数组临时对象的创建对性能影响是很大的。经过对该处理流程改进优化后,新的处理流程如下图:
- 优化后跟踪消息体保存处理流程
优化前和优化后的处理流程的差别在于,优化前每保存一条消息码流,都要创建一个ByteBuffer对象,而优化后把这一过程给省略了,直接通过IO写入数据,减少了中间码流的转换过程。
报文隐藏密码方法的性能优化
在某OM系统的MML报文隐藏密码方法中,优化前存在三个问题:第一个问题是StringBuffer没有按需创建;第二个问题是存在多余处理逻辑;第三个问题是StringBuffer没有指定初始容量大小。以下是隐藏密码方法优化前的代码:
public static String hidePassword(final String msg)
{
if (null == msg || "".equals(msg))
{
return msg;
}
Matcher matcher = getPattern().matcher(msg);
//StringBuffer没有按需创建,因为如果一开始就没有发现匹配的话,StringBuffer对象就是多余的;
//StringBuffer没有指定初始大小,实际上StringBuffer的容量可以用msg.length()来指定;
StringBuffer sbf = new StringBuffer();
int iLastEnd = 0;
int iEnd = 0;
int iValueIdx = 0;
while (matcher.find())
{
iEnd = matcher.end();
sbf.append(msg.substring(iLastEnd, iEnd));
iValueIdx = msg.indexOf('"', iEnd);
if (0 <= iValueIdx)
{
sbf.append("*****");
iLastEnd = iValueIdx;
}
else
{
iLastEnd = iEnd;
}
}
if (iLastEnd < msg.length())
{
//如果一开始就没有发现匹配的话,即iLastEnd==0,这时只需要直接返回msg就行了,
//所以在这种情况下,下面的语句就是多余的。
sbf.append(msg.substring(iLastEnd));
}
return sbf.toString();
}
- 优化前的报文隐藏密码方法的代码
经过优化后, 报文隐藏密码方法的代码如下:
public static String hidePassword(final String msg)
{
if (null == msg || msg.length()==0)
{
return msg;
}
Matcher matcher = getPattern().matcher(msg);
StringBuffer sbf = null;
int iLastEnd = 0;
int iEnd = 0;
int iValueIdx = 0;
while (matcher.find())
{
//将sbf的new操作放到下面来进行。
if(null == sbf)
{
//最后StringBuffer的长度肯定与msg的长度差不多,所以可以给其指定初始大小
sbf = new StringBuffer(msg.length());
}
iEnd = matcher.end();
sbf.append(msg.substring(iLastEnd, iEnd));
iValueIdx = msg.indexOf('"', iEnd);
if (0 <= iValueIdx)
{
sbf.append("*****");
iLastEnd = iValueIdx;
}
else
{
iLastEnd = iEnd;
}
}
//如果一开始就没有发现匹配的话,则直接返回msg就行了。
if(0 == iLastEnd)
{
return msg;
}
else if (iLastEnd < msg.length())
{
sbf.append(msg.substring(iLastEnd));
}
return sbf.toString();
}
- 优化后的报文隐藏密码方法的代码
在实际代码开发中,没有按需创建对象是一个经常容易犯的错误。很多开发人员往往图一时编码方便或时间紧张来不及细想,将一些对象提前创建出来,而在后面某些条件分支中又完全用不到这些对象。
跟踪过滤在for循环里面创建Map对象,而不是重用Map对象
在某OM系统的跟踪过滤功能中,有一段热点代码如下图:
……
public void executeColumnFilter(String[] selectedValues)
{
……
for (int tableIndex = 0; tableIndex < size; tableIndex++)
{
Vector rowData = (Vector) tableDataV.get(tableIndex);
String detailMsgStr = getDetailMsgStr(tableIndex);
……
}
……
}
……
private String getDetailMsgStr(int tableIndex)
{
String detailMsgStr = "";//消息详细解释码流字符串
HashMap detailParameter = new HashMap(); //该对象没有重用
……
}
……
- 优化前的跟踪过滤代码片断
在上面显示的代码中,for循环里面每次都调用了getDetailMsgStr()方法,而该方法每次都会创建一个临时HashMap对象,方法调用结束后,HashMap就不再使用了。经过测试(512内存、CPU2.4G),在for循环里面创建50000个HashMap对象(不指定初始容量大小)的开销是:需要时间约16毫秒、需要内存约320K。所以,在性能比较紧张的情况下,如何重用该Map对象还是有意义的。
经过优化后,代码结构如下图:
……
public void executeColumnFilter(String[] selectedValues)
{
……
Map map=new HashMap(3); //在for循环外面创建对象,在使用时进行重用。
for (int tableIndex = 0; tableIndex < size; tableIndex++)
{
Vector rowData = (Vector) tableDataV.get(tableIndex);
map.clear();
String detailMsgStr = getDetailMsgStr(tableIndex,map);
……
}
……
}
……
private String getDetailMsgStr(int tableIndex,Map map)
{
String detailMsgStr = "";//消息详细解释码流字符串
……
}
……
- 优化后的跟踪过滤代码片断
虽然优化后的代码比优化前更加高效,但可读性要比优化前差些,需要多加一些注释进行说明。从这个优化方案,可以总结出以下结论:
- 在很多情况下,由于方法的调用层次太多,我们通常是无意识的或没有察觉到在for、while等循环里面创建了大量临时对象。
- 在设计类的时候,如果方法的粒度太细,则会由于方法之间没法共用局部变量,通常会导致创建的临时对象会比粗粒度的方法要多。
- 如果通过优化来减少局部变量,一般代码的可读性会变差。所以我们通常只需要对热点方法进行优化即可,对不频繁调用的方法进行优化,往往是得不偿失的。热点方法可以通过业务分析、性能测试等手段来找出。
集合类使用规则
在java API的集合框架(Java Collections Framework)中,提供了丰富的集合处理类,包括无序集合(Set集合)、有序集合(List集合)和映射关系集合(Map集合)。在通常情况下,集合框架提供了足够的功能供我们使用,我们关注的重点是如何选择这些集合类。但需要指出的是,java API的集合框架也并没有想象的那么完善,它不可能解决所有应用场景下的问题。我们在使用集合类进行编程时,常常面临以下问题:
- 集合类的选择问题。实现同一个业务处理,往往可以有多个集合类供选择,但是在特定的应用场景下,出于性能的考虑只有一种集合类是最佳的选择。例如在单线程环境下,使用ArrayList会比Vector更加高效;在需要随机提取集合中的数据时,ArrayList会比LinkedList更加高效。
- 采用特定的集合类。Java API的集合框架是基于对象模型来设计的,其操作的数据必须是对象,不能是基本类型。这种约束在某些应用场景下,将会产生性能问题。例如本只是对纯数字进行处理的业务,采用集合框架后,所有数据就都需要用Integer等对象来表示,而不能用基本类型。对于这种情况,我们可以根据业务需要采用特定的集合类(这种特定集合类可以是自己开发,也可以采用Trove这样的第三方开源类库),以优化系统性能。
- 结构体和集合类的选择问题。在实际开发中,当我们需要在方法之间传递多个数据时,既可以通过一个结构体对象(java bean)来传递多个属性值,也可以用一个ArrayList或Vector来传递多个属性值。显然,用结构体来传递属性值会更加高效,而用集合类来传递属性值通用性会更强一些。
规则描述
建议1.4.1.1:在代码开发中,需要根据应用场景合理选择集合框架中的集合类,应用场景可按单线程和多线程来划分,也可按频繁插入、随机提取等具体操作场景来划分
建议1.4.1.2:对于热点代码,可以采用特定的集合类来提供系统性能,特定集合类可以是自己开发,也可以直接采用Trove这样的第三方开源类库
建议1.4.1.3:当需要在方法之间传递多个属性值时,从性能角度考虑,应优先采用结构体,而非ArrayList或Vector等集合类
案例研究
根据业务处理特点,开发特定的集合类
在某网管系统中,故障查询数据中的流水号占用了大量内存。当数据量达到一定数值(一般达到300万)时,容易造成前台内存溢出。其原因主要是流水号的存取是采用集合框架的ArrayList来存取的。由于ArrayList必须以Object的方式保存内容,因此在保存流水号的时候必须用Integer对象,而不是基本整数类型。在java中一个Integer对象占用的内存大约为32个字节,而int类型只占用4个字节,所以在大数据量情况下,采用Integer对象比int类型耗费的内存要多得多。针对上面的问题,优化思路是开发一个特定的集合类IntArrayList,该类有两个特点:
- 保持与原接口兼容。它实现List接口,那么对外的接口几乎和原来ArrayList类一致,这样对现有系统的改动是非常小的,只需要用新写的类替换掉程序中原有的类。
- 用基本数据类型替换对象。在IntArrayList类里面用一个int型数组来保存数据而不是用Object对象数组,从而达到减少内存占用的目的。
IntArrayList类的参考实现如下:
public class IntArrayList implements List
{
int size = 0;
//保存Integer数值的数祖
int[] elementData = null;
public IntArrayList(int initialCapacity)
{
super();
if (initialCapacity < 0){
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
this.elementData = new int[initialCapacity];
}
/**
* 增加数据
*/
public boolean add(Object o)
{
try
{
ensureCapacity(size + 1); // Increments modCount!!
elementData[size++] = ( (Integer)o).intValue();
return true;
}
catch (Exception ex){}
return false;
}
/**
* 获取数据
*/
public Object get(int index)
{
RangeCheck(index);
return new Integer(elementData[index]);
}
/**
* 删除指定位置的数据
*/
public Object remove(int index)
{
Object oldValue = null;
try
{
RangeCheck(index);
oldValue = new Integer(elementData[index]);
int numMoved = size - index - 1;
if (numMoved > 0){
System.arraycopy(elementData, index + 1, elementData, index,numMoved);
}
sizee--;
}
catch (Exception ex){
return oldValue;
}
}
}
- IntArrayList类的参考实现
Trove集合类和java集合类的性能对比
Trove集合类是一种开放源代码的java集合包,遵从LGPL协议(可以商用),提供了java集合类的高效替代品。Trove可以从http://trove4j.sourceforge.net/处获取。相对于java集合框架,Trove具有以下特点:
- 提供了基于基本数据类型(byte 、 short 、 char 、 int 、 long 、 float 、 double 和 boolean)的Map集合类。比如HashMap,根据键值对的组合关系共有81种Map,如TIntIntHashMap、TIntObjectHashMap。
- 提供了可以存储基本数据类型的List和Set类。如TIntHashSet、TFloatArrayList等。
- 采用开放选址方式而非链接方式来实现映射。在java集合框架中,多数映射都是使用链接实现,就是说如果多个键映射到表中的同一索引位置,则索引位置保存一个链表,其中存放映射到该位置的所有元素。开放选址映射则假设表中邻近的位置存在没有使用的索引。如果目标位置已经被占用,映射实现就查看附近的几个位置找到一个没有使用的位置。这种方法不需要链表节点,因此 Trove 映射和相同的核心集合类相比占用的内存更少。
以下是对Map的put和get方法的性能对比测试:
TObjectIntHashMap intmap = new TObjectIntHashMap();
HashMap map=new HashMap();
int loop=300000;
long start=System.currentTimeMillis();
for(int i=0;i<loop;i++)
{
intmap.put("N"+i, 2);
int numInStock = intmap.get("N"+i);
}
long end=System.currentTimeMillis();
long time=end-start;
Integer intObj=new Integer(2);
start=System.currentTimeMillis();
for(int i=0;i<loop;i++)
{
map.put("N"+i, intObj);
Integer numInStockObj=(Integer)map.get("N"+i);
}
end=System.currentTimeMillis();
long time1=end-start;
System.out.println("time:"+time);
System.out.println("time1:"+time1);
以上代码某次执行时的输出结果如下:
time:1375
time1:1593
- Trove集合类和java集合类的性能对比
IO读写规则
IO读写是我们在实际开发中经常要遇到的功能实现。Java API为我们提供了庞大的IO读写库,从实现上来看可分为早期的基于流的IO库和新的基于块的NIO库,从功能上来看可分为基于字节操作的IO库和基于字符操作的IO库。在这么庞大的IO库下,对于同一个IO功能,可以编写出多种实现代码,但需要强调的是,一个设计拙劣的IO代码可能要比经过精心调整的IO代码慢上几倍。为了使我们开发的系统能高效运行,我们就必然面临一个问题:怎样编码才能使IO功能实现可以性能最优?
规则描述
规则1.5.1.1:进行IO读写操作时,必须使用缓冲机制
建议1.5.1.2:从性能角度考虑,应尽量优先使用字节IO进行读写,而避免用字符IO进行读写
案例研究
六种读写文件实现方式的性能比较
对于文件读写操作,java API中提供了多种类库可供我们选择,不同的选择和实现方式会产生不同的性能结果。以下是六种读写文件实现方式的测试代码:
//通过NIO实现文件读写(方式1)
FileInputStream fin1 = new FileInputStream("d:/test1.rar");
FileOutputStream fout1 = new FileOutputStream("d:/e/test1.rar");
FileChannel fcin = fin1.getChannel();
FileChannel fcout = fout1.getChannel();
int fileLength = (int)fcin.size();
long start=System.currentTimeMillis();
fcin.transferTo(0, fileLength, fcout);
fin1.close();
fout1.close();
long end = System.currentTimeMillis();
long time1 = end - start;
System.out.println("NIO_time1:"+time1);
//通过NIO实现文件读写(方式2)
FileInputStream fin11 = new FileInputStream( "d:/test11.rar" );
FileOutputStream fout11 = new FileOutputStream( "d:/e/test11.rar" );
FileChannel fcin11 = fin11.getChannel();
FileChannel fcout11 = fout11.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 512 );
start=System.currentTimeMillis();
while (fcin11.read(buffer)!=-1) {
buffer.flip();
fcout11.write( buffer );
buffer.clear();
}
fin11.close();
fout11.close();
end = System.currentTimeMillis();
long time11 = end - start;
System.out.println("NIO_time2:"+time11);
//通过IO进行批量读写
byte[] arr = new byte[512];
FileInputStream fin3 = new FileInputStream("d:/test3.rar");
FileOutputStream fout3 = new FileOutputStream("d:/e/test3.rar");
start = System.currentTimeMillis();
while (fin3.read(arr) != -1) {
fout3.write(arr);
}
fin3.close();
fout3.close();
end = System.currentTimeMillis();
long time3 = end - start;
System.out.println("IO_byteArray:" + time3);
//通过buffer IO进行读写
FileInputStream fin4 = new FileInputStream("d:/test4.rar");
FileOutputStream fout4 = new FileOutputStream("d:/e/test4.rar");
BufferedInputStream bufferInput=new BufferedInputStream(fin4);
BufferedOutputStream bufferOutput=new BufferedOutputStream(fout4);
int c=-1;
start = System.currentTimeMillis();
while ((c = bufferInput.read()) != -1) {
bufferOutput.write(c);
}
bufferInput.close();
bufferOutput.close();
end = System.currentTimeMillis();
long time4 = end - start;
System.out.println("IO_Buffer:"+time4);
//通过字符IO进行读写
FileReader reader=new FileReader("d:/test5.rar");
FileWriter writer=new FileWriter("d:/e/test5.rar");
char[] charArr = new char[512];
start = System.currentTimeMillis();
while (reader.read(charArr) != -1) {
writer.write(charArr);
}
reader.close();
writer.close();
end = System.currentTimeMillis();
long time5 = end - start;
System.out.println("IO_char:" + time5);
//直接通过IO进行读写(不使用缓冲)
c = -1;
FileInputStream fin2 = new FileInputStream("d:/test2.rar");
FileOutputStream fout2 = new FileOutputStream("d:/e/test2.rar");
start = System.currentTimeMillis();
while ((c = fin2.read()) != -1) {
fout2.write(c);
}
fin2.close();
fout2.close();
end = System.currentTimeMillis();
long time2 = end - start;
System.out.println("IO_noBuffer:"+time2);
以上代码某次执行的输出结果如下(读写的文件大小为3M):
NIO_time1:171
NIO_time2:250
IO_byteArray:235
IO_Buffer:344
IO_char:515
IO_noBuffer:10002
- 六种读写文件实现方式的测试代码
通过以上代码测试,可得出如下结论:
- 使用NIO或IO批量读写,性能比较好,这三种实现方案的性能应该是相当的;
- 使用IO Buffer读写文件,性能比NIO、IO批量读写性能要差些;但从理论上分析,IO Buffer和IO批量读写是等价的,只是多了一些判断操作,这个可以从BufferedInputStream的源代码中可以看出。
- 基于字符的IO读写比基于字节的IO读写要慢很多,所以我们应尽量基于字节IO进行读写操作;其原因是基于字符的IO读写多了频繁的字符转换操作。另外需要说明的是,一个char用两个字节保存字符,而byte只需要一个,因此用byte保存字符消耗的内存和需要执行的机器指令更少。更重要的是,用byte避免了进行Unicode转换。因此,如果可能的话,应尽量使用byte替代char。例如,如果应用必须支持国际化,则必须使用char;如果从一个ASCII数据源读取(比如HTTP或MIME头),或者能够确定输入文字总是英文,则程序可以使用byte。
- 如果直接通过IO进行读写,不使用缓冲区的话,会导致严重的性能问题;其原因主要是这种IO读写操作需要频繁的访问磁盘和调用操作系统底层函数。
数组、集合操作规则
在java API中,针对数组和集合的操作,专门封装了两个类:java.util.Arrays和java.util.Collections。在这两个工具类中,包含了数组及集合的常用操作方法,如拷贝、查找、排序等,这些方法一般来说算法实现上都是非常高效的。但是对工具类中的排序等方法,存在的问题是,这些方法由于不能从业务数据集合里面获取排序数据,因此在实现上就多了很多数据拷贝、克隆等操作,造成的结果是容易生成大量的临时对象。所以当我们需要对数组或集合进行拷贝、查找、排序等操作时,对于一般的应用应优先使用Arrays和Collections中提供的方法,但是对于热点代码,最好是参考java API中的方法实现,自己开发特定的排序等方法。
规则描述
建议1.6.1.1:对于数组、集合的拷贝、查找、排序等操作,如果是一般应用,可以优先采用java.util.Arrays和java.util.Collections中提供的工具方法;但是对于热点代码,最好是参考java API中的方法实现,自己开发特定的排序等方法,以减少临时对象的创建。
规则1.6.1.2:对于数组的拷贝,请使用System.arraycopy(…)方法
案例研究
错误的数组拷贝方法
一些对java API不太熟悉的开发人员,往往会犯数组拷贝的错误,没有用Arrays类中提供的工具方法而是自己写一个非常低效的数组复制方法。以下是相关的代码示例:
char[] sourceArr=new char[1000];
char[] destineArr=new char[1000];
//错误的数组拷贝方法
for(int i=0;i<sourceArr.length;i++)
{
destineArr[i]=sourceArr[i];
}
//正确的数组拷贝方法
System.arraycopy(sourceArr, 0, destineArr, 0, sourceArr.length);
- 数组拷贝的代码示例
JDK的排序算法实现
在Collections类中实现了排序工具方法,该方法的实现代码如下:
//Collections类中的sort方法
public static void sort(List list) {
Object a[] = list.toArray(); //新创建了一个数组
Arrays.sort(a);
ListIterator i = list.listIterator();
for (int j=0; j<a.length; j++) {
i.next();
i.set(a[j]);
}
}
//ArrayList类中的toArray()方法
public Object[] toArray() {
Object[] result = new Object[size];
System.arraycopy(elementData, 0, result, 0, size);
return result;
}
//Arrays.sort方法
public static void sort(Object[] a) {
Object aux[] = (Object[])a.clone(); //为了排序,又复制了一个数组
mergeSort(aux, a, 0, a.length, 0);
}
- JDK的排序算法实现
分析上面的代码实现,可以看出,在一次排序过程中,该排序算法产生了2个临时的数组对象,这对于那些动态排序功能(需要根据上报的数据频繁的进行排序)的实现,性能的影响是不能忽略的。
内存泄漏防范规则
和C++一样,内存泄漏问题也是java程序开发中需要重点关注的性能问题。一些常见的内存泄漏原因有:
- 往框架类或系统类对象(这些对象在系统运行过程中始终保持存活状态)中注册了一些事件监听器,在使用完后又没及时清除。
- 往集合对象(如ArrayList、Vector、HashMap)中只添加而不删除元素,并保持对集合对象的引用。
- 在一个对象中创建了一个线程,当对象不再使用时,又没有关闭该线程。
- 在JFrame、JDialog等窗口对象中,没有处理窗口关闭事件,导致窗口关闭时只是隐藏,而没有释放资源。
- 对于IO操作,没有在finally中作对应的关闭动作。
- 在重载的finallize()方法中,没有调用super.finallize()方法。
- 使用一个自定义的类装载器去装载类,当被装载的类不再使用时,仍然保持该类装载器的引用。
另外值得一提的是,将一些大的对象定义成静态的,也会造成类似于内存泄漏的问题。其原因是如果静态变量所属的类是被系统类装载的,则即使该类不再使用时也不会被卸载掉,这将导致静态对象的生存时间可能和系统一样长久,而不管该对象是否被使用。
规则描述
规则1.7.1.1:如果往框架类或者系统类对象中添加了某个对象,那么当该对象不再使用时,必须及时清除(这里的框架类、系统类指的是在系统整个运行过程中始终存在的对象类,如iView主框架的相关类)
规则1.7.1.2:当使用自己定义的类装载器去装载类时,在被装载的类不再使用后,需要保证该类装载器可以被垃圾回收
建议1.7.1.3:尽量不要将一些大的对象(对象本身比较大或其引用的对象比较多)定义成静态的
规则1.7.1.4:如果在一个对象中创建了一个线程,当对象不再使用时,必须关闭该线程
建议1.7.1.5:在JFrame、JDialog等窗口对象中,尽量处理窗口关闭事件并释放资源
规则1.7.1.6:在IO操作中,必须定义finally代码段,并在该代码段中执行IO关闭操作
案例研究
装载的适配类不能卸载导致代码区溢出
在某OM系统中,整个系统代码分为平台部分和适配部分。对于适配部分的代码,平台框架会通过一个自定义的类装载器实例DynClassLoader进行装载。但是当用户注销系统回到登录界面后,由于系统仍然保持对DynClassLoader实例的引用,导致所有通过DynClassLoader实例装载的适配类都不能卸载掉。这样产生的结果是,当用户重新登录到第2个、第3个适配版本时,由于所装载的适配类全部都不能卸载,使得JVM代码区的增长超越了设定的上限值,发生内存溢出。关于保持ClassLoader引用会导致所有被该ClassLoader加载的类都不能卸载的原因,我们可以分析一下jdk的ClassLoader代码,以下是部分代码片断:
public abstract class ClassLoader {
private static native void registerNatives();
static {
registerNatives();
}
// If initialization succeed this is set to true and security checks will
// succeed. Otherwise the object is not initialized and the object is
// useless.
private boolean initialized = false;
// The parent class loader for delegation
private ClassLoader parent;
// Hashtable that maps packages to certs
private Hashtable package2certs = new Hashtable(11);
// Shared among all packages with unsigned classes
java.security.cert.Certificate[] nocerts;
// The classes loaded by this class loader. The only purpose of this table
// is to keep the classes from being GC'ed until the loader is GC'ed.
private Vector classes = new Vector();
// The initiating protection domains for all classes loaded by this loader
private Set domains = new HashSet();
// Invoked by the VM to record every loaded class with this loader.
void addClass(Class c) {
classes.addElement(c);
}
……
- JDK的ClassLoader代码片断
从上面的代码说明我们可以知道,只要类装载器不被垃圾回收掉,则被该类装载器装载的所有类都不会被卸载掉。
设计性能规范
事件派发线程使用规则
Java的设计目标是灵活、易用和平台一致性。出于这一目的,在UI设计方面,java将界面的绘制和事件处理统一放在了一个独立的线程中进行,这个线程就是事件派发线程。由于事件派发线程只有一个,并且负责了关键的界面绘制和界面事件处理,所以如果该线程被阻塞或者处理的业务逻辑过重的话,会导致整个系统响应很慢、甚至发生灰屏现象。这对用户来说,就是严重的性能问题。所以在系统的设计开发中,对派发线程的使用必须格外谨慎。
规则描述
规则2.1.1.1:对于非界面的业务逻辑,应放在事件派发线程之外处理,保证事件派发线程处理的逻辑尽可能的少;避免在派发线程中执行时间较长、或执行时间具有较大不确定性(如访问远程服务器)的业务逻辑
建议2.1.1.2:对于高频度的界面更新事件,最好采用批量定时更新方式代替实时更新方式
案例研究
跟踪上报消息表格采用定时更新
在某OM系统中,出于性能优化,对跟踪上报消息显示功能采用了定时刷新机制来批量更新表格数据。当上报消息解析好后,直接将其添加到表格模型中,但不触发模型更新事件(也即在添加数据时,不调用fireTableRowsInserted、fireTableRowsDeleted等方法)。模型更新事件统一放在一个javax.swing.Timer里面定时进行触发(每300毫秒触发一次)。跟踪上报消息表格定时更新的处理流程如下图:
- 跟踪消息上报表格定时刷新处理流程
将非界面处理逻辑移到派发线程之外
在某OM系统中,有一个上报消息处理业务功能。在优化前,该功能实现将大量业务放到了派发线程中处理(主要有从缓冲区取消息、保存消息到文件、解析消息码流、将解析结果添加到表格、定时刷新表格界面),其流程实现如下图:
- 优化前的上报消息处理流程
基于让派发线程处理尽可能少的业务的原则,优化后,通过新增一个业务处理线程,并在该业务处理线程和派发线程之间添加一个表格模型缓冲区的方式,较好的实现了将大部分业务移到派发线程之外处理,最后的结果是派发线程只需要定时刷新表格界面就可以了。优化后的上报消息处理流程如下图:
- 优化后的上报消息处理流程
我们在实际开发中,经常会遇到这样一个问题:在一个连续的业务处理过程中,如何将非界面处理的业务逻辑隔离到派发线程之外?根据设计经验,基本可以得出这样一个结论:要想使两个线程协调工作,必须有一个可操作的共享数据区或对象。在上面的优化案例中,我们定义了一个表格模型缓冲区来使业务处理线程和派发线程协调工作。我们还可以调用javax.swing.SwingUtilities类的invokeAndWait和invokeLater方法,在业务处理线程环境下将一些界面处理逻辑添加到派发线程中进行处理(在这种情况下,事件派发队列就是业务处理线程和派发线程之间的共享数据区)。
界面组件设计规则
在Swing中,所有轻型(lightweight)组件都是通过java的绘图工具绘制出来的。每个轻型组件都有自己的paint()方法(默认的paint方法是从JComponent类中继承过来的)。当显示组件时,由派发线程调用顶层容器的paint()方法将容器及容器里面的所有子组件绘制出来。从性能角度讲,Swing的界面绘制机制存在以下问题:
- 整个界面绘制牵涉到的层次太多(每重新绘制一次界面,有可能要调用上千个方法)。
- 在一次界面的绘制过程中,容易产生大量的临时对象。可以通过测试得出,显示一个JPanel对象(里面只有一个JLabel对象)会产生7个SunGraphics2D对象。以后每刷新一次界面,就会有7个SunGraphics2D临时对象产生,而每个SunGraphics2D对象会占用192个字节。在一个实际应用系统中,如果表格、树等界面频繁刷新的话,每1秒中就有可能产生几兆的临时对象。
Swing轻型组件的绘制流程如下图:
- Swing轻型组件的绘制流程
从上面的流程图可以看出,Swing组件的绘制是一个层层往下的过程,组件首先绘制自己,如果有border则再绘制出border,然后绘制自己的子组件;对于子组件来说,首先绘制自己,然后再又绘制自己的子组件;每个组件在绘制自己时,需要进行相关的绘图区域范围计算。另外,需要指出的是,在java体系中,字符串的显示也是通过java自己的绘制机制绘制出来的。所以,在字符串的显示过程中,也会创建很多临时对象。
通过上面的分析,从性能角度上讲,Swing的实现并没有想象的那么好。因此在特殊的应用场景下,为了提高我们系统的性能,我们需要,也有必要根据业务处理特点,定义自己的界面绘制机制,甚至开发特定的界面组件。对于像JTable,JTree这样的界面对象,通过定制和优化,可以极大的提高其界面绘制的性能。
规则描述
建议2.2.1.1:为了提高系统性能,可以根据业务处理特点,定义自己的界面绘制机制
建议2.2.1.2:为了提高系统性能,可以根据业务处理特点,开发自己的界面组件
案例研究
实时跟踪上报消息采用优化表格进行显示
在某OM系统中,对实时跟踪模块,早期的跟踪消息表格绘制方式采用的是JDK默认绘制方式,通过BasicTableUI对象实现表中单元格的绘制。JDK的这种方式具有共用性,提供的功能也非常多,但存在的问题是每绘制1个单元格,都要至少拷贝1个Graphics2D临时对象,同时要调用表格中的swing组件的paint()进行组件的绘制,在单元格组件的绘制过程中,又创建了Graphics2D临时对象。
在后期的性能优化工作中,已经将实时跟踪上报消息采用优化表格进行显示。具体优化方法及步骤如下:
- 参考JDK的BasicTableUI类,自己开发一个TextOptimizeTableUI类,该类继承TableUI类。TextOptimizeTableUI类基本和BasicTableUI类相同,区别仅在于TextOptimizeTableUI绘制单元格时是直接调用Graphics2D对象绘制字符串或字符数组,而不是将单元格当作一个Swing组件,调用其paint()方法进行组件的自我绘制。
- 为了配合TextOptimizeTableUI实现绘制,需要定义一个TextOptimizeRenderer类。TextOptimizeRenderer类并不继承JLabel等组件对象,它只负责传输要绘制的数据给TextOptimizeTableUI类。
- 客户程序在使用优化表格时,需要做两件事情:1)构建TextOptimizeTableUI对象,通过JTable的public void setUI(TableUI ui)方法设置该对象;2)继承TextOptimizeRenderer类,重写public TextOptimizeLabel getOptimizeLabel(JTable table, int row, int column)接口方法,实现自己的renderer,并设置到表格对象中。
在实际开发中,一般有如下两种方法可以定义自己的界面绘制机制:
打开方式
物理内存增量(M)
虚拟内存增量(M)
JTextArea组件
52.4
53.3
UltraEdit程序
3.44
1.59
通过上面的内存增量数据可以看出,JDK的JTextArea组件打开4M左右的文件需要占用50M左右的内存,这在实际应用中是很难满足要求的,和UltralEdit程序比起来,性能要差一个数量级以上。
以下是我们自己开发的文本编辑组件和JTextArea组件的性能对比数据(将组件都放在一个JFrame中,然后读取一个1.91M的文本文件,共49431行):
打开方式
GC后的OLD区内存占用情况(M)
代码区内存占用情况(M)
使用JTextArea
12.653
4.686
使用自己开发的组件
4.464
4.160
建议2.3.1.1:对于一些关键的业务处理流程,需要尽量减少中间处理环节,从而避免创建不必要的临时对象
建议2.3.1.2:在一些关键的业务处理流程中,对于必须要用到的对象,可以采取重用对象机制,重复利用,避免每次都创建
建议2.3.1.3:对于大多数业务处理来说,临时对象都不是问题。只有对那些高频度或大数据量业务处理操作来说,并且通过性能测试证明的确是临时对象引起了性能问题,才需要进行临时对象的优化。
建议2.4.1.1:对于用户频繁进行开启、关闭的窗口组件,需要尽量采取重用机制,用界面隐藏代替界面关闭
建议2.4.1.2:如果一个操作需要很长时间(如大于60秒),则要在执行操作之前就弹出提示选择界面,让用户选择是否真要执行该操作
建议2.4.1.3:如果一个操作需要较长时间(如大于3秒),则最好弹出明确的进度条提示界面
建议2.4.1.4:如果一个操作比一般操作耗时较长(如大于1秒),那么可以给出非显要的界面提示(如在窗口的状态栏给出相关提示)
规则2.5.1.1:如果系统需要运行动态变化的负载,那么需要保证在可能的极限负载下,系统可以正常运行
建议2.6.1.1:对于需要较长时间执行的业务处理,可以考虑采用多线程机制将业务处理划分为几个相对独立的处理逻辑并发执行,或者将一部分处理逻辑提前或延后到系统空闲时间中执行,以缩短总的业务执行时间
在某OM系统中,有一个跟踪消息体码流过滤功能,其要求的业务处理流程如下:
采用上面优化后的处理逻辑,当跟踪回顾界面中有50000条消息时,执行过滤所花的时间可以由原来的10秒左右降为3秒左右。
对基于java开发的OM系统来说,在实际应用中,可以先对每个jar文件采用pack200方式进行压缩,然后对所有文件进行7z格式的压缩,实践证明,这种混合压缩方式制作的安装盘压缩比是非常优的。
Pack压缩格式最初是SUN公司为了减小JRE(J2SE v1.4.1 and J2SE1.4.2)安装盘大小而设计开发的。Pack压缩格式是JSR200项目,在JDK1.5中已提供实现。
Pack压缩格式的java实现在jdk1.5中已提供,可以通过java.util.jar.pack200工具类进行使用。关于Pack压缩格式的详细信息可以从以下地址获取:
http://jcp.org/en/jsr/detail?id=200;
jdk1.5 API:java.util.jar.pack200;
jdk1.5的bin目录下有pack200.exe和unpack200.exe工具程序,可以通过命令行实现对jar文件的打包和解包。
7z压缩格式介绍
7z是一种新的压缩格式(遵从LGPL协议,可以商用),它拥有目前最高的压缩比。7z格式的主要特征有:
- 公开的结构编辑功能
- 最高的压缩比
- 强大的AES-256加密
- 可更改和配置压缩的算法
- 最高支持16000000000 GB 的文件压缩
- 以Unicode 为标准的文件名
- 支持固实压缩
- 支持档案的文件头压缩
LZMA 算法是 7z 格式的默认标准算法。LZMA 算法的主要特征有:
- 高压缩比
- 可变字典大小(最大 4 GB)
- 压缩速度:运行于 2 GHz 的处理器可达到 1 MB/秒
- 解压缩速度:运行于 2 GHz 的处理器可达到 10-20 MB/秒
- 较小的解压缩内存需求(依赖于字典大小)
- 较小的解压缩代码:约 5 KB
- 支持 Pentium 4 的多线程(Hyper-Threading)技术及多处理器
目前支持7z格式的压缩软件有:7-Zip、WinRAR、PowerArchiver、TUGZip、IZArc。关于LZMA压缩算法的实现,当前已经有多个语言版本的软件开发工具包及源代码可供下载,包括:C,C++,C#,Java。关于7z和LZMA的详细资料可以从以下网址获取:
http://www.7-zip.org/zh-cn/7z.html
http://www.7-zip.org/zh-cn/sdk.html
附录B:性能测试专题
背景介绍
在对系统进行性能优化的过程中,性能测试工作起着至关重要的作用。一方面,在性能优化前,我们需要通过性能测试找出系统的性能瓶颈所在,做到有目的的优化;另一方面,我们在对系统做了一个性能优化方案后,仍然需要通过性能测试来验证方案的优化效果,对于没有明显性能优化效果的方案,我们一般是不建议给予实施的。
性能测试是一个比较具体化的工作,需要具体问题具体分析。总的来讲,在性能测试过程中都将面临两个问题:一个是性能测试工具的选择和使用,另外一个是性能测试方法即如何设计测试用例和分析测试数据的问题。对于java应用系统来说,目前有多种性能测试工具可供使用,下面将对这些工具一一做一个简要的介绍。不同的性能测试工具所关注的性能测试点是不一样的,所以我们在性能测试过程中,需要综合利用这些工具,从不同的关注点来对一个系统性能做出全面的评估。另外,下面也将对一些性能测试方法做一个简要的说明和介绍。
常用的java性能测试工具
Profiler工具介绍
目前,网络上有各种各样的profiler工具,一般最常用的是Borland公司的Optimizeit套件。通过Borland公司的profiler工具主要可以做以下事情:
- 检测和定位系统的内存泄漏情况;
- 观察java系统堆内存的实时变化情况;
- 分析系统运行过程中的对象创建情况;
- 观察java系统类加载实时变化情况;
Profiler工具的运行界面如下图:
- Profiler工具的运行界面
Visualgc工具介绍
Visualgc工具是sun公司开发的一个免费的性能测试工具,可以从sun公司网站上下载。通过visualgc工具主要可以做以下事情:
- 实时查看java系统中的Young区、Old区、Perm区的大小配置情况和内存使用情况;
- 查看java系统运行过程中类装载的数目及类加载的时间;
- 查看java系统运行过程中GC收集的次数及GC所耗时间;
Visualgc工具的运行界面如下图:
- Visualgc工具的运行界面
GC日志分析工具介绍
打印和分析GC日志,是对java系统进行性能测试的一个重要手段。对sun公司的hotspot虚拟机来说,可以添加类似如下的JVM参数来打印GC日志信息:
"-verbose:gc –XX +PrintGCDetails –Xloggc:c:gcloglog.txt"
打印出GC日志后,可以通过GC日志分析工具来进行分析,现在网络上有诸如GCViewer之类的免费工具可供使用,当然也可直接查看和分析GC日志数据。通过分析GC日志,可以做如下事情:
- 通过young区的内存变化情况,来分析系统临时对象的创建速度情况;
- 分析出垃圾收集的频率以及对系统带来的性能影响;
- 分析出young区、old区、perm区的内存扩充和收缩情况;
Windows性能检视器介绍
Windows性能检视器是windows操作系统自带的一个性能测试工具。通过windows性能检视器,可以记录和检测进程或整个操作系统的资源使用情况。在windows性能检视器中,包含大量的性能计数器,下表列出了常用的性能计数器名:
计数器名
类别
说明
等价的任务管理器功能
Working Set
Process
驻留集,当前在实际内存中有多少页面
Mem Usage
Private Bytes
Process
分配的私有虚拟内存总数,即提交的内存
VM Size
Virtual Bytes
Process
虚拟地址空间的总体大小,包括共享页面。因为包含保留的内存,可能比前两个值大很多
无
Page Faults / sec(每秒钟内的页面错误数)
Process
每秒中出现的平均页面错误数
链接到 Page Faults(页面错误),显示页面错误总数
Committed Bytes(提交的字节数)
Memory
"提交"状态的虚拟内存总字节数
Commit Charge:total
Processor Time
Processor
进程的CPU占用率
CPU Usage
- 常用性能计数器列表
性能检视器的配置界面如下:
- 性能检视器配置界面
如何分析性能测试数据
检查测试数据的真实性
在对测试数据进行正式分析前,首先需要检查测试数据是否真实可靠。测试时得到了不可靠的测试数据的主要原因有:
- 在进行测试时,忽略了某一测试操作。如在进行性能测试时,本来测试要求是要开启10个监控任务,但实际操作时却只开启了2个。
- 在进行测试时,测试环境发生了变化,和测试用例中规定的测试环境不符。如测试用例中规定跟踪上报速度是35条/秒,但在测试时,其它人员将测试环境做了更改,将跟踪上报速度改为了100条/秒。
- 测试时没有考虑操作系统及其它软件的影响。如在测试某java系统第1次启动时的持续时间时,还没有等到操作系统完全稳定下来,就开始了测试。再比如,在测试某java系统的CPU占用率时,没有遵守测试环境的要求,无意中开启了其它的软件,或者开启了操作系统中比较特殊的服务等。
导致测试数据失真的原因是非常多的,所以在分析测试数据之前需要检查测试数据的真实性和可靠性。检查的方法一般有:
- 和以前的经验数据进行比较,如果差别很大,应该考虑重新测试。
- 通过对业务代码进行理论分析,得出一个经验数据,如果测试数据和经验数据差别很大,应该考虑重新测试。
- 在条件允许的情况下,尽量采用多次测试,去掉偏差较大的测试数据。
通过Excel对测试数据进行处理
在数据量不大的情况下,通过Excel工具进行数据处理是一个比较好的方法(Excel最多支持65536行,256列)。通过Excel工具处理数据的常用方法有:
- 图表法。将原始数据绘制成折线图、数据趋势线、数据拟合线来进行分析。
- 有效值法。对原始数据求平均值、最大值、最小值来进行分析。
- 公式法。通过在Excel工具里面添加特定的公式,对原始数据进行分析。
分析测试数据的关注点
性能测试的一个难点就是如何对测试数据进行分析,并找出各种测试结果产生的原因。对测试数据进行分析时一般关注以下几个方面:
- 查看测试数据是否出现有规律的变化,如果有则分析产生这种规律的原因。
- 查看测试数据是否存在拐点,如果有则分析出现拐点的原因。
- 将不同版本的测试数据进行对比,查看数据的变化情况,分析产生这种变化的原因。
对测试项进行理论评估和公式推导
通过设计和执行测试用例来检测系统性能是最直接,也是最可靠的方式。但在实际操作中,如果对所有的情况都进行性能测试,往往工作量是巨大的,并且是得不偿失的。比如测试实时跟踪的一个性能优化方案的效果,首先在上报速度为200条/秒的情况下进行测试,发现优化效果明显。但是马上大家会存在疑惑:那么在上报速度为100条/秒、50条/秒、10条/秒等情况下,这种优化效果是否依然存在呢?换句话说,如果效果不是很明显,那么我们是否还有优化的必要呢?如果我们为了回答这些疑问,针对所有这些情况都进行测试的话,其工作量将是非常大的。
实际上,只要改变测试用例中的任何一个测试条件,都将产生一个新的测试用例。因此我们不可能对所有延伸出来的测试用例都进行测试。解决这个问题的办法应该采取理论和实践相结合的方式,首先通过基本测试用例得到几组测试数据,然后根据这些测试数据进行理论评估和公式推导,最后根据推导的公式给出当测试条件变化时的预期结果。
进行理论公式推导的方法是,首先根据业务代码建立起数据模型,然后将现有的测试数据代入数据模型中,得出可求解的公式。
参考文献
序号No.
文献编号或出处 Doc No.
文献名称 Doc Title
1
机械工业出版社,2003
Effective Java 中文版
3
http://java.sun.com/
Java Platform Performance: Strategies and Tactics
4
O'reilly & Associates, 2001
Java Performance Tuning
5
http://trove4j.sourceforge.net/
Trove集合类
6
IBM公司的developerworks 中国网站
性能观察: Trove 集合类
7
http://www.jedit.org/
Jedit源代码
8
Pack200资料
9
7z压缩格式资料