Java基础小技巧回顾–浅析String

对于字符串部分,小胖在《Java特种兵》一书穿插了不少讲解,会讲得更加透彻一些,本文是小胖几年前写的,当初还在初窥门径阶段,很多结论的总结仅用于简单参考:

本文非常简单,不过有很多朋友经常问,网上很多例子也写个大概,很多人也只是知道和大概,就本文而来读起来非常的轻松,不过算是一些小技巧;但是我们的程序中相信用得最多的就是char数组和byte[]数组,而String就是由char[]数组组成的,一般情况下我们就可以认为String用得是最多的对象之一。

有关Sring的空间利用率方面,这里不想多说,只能说很低很低,尤其是你定义的String长度很短的时候,简直利用率不好说;在前序的一篇文章中说明了关于java的对象空间申请方法以及对象在JVM内部如何做对其的过程,就能较为明确的知道一个String是多么的浪费空间;本文就不在多提及这方面的问题了。

再谈及到String与StringBuffer和StringBuilder的区别时,前面一篇文章中将他们循环做了一系列的性能对比,发现StringBuilder性能最高,大家都知道用StringBuilder来用了,但是要明白细节才是最好的;简单来讲String是不可变的字符串,而StringBuffer和StringBuilder是可变的字符串对象,而StringBuffer是在进行内容修改时(即char数组修改)会进行线程同步操作,在同步过程中存在征用加锁和访问对象的过程,开销较大,在方法内定义的局部变量中没有必要同步,因为就是当前线程使用,所以StringBuilder为一个非同步的可变字符串对象。

OK,我们介绍了基本的概念,可以回到正题了;那么String到底是一个神马东西,通过前面的对象结构来看,首先根据String内部的定义,应该有以下内容:一个char数组指针指向一个数组对象(数组对象也是一个对象,和普通对象最大的区别需要一个位置来记录数组的长度)、offset、count、hash、serialVersionUID(这个不用计算在对象的大小中,因为在JVM启动时就会被装入到方法区中)。其次,还有对象对其的过程,而String的内容为char数组引用,指向的数组对象的内部的内容,也就是一个String相当于就包含了两个对象,两个对象都有头部,以及对其方式,数组头部会多一个保存数组长度的区域,头部还会存储对象加锁状态、唯一标识、方法区指针、GC中的Mark标志等等相应的内容,如果头部存储空间不够就会在外部开辟一个空间来存储,内部用一个指针指向那块空间;另外对象会按照8byte对其方法进行对其,即对象大小不是8byte的倍数,将会填充,方便寻址。

String经常说是不可变的字符串,但是我个人并不习惯将他说成是常量,而很多人也对String字符串不可变以及StringBuilder可变有着很多疑惑之处,String可以做+,为什么说它不可变呢?String的+到底做了什么?有人说String还有一些内容可能会放在常量池,这是什么东西?常量池和常量池的字符串拼接结果是什么(我曾在网上看到有人写常量池中字符串和常量池中字符串拼接结果还在常量池,其实未必,后面我们用事实来说话)?

当你对上述问题了如指掌,String你基本了解得有点通透了;OK,在解释这个问题之前,我们先说明一个在Hotspot自从分代JVM产生后到目前为止(G1还没有正式出来之前)不变的道理就是,当你在程序中只要使用了new关键字或者通过任何反射机制实例化的任何对象都将首先放在堆当中,当然一般情况下首先是放在Eden空间中(在一些细节的版本中会有一些区别,如启动了TABL、或对象超过指定大小直接进入Old或对象连Eden也放不下也会直接进入Old);这是不用说的事实,总之目前我们只要知道它肯定是在堆当中的就可以了。

我们先来看一段非常非常简单的代码如下所示:

public class StringTest {

    public static void main(String[] args) {
        String a = "abc";
        String b = "def";

        String c = a + b;
        String d = "abc" + "def";

        String e = new String("abc");

        System.out.println(a == e);
        System.out.println(a.equals(e));
        System.out.println(a == "abc");
        System.out.println(a == e.intern());
        System.out.println(c == "abcdef");
        System.out.println(d == "abcdef");
    }
}

