重新认识Java——字符串(String)

对于任何变成语言来说,接触字符串都是不可避免,Java也不例外。Java中String类位于java.lang包下,是整个Java语言的基石。同时String类使用final关键词修饰,意味着外部调用者无法通过继承和重写来更改其功能。Java中的字符串与语言相比,也有其特殊性。本文深入地理解Java字符串,主要内容有:

  1. String的初始化
  2. String与常量池
  3. String的不变性
  4. String、StringBuffer与StringBuilder
  5. “+”操作符

String初始化

首先要强调的是,String并不是Java中的基础类型,它也是一个对象。在源代码层面来说,String有多种不同的初始化方法,本节就介绍这些初始化方法。

字面量法

String的字面量初始化法如下所示:

1
2
String a = "abc";
String b = "hello world";

这种方法首先从常量池中查找是否有相同值的字符串对象,如果有,则直接将对象地址赋予引用变量;如果没有,在首先在常量池区域中创建一个新的字符串对象,然后将地址赋予引用变量。

构造方法法

String的构造方法初始化法如下所示:

1
2
String a = new String("abc");
String b = new String("hello world");

String类的构造方法有:

1
2
3
4
5
public String() {} // 构造空串(注意与null的区别)
public String(String original) {} // 基于另外一个字符串构造一个新字符串对象
public String(char value[]) {} // 使用byte数组构造字符串
public String(char value[], int offset, int count){} // 使用byte数组以及偏移参数构造
public String(int[] codePoints, int offset, int count) {} // 基于Uncode编码数组以及偏移量构造

这种初始化方法与一般对象的初始化方法完全一样。与字面量法不同的是,每次调用构造方法都会在堆内存中创建一个新的字符串对象。下面的例子可以清楚地显示它们的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* 个人主页:http://hinylover.space
*
* Creation Date: 2016年7月6日 下午9:14:56
*/
package demo.blog.java.string;
/**
* 不同初始化方法的区别。
* @author xialei
* @version 1.0 2016年7月6日下午9:14:56
*/
public class StringEqual {
public static void main(String[] args) {
String a1 = "123";
String b1 = "123";
System.out.println(a1 == b1); // true
String a2 = new String("123");
String b2 = new String("123");
System.out.println(a2 == b2); // false
}
}

我们知道,Java中的“==”符比较的变量保存的实际内存数据,由于基础数据类型变量保存的是数据的实际值,而引用类型变量保存的是对象的地址,不同的地址代表着不同的对象。a1 == b1true表明a1和b1指向同一个对象,而a2和b2分别指向不同的对象。

String与JVM常量池

说起String ,就不能不提到JVM常量池,是笔试和面试中经常喜欢出题的点。

常量池

在正式进入常量池之前,首先简单地介绍一下JVM(Java虚拟机,由于市面上有多种不同的JVM,本文中仅考虑Hotspot VM)的内存结构,也是作为Java程序员必须要了解的内容(以后再深入JVM)。由于本文的主体并不是Java虚拟机,内容会比较粗糙,更加详细的JVM知识会在以后撰写。

JVM的内存顶层结构如下图所示:

JVM内存结构

下面分别来说说主要的内存区域:

  1. 程序计数器:这是一块比较小的内存区域,可以看做是当前线程所执行的字节码的行号指示器。每一个线程都会需要有一个独立的程序计数器,各个程序计数器之间相互不影响。如果线程当前执行的是Java方法,则计数器记录当前正在执行字节码指令地址,如果是Native方法,则计数器的值为空。
  2. 栈区:栈区是线程私有的,其生命周期与线程相同。栈区描述的是Java方法执行的内存模型(以后会详细研究)。
  3. 本地方法栈:功能与栈区的功能相似,不过是为Java中的Native方法服务的。
  4. 堆区:这是JVM管理的最大的一块内存区域,是所有线程共享的,几乎所有的对象实例都放置在这个区域。堆又可以细分为:年轻代、老年代。我们通常所说的垃圾回收就是发现在这个内存区域。
  5. 方法区:与堆一样,也是所有线程所共享的。由于习惯问题,方法区也被叫做永久代,因为上面放置的数据很难被回收(条件很苛刻)。

