死磕 Java 系列(一)—— 常用类(1) String 源码解析

时间:2019-10-18
本文章向大家介绍死磕 Java 系列(一)—— 常用类(1) String 源码解析,主要包括死磕 Java 系列(一)—— 常用类(1) String 源码解析使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

写在前面

这是博主新开的一个 java 学习系列,听名字就可以看出来,在这一些系列中,我们学习的知识点不再是蜻蜓点水,而是深入底层,深入源码。由此,学习过程中我们要带着一股钻劲儿,对我们不懂的知识充满质疑,力求把我们学过的知识点都搞清楚,想明白。


一、引言

在 java 的世界里,存在一种特殊的类,它们的创建方式极为特别,不需要用到 new XXX(当然也可以用这种方式创建), 但是却大量出现在我们的代码中,那就是 String 类。作为日常中使用频率最高的类,它是那么普通,普通到我们从来都不会去思考其底层是如何工作。

今天就让我们从 String 类开始说起,慢慢揭开它神秘的面纱。


二、String 概述

  1. String 是一个不可变类,对象一旦创建无法修改,对 String 对象的大多数操作,都是通过新建一个字符串对象,然后返回;
  2. String 重写了 hashCode( ) 和 equals( ),调用 equals( ) 时,比较的是对象的值;
  3. 在频繁修改字符串对象的情境下,建议使用 StringBuffer 或 StringBuilder;

三、String 源码

3.1 继承树

下面是 String 的继承树。

可以看到 String 类的继承树极为简单,仅仅实现了 Serializable,Comparable,CharSequence 三个接口,并且 String 类的修饰符为 final,这也就意味着字符串对象一旦被创建就无法改变。

分析:

  • 关于 String 类实现的几个接口?

    Comparable 接口只有一个 compareTo( ) 方法,用于比较两个实例化对象的大小。

    CharSequence 是字符序列接口,它定义了 length( ),charAt(int index),subSequence(int start, int end) 这些方法。实现该接口是因为 String,StringBuilder 和 StringBuffer 本质上都是通过字符数组实现的。

    Serializable 序列化接口,代表字符串对象可以序列化。关于 Java 序列化的更多内容,参见博客:

    深入理解JAVA序列化

  • 为什么 String 类为不可变类?

    ① 字符串常量池的需要

    在堆内存中有一片特殊的存储区域——字符串常量池,当我们创建一个 String 对象时,会检查和此对象是否存在于常量池中。若存在,则直接返回该对象的引用;反之则新建一个字符串对象,并把该对象放入到常量池中。常量池机制通过让多个实例共享一个对象,为我们节约了大量的内存空间。此时若对象为可变的,改变该对象后,其它指向这个对象的实例也会受到影响。显然,这将造成难以预料的后果。

    ② 缓存哈希值的需要

    每一个 String 对象的实例在内部都缓存了自己的哈希值,由于 String 是不可变类,那么一个 String 对象的哈希值一旦确定就不会变更。这个特性使得字符串很适合作为Map中的键,不仅仅在于无需重新计算键的哈希值,更在于不必担心存储节点的键的哈希值发生改变,导致再也无法找到该节点。

    ③ 安全性的需要

    在网络编程中,主机名和端口等内容都是以字符串的形式传入。因为字符串是可变的,黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。

    类加载器要用到了字符串,而字符串的不可变性保证了安全性,使得正确的类被加载。比如你想加载java.sql.Connection 类,而这个值被改成了 myhacked.Connection,那么会对你的数据库造成不可知的破坏。

    ④ 线程安全的需要

    不可变对象本身就是线程安全的,因为其不可变的特性,避免了多线程下其他线程对其修改,造成数据不同步的问题。


3.2 源码解析

3.2.1 字段

1
2
3
4
5
6
7
8
9
10
11

// String 类中的字段

// 1.String 类底层用于字符存储的数组,一旦创建无法修改(final)
private final char value[];

// 2.缓存 String 对象的哈希值
private int hash; // Default to 0

// 3.序列版本号
private static final long serialVersionUID = -6849794470754667710L;

分析:

  • 关于 final char value[ ]?

    String对象中的每一个字符在底层实际上存储在 value[ ] 这个数组中的,并且它是一个final修饰的属性,所以 String 对象一旦创建即不可被修改。因此所有对字符串对象的修改(如追加字符串,删除部分字符串,截取字符串)都不是在原来的对象基础上进行,而是新建一个 String 对象修改并返回,这会造成原对象的废弃,浪费资源且性能较差(特别是追加字符串和删除部分字符串)。

    若遇到字符串将被频繁修改的情况,建议不要使用 String,改用 StringBuffer 或 StringBuilder,更多介绍参见下面的分析。


3.2.2 构造方法

String 类中的构造方法有很多,下面挑选一些常用的进行解析。

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