请在没有在java上运行前猜猜结果是多少,然后再看结果。

结果如下:

false
true
true
true
false
true

如果你的结果不是猜得,而是直接自己通过理解得到的,后面的文章你就不用看了,对你来说应该没有多大意义,如果你某一个结果说得不对,或者是自己瞎猜出来的,OK,后文可能会对你的理解造成一些影响。

我们首先解释前面4个结果,再解释最后2个结果;前4个其实在前面的文章中已经说过他们的区别,不过为了方便文本继续向下说明,这里再说明一次,首先String a = “abc”这样的申请,会将对象放入常量池中,也就是放在Perm Geration中的,而String e = new String(“abc”)这个对象是放在Eden空间的,所以当使用a == e发生地址对比,两者肯定结果是不一样的;而当发生a == “abc”两个地址是一样的,都是指向常量池的对应对象的首地址;而equals是对比值不用多说,肯定是一样的;a == e.intern()为什么也是true呢,就是当intern()这个方法发生时,它会在常量池中寻找和e这个字符串等值的字符串(匹配的方法为equals),如果没有发现则在常量池申请一个一样的字符串对象,并将对象首地址范围,如果发现了则直接范围首地址;而a是常量池中的对象,所以e在常量池中就能找到的地址就是a的首地址;关于这个问题就不多阐述了,也有相关的很多说明,下面说下后面两个结果;算是较为神奇的结果,也是另很多人纳闷的结果,不过不用着急,说完后就很简单了。

后面两个结果一个是a指向常量池的“abc”,b指向常量池中的“def”,c是通过a和b相加,两个都是常量池对象;而d是直接等价于“abc”+“def”按照道理说,两个也是常量池对象,为什么两个对象和常量池的“abcdef”比较的结果不一样呢?(关于他们为什么是在常量池就不多说了,上面那一段已经有结果了);我们不管怎么样,首先秒杀掉一句话就是:常量池的String+常量池String结果还在常量池,这句话是不正确的,或者你的测试用例正好是后者,那么你中招了,很多事情只是通过测试也未必能得出非常有效的结果,但是较为全面的测试会让我们得出更多的结论,看看我们两种几乎一摸一样的测试,但是结果竟然是不一样的;简单说结果是前者的对象结果不是在常量池中(记住,常量池中同一个字符串肯定是唯一的),后者的结果肯定在常量池;为什么,不是我说的,是Hotspot VM告诉我的,我们做一个简单的小实验,就知道是为什么了,首先将代码修改成这样:

public class StringTest {

    public static void main(String[] args) {
        String a = "abc";
        String b = "def";

        String c = a + b;
    }
}

我们看看编译完成后它是个什么样子:

C:\>javac StringTest.java

C:\>javap -verbose StringTest

Compiled from "StringTest.java"
public class StringTest extends java.lang.Object
  SourceFile: "StringTest.java"
  minor version: 0
  major version: 50
  Constant pool:
const #1 = Method       #9.#18; //  java/lang/Object."<init>":()V
const #2 = String       #19;    //  abc
const #3 = String       #20;    //  def
const #4 = class        #21;    //  java/lang/StringBuilder
const #5 = Method       #4.#18; //  java/lang/StringBuilder."<init>":()V
const #6 = Method       #4.#22; //  java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
const #7 = Method       #4.#23; //  java/lang/StringBuilder.toString:()Ljava/lang/String;
const #8 = class        #24;    //  StringTest
const #9 = class        #25;    //  java/lang/Object
const #10 = Asciz       <init>;
const #11 = Asciz       ()V;
const #12 = Asciz       Code;
const #13 = Asciz       LineNumberTable;
const #14 = Asciz       main;
const #15 = Asciz       ([Ljava/lang/String;)V;
const #16 = Asciz       SourceFile;
const #17 = Asciz       StringTest.java;
const #18 = NameAndType #10:#11;//  "<init>":()V
const #19 = Asciz       abc;
const #20 = Asciz       def;
const #21 = Asciz       java/lang/StringBuilder;
const #22 = NameAndType #26:#27;//  append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
const #23 = NameAndType #28:#29;//  toString:()Ljava/lang/String;
const #24 = Asciz       StringTest;
const #25 = Asciz       java/lang/Object;
const #26 = Asciz       append;
const #27 = Asciz       (Ljava/lang/String;)Ljava/lang/StringBuilder;;
const #28 = Asciz       toString;
const #29 = Asciz       ()Ljava/lang/String;;