常量池(准确地说是运行时常量池),在JDK1.6及以前都是方法区中的一部分,在JDK1.7之后被移入堆区,用来存放编译时生成的各种字面量和符号引用。下面来看一下普通类编译后的字节码。

1
2
3
4
5
6
public class Test {
public static void main(String[] args) {
String a = "123";
}
}

上面的源代码编译完后的字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = String #14 // 123
#3 = Class #15 // Test
#4 = Class #16 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 Test.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 123
#15 = Utf8 Test
#16 = Utf8 java/lang/Object
{
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 4: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: ldc #2 // String 123
2: astore_1
3: return
LineNumberTable:
line 7: 0
line 8: 3
}

第6~21行是编译后产生的常量池(编译时常量池),这些常量在类加载时被保存到运行时常量池中,代码中123字符串在编译后放入常量池中(见第19行),这也就解释了上一节示例中的a1 == b1true的问题。常量池中的大部分数据都来自编译时常量池,也可以在运行时将数据放入常量池。

intern()方法

intern()方法的作用是在常量池中查找值等于(equals)当前字符串的对象,如果找到,则直接返回这个对象的地址;如果没有找到,则将当前字符串拷贝到常量池中,然后返回拷贝后的对象地址。下面的代码可以解释intern()的功能。

1
2
3
4
5
6
7
8
String a = "123";
String b = new String("123");
String c = new String("123");
System.out.println(a == b); // false
System.out.println(a == b.intern()); // true
System.out.println(c == b.intern()); // false
System.out.println(c.intern() == b.intern()); // true

由于String a = “123”;产生的字符串对象会直接放入常量池中,当调用b.intern()方法时,由于已经存在值为123的对象(即a所指向的对象),直接返回这个对象,所以a == b.intern()判定为true

String的不可变性

类的不可变性

什么叫做类的不变性?简单地说,就是其实例一旦创建完成,在其整个生命周期内状态都不会发生变化。状态这个词有一些抽象,在Java中对象的状态是由其成员变量来表现的,那么状态不变即是成员变量不变(具体来说,基本类型变量的值不变、引用类型变量的引用地址不变)。不可变的类有不少好处:

  1. 更加易于设计、实现和使用。
  2. 并发时,不容易出错,并且更加安全。

为了使类成为不可变,要遵循下面五条规则(引用自《Effective Java》):

  1. 不提供任何会修改对象状态的方法;
  2. 保证类不能被扩展;
  3. 所有的成员变量都被final修饰的;
  4. 所有的成员变量都是private的;
  5. 确保对于任何可变组件的互斥访问。如果一个类的成员变量引用了可变对象,则必须确保外部调用类无法获取指向这些对象的引用。

当然,所有的这些条件都是针对正常调用而言的,如果使用反射,则仅仅满足上述的条件也无法保证对象不可变。

String的不可变性

首先来通过一个图文案例来说明String不可变性。

1、声明一个String类型变量。

1
String s = "abcd";

String-1

2、将字符串变量赋予另外一个String类型变量。

1
String s2=s;

String-2

3、连接另外一个字符串对象。

1
String s3 = s.concat("ef");

String-3

可以看到s3指向是另外一个对象,而不是原来a所指向的对象。在调用concat(String)方法之后,a所指向的对象状态并没有发生改变,而是生成了一个新的对象。下面是concat(String)方法的源代码(JDK1.8):

1
2
3
4
5
6
7
8
9
10
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}

注意到第9行代码,它直接创建一个新的String对象。

为什么String要设计为不可变呢?主要原因如下:

  1. 字符串池(String pool)的需求。之前已经说过,通过字面量发初始化一个Java字符串时,会将这个字符串保存在常量池中。如果定义了另外一个相同值的字符串变量,则直接指向之前初始化的那个对象。如果字符串是可变的,改变另一个字符串变量,就会使另一个字符串变量指向错误的值。

  2. 缓存字符串hashcode码的需要。字符串的hashcode是经常被使用的,字符串的不变性确保了hashcode的值一直是一样的,在需要hashcode时,就不需要每次都计算,这样会很高效。

  3. 出于安全性考虑。字符串经常作为网络连接、数据库连接等参数,不可变就可以保证连接的安全性。