// String 类的构造方法(部分)

// 1.空参构造方法,实际上返回的是一个为 null 的数组
public () {
this.value = "".value;
}

// 2.创建指定的字符串对象,新创建的字符串是参数字符串的副本
public (String original) {
this.value = original.value;
this.hash = original.hash;
}

// 3.根据字符数组创建字符串对象
public (char value[]) {
// 将传入的数组复制一份然后返回
this.value = Arrays.copyOf(value, value.length);
}

// 4.从字符数组的第几位开始,再取几个元素创建字符串对象
public (char value[], int offset, int count) {
if (offset < 0) {
// 越界检查
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
// 当偏移值小于字符数组的长度,且 count == 0 时创建空字符串
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// offset + count > value.length,很明显越界了
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

// 5.这里是把 StringBuffer 和 StringBuilder 传进来,然后返回对应的字符串
public (StringBuffer buffer) {
synchronized(buffer) {
this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
}
}

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

分析:

  • 关于 StringBuffer 和 StringBuilder?

    我们在前面说过,对字符串对象的裁剪或者添加字符的操作都不是在原字符串的基础上进行的,然后通过新建所需的字符串对象然后返回,这必然造成原对象的浪费与不必要的操作开销。

    于是 java 给我们提供了 StringBuffer 和 StringBuilder。虽然它们的基本使用与 String 大致相同,但是底层的实现却有很大差异。二者均为可变的字符序列,即可以在原字符串对象的基础上进行修改。二者的区别在于 StringBuffer 是线程安全的,StringBuilder 是线程不安全的。当我们需要对字符串进行频繁修改时,效率:StringBuilder > StringBuffer > String。

    因此,建议在频繁修改字符串对象的条件下使用 StringBuilder or StringBuffer 取代 String。


3.2.3 公共方法

下面的是String 类中的公共方法,节选部分常用的作解析。

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259

// String 类公共方法(部分),底层的实际操作对象为 value

// 1.返回字符串的长度
public int length() {
return value.length;
}

// 2.判空方法
public boolean isEmpty() {
return value.length == 0;
}

// 3.按照指定索引返回字符
public char charAt(int index) {
// 越界检查
if ((index < 0) || (index >= value.length)) {
throw new StringIndexOutOfBoundsException(index);
}
return value[index];
}

// 4.返回指定索引上字符的 Unicode 编码
public int codePointAt(int index) {
if ((index < 0) || (index >= value.length)) {
throw new StringIndexOutOfBoundsException(index);
}
return Character.codePointAtImpl(value, index, value.length);
}

// 5.下面是 String 类中很重要的一个方法,比较指定对象与字符串对象的值
public boolean equals(Object anObject) {
if (this == anObject) {
// 两个比较对象的地址相同,值肯定相同,直接返回 true
return true;
}
// 传进来的对象为 String 类时,才进行比较
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
// 字符串长度相等才继续比较
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
// 下面是对数组中储存的每个元素进行比较
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}

// 6.同上,不同之处在于传进来的对象就是字符串类型的
public boolean equalsIgnoreCase(String anotherString) {
return (this == anotherString) ? true
: (anotherString != null)
&& (anotherString.value.length == value.length)
&& regionMatches(true, 0, anotherString, 0, value.length);
}

// 6.对两个两个字符串对象中每个字符的 Unicode 值进行比较
// 返回负数说明参数字符串的大;正数则相反;返回 0 说明两个字符串相等
public int compareTo(String anotherString) {
int len1 = value.length;
int len2 = anotherString.value.length;
int lim = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value;

int k = 0;
while (k < lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}

// 7.判断一个字符串从 toffset 位置开始是否以 prefix 字符串开头
大专栏  死磕 Java 系列(一)—— 常用类(1) String 源码解析>public boolean startsWith(String prefix, int toffset) {
char ta[] = value;
int to = toffset;
char pa[] = prefix.value;
int po = 0;
int pc = prefix.value.length;
// 下面这两种情况都没有比较的意义
if ((toffset < 0) || (toffset > value.length - pc)) {
return false;
}
while (--pc >= 0) {
// 从字符串的 toffset 位置开始,将每个字符与 prefix 的每个字符作比较
if (ta[to++] != pa[po++]) {
return false;
}
}
return true;
}

// 8.重载方法,即默认从字符串的头位置开始
public boolean startsWith(String prefix) {
return startsWith(prefix, 0);
}

// 9.判断是否以 suffix 结尾
public boolean endsWith(String suffix) {
// 这里很巧妙,还是利用了之前的方法,从前往后比较
return startsWith(suffix, value.length - suffix.value.length);
}

// 10.这也是一个非常重要的方法,返回字符串对象的哈希值,更多介绍参见分析
public int hashCode() {
int h = hash; // 默认为 0
if (h == 0 && value.length > 0) {
// 这里保证了每个字符串对象的哈希值只会计算一次
char val[] = value;

for (int i = 0; i < value.length; i++) {
// 此处的 31 很奇怪,留个悬念
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

// 11.从指定位置开始,对字符串进行截取到末尾,然后返回
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
// 获取截取长
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 当 beginIndex != 0 时,返回的实际上一个新的字符串对象(不是操作原对象)
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

// 12.同上,范围截取
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);
}

// 13.这个方法是在 CharSequence 中定义的,作用和上面的方法一样不同之处在于返回值不同
// 因为 String 实现了 CharSequence 接口,CharSequence 可以直接下转为 String 对象
public CharSequence subSequence(int beginIndex, int endIndex) {
return this.substring(beginIndex, endIndex);
}

// 14.将指定的字符串与本字符串对象进行连接
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);
}

// 15.将原字符串中的某个字符替区拉布换成新的字符
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;
}

// 16.去掉字符串头尾的空格
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--;
}
// (st > 0) || (len < value.length) 说明头或尾存在空格,然后对字符串进行截取
return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}