{
public StringTest();
  Code:
   Stack=1, Locals=1, Args_size=1
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return
  LineNumberTable:
   line 2: 0

public static void main(java.lang.String[]);
  Code:
   Stack=2, Locals=4, Args_size=1
   0:   ldc     #2; //String abc
   2:   astore_1
   3:   ldc     #3; //String def
   5:   astore_2
   6:   new     #4; //class java/lang/StringBuilder
   9:   dup
   10:  invokespecial   #5; //Method java/lang/StringBuilder."<init>":()V
   13:  aload_1
   14:  invokevirtual   #6; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   17:  aload_2
   18:  invokevirtual   #6; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   21:  invokevirtual   #7; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;
   24:  astore_3
   25:  return
  LineNumberTable:
   line 7: 0
   line 8: 3
   line 10: 6
   line 13: 25

}

说明(这里不解释关于栈的计算指令,只说明大概意思):首先看到使用了一个指针指向一个常量池中的对象内容为“abc”,而另一个指针指向“def”,此时通过new申请了一个StringBuilder(jdk 1.5以前是StringBuffer),然后调用这个StringBuilder的初始化方法;然后分别做了两次append操作,然后最后做一个toString()操作;可见String的+在编译后会被编译为StringBuilder来运行(关于为什么性能还是比StringBuilder慢那么多,文章后面来说明),我们知道这里做了一个new StringBuilder的操作,并且做了一个toString的操作,前面我们已经明确说明,凡是new出来的对象绝对不会放在常量池中;toString会发生一次内容拷贝,但是也不会在常量池中,所以在这里常量池String+常量池String放在了堆中;而下面这个后面那种情况呢,我们也用同样的方式来看看结果是什么,代码更简单了:

public class StringTest {

    public static void main(String[] args) {
        String d = "abc" + "def";
    }
}

看下结果:

C:\>javac StringTest.java

C:\>javap -verbose StringTest

Compiled from "StringTest.java"
public class StringTest extends java.lang.Object
  SourceFile: "StringTest.java"
  minor version: 0
  major version: 50
  Constant pool:
const #1 = Method       #4.#13; //  java/lang/Object."<init>":()V
const #2 = String       #14;    //  abcdef
const #3 = class        #15;    //  StringTest
const #4 = class        #16;    //  java/lang/Object
const #5 = Asciz        <init>;
const #6 = Asciz        ()V;
const #7 = Asciz        Code;
const #8 = Asciz        LineNumberTable;
const #9 = Asciz        main;
const #10 = Asciz       ([Ljava/lang/String;)V;
const #11 = Asciz       SourceFile;
const #12 = Asciz       StringTest.java;
const #13 = NameAndType #5:#6;//  "<init>":()V
const #14 = Asciz       abcdef;
const #15 = Asciz       StringTest;
const #16 = Asciz       java/lang/Object;

{
public StringTest();
  Code:
   Stack=1, Locals=1, Args_size=1
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return
  LineNumberTable:
   line 2: 0

public static void main(java.lang.String[]);
  Code:
   Stack=1, Locals=2, Args_size=1
   0:   ldc     #2; //String abcdef
   2:   astore_1
   3:   return
  LineNumberTable:
   line 11: 0
   line 13: 3

}

这下看下可能有人一下通透了,可能有人觉得更加模糊了,怎么编译完后比前面那个少那么多,是的,就是少那么多,因为当发生“abc” + “def”在同一行发生时,JVM在编译时就认为这个加号是没有用处的,编译的时候就直接变成成

String d = "abcdef";

同理如果出现:String a = “a” + 1,编译时候就会变成:String a = “a1″;

