java.lang.String 源码解读

缘起

写了若干篇源码分析文章才发觉漏了一个java中最常用的对象——java.lang.String(下简称String). java程序员100%用过它的. 怎么能不对其进行玩味呢? 嘻嘻~

分析

本文基于的是JDK1.8

String是JDK1.0伊始就存在的元老了.

首先看看String最重要、被设计者保护的最好的一个属性——value

1
2
3
4
5
6
7
8
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];

/** Cache the hash code for the string */
private int hash; // Default to 0
...

​ 源码1

因为value被修饰成final的了,所以肯定在构造器或者代码块中初始化才行. 例如String的所有构造器中都对value进行了初始化操作.

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
public String() {
this.value = "".value;
}
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
...

​ 源码2

注意,上面传入char[]的构造器无一例外的都使用了Arrays.copyOfRange, 这个api底层调用的是System.arraycopy. 这个api简单讲就是对于基本类型的数组就是深复制,对于引用类型数组进行的是浅拷贝.(参见【2】)

因为传入的是char[]数组, 所以是基本类型的,所以实现的是深复制.

然后看看String的hashCode方法和equals方法

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
    public boolean equals(Object anObject) {
if (this == anObject) { //如果引用指向的内存值都相等 直接返回true
return true;
}
//instanceof判断是否属于或子类 但Stringfinal修饰不可继承 只考虑是否为String类型
//上面说过String的成员为char数组,equals内则比较char类数组元素是否一一相等
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
//长度不相等返回false
if (n == anotherString.value.length) { // 归为value属性(字符数组)的逐字符比较
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
//从后往前单个字符判断,如果有不相等,返回false
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
// 同样的字符串的值一定相等 但不同的字符串其实也有可能得到同样的hashcode值
public int hashCode() {
int h = hash;
//如果hash没有被计算过,并且字符串不为空,则进行hashCode计算
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) { //s[0,...,n-1]的计算过程是s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
h = 31 * h + val[i];
}
// 缓存哈希值
hash = h;
}
return h;
}

​ 源码3

注意,为什么hashCode中选择的因子是31? 这个参见【1】,简而言之就是

1
2
1. 31是一个不大不小的奇质数(如果选择一个偶数会在乘法运算中产生溢出,导致数值信息丢失),是作为 hashCode 乘子的优选质数之一。另外一些相近的质数,比如37、41、43等等,也都是不错的选择。那么为啥偏偏选中了31呢?因为31可以被 JVM 优化,31 * i = (i << 5) - i
2. 如果你对超过 50,000 个英文单词(由两个不同版本的 Unix 字典合并而成)进行 hash code 运算,并使用常数 31, 33, 37, 39 和 41 作为乘子,每个常数算出的哈希值冲突数都小于7个,所以在上面几个常数中,常数 31 被 Java 实现所选用也就不足为奇了。

其次,所有的String类中方法并不是改变String对象自己,而是重新创建一个新的String对象. 例如

1
2
3
4
5
6
7
8
9
10
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);
}

​ 源码4

我们关注源码4的第9行,value是一个字符数组,而我们之前已经说过了,一旦传入的是字符数组的话,则必定是底层调用System.arraycopy 深复制一份字符数组供新创建的String对象使用的. 也就是我就是不想和原先的String对象共用一个字符数组. 其实,整个String的源码看下来,作者处理涉及value属性的都很小心——都不会让这个属性发布出去. 这其实才是String实现不变性的关键,而不是修饰 value的final,因为这个final只是表示value初始化之后不能指向其他的字符数组,但是架不住我们直接修改value啊(马上就会展示使用反射暴力修改value的代码)~ 但是因为String的设计者做到了以下2点

  1. String 设计为final的,所以不能继承——更别说覆写其方法
  2. value被设计为private final的, 并且String的任何方法都没有出现——发布value的情况(甚至String.getChars方法(这个方法对于java码农最常用的System.out.println(String)要用)都没有发布这个value属性——依旧是使用System.arraycopy方法). 所以这个value压根外部代码无法修改它(后面说的反射暴力修改这种非正常使用String不算)!!! 这其实是String不变性的核心, 而并不是上面说的2个final. 当然,value的private属性的作用其实也相当于显式地不发布这个value.

关于直接修改value还是右手段的——所以不论你是private还是final还是不发布. 都是防君子不防小人——用反射h还是可以暴力修改value的(但是这与String的不可变性不矛盾, 因为这种修改手段并不在正常使用者范围,String的不可变性是为了保证正常使用者不会改变String的值)

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void testReflection() throws Exception {
String s = "Hello,World";
System.out.println("s = " + s); //Hello World
//获取String类中的value字段
Field valueOfString = String.class.getDeclaredField("value");
//改变value属性的访问权限
valueOfString.setAccessible(true);
//获取s对象上的value属性的值
char[] value = (char[]) valueOfString.get(s);
//改变value所引用的数组中的第5个字符
value[5] = '_';
System.out.println("s = " + s); //Hello_World(使用反射暴力修改)
}

