高效的Java字符串 -- 一些实验和一点经验

近期写了比较多的和Java有关的blog,原因在于最近正在对自己之前做的一个Java系统做性能调优。在这个过程中,我积累了一些经验,也学到了不少东西。本篇亦是如此。

高效的Java字符串 -- 一些实验和一点经验

在我的系统中,有一个查询,它会在内存中的一个Index上做搜索,然后将查找到的所有数据项填入一个JSONObject中,最后调用这个JSONObject的toString函数转换成字符串,通过网络发送出去。
实验观察到,如果大量使用这个查询,JVM会频繁地调用Garbage Collection (GC)。我一开始以为是我的data structure没有写好,导致搜索很耗资源,后来通过进一步实验发现,是搜索后将结果转换为字符串这个步骤消耗了大量的内存。
于是,我将”Convert result to string”这个步骤的代码抽取出来,反复执行,查看前后的内存消耗。上图就是实验的结果,解读一下图中的最后一列:
rs 200表示的是一个查询结果中包括了200个数据项(其它以此类推);
4600的单位是KB,表示的是这么一个查询结果转换为字符串所大致消耗的内存,也就是图中的蓝色区域;
56的单位也是KB,表示的这个结果转换为String类型后,这个String的size。也就是图中的红色区域(但愿还能看得见);
所以,这么一个查询结果的转换产生了大量的临时变量,它们消耗了大概4MB的内存,如果一秒钟处理250个这样的查询就是1GB,这样就不难理解为什么我的程序大概几十秒就需要做minor GC了,虽然我有10G+的内存。

以上只是一个引子,虽然由于实验不是很严谨,在实验数据上会存在一定的偏差,但是多少可以说明一点,Java本身的字符串操作很有问题,它有可能成为你程序的bottleneck。所以,以下对于Java字符串优化的方法确实不是我吃饱了撑着。

Strings are immutable. A String cannot be altered once created.
Java中的String是不可变的,这是String最重要的一条性质,也是性能不好的根本。String的源代码是这样写的:

public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ private final char value[]; //............... }

char value[ ]就是用于存储具体的字节流的,可以看见,它被final修饰了,所以一旦初始化就不能再修改。那些String类提供的看起来能够修改字符串的函数其实都是创建了一个新的String,然后将新的String的引用传递回来。(而一个取子串的操作substring不会拷贝整个字符串,相反,它只是对原有的charArray产生新的指针。)

特别的,在操纵大型字符串的时候,常常会需要“+”操作(例一):

String s1 = "String a"; String s2 = "string b"; String s3 = s1 + " " + s2;

如果不考虑编译器的优化(这个稍后再说),在创建s3的过程中,首先会产生一个临时的String变量,存储s1 + “”,然后再产生一个临时变量,将前一个临时变量和s2连接起来。所以,即便这么简单的连接操作,也产生了两个临时变量,每个临时变量都有自己独自的char[]。

再来个例子(例二):

String str = “hello”; for (int i = 0; i < 10000; ++i) { str = str + “ ” + i; }

for循环里面这条语句,按照我们之前的介绍需要两个临时变量。所以整个例子会创建10000 * 2个临时变量.同样的,这些临时变量也都会有自己独立的char[],虽然只使用一次就废弃了。而且更加恐怖的是,随着循环的进行,临时变量中的char[]会随着str的增大而增大。

所以,如果需要频繁的使用”+”操作符进行字符串的连接,不要使用String,而是改用StringBuilder (例三):

StringBuilder sb = new StringBuilder(); sb.append(“hello”); for(int i = 0; i < 10000; ++i) { sb.append(“ ”).append(i); } String str = sb.toString();

使用StringBuilder的话,不会产生临时变量,取而代之的是StringBuilder内部的
char value[];
注意,它和String不同的就在于没有用final修饰。每次调用append的时候,会将想追加的字符串copy到value中。如果初始化的char[]数组已经填满了,那么StringBuilder会自动的调用void expandCapacity函数,将value的size扩大一倍。所以,使用StringBuilder不会产生任何的临时变量。
当然,值得一提的是,所谓的expandCapacity函数,其实也是创建一个新的char数组,它的size是原先数组的size的一倍,然后将旧的char数组中的值都拷贝到新的char数组中,然后,StringBuilder就使用这个新的char数组作为自己内部的char value[]。所以,为了避免这样的数组拷贝,应该尽可能的在初始化StringBuilder的时候设置合理的内部数组大小:

StringBuilder sb = new StringBuilder(20005); sb.append(“hello”); for(int i = 0; i < 10000; ++i) { sb.append(“ ”).append(i); } String str = sb.toString();

关于StringBuilder的使用,就不多说了,网上一搜一大堆资料,还有挺多实验对比数据。

编译器优化:
现在的JDK一般都会对String的”+”操作进行自动优化,比如:
String str = “hello” + “ ” + “world”;
这行代码在编译期间就会被优化成:
String str = “hello world”;

而像例一中的代码:
String s3 = s1 + " " + s2;
编译器也会用StringBuilder进行优化;
但是对于类似例二这样的循环,貌似就没法优化了,所以每循环一次,还是会产生一个临时变量。

介绍完了StringBuilder,该切入正题了:怎样将一个Object转换成字符串。先看一段代码:

class A { //private data @Override public String toString() { // ........... } } class B { //private data @Override public String toString() { //.......... } } public class C { private A a; private B b; private String data; @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(data).append(" ").append(a.toString()).append(" ") .append(b.toString()); return sb.toString(); } }

这是一般的将Object转换为String的写法,就是重载它的toString()函数。
C中包含了A,B的实例a,b,所以,在C的toString()函数中,我们会首先调用a和b的toString,然后将它们和C内部其它的数据连接起来。在这个过程中,虽然我们也用到了StringBuilder,但是在调用a.toString()和b.toString()的时候仍然产生了两个临时的String变量,这两个变量使用一次就废弃了。如果假设A和B中又内嵌了其它的类型,那么在A和B的toString()函数中就也需要去调用这些类型的toString(),这样会导致更多的临时变量。
在JSONObject这个类中,就是这么做的。最外层的JSONObject的toString()函数会调用它内嵌的所有类型实例的toString(),所以一旦这个JSON嵌套的层级比较多,那么大量的临时变量会被创建。

在<Java Performance Tuning>这本书中,提供了一个改进的方法:

class A { //private data public void appendTo(StringBuilder sb) { sb.append(......); } @Override public String toString() { StringBuilder sb = new StringBuilder(); appendTo(sb); return sb.toString(); } } class B { //private data public void appendTo(StringBuilder sb) { sb.append(......); } @Override public String toString() { StringBuilder sb = new StringBuilder(); appendTo(sb); return sb.toString(); } } public class C{ private A a; private B b; private String data; public void appendTo(StringBuilder sb) { sb.append(data).append(" "); a.appendTo(sb); sb.append(" "); b.appendTo(sb); } @Override public String toString() { StringBuilder sb = new StringBuilder(); appendTo(sb); return sb.toString(); } }

上述代码中,每个类都新建了一个void appendTo(StringBuilder sb)的方法。在这个方法中,每个类都将自己需要转换的字符串append到给定的StringBuilder中。外层的类(比如C class)的appendTo方法中,会调用嵌套类的appendTo方法。而原先的toString()函数,只需要初始化一个StringBuilder,然后将这个StringBuilder实例传递个给自己的appendTo函数,最后调用StringBuilder的toString,返回一个String变量。整个过程中不需要创建任何临时的String变量,只有最后一步产生一个String作为结果返回。

我采用这个方法将我的代码重写。最后,我将这个代码的效果优化到由原先的每个查询结果4600KB降到了大概300KB (其实还可以再优化的,不过这个结果对我来说已经足够了)。

结语

虽然Java相比于C++的不同就在于它牺牲了一定的性能和对细节的某些控制来达到编程的简单,但是这并不意味着就不能写出高效的Java代码来。当然,这不简单,需要一定的技巧。
另外,虽然俺这篇blog说了很多StringBuilder的好处。但是,在<Java Performance Tuning>中,它将StringBuilder定义为“double-edged sword”—双刃剑。说明StringBuilder也不是放在任何地方都是合适的。至于具体的细节就直接看书吧。