Hello,我是小护士。今天为大家带来String
、StringBuffer
、StringBuilder
的源码阅读,虽然这三个类都是面试必问的,但其实内容很简单,也不用死记硬背。开门见山,首先有请String
登场。
String的内部结构比较简单,只有四个字段和几个重要方法:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
private int hash;
private static final long serialVersionUID = -6849794470754667710L;
private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0];
...
}
char value[]
:Java模仿C语言的字符串实现,使用字节数组做存储;使用final
意味着数组一旦创建就不能变更。int hash
:哈希值并非是初始化时就计算好,而是调用hashCode()
才开始计算并缓存到该字段中。serialVersionUID
:Java序列化机制需要用的,这里暂时不展开序列化相关内容。serialPersistentFields
:Java序列化机制需要用的,也是不展开讲,直到nio包才讲。public int length() {
return value.length;
}
别看上面length()
方法这么简单,有没有思考过为什么char[]
数组会自带length
字段?为什么Java的字符串不需要'\0'
作为结束符,而C/C++就需要?
真正原因在于JVM是这样定义数组结构的:
template <typename T>
class Array: public MetaspaceObj {
...
protected:
int _length; // the number of array elements
T _data[1]; // the array memory
void initialize(int length) {
_length = length;
}
...
}
JVM数组源码地址:
https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/array.hpp
虽然是C++代码,但并不妨碍Java开发者阅读Array
类的定义,在protected
那里就声明了int _length
和T _data[1]
两个重要字段。其中,_length
就是用来标记该字符串的长度,通过这个字段可以计算出字符串在内存中的结束位置。因此,Java字符串就不需要\0
作为字符串结束符来标记结束位置。而C/C++的数组就真的是纯数组了,例如_data[1]
字段。
public String toString() {
return this;
}
public char[] toCharArray() {
char result[] = new char[value.length];
System.arraycopy(value, 0, result, 0, value.length);
return result;
}
如果想要把字符串深拷贝一份出来,使用toString()
方法是不行的,因为它只是做浅拷贝处理,直接返回对象引用。所以,要使用toCharArray()
方法,通过System.arraycopy()
这个本地方法实现深拷贝。
关于这个本地方法,小护士在这里稍微跟踪一下代码,路径如下:
1. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/java.base/share/native/libjava/System.c
找到 JVM_ArrayCopy
关键词 。往下走:
2. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/include/jvm.h
在h文件找到 JVM_ArrayCopy(...);
函数声明,追踪cpp文件:
3. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/prims/jvm.cpp
在cpp文件中找到 JVM_ENTRY(void, JVM_ArrayCopy(...))...JVM_END
。
在方法定义中找到真正做copy的函数s->klass()->copy_array(s, src_pos, d, dst_pos, length, thread);
。
往上几行找到 arrayOop s = arrayOop(JNIHandles::resolve_non_null(src));
。
这里的 arrayOop
是类型的别名。
4. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/access.inline.hpp
看头文件 access.inline.hpp ,找到 access.hpp 。
5. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/access.hpp
寻找 arrayOop
声明,从access.hpp找到 include "oops/oopsHierrachy.hpp"
。
6. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/oopsHierarchy.hpp
在 oopsHierarchy.hpp 中找到以下类型别名定义 typedef class arrayOopDesc* arrayOop;
。
可知类型真名 arrayOopDesc
。
7. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/arrayOop.hpp
根据 oopsHierarchy.hpp 注释描述,由于 arrayOopsDesc
是视为抽象类,在编译的时候C++编译器会根据头文件选择性地找到实现类。因此,回看 jvm.cpp 就会发现只仅仅包含了 objArrayOop.inline.hpp 。
8. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/objArrayOop.hpp
通过 objArrayOop.inline.hpp 直接找到对应的 objArrayOop.hpp。发现klass字段正是使用 objArrayKlass
。
9. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/objArrayKlass.cpp
最后经过 klass()
返回的 objArrayKlass
指针来到 void ObjArrayKlass::copy_array(...){...}
。
该方法在底部做内部函数调用 do_copy(...)
。
这里面调用了 ArrayAccess<>::oop_arraycopy(...)
函数做拷贝。
10. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/access.hpp
ArrayAccess
是在 access.hpp 里面声明的。调用了它的内部类 AccessInternal
。
11. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/oops/accessBackend.cpp
AccessInternal
在 accessBackend.cpp 中声明。里面的方法大部分都是使用 Copy
工具类。
12. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/utilities/copy.cpp
最后来到了这里。嗯,追踪结束。小护士表示很满意。
public native String intern();
又是一个本地方法,听说是可以把字符串变量转为常量池的常量。小护士再次跟踪JVM源码看个究竟:
1. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/java.base/share/native/libjava/String.c
先找到String.c
文件,找到函数定义。
JNIEXPORT jobject JNICALL
Java_java_lang_String_intern(JNIEnv *env, jobject this)
{
return JVM_InternString(env, this);
}
2. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/prims/jvm.cpp
再次来到jvm.cpp
核心文件,找到函数定义。
JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
JVMWrapper("JVM_InternString");
JvmtiVMObjectAllocEventCollector oam;
if (str == NULL) return NULL;
oop string = JNIHandles::resolve_non_null(str);
oop result = StringTable::intern(string, CHECK_NULL);
return (jstring) JNIHandles::make_local(env, result);
JVM_END
其中,StringTable::intern(string, CHECK_NULL);
才是真正做intern()
处理的。而StringTable
是字符串表,Java常量池的实现。
3. https://github.com/unofficial-openjdk/openjdk/blob/jdk/jdk/src/hotspot/share/classfile/stringTable.cpp
追查到stringTable.cpp
文件,找到intern()
函数定义。
oop StringTable::intern(Handle string_or_null_h, jchar* name, int len, TRAPS) {
// shared table always uses java_lang_String::hash_code
unsigned int hash = java_lang_String::hash_code(name, len);
oop found_string = StringTable::the_table()->lookup_shared(name, len, hash);
if (found_string != NULL) {
return found_string;
}
if (StringTable::_alt_hash) {
hash = hash_string(name, len, true);
}
return StringTable::the_table()->do_intern(string_or_null_h, name, len, hash, CHECK_NULL);
}
可以看到有两个函数主导intern()
逻辑。look_shared()
是查找目标字符串是否已经在字符串表中,有就可以复用。如果没有,则执行do_intern()
,把目标字符串加入到字符串表中。这里说字符串表(StringTable)就是字符串常量池。
到了这里就不需要再深入往下看look_shared()
或do_intern()
的具体代码细节了。已经超纲了。
static int indexOf(
char[] source,
int sourceCount,
char[] target,
int targetOffset,
int targetCount,
int fromIndex) {
...
char first = target[targetOffset];
int max = sourceOffset + (sourceCount - targetCount);
for (int i = sourceOffset + fromIndex; i <= max; i++) {
/* Look for first character. */
if (source[i] != first) {
while (++i <= max && source[i] != first);
}
/* Found first character, now look at the rest of v2 */
if (i <= max) {
int j = i + 1;
int end = j + targetCount - 1;
for (int k = targetOffset + 1; j < end && source[j] == target[k]; j++, k++);
if (j == end) {
/* Found whole string. */
return i - sourceOffset;
}
}
}
return -1;
}
如果看到这么大段代码,千万别怂。其实很多谣言说Java String 的查找方法indexOf(String str)
用了KMP算法,其实不是。仍然是一个一个字符去匹配,找到第一个匹配的就往下继续匹配剩余字符。这是一个简单的两层循环处理,并不是很高深算法。
号外,这个方法还会被JVM做深度优化。直接替换掉字节码,换成JVM根据具体硬件环境优化过的函数。
关键词:HotSpot JVM intrinsics
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
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;
}
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;
}
equal()
方法的逻辑比较朴素,先对比是不是同一个对象,若不是,则把两个字符串的char[] value
进行数组长度比较,若长度相同,则对它们的数组元素进行逐一比较。
hashCode()
方法的逻辑稍微偏向数学化,因为哈希码的类型是int
,也就是32位字长。众所周知,对于哈希码来说,最好是通过%
求余算法得出大范围的数对应小范围的数。但字符串是由连续的字符组成的字符数组,而又因为ascii码和unicode码可以把字符与整数进行一一对应,因此,hashCode()
方法直接遍历字符数组元素,通过h = 31 * h + val[i]
的累加方式进行哈希码的计算。
那么问题来了,为什么hashCode()
要 哈希码×31 哈 希 码 × 31 ,目前没有明确答案。小护士只知道如果是乘以32的话,就可以转化为左移运算, h×32=h<<5 h × 32 = h << 5 。有部分观点说因为31是素数、质数,具有某种数学意义,小护士表示保留意见。当然,由于31 * h
与val[i]
相加,而不是val[i]
纯粹叠加,因此大大减少了哈希碰撞概率。
哈希码是int
的有符号数,其算法是纯粹的累加,该值会出现正溢出,也就是会出现负数,而且哈希碰撞的概率还是有的。
StringBuffer
是“线程安全”的字符串拼接类,但并非所有方法都是线程安全的,很大部分insert()
方法都直接调用父类的方法。StringBuffer
的所有append()
方法都加了synchronized
关键字修饰,以表示这是线程安全的同步方法。
小护士拿其中一个append()
方法举例子:
...
private transient char[] toStringCache;
...
@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);
}
...
toStringCache
是一个缓存字段,每次StringBuffer
的char[] value
被修改的时候都会清空缓存。而该缓存值会在toString()
被调用的时候才会深拷贝字符数组,刷新缓存,这里return new String(toStringCache, true);
的用法属于历史遗留问题,里面再次深拷贝了一次字符数组。
深度思考:为什么需要toStringCache
?
简单回答:因为StringBuffer
部分insert()
方法不是线程安全的。
StringBuilder
则是StringBuffer
的非线程安全版本,用于局部变量处理的场景。
来看看它的其中一个append()
方法:
...
@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);
}
...
StringBuilder
没有toStringCache
字段做缓存,它的toString()
方法也没有出现两次深拷贝。
相同点:
char[] value
作为内部存储的数据结构。StringBuffer
和StringBuilder
的抽象类都是AbstractStringBuilder
。不同点:
String
是Java内置的特殊数据类型,StringBuffer
和StringBuilder
则是字符串拼接类String
的char[] value
加了final
,不可变;AbstractStringBuilder
的char[] value
则可变。StringBuilder
线程不安全,StringBuffer
线程安全但部分方法非线程安全,比如insert()
方法。实际面试过程中,面试官不会让你说得太详细,大概说出上面六个点也差不多了。
由于AbstractStringBuilder
的char[] value
数组长度没有上限,如果无限添加字符串,直到超出int
所表示的最大值,导致发生正溢出变为负数,就会抛出OutOfMemoryError
错误。
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if (newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if (newCapacity < 0) {
if (minimumCapacity < 0) // overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}
因为AbstractStringBuilder
的char[] value
是可变的,可以通过append()
等扩容,也可以通过delete()
等缩容。问题来了,因为这些方法存在线程安全问题,count
字段作为char[] value
的数组长度标记在执行过程就会容易出现数据不一致问题。
abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
/**
* The count is the number of characters used.
*/
int count;
...
}
为了方便大家日后技术交流,小护士在这里特别推荐如下交流方式:
QQ群:JAVA高级交流(329019348)
QQ群:大宽宽的技术交流群(317060090)
如果有对博文内容有任何技术问题,欢迎评论留言或加群讨论,谢谢大家。