Java源码分析——String

​ 作为Java 中八种基本类型之一的char 的封装类型,String 类可以说是Java程序员每天打交道最多的一个类了。所以,了解String 类的实现与原理是十分有必要的。

注:本文基于jdk_1.8.0_144

String 类是不可变的

类及成员变量

1
2
3
4
5
6
7
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
//值的存储
private final char value[];
//hash值
private int hash;
}

​ 由类的构成可以看出:

  • 类由final 修饰,不可被继承。不可被继承,则无子类,相关方法不可被重写,不会改变原有行为。生成的String 对象不可变,多线程下可保证安全。同时,final 类可被编译器优化,提高了效率。
  • 类的私有成员变量valuefinal 修饰,一旦对象被创建,则值不可再修改。
  • String 类内部实际是由一个字符数组构成。

同时,String 类并没有对外提供任何可以操作value 数组内容的方法,保证了不可变性。当然,没提供相应的方法,不代表就不可以修改,例如,通过反射去改变私有属性的值。但是,反射是非常规手段,在正常使用情况下,我们是不能改变String 实例化对象的值的。

对外提供的常用方法

常见构造器

1
2
3
4
5
6
7
8
9
//默认构造器,实例化对象值是一个空串
public String() {
this.value = "".value;
}
//实例化一个和传入值相同串的对象
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

以上两种构造器,在自动装箱的情况,已经不怎么使用了。

1
2
3
4
5
6
7
8
9
10
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}

public String(char value[], int offset, int count) {
/**
* 参数检查部分省略
*/
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

实际上就是将指定字符数据的值,拷贝到字符串的value 数组。

1
2
3
4
5
6
7
8
9
public String(StringBuffer buffer) {
synchronized(buffer) {
this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
}
}

public String(StringBuilder builder) {
this.value = Arrays.copyOf(builder.getValue(), builder.length());
}

对于参数为StringBuilderStringBuffer 的类型,通常不这么用,一般使用toString() 方法。

1
2
3
4
5
6
7
public String(byte bytes[]) {
this(bytes, 0, bytes.length);
}

public String(byte bytes[], Charset charset) {
this(bytes, 0, bytes.length, charset);
}

通过字节数组和得到字符串对象,对于字符编码一定要设置,不然可能会引起不同平台之间的差异,细节如下

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
//通过StringCoding.decode方法来转成字符串
public String(byte bytes[], int offset, int length) {
checkBounds(bytes, offset, length);
this.value = StringCoding.decode(bytes, offset, length);
}
//根据默认编码来解码,如果默认编码不支持,则使用"ISO-8859-1"编码
static char[] decode(byte[] ba, int off, int len) {
String csn = Charset.defaultCharset().name();
try {
return decode(csn, ba, off, len);
} catch (UnsupportedEncodingException x) {
warnUnsupportedCharset(csn);
}
try {
return decode("ISO-8859-1", ba, off, len);
} catch (UnsupportedEncodingException x) {
MessageUtils.err("ISO-8859-1 charset not available: "+ x.toString());
System.exit(1);
return null;
}
}
//获取平台的默认编码,如果获取不到,则返回"UTF-8"
public static Charset defaultCharset() {
if (defaultCharset == null) {
synchronized (Charset.class) {
String csn = AccessController.doPrivileged(
new GetPropertyAction("file.encoding"));
Charset cs = lookup(csn);
if (cs != null)
defaultCharset = cs;
else
defaultCharset = forName("UTF-8");
}
}
return defaultCharset;
}

简而言之,使用 byte[] 构造 String ,如果没有指定编码,则会获取平台系统的默认编码进行解码操作;如果还没有的话,则使用ISO-8859-1 编码。

还有一个构造器比较特殊,一般不看源码很难发现。

1
2
3
4
String(char[] value, boolean share) {
// assert share : "unshared not supported";
this.value = value;
}

上面介绍的几种构造器,都是将字符数组进行Arrays.copyOf 操作,而这个构造器并没有使用复制操作,而是直接数组赋值。既然是赋值,那么是不是说,改变传入数据的值,String 的值也变了,也就打破了String 类的不可变性?答案是否定的,该构造器没有指定为public ,而是省略的protected ,这样就不会被外部调用。同时,String 类不可被继承,也就没有子类可以调用。结果只剩下一种了,包内用,用来提高jdk 的效率。实际搜索其相关引用,就会发现,该构造器的引用全在java.lang.* 下。

这样的好处呢?

  • 效率高,省略了复制操作,而是直接指向内存
  • 省内存,同样的内容,不需要占用两份内存

常用方法

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
//从指定下标截取
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
//从指定下标范围截取
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}

字符截取,实际在最后也是重新new 一个字符串对象,再次印证了不可变性。

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//字符串拼接
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);
}
//转小写
public String toLowerCase(Locale locale) {
/**
* 省略过程
*/
return new String(result, 0, len + resultOffset);
}
//转大写
public String toUpperCase(Locale locale) {
/**
* 省略过程
*/
return new String(result, 0, len + resultOffset);
}
//去除空格
public String trim() {
int len = value.length;
int st = 0;
char[] val = value; /* avoid getfield opcode */
while ((st < len) && (val[st] <= ' ')) {
st++;
}
while ((st < len) && (val[len - 1] <= ' ')) {
len--;
}
return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}
//字符替换
public String replace(char oldChar, char newChar) {
if (oldChar != newChar) {
int len = value.length;
int i = -1;
char[] val = value; /* avoid getfield opcode */

while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
if (i < len) {
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ? newChar : c;
i++;
}
return new String(buf, true);
}
}
return this;
}