再例如:

final String a = "a";
final String b = "ab";
String c = a + b;

在编译时候,c部分会被编译为:String c = “aab”;但是如果a或b有任意一个不是final的,都会new一个新的对象出来;其次再补充下,如果a和b,是某个方法返回回来的,不论方法中是final类型的还是常量什么的,都不会被在编译时将数据编译到常量池,因为编译器并不会跟踪到方法体里面去看你做了什么,其次只要是变量就是可变的,即使你认为你看到的代码是不可变的,但是运行时是可以被切入的。

就是这么简单,运行时自然直接就在常量池中是一个对象了,而不需要每次访问到这里做一个加法操作,有引用的时候,JVM不确定你要拿引用去做什么,所以它并不会直接将你的字符串进行编译时的合并(其实在某些情况下JVM可以适当考虑合并,但是JVM可能是考虑到编译时优化的算法复杂性,所以这些优化可能会放在运行时的JIT来完成,但JIT优化这部分java代码是有一些前提条件的)

所以并不是常量池String+常量池String结果还在常量池,而是编译时JVM就认为他们没有必要做,直接合并了,就像JVM做if(true)和if(false)的优化一样的道理,而前者如果是引用给出来的常量池对象,JVM在拼接过程中是通过申请StringBuilder来完成的,也就是它的结果就像普通对象一样放在堆当中的。

好了,反过来一切都很明了了,String为什么不可变,因为+操作是新申请了对象;+到底做了什么,是申请了一个StringBuilder来做append操作,然后再toString成一个新的对象;如果不是new出来的字符串或者是通过.intern()得到的字符串,则是常量池中的对象;常量池中的字符串和常量池中的字符串拼接,他们的结果不一定还在常量池,如果还在常量池只有一种可能性就是编译时就合并了,因为运行时new出来的StringBuilder是不可能放在常量池中的,我们绝大部分字符串拼接都是有引用的,而不是直接两个常量串来做的。

下面回顾最后一个问题就是,既然String拼接是通过StringBuilder来完成的,那么为什么String的+和StringBuilder会有那么大的差距呢?这是一个值得考虑的问题,如果String的+操作和StringBuilder是一样的操作,那么我们的StringBuilder就没有多大存在的必要了,因为apend太多字符串是一件非常恶心的事情。

首先你会发现,如果在同一条代码中(不一定是同一行代码,因为java代码可以相互包装嵌套,指对于成来讲基本的一条代码),

如String a = a + b + c;这条代码算是同一行,而System.out.println(a + b + c + String.format(d , “[%s]“));对于d就会单独处理后,再和a + b+ c处理,然后再调用System中的静态成员out对象中的println方法;

回到正题,对于同一条代码中,如果发生这种加法操作(不是编译时合并的),那么你在通过javap命令分析时会发现,他们的结果回将其申请一个StringBuilder然后进行append,不论多少个字符串都会append,然后最后toString()操作,这就纳闷了,为什么性能差距会那么大(在循环次数越多的时候差距会越来越大),最终没办法,我们用多行和循环测试,又看了下两者之间的区别,在使用String做+操作时,如果是多条代码或者在循环中做的话,每条代码都会做一个新的new StringBuilder,然后最后会toString一下,也就是当两个字符串相加时,会“最少”多申请一个StringBuilder然后再转换为一个String(虽然是将StringBuilder中内容拷贝到一个新的String中,但是空间是两块),所以浪费空间比较快,而且如果字符串越长,循环的过程中就会逐步进入old,而且old中的东西也会越来越多,导致了疯狂的GC,最后会疯狂的Full GC,再多的内存也会很快达到Full GC,只要你做循环;其实在常规应用中,一般你只需要做几行的字符串叠加也无所谓,如果能写成一行就写成一行,如果非要写成多行还想要性能的话,就用StringBuilder吧;其实快并不是在多少申请了对象,因为java申请对象的速度非常快速,不存在说因为多申请了两个对象就会导致什么大的问题,大的问题是因为这些临时空间所产生的垃圾,最终导致了疯狂的GC,上述两种情况在做多次循环的过程中本地使用代码:-XX:+PrintGCDetails来运行,你会发现,使用String做加法,刚开始会疯狂的YGC,过一段后会疯狂的FullGC,最后内存溢出,而使用StringBuilder几乎不会做GC,要做应该是做YGC,如果发生FGC一般说明这个字符串已经快把OLD区域撑满了,也就说马上要内存溢出了,而前者临时对象也应该去掉的,但是它会比StringBuilder叠加次数更少的时候,发生内存溢出,那是因为对象比较大的时候,临时对象已经在old区域,而前一个临时对象正好是要作为后一个对象的拷贝,所以在后面那个对象还没有拷贝成功前,前面那个对象的空间还不能被释放,那么很明显,old区域的利用率一般到一半的时候就溢出了。

