1. Java

浅析String

1.写在前面

Java语言中,String可以说是除了除了八大primitive type之外的第九大”基本类型”了。众所周知,从Java9开始,String的内部存储的实现从char数组改为了byte数组,其主要原因是:字符串使用的场景太多,且大多是ASCII(8bit),用char数组存储就有点浪费,char为16bit。所以改用byte数组来存放,但是8bit存拉丁文基本够了,汉字等就明显不够了!每个汉字需要占用两个byte才行,这就存在一个问题,解析的时候是取一个byte作为一个字符还是取两个byte?为了解决这个问题,String类还加入了一个byte类型的coder field用来指明byte数组里数据的编码方式。目前可以使用LATIN1 或UTF16,所以,能用LATIN1编码的用LATIN1,不行就UTF16。

2.特殊之处

正是因为String经常被使用,所以针对其优化就很有必要,这也是String和别的类总是不大一样的原因。比如:

Object o = new Object();
String s = "Hello";
Integer i = 100;

通常,我们new一个类会采取第一行这种方式,但是String类可以直接使用=将字符串”赋值”给String类型的引用变量(第二行),就像primitive type的包装类一样,如第三行。我们都知道,第三行,javac会对其进行”装箱”操作,可以通过查看编译后的字节码来证明,字节码如下:

 0 new #2 <java/lang/Object>
 3 dup
 4 invokespecial #1 <java/lang/Object.<init> : ()V>
 7 astore_1
 8 bipush 100
10 invokestatic #7 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
13 astore_2
14 ldc #13 <Hello>
16 astore_3
17 return

显而易见,对于Integer i = 100;具体操作是调用Integer 的静态方法valueOf()将int型100包装成Integer类型并返回引用。但是String并没有采取类似的方法,而是直接将”Hello”字符串在字符串常量池中的引用返回,注意:因为本代码常量池之前并无”Hello”,所以会在常量池中新生成一个”Hello”对象并返回引用。这里提到的字符串常量池是一块内存区域,对于相同的字符串,内存中只需一个实例即可,大家都引用同一地址,如果大家的字符串相同,这样做也是为了节省内存提高性能呗。顺带提一句:这个字符串常量池在Java8之前存放于PremGen Space;Java8及之后存放于Heap。

那末,如何看出字符串常量的复用?如下代码所示:

String s = "Hello";
String t = "Hello";
System.out.println(s==t);

其结果为true,引用地址相同。在第一行是jvm在字符串常量池中生成了一个”Hello”,并将引用地址返回给s,第二行因为和第一行字符串相同,所以jvm直接将字符串中已经存在的”Hello”引用地址返回给t。ok,再看如下代码:

String s = new String("Hello");
String t = new String("Hello");
System.out.println(s==t);

现在,我显式的使用new关键字生成两个String类实例,这次返回还是true吗?显然不是,再看字节码:

 0 new #7 <java/lang/String>
 3 dup
 4 ldc #9 <Hello>
 6 invokespecial #11 <java/lang/String.<init> : (Ljava/lang/String;)V>
 9 astore_1
10 new #7 <java/lang/String>
13 dup
14 ldc #9 <Hello>
16 invokespecial #11 <java/lang/String.<init> : (Ljava/lang/String;)V>
19 astore_2
20 getstatic #14 <java/lang/System.out : Ljava/io/PrintStream;>
23 aload_1
24 aload_2
25 if_acmpne 32 (+7)
28 iconst_1
29 goto 33 (+4)
32 iconst_0
33 invokevirtual #20 <java/io/PrintStream.println : (Z)V>
36 return

可以看出,”Hello”首先在字符串常量池中生成一个实例,然后经过两次new,参数都是来自相同的字符串常量池中的”Hello”,最终在Heap中new了两个不同的String实例,所以s和t的引用地址显然是不同的。

显然,这样操作内存中就有了三份相同的字符串”Hello”,这显然不够优雅,我之前new的两个String对象现在后悔了,想用字符串常量池中的行不行?当然可以,java提供了intern()方法,使用方法如下:

String r = "Hello";
String s = new String("Hello");
String t = new String("Hello");
s = s.intern();
System.out.println(r==s);

首先使用r来在字符串常量池中创建”Hello”实例,接下来new两个String实例。再通过s.intern()操作,将s指向的String实例移入字符串常量池,发现字符串常量池已有,则直接返回已有引用地址给s。此时打印结果为true。

最后再看一个字符串拼接的例子:

String s = "Hello";
String t = "He";
String r = "llo";
String tr = t + r;
System.out.println(s==tr);

这里打印的是false,why?这里为什么不直接引用常量池里的”Hello”?,哈哈,通过查看字节码可以发现t+r操作是通过makeConcatWithConstants()方法来实现的(jdk9及之后),StringBuilder实现(jdk5-8),最终是在Heap中生成一个新的实例,所以这里是false;当然如果将tr=t+r改成tr=”He”+”llo”,在javac会将其优化为tr=”Hello”直接用字符串常量池了,这就没有后续的事了。

3.注意事项

在实际使用String的时候,需要注意以下细节:

  • 做大量字符拼接的操作时使用StringBuilder的append()方法(单线程情况下),不要使用变量+字符串的形式。
  • 正确、合理使用字符串常量池,和intern()方法,提高效率。