String类是Java编程中应用最广泛的类,所以每一位程序员都应该仔细研究一番String类的内部实现,这样对我们理解String对象的工作机制、避免错误和提升代码效率有很大好处。你若打开Java语言规范 (Java 9),可以看到4.3.3节中对String对象的简述[1]:
- String对象的实例,表示Unicode码的序列。
- 一个String对象,有一个恒定不变的值。
- 字符串字面量是对String实例的引用。
- 非常量表达式时,“+“操作符连接两个String对象,总是会隐式地产生一个新的String对象。
p.s. 所谓常量表达式的定义,在Java中有一系列规范,对于String,简单地说,就是形如下面这种表达式:
"The integer " + Long.MAX_VALUE + " is mighty big."
即仅由一系列字符串字面量或者字符串常量组成的表达式。下面,就详细研究String类。
1.不可变类----String
String对象是不可变的,所谓不可变就是指一个对象,在它创建完成之后,不能再改变它的状态。如果你仔细查看了String的源码或者API 文档,就会发现String类中的所有变更String内容的方法,实际上都new了一个新的String对象, 例如subsring()方法,该方法截取片段如下所示:
public String substring(int beginIndex, int endIndex) { ...... ...... return ((beginIndex == 0) && (endIndex == value.length)) ? this: new String(value, beginIndex, subLen); }
注意到,return语句中的new String(value, beginIndex, subLen)语句,实际上就是创建了一个新的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[]; ...... public String(String original) { this.value = original.value; this.hash = original.hash; } }
实际上,String对象持有一个char数组的引用,而这个数组就是String字面量所引用的值。 该数组定义为private final, private使外部不能访问该数组,final则表示该数组的引用一旦初始化就不能改变。此外,String类本身定义为final的,不可被继承,所有属性均为private,由此,实现了String的不可变性质。
但是,String对象真的不可变么?换句话说,有没有办法改变value[]的值,能不能把['a','b','c']改为['a','b','d']?答案是肯定的,通过反射机制,可以获取value[]的访问权限并更改数组中的值。可见,反射是强大的但不是安全的,一般应用开发中,尽可能避免使用反射。
2. String连接操作符"+"
2.1 操作符"+"对产生对象的影响
在诸如: op1 + op2 + op3 的表达式中, 只要有一个操作数是String类型,其它操作数都会在运行时转换成String类型,结果是一个新创建的String对象的引用(除非是常量表达式)。举一个例子来解释一下这句话的含义。
1 public class OperationAddOnString { 2 public static void main(String[] args) { 3 String a = "hello" + "world"; // (1) 4 String b = "hello"; // (2) 5 String c = "world"; // (3) 6 String d = b + c; // (4) 7 String e = "helloworld"; // (5) 8 System.out.println(e == a); // (6) 运行结果:true 9 System.out.println(a == d); // (7) 运行结果:false 10 } 11 }
(1) 首先,创建一个引用a,指定为String类型,其次字面量“hello”的String对象和“world”的对象分别被创建于堆内存中的字符串常量池中,现在,创建了两个对象。最后,操作符“+”将常量池中的两个对象“hello”,“world”相连接,创建了一个字面量为“helloworld”的新String对象, 并储存于字符串常量池中。即,第一行一共创建了三个对象,且都储存于常量池中。
(2) 创建一个引用b, 指向字符串常量池中字面量为“hello“的对象,没有创建新的String对象。
(3) 同上。
(4) 创建一个String类型的引用d,将b引用指向的对象和c指向的对象相连,由于此表达式不是常量表达式,编译器不能确定b和c所指向的对象,所以,b + c 运行时创建了一个新的String对象,储存于堆内存中(非常量池)。这行创建了一个新对象,这个对象的字面量为"helloworld",与a相同,但是一个存于字符串常量池,另一个存储于堆中。
(5) 创建一个String类型的引用e,由于字面量“helloworld”的对象已存在于常量池,将e指向其即可。这行没有创建新的对象。
(6) e和a都指向常量池中的“helloworld”,所以结果是true。
(7) a和d指向不同的对象,所以结果是false。
如果想让(7)的结果为true,有什么办法?前面章节说过,如果表达式是常量表达式,“+”操作符就不会产生新的对象,因此,只需要将引用b和c声明为final的即可
public class OperationAddOnString { public static void main(String[] args) { String a = "hello" + "world"; final String b = "hello"; final String c = "world"; String d = b + c; // 由于b,c是final的,此为常量表达式,编译时就能确认字符串字面量,指向a引用所指向的常量池中的对象 System.out.println(a == d); // true } }
2.2 编译器对"+"操作的优化
假设表达式中有多个“+”, 按照上述逻辑,一个表达式中可能产生多个String对象,而许多String对象只是作为中间对象,并不在最终结果中体现,这无疑是一种浪费。为了降低中间String对象所带来的的性能和内存浪费,Java编译器采用StringBuilder
类或者相似的技术。StringBuilder对象是可变对象,创建一个StringBuilder对象之后,可以改变其所代表的字符串字面量。例如:
1 public class StringCatenation1 { 2 public static void main(String[] args) { 3 String a = "a"; 4 String b = "b"; 5 String c = "c"; 6 String d = a + b + c; 7 System.out.println(d); 8 } 9 }
第6行拼接了多个字符串对象,且是非常量表达式,将新的字符串对象指向给引用d。使用javap -c StringCatenation1后反汇编StringCatenation1.class后,结果如下:
1 public class strings.StringCatenation1 { 2 public strings.StringCatenation1(); 3 Code: 4 0: aload_0 5 1: invokespecial #1 // Method java/lang/Object."<init>":()V 6 4: return 7 8 public static void main(java.lang.String[]); 9 Code: 10 0: ldc #2 // String a 11 2: astore_1 12 3: ldc #3 // String b 13 5: astore_2 14 6: ldc #4 // String c 15 8: astore_3 16 9: new #5 // class java/lang/StringBuilder 17 12: dup 18 13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V 19 16: aload_1 20 17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 21 20: aload_2 22 21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 23 24: aload_3 24 25: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 25 28: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 26 31: astore 4 27 33: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 28 36: aload 4 29 38: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 30 41: return 31 }
反编译后类似于汇编语言,但是你不需要理解每一行的意思, 这里主要看标红的行,#5表示创建一个StringBulder对象, #7则是分别stringBuilder.append(a),stringBuilder.append(b),stringBuilder.append(c), #8是调用stringBuilder.toString()方法,将最终的结果String对象赋予引用d。通过编译器自动使用StringBuilder的技术,就可以解决字符串拼接产生大量中间String对象的问题,降低内存损耗。
下面看另一个关于字符串拼接的问题,在循环中拼接字符串。根据上面内容,我们知道,编译器在拼接字符串变量时,会隐式地创建StringBuilder对象来进行,那么,我们是否就无需担心在循环中随意用String对象来拼接字符串了呢?
1 public class StringCatenation2 { 2 public String implicit(String[] fields) { 3 String result = ""; 4 for (int i = 0; i < fields.length; i++) { 5 result += fields[i]; 6 } 7 return result; 8 } 9 }
在循环之外声明一个String变量result,并赋予空字符串,将参数传过来的字符串数组遍历拼接到result上。将该代码反汇编之后的结果如下:
1 public java.lang.String implicit(java.lang.String[]); 2 Code: 3 0: ldc #2 // String 4 2: astore_2 5 3: iconst_0 6 4: istore_3 7 5: iload_3 8 6: aload_1 9 7: arraylength 10 8: if_icmpge 38 11 11: new #3 // class java/lang/StringBuilder 12 14: dup 13 15: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V 14 18: aload_2 15 19: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 16 22: aload_1 17 23: iload_3 18 24: aaload 19 25: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 20 28: invokevirtual #6 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 21 31: astore_2 22 32: iinc 3, 1 23 35: goto 5 24 38: aload_2 25 39: areturn
注意#5, 确实如我们所料,编译器自动创建了StringBuilder对象, 但是注意35行 goto关键字, goto表示跳跃到指定的行,这里指向第5行 iload_3, 不需要理解这是什么意思,只需要知道,从 5行到35行,是一个循环,可能会执行多次即可,那么第11行的new StringBuilder对象也会执行多次,创建多个StringBuilder对象,这样,就没有达到消灭中间对象的效果。 对于这种循环拼接字符串的场景,我们应该在循环外层,显式定义StringBuilder对象,在循环中显式使用StringBuilder.append()方法来拼接对象,如下所示:
1 public class StringCatenation2 { 2 public String explicit(String[] fields) { 3 StringBuilder builder = new StringBuilder(); 4 for (int i = 0; i < fields.length; i++) { 5 builder.append(fields[i]); 6 } 7 return builder.toString(); 8 } 9 }
反汇编后:
1 public java.lang.String explicit(java.lang.String[]); 2 Code: 3 0: new #3 // class java/lang/StringBuilder 4 3: dup 5 4: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V 6 7: astore_2 7 8: iconst_0 8 9: istore_3 9 10: iload_3 10 11: aload_1 11 12: arraylength 12 13: if_icmpge 30 13 16: aload_2 14 17: aload_1 15 18: iload_3 16 19: aaload 17 20: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 18 23: pop 19 24: iinc 3, 1 20 27: goto 10 21 30: aload_2 22 31: invokevirtual #6 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 23 34: areturn
现在,反汇编后,我们发现,new StringBuilder过程在循环之外了,只创建一个StringBuilder对象。
3. String真身
从本质上来说,Java中的String就是字符的数组, 在String类源码中,String的第一个属性就是:
private final char value[]; // final修饰value, 表示其引用不可变但可以改变数组内部内容, private使其不可见,所以String为不可变类。当然,上面提到过可以采用反射来修改数组内容从而让String不再“不可变”。
用来存储字符串字面量的字符,比如String day = "hello" 中, 被引号括起来的“Hello,world!” 是字符串字面量, 以上表达式,创建了一个String对象,其字面量是“Hello,world!”, 那么value中存储的是
['h','e','l','l','o']
除此之外,还可以是用new关键字来调用构造器创建String对象,String有多达十几种构造器,每种构造器允许程序员传入不同类型或者不同数量的参数来构造String对象,比如用字符数组来构造String对象:
1 char[] words = {'h','e','l','l','o'}; 2 String say = new String(words ); 3 System.out.println(say);
源码中,这一构造器就是把字符数组words复制一份赋值给String的属性value而已,可见,字符数组就是String的本质。注意到,构造器中使用Arrays.copyOf(value,value.length)赋值并创建了一个新的字符数组对象,在本篇文章开篇第一节中我就强调String是不可变类,创建一个新的数组对象就是防止用户修改原来的数组从而修改到String对象所持有的数组而违反了不可变的宗旨。
1 /** 2 * Allocates a new {@code String} so that it represents the sequence of 3 * characters currently contained in the character array argument. The 4 * contents of the character array are copied; subsequent modification of 5 * the character array does not affect the newly created string. 6 * 7 * @param value 8 * The initial value of the string 9 */ 10 public String(char value[]) { 11 this.value = Arrays.copyOf(value, value.length); 12 }
既然字符数组就是String的本质,那么对于String对象的所有操作就是对字符数组的操作,比如String提供了获取字符串长度(字符的数量)的方法length(), 源码中就是获取字符数组的长度:类似地,charAt(int index) 方法也就是获取数组中的指定位置的字符。
1 /** 2 * Returns the length of this string. 3 * The length is equal to the number of <a href="Character.html#unicode">Unicode 4 * code units</a> in the string. 5 * 6 * @return the length of the sequence of characters represented by this 7 * object. 8 */ 9 public int length() { 10 return value.length; 11 }
1 public char charAt(int index) { 2 if ((index < 0) || (index >= value.length)) { 3 throw new StringIndexOutOfBoundsException(index); 4 } 5 return value[index]; 6 }
理解“字符串的本质就是字符数组”非常重要,下面就做一个小练习来巩固这一思想: 将字符串"hello, world" 以相反顺序输出,并创建一个相反顺序的String对象,不得使用任何Java或者第三方工具提供的方法。
1 public class ReverseString { 2 3 public static void main(String[] args) { 4 String origin = "hello,world"; 5 int len = origin.length(); 6 char[] tempChar = new char[len]; 7 char[] reverseChar = new char[len]; 8 9 for (int i = 0; i < len; i++) { 10 tempChar[i] = origin.charAt(i); 11 } 12 13 for (int j = len - 1; j >= 0; j--) { 14 reverseChar[j] = tempChar[len-j-1]; 15 } 16 17 System.out.println(Arrays.toString(tempChar)); 18 System.out.println(Arrays.toString(reverseChar)); 19 20 String reverse = new String(reverseChar); 21 System.out.println(origin); 22 System.out.println(reverse); 23 } 24 } 25 26 =====输出结果===== 27 [h, e, l, l, o, ,, w, o, r, l, d] 28 [d, l, r, o, w, ,, o, l, l, e, h] 29 hello,world 30 dlrow,olleh