// 18.返回字符串对象的字符数组
public char[] toCharArray() {
// Cannot use Arrays.copyOf because of class initialization order issues
char result[] = new char[value.length];
// 这里也是复制一份新数组返回
System.arraycopy(value, 0, result, 0, value.length);
return result;
}

// 19.下面都是 valueOf() 的重载方法,并且是静态的(只贴了部分)
// 用于将传入的对象或者基本类型转换成字符串
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}

public static String valueOf(char data[]) {
return new String(data);
}

public static String valueOf(char data[], int offset, int count) {
return new String(data, offset, count);
}

// 20.返回字符串对象在常量池中的引用,如果常量池中不存在该字符串
// 则将该字符串的引用放入到常量池中
public native String intern();

分析:

  • 关于 == 和 equals( )?

    ① == 对于基本类型来说,是值比较;对于引用类型来说,是地址比较;

    ② equals( ) 是超类 Object 中规定的一个非静态的方法,只能由对象调用(基本类型无法使用),其默认实现如下:

    1
    2
    3
    public boolean equals(Object obj) {
    return (this == obj);
    }

    可以看出,equals( ) 默认是对两个对象的地址进行比较。但是对于重写了 equals( ) 方法的子类来说,比较的内容不再是对象的地址,比如 String 类中比较的就是字符串对象的值。

  • 关于 hashCode( ) 和 equals( )?

    在 java 的世界里,为了在某些情况下对对象加以区分(比如 HashMap),在超类 Object 里面定义了 hashCode( ) 方法,默认实现如下:

    1
    public native int hashCode();

    这是一个本地方法,根据对象的内存地址计算出来的整型值。理论上来说,地址不同的两个对象,它们的哈希值肯定是不同的,也就达到了区分对象的目的。既然如此,为什么 String 类还要重写 hashCode( ) 方法呢?

    我们先要了解下面一个规则:通过 equals( ) 方法判断相等的两个对象必须具有相同的哈希值!

    先看下面一段代码:

    1
    2
    3
    4
    5
    // 新建两个String对象,它们被分配到堆中,地址不同
    String str1 = new String("a");
    String str2 = new String("a");
    System.out.println(str1.equals(str1)); // true
    System.out.println(str1.hashCode() == str2.hashCode()); // true

    str1 和 str2 是两个不同对象的实例(通过 new 创建的字符串分配在堆中,而不是常量池中),它们所指向的内存地址肯定不同。如果 String 类没有重写 hashCode( ) 方法,那么代码中第 4 行的结果肯定为 false。但是由于 String 类重写了 equals( ) 方法,导致第 3 行代码的结果为 true(比较的是对象的值)。这显然不符合我们之前的规则。所以 String 类必须重写 hashCode( ) 方法。

    实际情况下,类中一旦重写了 equals( ) 方法,就必须重写 hashCode( ) 方法!

  • 关于 hashCode( ) 方法中的参数 31?

    为了分析的方便,再次贴出 hashCode( ) 的源码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public int hashCode() {
    int h = hash;
    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;
    }

    不难总结出如下规律,当 n = 3 时(n 为字符串的长度):

    1
    2
    3
    4
    5
    i = 0 -> h = 31 * 0 + val[0]
    i = 1 -> h = 31 * (31 * 0 + val[0]) + val[1]
    i = 2 -> h = 31 * (31 * (31 * 0 + val[0]) + val[1]) + val[2]
    h = 31 * 31 * 31 * 0 + 31 * 31 * val[0] + 31 * val[1] + val[2]
    h = 31 ^ (n - 1) * val[0] + 31 ^ (n - 2) * val[1] + val[2]

    上面的公式不是重点,只是为了便于下面的分析。先直接说出为什么选择 31 作为乘子的原因。

    ① 31 不大不小,是 hashCode 乘子的优选质数之一;

    ② 31 可以被 JVM 优化,31 * i = (i << 5) - i

    第二点很容易想到,因为在计算机中,位运算比常数运算快,那么第一点是什么意思呢?为什么 31 不大不小,正合适?我们不妨取 质数 2 和 101(一个较小,一个较大),n = 6 分别带入到上面的公式中,并且仅仅取结果中次数最高的那一项。结果分别为 2^5 = 32,101^5 = 10,510,100,501。这说明什么呢?

    其实,计算结果在很大程度代表了散列空间的大小。结果越小,散列空间越小,元素分布越紧密,冲突的概率就越大;空间太大则会超过 int 的表示范围(101^5 就超了),导致哈希值信息丢失。那么,让我们再来看看 31 的表现如如何:31^5 = 28629151。相较于上面的结果来说,这个算很好了。

    其实,像 31 这样的优选质数还很多,比如 37、41、43 等,为什么选择 31,应该也是经过很多测试得到的结果。这里面涉及到很多数学方面的知识,就不介绍了。

  • 笔试常考题型之判断两个字符串实例是否相等?

    下面这部分内容是笔试中经常考到的,很多人都不清楚,博主也是,下面就来详细说说。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    String s1 = new String("abc");
    String s2 = new String("abc");
    String s3 = "abc";
    String s4 = "abc";
    System.out.println(s1 == s2); // false
    System.out.println(s1 == s3); // false
    System.out.println(s4 == s3); // true
    String s5 = "ab";
    final String s6 = "ab";
    String s7 = "ab" + "c";
    String s8 = s5 + "c";
    String s9 = s6 + "c";
    System.out.println(s7 == s3); // true
    System.out.println(s8 == s3); // false
    System.out.println(s9 == s3); // true

    首先需要说明的是,当我们通过字面量赋值创建字符串对象时(String s3 = “abc”),会现在常量池(jdk 1.7 位于堆中)中查找是否存在相同的字符串,若存在,则将池中的引用直接返回;反之,则在池生成一个新的字符串对象,然后将引用返回,即通过字面量赋值创建的字符串对象都处于常量池中。但是通过 new String(“abc”) 这种方式创建字符串对象时,JVM 首先在池中查找有没有 “abc” 这个字符串对象,如果有,则在堆中再创建一个 “abc” 字符串对象(里面存放着对常量池中 “abc” 的地址引用),然后将对象的地址返回;如果没有,则会在常量池中创建一个 “abc” 对象,然后在堆中创建一个字符串对象(里面存放着对常量池中 “abc” 的地址引用),最后将堆中对象的地址返回 。这也就解释了第 5,6,7 代码的运行结果。

    常量字符串的 “+” 操作,在编译阶段直接会合成为一个字符串。String s7 = "ab" + "c",在编译阶段会直接合并成语句 String s7 = "abc",于是会去常量池中查找是否存在”abc”,从而进行创建或返回引用。当常量字符串和变进行量拼接时(如 String s8 = s5 + “c”),会调用 stringBuilder.append( ) 在堆上创建新的对象。对应13、14 行代码的运行结果。

    对于 final 字段,在编译期直接进行了常量替换String s9 = s6 + "c" 实际上相当于 String s9 = "ab" + "c",所以最后的结果为 true。

    掌握了上面的知识,我们趁热打铁,再来说说 intern( ) 方法的作用。先看下面一段代码:

    1
    2
    3
    4
    String str2 = new String("str") + new String("01");
    str2.intern();
    String str1 = "str01";
    System.out.println(str2 == str1); //true

    按照我们之前的分析,此处的结果应该为 false,但是返回的却是 true,问题就处在 str2.intern( ) 上。该方法会返回字符串对象在常量池中的引用;如果池中不存在该对象,则将该对象在堆中的引用复制到方法区中(jdk 1.7 以后),然后再返回引用。

    当执行 str2.intern( ) 时,因为常量池中没有”str01”这个字符串,所以会在常量池中生成一个对堆中的”str01”的引用(注意这里是引用 ,在 jdk 1.7 之前是生成原字符串的拷贝)。在进行 String str1 = "str01" 的时候,常量池中已经存在了”str01”的引用,直接返回,所以执行结果为 true。


四、总结

在这一篇博客中,我们详细介绍了 String 的底层源码,对字符串对象的创建方式以及不同创建方式下,它们在内存中的分布进行了详细的讲解,同时还普及了 hashCode( ) 和 equals( ),知道为什么重写了 equals( ) 方法,就必须重写 hashCode( ) 方法。最后,我们还知道了字符串家族中另外两枚成员:StringBuffer 或 StringBuilder 以及它们的引用场景。

原文地址:https://www.cnblogs.com/sanxiandoupi/p/11699425.html