String、StringBuffer与StringBuilder

StringBufferStringBuilder是创建字符串常用的类,采用构建器模式来构建字符串对象,使得可以在运行时动态地构建字符串对象。下表是它们各自的特点。

可变性 线程安全
String 不可变 线程安全
StringBuffer 可变 线程安全
StringBuilder 可变 非线程安全

“+”操作符

String是一个异类,除了基本类型及其包装类之外,只有它可以使用”+” 操作符号。在String中,”+” 表示字符串连接,而不是数学运算中的加法运算。下面通过一些例子(例子引用自《深入理解Java:String》)来说明不同场景下使用”+”操作符的特点。

编译时优化

1
2
3
4
5
6
7
8
/*
* 由于常量的值在编译的时候就被确定(优化)了。
* 在这里,"ab"和"cd"都是常量,因此变量str3的值在编译时就可以确定。
* 这行代码编译后的效果等同于: String str3 = "abcd";
*/
String str1 = "ab" + "cd";
String str11 = "abcd";
System.out.println("str1 = str11 : "+ (str1 == str11)); // true

为了提高效率和减少内存占用,Java编译器会在编译时做一些其力所能及的事情。上面的代码中,由于在编译时即可以确定str1的值为”abcd”,所以编译时,直接将”abcd”字符串对象赋予str1,所以str1和str11引用的是常量池中的同一个对象。

利用StringBuilder实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* 局部变量str2,str3存储的是存储两个拘留字符串对象(intern字符串对象)的地址。
*
* 第三行代码原理(str2+str3):
* 运行期JVM首先会在堆中创建一个StringBuilder类,
* 同时用str2指向的拘留字符串对象完成初始化,
* 然后调用append方法完成对str3所指向的拘留字符串的合并,
* 接着调用StringBuilder的toString()方法在堆中创建一个String对象,
* 最后将刚生成的String对象的堆地址存放在局部变量str3中。
*
* 而str5存储的是字符串池中"abcd"所对应的拘留字符串对象的地址。
* str4与str5地址当然不一样了。
*
* 内存中实际上有五个字符串对象:
* 三个拘留字符串对象、一个String对象和一个StringBuilder对象。
*/
String str2 = "ab";
String str3 = "cd";
String str4 = str2 + str3;
String str5 = "abcd";
System.out.println("str4 = str5 : " + (str4 == str5)); // false

上面的源代码编译后的字节码如下,第5行创建了一个StringBuilder对象,并在第9行和第11行分别将字符串”ab”和”cd”append到对象中,生成新的字符串对象”abcd”,并将引用赋予变量str4。str5引用的对象保存在常量池中,而str4引用的对象是保存在Java堆中的,它们不是同一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
0: ldc #2 // String ab
2: astore_1
3: ldc #3 // String cd
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: ldc #8 // String abcd
27: astore 4
29: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
32: new #4 // class java/lang/StringBuilder
35: dup
36: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
39: ldc #10 // String str4 = str5 :
41: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
44: aload_3
45: aload 4
47: if_acmpne 54
50: iconst_1
51: goto 55
54: iconst_0
55: invokevirtual #11 // Method java/lang/StringBuilder.append:(Z)Ljava/lang/StringBuilder;
58: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
61: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
64: return

final字符串编译时优化

1
2
3
4
5
6
7
8
9
/*
* JAVA编译器对string + 基本类型/常量 是当成常量表达式直接求值来优化的。
* 运行期的两个string相加,会产生新的对象的,存储在堆(heap)中
*/
final String str8 = "b";
String str9 = "a" + str8;
String str89 = "ab";
System.out.println("str9 = str89 : "+ (str9 == str89)); // true
//↑str8为常量变量,编译期会被优化

final修饰的变量str8表示字符串常量,str8不可能引用其他的字符串。在编译时,直接将”ab”字符串赋予str9变量,所以,上面的判断结果为true。


参考文献

《深入理解Java虚拟机 JVM高级特性与最佳实践》
《Effective Java》
深入理解Java:String


本文由xialei原创,转载请说明出处http://hinylover.space/2016/07/10/relearn-java-string/