jdk源码分析——String类

Posted 自由水鸟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了jdk源码分析——String类相关的知识,希望对你有一定的参考价值。

一.几个概念

在我们正式开始看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!";
}

jdk源码分析(三)——String类

在上图中我们可以看到,字面量"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是线程安全的原因。
大体来看,StringStringBufferStringBuilder三个类的差别主要如下:

线程安全 可变
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


以上是关于jdk源码分析——String类的主要内容,如果未能解决你的问题,请参考以下文章

JDK源码分析String的存储区与不可变 专题

Java源码分析——String

JDK源码阅读之 HashMap

1.3JDK源码阅读之String

jdk源码分析——Object类

我的jdk源码:String 一个特殊而强大的类!