最后补充一个话题,其实StringBuilder也有一些问题,就是在动态扩容的过程中,每次增加2倍的空间,并不是在原有空间上做类似的C语言的realloc操作,而是新申请一个2倍大小的空间,将这些内容再拷贝过去;StringBuilder之所以可以动态增加是因为一个预先分配的char长度,如果没有满可以继续在后面添加内容,如果满了就申请一个2倍的空间,然后将前面的拷贝过去;不难说出两个问题,所谓的动态扩容只是逻辑上的实现,而并非真正的动态扩容,这也有它的内存安全性考虑,而String是多长,数组的长度就多长(注意:这个长度和前面说的对象大小关系并不大,对象大小前面有一定的介绍);另一个可以看出的问题就是动态扩容的过程中同样会产生各种各样的垃圾对象,其实在循环的过程中,看得往往还没有那么明显,在多线程访问多个随机方法,每个随机方法内部都会去做一些apend,而且都大于10的时候,临时对象就多了;不过还好,它的临时对象只是char数组,而不是String对象,前面说了,String对象相当于两个对象,前面那个对象的大小也是很大的;但是如果你需要考虑这样的细节,那么请在编写StringBuilder的时候,预先写好你认为它可能的最大长度,尤其是被反复调用的代码,如StringBuilder builder = new StringBuilder(2048);一般的小对象没有必要这样做,而且一次申请对象如果过大可能很容易进入old区域,甚至于直接进入old区域,这是我们不想看到的;但是这种方法就要求每一位程序员都要有非常高的素质和修养,但是大多数的程序员你可能叫他写StringBuilder就够意思了,呵呵,更加不要说叫他去些意思了,那么这个办法并不能让所有的程序员所接受,目前的Hotspot还未解决这个问题,但是JRockit已经有一种解决方案了,它的解决方案很好的一种方法,就是在编译时它就能决定在这个局部方法内部你会发生多少次的append操作,那么它的StringBuilder内部做的就不是char数组,而是一个String[],预先分配数组的长度就是和append次数一样大小的数组,每做一次append就像数组下标增加1,并且放在对应的数组位置,并记录下总体的长度,待这个对象发生toString操作时,此时再申请一个这个长度一样大小的char[]空间,将数据拷贝进去,就解决了所有的临时对象的问题,对于在增加了一次间接访问和toString时候发生的逐个拷贝这些开销都是可以接受的(只要append的次数不是特别的多,一般append的次数也不可能特别多,所以利用循环测试出来的性能区别这个时候也是不靠谱的);

最后,所谓的String拼接和StringBuilder下的使用,只要不是太大的字符串或者太多次数的拼接或者高并发访问的代码段做了2行代码以上的拼接,String做加法几乎和StringBuilder区别不大;太大的字符串产生的太大的临时空间,太多的拼接次数是产生太多的临时空间,同一条代码中作String的拼接(不论拼接次数)和使用StringBuilder做append效果一致,只是每次append结果在这行发生完成后会发生toString操作,而默认申请的StringBuilder大小默认为10,如果超过限制则翻倍,这也算是一个限制。

其余的就没什么了,此文闲扯,做做实验便知道,使用命令分析更加深入,关于动态扩展,在集合类里面也有类似的情况,需要注意。



相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部