这里讲一个小插曲. 既然源码4讲到了subString方法,那么就不得不提及JDK6中关于subString方法的内存泄漏问题.

在JDK6中的subString的写法是

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
    /** The value is used for character storage. */
private final char value[];

/** The offset is the first index of the storage that is used. */
private final int offset;

/** The count is the number of characters in the String. */
private final int count;
...
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > count) {
throw new StringIndexOutOfBoundsException(endIndex);
}
if (beginIndex > endIndex) {
throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
}
//虽然这边返回的是新的String对象,但构造方法中还引用着原先的value
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
}
...
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
//这边开始this.value = value; 出现问题,这三个个原来为String类中的三个私有成员变量,因为这种实现还在引用原先的字符串变量value[] 通过offset(起始位置)和count(字符所占个数)返回一个新的字符串,这样可能导致jvm认为最初被截取的字符串还被引用就不对其gc,如果这个原始字符串很大,就会占用着内存,出现内存泄漏等gc问题。

JDK7以后的写法就是

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
//虽然这边还是有offse和count参数 但不是成员变量了
private final char value[];

/** Cache the hash code for the string */
private int hash; // Default to 0
...
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);//构造函数参数顺序也有所变化
}
...
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
//新的不是引用之前的而是重新新建了一个。
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

JDK7明显就克服了内存泄漏的问题.

当时学javase的时候,我们就被谆谆教导

String是不可变的哈~

什么意思? 就是比如

1
2
3
4
String a = "Hello";
String b = a.replace('e', 'f');
System.out.println(a); // 依旧是Hello
System.out.println(b); // Hfllo

​ 示例①

即replace方法并没有改变原先的String, 原先的String其实还是Hello. 新搞出来的”Hfllo” 其实是一个新的字符串(并没有在常量池中存放”Hfllo”, 这是后话, 后面会细说).

其实就上述代码而言,并不涉及到常量池的问题. 因为你看看replace方法的源码嘛~

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 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]; // buf是新拷贝出来的,并不是和原本的String对象共用的
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;
}

​ 源码5

跟进第22行

1
2
3
String(char[] value, boolean share) {
this.value = value;
}

​ 源码6

所以replace方法并没有使用原先的String(即”Hello”)的value属性. 而是自己在源码5的第13行新造了一个buf. 然后如果你去跟Syetem.out.println(String)的源码会来到

java.io.PrintStream.println(String)

1
2
3
4
5
6
public void println(String x) {
synchronized (this) { // PrintStream是竞争资源,所以需要加锁
print(x);
newLine();
}
}

​ 源码7

然后一路狂跟源码来到

java.io.Writer.write(String, int, int)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void write(String str, int off, int len) throws IOException {
synchronized (lock) {
char cbuf[];
if (len <= WRITE_BUFFER_SIZE) { // 最少是缓冲区的长度(1024)
if (writeBuffer == null) {
writeBuffer = new char[WRITE_BUFFER_SIZE];
}
cbuf = writeBuffer;
} else { // Don't permanently allocate very large buffers.
cbuf = new char[len];
}
str.getChars(off, (off + len), cbuf, 0);
write(cbuf, 0, len);
}
}

​ 源码8

注意,最后打印的是cbuf, 而cbuf是通过String的getChars方法灌注入字符的.

1
2
3
4
5
6
7
8
9
10
11
12
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
if (srcBegin < 0) {
throw new StringIndexOutOfBoundsException(srcBegin);
}
if (srcEnd > value.length) {
throw new StringIndexOutOfBoundsException(srcEnd);
}
if (srcBegin > srcEnd) {
throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
}
System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}

​ 源码9

看到了熟悉的代码没? System.arraycopy——根据【2】的结论,对于数组元素是基本类型的数组(例如这里是字符数组),就是深拷贝. 所以String 就像是一个吝啬鬼一样——绝对不会发布它的value属性的. 关于”发布”这一名词是从《java并发编程实践》中学到的(【4】). 至于源码8的len是通过String的length方法得到的

1
2
3
public int length() {
return value.length;
}

​ 源码10

依旧没有发布value属性!!!

