一.几个概念
在我们正式开始看String源码之前,先来了解几个概念,对这几个概念的理解,将有助于提升我们对代码的认识。
1.字面量
字面量是用于表达源代码中一个固定值的表示法。数字,字符串等都有字面量表示。例如:
final int n = 1;
String s = "Hello World!"
上述代码中1、"Hello World!"就是字面量。
2.常量池
(1)class文件中的常量池
在class文件中,除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用。
我们编写如下代码,并查看其class文件内容:
public class Literals {
final int n = 1;
String s = "Hello World!";
}
在上图中我们可以看到,字面量"1"、"Hello World!"出现在Constant pool列表中。
(2)运行时常量池
根据《java虚拟机规范》的规定,java虚拟机所管理的内存将会包括以下几个运行时数据区域:
class文件的常量池中的信息,将在类加载后进入方法区中的常量池存储。
3.字符集
字符集是一个系统支持的所有抽象字符的集合。常见的字符集有ascii字符集、Unicode字符集。
4.字符编码
字符编码是我们对字符集的一套编码规则,将具体的字符进行“数字化”,便于计算机理解和处理。例如我们常用的UTF-8字符编码是对Unicode字符集的一种具体编码规范。
5.码位
我们已经知道了字符集和字符编码的概念,那么如何对具体的字符集进行字符编码呢?这就要用到码位(code point)的概念:码位是表示一个字符在码空间中的数值。例如:ascii包含128个码位(范围是0-127),数字0的码位是48。
二.核心代码
1.类定义
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence
需要注意的是,String类被声明为final的,意味着它不可以被继承。
另外,类实现了Serializable
接口使它可以被序列化;实现了Comparable
接口便于字符串之前的比较;实现了CharSequence
接口,该接口是char值的一个可读序列,它声明了如下几个方法:
public interface CharSequence {
// 获取字符序列长度
int length();
// 获取某个指定位置的字符
char charAt(int index);
// 获取子序列
CharSequence subSequence(int start, int end);
// 将字符序列转换为字符串
public String toString();
}
2.存储机制
类的定义中实现了CharSequence
接口,我们其实已经大概可以了解,String是基于“字符序列”来实现的。通过看源代码,我们可以确认:String是基于字符数组来进行字符的存储与管理的。代码如下:
// 字符数组,用于存储字符串中的字符
private final char value[];
// 字符串中第一个字符的下标
private final int offset;
// 字符串中存储的字符个数
private final int count;
以上代码便构成了String工作的基础:使用value数组来进行字符存储,使用offset和count来进行标记和记录。基本所有的方法都是围绕着这三个家伙展开的。
当我们运行如下代码时,程序实际上做了哪些事情呢?
String s = "Hello World!";
(1)在常量池中添加"Hello World!"字面量。
(2)在堆区创建一个String类型的对象实例。
(3)在栈区本地变量表中创建变量s,并指向堆区中的实例。
如下图所示:
此外,为了节省空间,实际上String实例中的字符数组是可以被其他String实例复用的,这也就是offset
变量和count
变量存在的原因了,我们稍后再继续讨论这个问题。
3.常用方法
(1)构造方法
我们常用的构造方法有如下几个:
// 利用另一个字符串来生成一个新的字符串
String s1 = new String("Hello World!");
// 利用字节数组来生成字符串
String s2 = new String(s1.getBytes(), 0, s1.length(), "UTF-8");
char[] charArray = {'j', 'a', 'v', 'a'};
// 利用字符数组来生成字符串
String s3 = new String(charArray);
我们分别来看一下这三个构造方法。
第一个构造方法:
public String(String original) {
// 获取原字符串中的字符个数
int size = original.count;
// 获取原字符数组
char[] originalValue = original.value;
char[] v;
// 判断原字符数组长度是否大于有效字符个数,之所以需要判断,是因为有可能offset不等于0
// 即字符数组不是从第一个位置开始存储的
if (originalValue.length > size) {
// 获取原字符串中的首字符下标
int off = original.offset;
// 对原数组进行拷贝
v = Arrays.copyOfRange(originalValue, off, off + size);
} else {
// 原字符数组长度等于字符个数,也即offset=0
v = originalValue;
}
// 以下是对构成字符串的3个要素进行赋值
this.offset = 0;
this.count = size;
this.value = v;
}
第二个构造方法:
public String(byte bytes[], int offset, int length, String charsetName)
throws UnsupportedEncodingException {
if (charsetName == null)
throw new NullPointerException("charsetName");
checkBounds(bytes, offset, length);
// 将字节数组反序列化为字符数组
char[] v = StringCoding.decode(charsetName, bytes, offset, length);
this.offset = 0;
this.count = v.length;
this.value = v;
}
// 边界检查,检查传入的字节数组,起始下标、长度是否有效
// 这里有一个疑惑:为何该方法被声明为static的?不知是何用意
// 因为这个方法只在构造方法中被用到了,不是static也完全没有问题
private static void checkBounds(byte[] bytes, int offset, int length) {
if (length < 0)
throw new StringIndexOutOfBoundsException(length);
if (offset < 0)
throw new StringIndexOutOfBoundsException(offset);
if (offset > bytes.length - length)
throw new StringIndexOutOfBoundsException(offset + length);
}
这里我们看到,代码的核心逻辑在这一句:
char[] v = StringCoding.decode(charsetName, bytes, offset, length);
我们继续看StringCoding.decode
的实现:
// 线程级缓存,缓存反序列化器
private static ThreadLocal decoder = new ThreadLocal();
static char[] decode(String charsetName, byte[] ba, int off, int len)
throws UnsupportedEncodingException {
// 从线程级缓存中获取反序列化器
StringDecoder sd = (StringDecoder) deref(decoder);
// 如果charsetName为null,默认使用ISO-8859-1字符编码
String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
// 缓存中没有反序列化器,或者虽然有,但是之前反序列化的字符集与这次不同,则重新生成decoder
if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
|| csn.equals(sd.charsetName()))) {
sd = null;
try {
Charset cs = lookupCharset(csn);
if (cs != null)
sd = new StringDecoder(cs, csn);
} catch (IllegalCharsetNameException x) {
}
if (sd == null)
throw new UnsupportedEncodingException(csn);
// 将decoder放入线程级缓存,以备下次使用
set(decoder, sd);
}
// 调用StringDecoder完成反序列化
return sd.decode(ba, off, len);
}
// 从缓存中获取反序列器,此处使用了软引用,便于jvm在内存不足时,释放该缓存
private static Object deref(ThreadLocal tl) {
SoftReference sr = (SoftReference) tl.get();
if (sr == null)
return null;
return sr.get();
}
// 判断字符集是否支持,并加载字符集处理类
private static Charset lookupCharset(String csn) {
if (Charset.isSupported(csn)) {
try {
return Charset.forName(csn);
} catch (UnsupportedCharsetException x) {
throw new Error(x);
}
}
return null;
}
// 将对象的软引用放入线程级缓存
private static void set(ThreadLocal tl, Object ob) {
tl.set(new SoftReference(ob));
}
这段代码较长,大体是利用了线程级缓存来缓存decoder,这样就不必每次都实例化新的decoder,同时线程级缓存也确保了反序列化的操作是线程安全的。其中ThreadLocal和SoftReference结合的用法可以为我们所借鉴。
第三个构造方法:
public String(char value[]) {
this.offset = 0;
this.count = value.length;
this.value = StringValue.from(value);
}
而StringValue.from(value)
方法的具体实现如下:
static char[] from(char[] value) {
return Arrays.copyOf(value, value.length);
}
也就是进行了数组拷贝,代码比较简单,我们不再赘述。
(2)字符串比较方法
public int compareTo(String anotherString) {
int len1 = count;
int len2 = anotherString.count;
// 获取两个字符串中长度较小者的长度
int n = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value;
int i = offset;
int j = anotherString.offset;
// 如果两个字符串的offset相等
if (i == j) {
int k = i;
int lim = n + i;
// 逐个字符比较,如果相同位上的字符不同,则按照Unicode的大小进行比较
while (k < lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
} else { // 两个字符串的offset不相等
// 逐个字符比较,如果相同位上的字符不同,则按照Unicode的大小进行比较
while (n-- != 0) {
char c1 = v1[i++];
char c2 = v2[j++];
if (c1 != c2) {
return c1 - c2;
}
}
}
// 如果仍然没有比较出大小,说明前面n个字符都相等,则长度大的字符串更大
return len1 - len2;
}
对于这个方法的实现,我有些疑惑,原理上是对两个字符串中的字符数组进行逐个比较,这种比较方法即是”字典顺序“比较。我的疑惑在于,为什么要判断两个字符串的offset是否相等呢?直接进行else分支中的while循环不就可以了吗?这一点暂时没有想通。
我们常用的equals方法也是基于“字典顺序”比较,主要逻辑与compareTo
方法类似,此处就不再贴出代码。
(3)hashCode方法
// 缓存字符串的hashCode,默认为0
private int hash;
// 计算字符串的hashCode
public int hashCode() {
int h = hash;
int len = count;
// 如果之前没有计算过hashCode,且字符串长度不为0,则进行计算
if (h == 0 && len > 0) {
int off = offset;
char val[] = value;
// 利用公式h=31*h + c计算hashCode,c为字符数组中每个字符的code point
for (int i = 0; i < len; i++) {
h = 31*h + val[off++];
}
// 将计算好的hashCode缓存起来,以便下次使用
hash = h;
}
return h;
}
我们在jdk源码分析(一)中分析如何覆盖hashCode方法时,曾讲到《effective java》中提到的一种方法,此处即是使用了这种方法来计算hashCode。
此外,在这段代码中,值得注意的是:将整数值与char值相加会得到什么呢?根据java基本类型间的强制转换规则,char型将会被转换为int型,然后与int类型的值相加。那么char在转换为int时该如何取值呢?其实这就利用了码位(code point)的概念,我们可以通过程序来看一下。
String s = "abc123中国";
for (int i = 0; i < s.length(); i++) {
System.out.println((int)s.charAt(i) + "," + s.codePointAt(i));
}
运行程序,得到的结果如下:
97,97
98,98
99,99
49,49
50,50
51,51
20013,20013
22269,22269
由此可知,char字符转换为整型时,其值为其在Unicode字符集中的码位。
(4)substring方法
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);
}
return ((beginIndex == 0) && (endIndex == count)) ? this :
new String(offset + beginIndex, endIndex - beginIndex, value);
}
在经过对参数的校验后,substring方法最终调用了一个有三个参数的构造方法,我们来看一下:
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
我们刚才在讲到String的存储结构时说,不同String实例是可以共用字符数组的,此处得到了印证:利用substring方法得到的子字符串和原字符串使用同一个字符数组value
,只是offset和count不同而已。
String类中的方法还有很多,例如用于字符串连接的concat
方法,字符串查找的indexOf
方法,字符串替换的replace
方法,以及获取子字符串的substring
方法等等,这些方法的原理不外乎围绕着字符数组value
、下标offset
、字符串长度count
这几个变量来展开,万变不离其宗,此处不一一列举。
三.相关类
除了String类之外,我们日常编码时还经常使用StringBuffer和StringBuilder,它们是对String的有益补充。由于String中的字符数组被声明为final的,在赋值后就不允许被修改了,因此通常意义上,我们认为String是”不可变“的。当我们需要对字符串的值进行频繁修改时,就可以使用StringBuffer和StringBuilder了。
我们来简单看一下这两个类。
public final class StringBuffer
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
从定义中我们发现,他们继承自同一个父类AbstractStringBuilder
,同时也实现了CharSequence
接口,而String类也同样实现了CharSequence
接口,因此这三个类具有很多相同的方法,我们就拿length
方法来比较一下。
在StringBuilder
类中,length
方法(继承自AbstractStringBuilder
类)如下:
public int length() {
return count;
}
而在StringBuffer
类中,length
方法如下:
public synchronized int length() {
return count;
}
显然,在StringBuffer中,方法的调用是同步的,在多线程环境中,一个线程需要等待另一个线程执行完length
方法后,才可以执行,这也就是为什么我们常说StringBuffer
是线程安全的原因。
大体来看,String
、StringBuffer
、StringBuilder
三个类的差别主要如下:
类 | 线程安全 | 可变 |
---|---|---|
String | 是 | 否 |
StringBuffer | 是 | 是 |
StringBuilder | 否 | 是 |
此外,刚才说String在通常意义上我们认为是”不可变“的,但是也并非绝对,我们仍然可以利用反射来改变String的值,如下:
String java = "java";
System.out.println("old value:" + java);
try {
Field field = java.getClass().getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(java);
value[0] = 'g';
System.out.println("new value:" + java);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
执行程序,我们会得到如下结果:
old value:java
new value:gava
参考资料
1.《深入理解java虚拟机》
2.Java常量池理解与总结
3.初探Java字符串
4.Java常量池理解与总结
5.维基百科:码位
6.维基百科:Unicode
本文已迁移至我的博客:http://ipenge.com/40983.html