以下代码或分析来源于 jdk8
简介
String 类由 final 标记,实现了 java.io.Serializable
,Comparable<String>
,CharSequence
(字符序列)三个接口。
String/StringBuilder/StringBuffer 本质上都是通过字符数组实现的。
String 是字符串__常量__,对 String 值的操作是产生新的 String 对象。
StringBuilder/StringBuffer 都是字符串__变量__,即可变的字符序列,都继承自 AbstractStringBuilder,实现 CharSequence 接口。对 StringBuffer/StringBuilder 类操作是对字符数组进行扩容。
StringBuffer 是线程安全的,StringBuilder 是非线程安全的。
源码浅析
String 类包含 char[] value
属性,StringBuffer/StringBuilder 的父类 AbstractStringBuilder
包含 char[] value
属性。同时注意此 value 在 String 中的声明为 private final char value[]
,对于字符串的处理都是在处理 value 属性。
String 类做如此多的限定是一种 Immutable 设计模式的典型应用,String 变量一旦初始化后就不能更改,禁止改变对象的状态,从而增加共享对象的坚固性、减少对象访问的错误,同时还避免了在多线程共享时进行同步的需要。
另外一点对与 String 类如此设计的猜测,设计到 java 对于 String 类型本身的设计,字符串都存储在常量池中用于共享从而提高性能的,但是如果 String 值是可变的,那么就可能引起冲突,所以需要设计成不可变的,以此来避免字符串共享所引起的数据冲突。
常用功能实现
hashcode 方法实现:
public int hashCode() {
int h = hash;//hash默认是0
if (h == 0 && value.length > 0) { //只算一次
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
indexOf 方法:
public int indexOf(int ch, int fromIndex) {
final int max = value.length;
if (fromIndex < 0) {
fromIndex = 0;
} else if (fromIndex >= max) {
// Note: fromIndex might be near -1>>>1.
return -1;
}
//Character.MIN_SUPPLEMENTARY_CODE_POINT = 65536 也就是两个字节所能存储的最大值
if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
// handle most cases here (ch is a BMP code point or a
// negative value (invalid code point))
final char[] value = this.value;
for (int i = fromIndex; i < max; i++) {
if (value[i] == ch) {
return i;
}
}
return -1;
} else {
return indexOfSupplementary(ch, fromIndex);
}
}
/**
* Handles (rare) calls of indexOf with a supplementary character.
*/
//java中特意对超过两个字节的字符进行了处理
private int indexOfSupplementary(int ch, int fromIndex) {
if (Character.isValidCodePoint(ch)) {
final char[] value = this.value;
final char hi = Character.highSurrogate(ch);
final char lo = Character.lowSurrogate(ch);
final int max = value.length - 1;
for (int i = fromIndex; i < max; i++) {
if (value[i] == hi && value[i + 1] == lo) {
return i;
}
}
}
return -1;
}
java 特意对多余两个字节的字符例如一些 emoji 做了特殊的处理。
在 lastIndexOf 方法中,有这样一段对于多重循环的处理值得注意以下:
static int lastIndexOf(char[] source, int sourceOffset, int sourceCount,
char[] target, int targetOffset, int targetCount,
int fromIndex) {
...
startSearchForLastChar:
while (true) {
while (i >= min && source[i] != strLastChar) {
i--;
}
if (i < min) {
return -1;
}
int j = i - 1;
int start = j - (targetCount - 1);
int k = strLastIndex - 1;
while (j > start) {
if (source[j--] != target[k--]) {
i--;
continue startSearchForLastChar;
}
}
return start - sourceOffset + 1;
}
使用了 continue label
的方式来处理多重循环下的跳出,这是 java 中存在但是很少被使用的方式,类似 c 语言中的 goto 语句,java 可以在循环体前设置跳转标志,然后配合 continue
/break
关键字更好的控制多重循环。
重点讲解以下 split 方法:
public String[] split(String regex, int limit) {
char ch = 0;
if (((regex.value.length == 1 &&
".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
(regex.length() == 2 &&
regex.charAt(0) == '\\' &&
(((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
((ch-'a')|('z'-ch)) < 0 &&
((ch-'A')|('Z'-ch)) < 0)) &&
(ch < Character.MIN_HIGH_SURROGATE ||
ch > Character.MAX_LOW_SURROGATE))
{
int off = 0;
int next = 0;
boolean limited = limit > 0;
ArrayList<String> list = new ArrayList<>();
while ((next = indexOf(ch, off)) != -1) {
if (!limited || list.size() < limit - 1) {
list.add(substring(off, next));
off = next + 1;
} else { // last one
//assert (list.size() == limit - 1);
list.add(substring(off, value.length));
off = value.length;
break;
}
}
// If no match was found, return this
if (off == 0)
return new String[]{this};
// Add remaining segment
if (!limited || list.size() < limit)
list.add(substring(off, value.length));
// Construct result
int resultSize = list.size();
if (limit == 0) {
while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
resultSize--;
}
}
String[] result = new String[resultSize];
return list.subList(0, resultSize).toArray(result);
}
return Pattern.compile(regex).split(this, limit);
}
其中的第一个 if 有一段非常复杂的判断,且体现了 jdk 源码中极致的优化。考虑这样的情况,如果传入的仅仅只有一个字符,并且这个字符不是任何特殊的正则表达式,明显我们不需要经过正则表达式编译一次。同样,如果是两个字符,并且这两个字符是以 \
开头,并且不是字母或者数字的时候,例如'|',为什么会有这种情况呢?
用实际代码试一试:
String s = "a|b|c";
String[] split = s.split("|");
for (String s1 : split) {
System.out.println(s1);
}
---------输出---------
a
|
b
|
c
很奇怪吧?这是出于第一个判断 ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1)
,判断的是是否是特殊的正则表达式,而 |
符号正好位列其中,|
在正则中表示__或__,而 "|"
就等同于 ""
。所以就相当于把字符串用空格分开。于是就有了这一个考虑,通过转移,让 '|'
字符生效,里面又包含了一个有意思的代码片段:(((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&((ch-'a')|('z'-ch)) < 0 && ((ch-'A')|('Z'-ch)) < 0))
。判断字符是否是数字或者字母,使用或运算减少代码量提高效率。之后的第三个判断是用于判断是否是两个字节的 unicode 字符。
还有一条很优雅的代码片段值得注意:
boolean limited = limit > 0;
ArrayList<String> list = new ArrayList<>();
while ((next = indexOf(ch, off)) != -1) {
if (!limited || list.size() < limit - 1) {
list.add(substring(off, next));
off = next + 1;
} else { // last one
//assert (list.size() == limit - 1);
list.add(substring(off, value.length));
off = value.length;
break;
}
}
...
// 将最后剩余的一段连接上
if (!limited || list.size() < limit)
list.add(substring(off, value.length));
// 构造结果集合
int resultSize = list.size();
if (limit == 0) {
while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
resultSize--;
}
}
String[] result = new String[resultSize];
return list.subList(0, resultSize).toArray(result);
将判断 limit 和分配剩余字符串两者巧妙的结合。
Immutable 设计模式
String 类有两个构造方法值得注意:
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
public String(char value[], int offset, int count) {
...
if (count <= 0) {
...
if (offset <= value.length) {
this.value = "".value;
return;
}
}
...
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
这两个构造方法都调用了 Arrays.copy
方法,经过查看源码,都是复制数组,也就是说传入的 char[]数组在外部的变化并不会影响 String 本身的值,体现不可变的原则,其他类似的传入数组的构造器都是类似的行为。也因此,String 所有能够返回值的方法也同样是采用类似的复制数组的方式,例如:
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
...
System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}
在源码中到处充斥着这样的代码,3000 多行代码(包含注释),都在严谨的遵循着这个原则。
String 的本质
String 类中所有修改值的方法都是返回新的对象,例如 String.substring 方法:
//截取字符串
public String substring(int beginIndex, int endIndex) {
...
int subLen = endIndex - beginIndex;
...
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}
处理 value 返回新的字符串对象,new String(value,beginIndex,subLen)构造函数会截取数组并复制给新 String 对象的 value。
但是我们是否能够通过反射来修改呢?做一个尝试:
String s1 = "abcd";
String s2 = "abcd";
String s3 = new String("abcd");
String s4 = new String(s1);
System.out.println("s1==s2:" + (s1 == s2));
System.out.println("s1==s3:" + (s1 == s3));
System.out.println("s3==s4:" + (s3 == s4));
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(s1);
value[1] = 'p';
System.out.println("s1 = " + s1);
System.out.println("s2 = " + s2);
System.out.println("s3 = " + s3);
System.out.println("s4 = " + s4);
System.out.println("s1==s2:" + (s1 == s2));
System.out.println("s1==s3:" + (s1 == s3));
System.out.println("s3==s4:" + (s3 == s4));
输出如下:
s1==s2:true
s1==s3:false
s3==s4:false
s1 = apcd
s2 = apcd
s3 = apcd
s4 = apcd
s1==s2:true
s1==s3:false
s3==s4:false
由此可以看出,反射能够修改 String 的值,但是另外出现了一个有趣的点,在修改了 s1 的值之后,s2/s3/s4 的值都经过了修改。一个一个的看,先看最简单的 s4。
通过 s1 来 new 一个新的 String 对象,翻看构造器源码:
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
很简单,是通过传递引用 value 来构造的新的 String 对象,也就是说 s1 和 s4 享有同一个 char 数组。所以无论我们修改 s1 还是 s4 的值,都是修改的这个 char 数组。所以 s4 随着 s1 变化,可以理解。
接下来看 s2/s3 为什么变化:
表面上看,s1 与 s2 的赋值虽然都是“相等”的字符串,但是无论声明还是使用看起来都确实是分开的。这里从 jdk 源码上我们无从得知具体原因。这牵扯出了我们对于 String 类型的最大疑问,字符串的本质到底是怎样的,jvm 是如何对待一特殊的类型的。探究出 s2 自然而然就能了解 s3 变化的始末。
其实从前面的源码查看中,我们已经能够看出来,字符串的本质就是__字符数组__。但是按照不同的方式来初始化出来的字符串却并不是被一视同仁的。
String 的定义方法归纳出来大致分为三种方式:
- 双引号直接赋值:
String str = "str"
; - 使用 new 关键字:
String str = new String("str")
; - 连接字符串:
String str = "s" + "tr"
;
在此,需要先了解一下 jvm 中的堆栈、常量池的概念。
常量池
常量池指的是在编译期就被确定,并且保存在已编译的.class 文件中的一些数据,包含代码中所定义的各种基本类型(如 int、long 等等)和对象型(如 String 及数组)的常量值(final)还包含一些以文本形式出现的符号引用。比如:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
堆
一个运行时的数据区域,类的对象就是在这里分配空间的。这些对象通过 new、newarray、 anewarray 和 multianewarray 等指令建立,它们不需要程序代码来显式的释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存 大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java 的垃圾收集器会自动收走这些不再使用的数据,也因此大小和声明周期并不确定。但缺点是,由于要在运行时动态 分配内存,存取速度较慢。堆中的对象不可以共享(注意,这里的共享不是指我们在 java 代码中的赋值给另外一个引用,而是指:Object a = new Object();Object b = new Object()
则 a == b
的情况)
栈
存取速度比堆要快,仅次于寄存器,栈数据可以共享(int a = 1;int b = 1
则 a == b
),存放基本类型的变量数据和对象的引用,但对象本身不存放在栈中,而是存放在堆(new 出来的对象)
ps:网上有说对象不一定存储在堆中,这句话是对的,但是举出来的反例是字符串对象存储在常量池中,这个反例是错误的,大胆举证一个最简单的例子,
String a = new String("a");String b = new String("a")
,如果按照这种说法,那么a == b
,因为常量池中的数据是共享的,然后明显不对,至于更深层次的原因下面讲到。正确的反例应当是《深入理解 java 虚拟机 》一书中所说到的 “随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了”。通熟点就是 java 底层优化越来越变态导致的。
解析 String 的定义
在编译期就被确定的(以双引号定义的)字符串就被存储在常量池中,如果运行期(new)才能确定的就存储在堆中。
这里了解以下 String.intern 方法。String 的 intern()方法会查找在常量池中是否存在一份 equal 相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。
运行时常量池相对于 CLass 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 CLass 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是 String 类的 intern()方法。
- 第一种定义方法执行过程(
String str = "str"
):在程序编译期间,编译程序先去字符串常量池检查,是否由"str"存在,如果不存在,则在常量池中开辟一个内存空间存放"str",如果存在,则不开辟。接着,会在堆中创建一个 String 对象,注意这里和网上有些说法不一样,下面我们通过代码证明这里的正确性,之后在栈中开辟一块空间,命名为 str,存放对象的__地址__。
String s = new String("str");
String s2 = s.intern();
System.out.println(s == s2);
-------输出---------------
false
明显看出 s2 并不是指向的 s,而是指向的"str"字符串所指向的对象。
-
第二种定义方法执行过程(String str = new String("str")):在程序编译期间,编译程序先在字符串常量池检查,是否存在"str",如果不存在,则在常量池中开辟一个内存空间存放"str"。如果不存在,则开辟一个内存空间,否则不开辟。然后在内存堆中开辟一个空间,存放 new 出来的 String 实例,之后在栈中开辟一个空间,命名为 str,存放堆中 String 实例的内存 地址 ,这个过程就是将引用 str 指向 new 出来的 Sring 实例。
-
第三种定义方法执行过程:对于第三个方法的过程会稍显复杂,在这个举例中 jvm 会进行小技巧优化。我们将定义的形式变化一下,绕过这个优化,然后根据字节码来判断,当我们进行字符串拼接初始化字符串时会发生什么。
原代码如下:
String s = "1"+"2"+"3";
System.out.println(s);
String s2 = "2";
for (int i = 0; i < 10; i++){
s2 += s2;
}
System.out.println(s2);
字节码输出如下:
Code:
0: ldc #2 // String 123
2: astore_1
3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
6: aload_1
7: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
10: ldc #5 // String 2
12: astore_2
13: iconst_0
14: istore_3
15: iload_3
16: bipush 10
18: if_icmpge 46
21: new #6 // class java/lang/StringBuilder
24: dup
25: invokespecial #7 // Method java/lang/StringBuilder."<init>":()V
28: aload_2
29: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
32: aload_2
33: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
36: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
39: astore_2
40: iinc 3, 1
43: goto 15
46: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
49: aload_2
50: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
53: return
简单描述一下关键:
-
0: ldc #2 // String 123
代表引用常量池里的值,这里代码经过编译器优化之后,"1"+"2"+"3"
被优化成了"123"
-
astore_2 代表将 ldc 的值“2”存到__局部变量表__(用于存放方法参数和方法内部定义的局部变量)中的第 2 个槽中
-
13-43 行是一个循环。也就是我们的 0-10 的循环,可以看到在这个循环中在不停的 new StringBuilder(21 行)。这种没有经过编译器优化的拼接是采用 StringBuidler 实现的。
-
最后会调用 StringBuilder 的 toString 方法,然后 jvm 会拿到字符序列,并按照 String 对象的处理方式进行处理。
简单对比我们主动采用 StringBuider 类拼接字符串。
源码:
StringBuilder s2 = new StringBuilder("2");
for (int i = 0; i < 10; i++){
s2.append("2");
}
System.out.println(s2);
字节码:
0: new #2 // class java/lang/StringBuilder
3: dup
4: ldc #3 // String 2
6: invokespecial #4 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: astore_1
10: iconst_0
11: istore_2
12: iload_2
13: bipush 10
15: if_icmpge 31
18: aload_1
19: ldc #3 // String 2
21: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: pop
25: iinc 2, 1
28: goto 12
明显看到在 0 处 new 了一次 StringBuilder 之后,就一直在重用此对象。显然提高了很多的效率。
回到定义方式继续探讨,三种定义方式都抛出了一个非常重要的问题,new 出来的 String 对象实例和在常量池中的字符串是什么关系?有什么区别?为什么直接定义的字符串同样可以调用 String 对象的各种方法呢?
以”String s1 = new String("somestring")“为例,在字符串常量池中存储"somestring"字符序列,在堆中开辟 String 对象空间,在栈中存储这个地址命名为 s。首先就需要弄清楚,怎么通过对象找到常量池中的字符串?
在解析阶段,虚拟机发现字符串常量 somestring,它会在一个内部字符串常量列表【各种虚拟机实现方式可能不一样,例如 hotspot 使用 hashtable 实现】中查找,如果没有找到,那么会在堆中创建一个包含字符序列[somestring]的 string 对象_s_,然后把这个字符序列和对应的 String 对象做为名值对([somestring],s)保存到内部字符串常量列表中。
弄懂了这些原理,我们就能够清楚的知道前面 s1/s2/s3/s4 发生变化的原因了,很简单,因为都是操作的同样的一个字符序列,这也是 java 废了很大力气去优化的一点,让所有的想同字符串字面值只存储一次,以实现性能、空间利用的最大提升。
完成以上问题之后,就可以完美解决面试经常遇到的以下问题:
- 得出以下输出
String s1 = "str";
String s2 = "str";
String s3 = new String("str");
String s4 = new String(s1);
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s3 == s4);
System.out.println(s1 == s4);
- 每一行代码((每行代码分开运行,不考虑前置因素)),各自产生了多少个对象,字符串常量池里面有哪几个值。
String s2 = "str";
String s3 = new String("str");
String s5 = new String("str")+"str";
StringBuilder/StringBuffer 源码解析
弄清楚了 String,那么 StringBuilder/StringBuffer 其实已经相当清楚了,本质上讲 StringBuilder/StringBuffer 是对字符数组进行扩容的对象,都继承字 AbstractStringBuilder。查看 AbstractStringBuilder 的源码,发现与 String 一样,包含 char[] value 和 int count。但是与 String 不同的是,它们没有 final 修饰符。因此得出结论:String、StringBuffer 和 StringBuilder 在本质上都是字符数组,不同的是,在进行连接操作时,String 每次返回一个新的 String 实例,而 StringBuffer 和 StringBuilder 的 append 方法直接返回 this,所以这就是为什么在进行大量字符串连接运算时,不推荐使用 String,而推荐 StringBuffer 和 StringBuilder。
StringBuilder 和 StringBuffer 的源码
查看两个类的 append 和 toString 方法:
//StringBuilder
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
//StringBuffer
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
@Override
public synchronized String toString() {
if (toStringCache == null) {
toStringCache = Arrays.copyOfRange(value, 0, count);
}
return new String(toStringCache, true);
}
可以看出第一个区别,StringBuffer 采用 synchronized 修饰,是线程安全的,而 StringBuilder 不是。
另外一点,在 StringBuffer 中有这样一个属性 private transient char[] toStringCache;
。它缓存了字符数组,StringBuffer 下所有的修改方法都会清空掉这个缓存,只有在执行 toString 时才会赋值,而在 StringBuilder 中完全没有这种机制。这是为什么呢?首先要指明的是,其实这个缓存意义不算很大,因为两次 toString 而没有改变的情况是真的少见。但是从设计上来说,这种缓存对于真正遇到这种情况的性能是有提升的,而 StringBuffer 因为线程安全,所以可以设置这样一个缓存,但是 StringBuilder 类并不是线程安全的,如果这样设计会导致可能产生不一致的情况,对照我们平时写的代码来看,缓存应该在同步安全的条件下才被设置以用来提升性能,否则这将导致产生 toString 结果与预期不一致。
所以在使用选择上来说,如果在多线程环境可以使用 StringBuffer 进行字符串连接操作,单线程环境使用 StringBuilder,它的效率更高。
答案
true,false,false,false
对象:1,2,3。常量池:"str","str",["str","strstr"]
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于