回顾”示例①”代码,虽然简单,但细想一下,不就是任何正常使用String对象的过程吗? 你要使用String,无非要得到2个信息——字符数组长啥样? 它有多长? 也就是源码9和源码10,但是这2段源码我们都看过了——都没有发布value属性. 所以任何一段外部代码来调用一个String,因为无法修改它的value属性,所以在外部代码看来——String就是不可变的!!! 例如”示例①”中的replace调用, 其实也是没有发布value,而只是重新建了一个String对象(分配一块堆内存,然后栈内存中的本地引用变量指向它,String的内存结构我们后面会细说,剧透一下,对于这一点,JDK6和JDK7是分水岭).所以示例①中的b其实指向是新的堆内存,因此打印a并没有变化.

最后再次强调, 实现String不变性的并不是修饰String的final以及修饰value属性的final,而是在String的所有方法中都没有发布value属性这一点(包括value属性自身的private修饰也是为了这一点). 至于作者将String的修饰符搞成final的,恐怕是为了防止程序员继承此类,覆写方法,然后写出一些不安全的String的子类吧~

所以网上很多文章的标题取为 “Java中String为什么被设计成final的?” , 题目本身就只是在论述一个非主流问题. 因为final与String的两个主流问题 1. String为什么可以对外保持不变性? 2. String的内存结构是什么样子的? 关系甚少. 至少不是直接关系. 所以网上这种人云亦云的文章很多,大多抄来抄去,缺乏独立思考能力.

最后,我们问:为什么要实现String的不可变性呢?

  1. 首当其冲的理由就是两个字——“安全”. 因为String被许多的Java类(库)用来当做参数,例如 网络连接地址URL,文件路径path,还有反射机制所需要的String参数等, 假若String不是固定不变的,将会引起各种安全隐患。对于一个方法而言,参数是为该方法提供信息的,而不是让方法改变自己。

  2. 注意到String的hash属性了么? 一旦计算完毕hashCode方法之后,就可以放心的使用其hash属性缓存其hashCode方法的结果值. 因为hashCode方法是基于value属性计算的,而我们的value经过本文分析是不会变的,所以只需要计算一次就可以天长地久的缓存起来而不必担心其发生改变. 那有人会问了——这又有什么好处呢? 注意我们常用的HashMap的get方法(【5】)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    看到了没有? 第8行源码,要计算key的hashCode方法, 而对于String做key的话,则因为使用其hash属性缓存了,所以结果是秒出的. 而HashMap的get方法使用频率不用我多说,那是相当的高啊~ 这就是为什么不推荐大家使用可变的对象做HashMap的key而推荐使用String做key的原因了(因为可变的对象每次get的时候保险起见都要重新计算一遍它的hashCode). 所以大家平时写代码最多的使用的是HashMap<String,Object>.

  3. 如果String可变的话,则使用String做key和使用可变的对象做key是一样的,就算我们可以不计较2中说的每次重新计算hashCode带来的性能损耗,但是有一个致命到无法接受的问题就是——key可以改变!!!意味着可能HashMap(HashSet也是如此)中,我们不小心的一波操作,HashMap中存在两个key的hash值一样! 这不仅从业务代码角度无法接受,单从HashMap的运行算法角度也是无法接受的! 因为被改变哈希值的那个key正处于错误的位置上(它在的应该处于的位置是根据hash&(数组长度-1)得到的,但是显然和它现在处于的位置不符). 这对于HashMap的运行时极度不利的——真不知道会变成什么乱七八糟的样子!!!

  4. 不变性利于无锁并发,使得并发程度可以达到很高. 这一点在【6】中已经总结过了(【6】中搜索”实现线程安全的手段有”即可)

  5. 防止了内存泄漏.

注意,网上很多文章也说String的不变性的一大作用是为了实现”字符串常量池”, 其实我想说这是两个问题, 虽然String的不变性利于维护字符串常量池. 关于字符串常量池和String的内存结构我放在下一篇文章(【7】)细说.

参考

【1】https://segmentfault.com/a/1190000010799123

【2】https://yfsyfs.github.io/2019/07/02/Arrays-copyOf-%E5%92%8C-System-arraycopy-%E6%BA%90%E7%A0%81%E8%A7%A3%E8%AF%BB/

【3】https://www.cnblogs.com/Kidezyq/p/8040338.html

【4】https://yfsyfs.github.io/2019/06/27/java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B%E5%AE%9E%E8%B7%B5-3-2-%E5%8F%91%E5%B8%83%E5%92%8C%E9%80%B8%E5%87%BA/

【5】https://yfsyfs.github.io/2019/06/07/java-util-HashMap-%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/

【6】https://yfsyfs.github.io/2019/06/27/ThreadLocal-%E6%BA%90%E7%A0%81%E8%A7%A3%E8%AF%BB/

【7】https://yfsyfs.github.io/2019/07/03/JDK7-%E4%B8%AD%E7%9A%84String%E7%9A%84%E5%86%85%E5%AD%98%E7%BB%93%E6%9E%84/