不论是字符转大小写,拼接,去空格,还是字符替换,实际返回的也是一个新对象。

连接符号+

上面已经说了,String 类是不可变的,那么我们在做字符串使用 + 号在进行拼接的时候,势必会产生中间变量,如果拼接字符串有很多个,会不会产生很多个中间值,造成内存浪费,性能牺牲?

1
2
3
4
5
6
String s1 = "s1";
String s2 = "s2";
String s3 = "s3";
String s4 = "s4";
String result = s1 + s2 + s3 + s4;
System.out.println(result);

最终结果result 会进行三次操作产生三个String 对象,还是其它的情况?通过 javap -c Test 命令反编译 .class 文件,得到结果如下

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
Code:
0: ldc #16 // String s1
2: astore_1
3: ldc #18 // String s2
5: astore_2
6: ldc #20 // String s3
8: astore_3
9: ldc #22 // String s4
11: astore 4
13: new #24 // class java/lang/StringBuilder
16: dup
17: aload_1
18: invokestatic #26 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String;
21: invokespecial #32 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
24: aload_2
25: invokevirtual #35 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: aload_3
29: invokevirtual #35 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
32: aload 4
34: invokevirtual #35 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
37: invokevirtual #39 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
40: astore 5
42: getstatic #43 // Field java/lang/System.out:Ljava/io/PrintStream;
45: aload 5
47: invokevirtual #49 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
50: return

可以看到,在进行字符串拼接的时候,实际上是先初始化一个 StringBuilder 对象,再用 append 方法进行拼接,最后 toString() 返回结果。这样就不会出现上述我们所说的产生多个中间 String 对象的问题了。

另外,如果在一个 for 循环里,还是使用 + 来进行字符拼接的话,就会产生多个 StringBuilder 对象,这种情况下就需要我们自己进行代码优化,避免在 for 循环里使用 + 来进行字符拼接。

上面的例子中,使用 + 来进行操作,拼接的都是对象,如果拼接的是常量字符串呢?

1
2
String s = "abc" + "def" + "zzz";
System.out.println(s);

同样使用 javap -c Test 来看看

1
2
3
4
5
6
7
Code:
0: ldc #16 // String abcdefzzz
2: astore_1
3: getstatic #18 // Field java/lang/System.out:Ljava/io/PrintStream;
6: aload_1
7: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: return

可见,编译做了优化,直接得到了结果,并没有进行 append 拼接,这是编译期就已经确定了的。

字符串对 null 的处理

在上面的字符拼接中,如果出现了某个字符串是 null 呢?这种情况会怎么处理?

1
2
3
4
5
6
7
String s1 = null;
System.out.println(s1);
String s2 = "s2";
String s3 = "s3";
String s4 = "s4";
String result = s1 + s2 + s3 + s4;
System.out.println(result);

运行结果如下

1
2
null
nulls2s3s4

很明显,null 对象的输出结果是字符串 “null”,通过查看源码,结果如下

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
//System.out.print
public void print(String s) {
if (s == null) {
s = "null";
}
write(s);
}
//append方法
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
//对于空对象的AbstractStringBuilder处理
private AbstractStringBuilder appendNull() {
int c = count;
ensureCapacityInternal(c + 4);
final char[] value = this.value;
value[c++] = 'n';
value[c++] = 'u';
value[c++] = 'l';
value[c++] = 'l';
count = c;
return this;
}

jdk 已经在源码里做了处理,将空对象转成 “null” 字符串。进行 + 操作的时候,会调用 append 。如果对象为 null ,会调用 appendNull 。这样就不会出现 NPE 了。

字符串比较判断

对于相等判断,有两种方式,一种是 == ,对于引用类型,比较是否是同一个对象;一种是 equals ,比较的是内容是否相等。而对于 String 对象,大部分情况下比较的是值是否相等,推荐使用 equals 来进行比较。值得注意的是 String.equals("abc") 可能导致空指针异常,最好写成 "abc".equals(String) ,避免异常的出现。

String 常量池

字符串的分配,对于内存和时间的开销也是很大的。所以为了提高效率,减少开销,JVM 维护了一个字符串常量池。在实例化字符串常量时,会进行常量池检查。

如果字符串在常量池已经存在,就会返回池中的实例引用。如果字符串在常量池不存在,就会实例化并放至常量池,即创建了两个对象:一个丢到常量池中的对象,一个返回的存于堆中的 String 对象。

由于 String 类的不可变性,才有了这种实现的可能,不用担心共享导致的一系列的问题。

至此,在面试中常见,在工作中基本不会出现的判断题就有了结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String s1 = "a1bc";
String s2 = "a1bc";
System.out.println(s1 == s2);//true

String s1 = new String("a1bc");
String s2 = "a2bc";
System.out.println(s1 == s2);//false

String s1 = new String("a1bc");
String s2 = new String("a1bc");
System.out.println(s1 == s2);//false

String s1 = "a1bc";
String s2 = "a" + 1 + "bc";
System.out.println(s1 == s2);//true

总结

  • String 类是不可变的,一旦对象被创建,值不可变。同时,String 类提供的所有方法,都没有改变 value 的值,而是返回一个新的对象。
  • 当需要for 循环或者多次修改来改变字符串值的时候,最好根据情况来优化代码使用 StringBuilder 或者 StringBuffer
  • 字符串内容判断,不要简单的使用 == ,要使用 "abc".equals(String) 的形式。
  • 常量池的存在,提高了效率,减少了开销。