作为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[]; private int hash; }
|
由类的构成可以看出:
- 类由
final
修饰,不可被继承。不可被继承,则无子类,相关方法不可被重写,不会改变原有行为。生成的String
对象不可变,多线程下可保证安全。同时,final
类可被编译器优化,提高了效率。 - 类的私有成员变量
value
被 final
修饰,一旦对象被创建,则值不可再修改。 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()); }
|
对于参数为StringBuilder
和 StringBuffer
的类型,通常不这么用,一般使用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
| public String(byte bytes[], int offset, int length) { checkBounds(bytes, offset, length); this.value = StringCoding.decode(bytes, offset, length); }
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; } }
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; 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;
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);
|
运行结果如下
很明显,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
| public void print(String s) { if (s == null) { s = "null"; } write(s); }
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; }
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);
String s1 = new String("a1bc"); String s2 = "a2bc"; System.out.println(s1 == s2);
String s1 = new String("a1bc"); String s2 = new String("a1bc"); System.out.println(s1 == s2);
String s1 = "a1bc"; String s2 = "a" + 1 + "bc"; System.out.println(s1 == s2);
|
总结
String
类是不可变的,一旦对象被创建,值不可变。同时,String
类提供的所有方法,都没有改变 value
的值,而是返回一个新的对象。- 当需要
for
循环或者多次修改来改变字符串值的时候,最好根据情况来优化代码使用 StringBuilder
或者 StringBuffer
。 - 字符串内容判断,不要简单的使用
==
,要使用 "abc".equals(String)
的形式。 - 常量池的存在,提高了效率,减少了开销。