OpenJDK源码阅读解析:Java11的String类源码分析详解

KaelLi 2019年2月26日14:32:06
评论
3,0745

从今天开始,打算对OpenJDK的源码选择一部分进行阅读,算是一次自我学习与提升吧。因为个人在Java方面的能力有限,所以大家且将参考吧,希望不会把读者带到坑里……

在之前查看OpenJDK的Java源码一文里曾经提到,这一系列文章对于Java源码的阅读解析,都基于OpenJDK_11.0.1版本。至于要阅读的第一个类源码,就从String开始吧,凡是对Java比较熟悉的人,应该都了解String在开发中有多么重要的地位,想来从String类开始应该不会引起什么质疑。

String的类声明

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence

public没什么好说的,final修饰符则决定了String类是不可继承的且对象是不可变的,你无法自己写一个继承自String的类,而String对象的不可变性也是大家耳熟能详的。

实现了java.io.Serializable、Comparable和CharSequence共3个接口:

  • Serializable是用来保证String可以序列化和反序列化的。
  • Comparable的compareTo(String str)方法则是实现String的对比排序的。
  • CharSequence的length()方法用来返回字符串长度,charAt(int index)则可以获取到给定位置的单个字符,subSequence(int start, int end)则提供了截取子字符串的功能,toString()方法对于String来说就是返回它自己了。

String的属性字段

@Stable
private final byte[] value;

@Stable注解和final表明value是稳定不可更改的(实际上还是能通过反射来更改)。毫无疑问,value是String的核心属性,String字符串本质上就是一个字符数组嘛,所以String的“值”实际上就在这个数组里。在以前value是char[]型的(至少Java8还是如此),而现在变成了byte[]的。

private final byte coder;

coder是value中的存放的byte的编码标识符,coder可能的值是LATIN1UTF16。

private int hash;

用来记录String的hash值。

private static final long serialVersionUID = -6849794470754667710L;

序列化与反序列化使用,不多解释。

static final boolean COMPACT_STRINGS;
static {
    COMPACT_STRINGS = true;
}

决定是否开启了字符串压缩,默认是开启的,如果要更改,是需要更改JDK配置的,JVM能获取到设定的值,从而决定是否开启压缩功能。如果开启了压缩功能,那么当字符串的全部字符都在ASCⅡ编码范围内时,coder就是LATIN1,这样会节省存储空间。而如果全部字符不能完全由ASCⅡII编码表示时,coder是UTF16,即用UTF16编码。

private static final ObjectStreamField[] serialPersistentFields =
    new ObjectStreamField[0];

可能很多人不了解这个serialPersistentFields,看定义,它是一个私有的、静态的、不可改变的ObjectStreamField数组,实际上在JDK源码里有很多类都有这样一个属性,这些类的共同点是都实现了java.io.Serializable接口。在默认情况下一个实现了Serializable接口的类,所有的非 transient 非 static 修饰的字段都会被序列化,但如果还定义了serialPersistentFields字段,则只有serialPersistentFields里添加的字段才会被序列化。当一个字段用transient修饰,但又位于serialPersistentFields数组里时,它依然会被序列化——说明serialPersistentFields的作用优先级是比transient高的。

在String类里,serialPersistentFields是一个容量为0的空数组,显然String的字段都不会被序列化。

@Native static final byte LATIN1 = 0;
@Native static final byte UTF16  = 1;

这2个常量算是2个标志,分别代表了LATIN1编码和UTF16编码。@Native注解则说明这俩值可能来源于native代码(即实现JVM的C/C++)。

String的构造方法

String的构造方法有很多种,除去已经Deprecated的之外,还有接近20个。我们先来看一下没有用public修饰的构造方法(仅用于package内部),那些public构造方法,大部分都是通过这3个构造方法实现的:

String(char[] value, int off, int len, Void sig) {
     if (len == 0) {
         this.value = "".value;
         this.coder = "".coder;
         return;
     }
     if (COMPACT_STRINGS) {
         byte[] val = StringUTF16.compress(value, off, len);
         if (val != null) {
             this.value = val;
             this.coder = LATIN1;
             return;
         }
     }
     this.coder = UTF16;
     this.value = StringUTF16.toBytes(value, off, len);
}

value参数是给定的字符数组,off是开始位置,len代表长度,最后的Void型参数是用来跟其他的public构造方法做区分的。如果长度是0,则直接初始化为空字符串。如果开启了压缩,则通过StringUTF16中的方法进行压缩操作,coder为LATIN1,每个byte表示了相应字符的8个低位,然后再赋值。这样每个char最终对应了一个byte,空间占用最少。如果不开启压缩,则coder为UTF16,通过StringUTF16中的方法把char转成byte。显然一个char最终会转成2个byte。

String(AbstractStringBuilder asb, Void sig) {
     byte[] val = asb.getValue();
     int length = asb.length();
     if (asb.isLatin1()) {
         this.coder = LATIN1;
         this.value = Arrays.copyOfRange(val, 0, length);
     } else {
         if (COMPACT_STRINGS) {
             byte[] buf = StringUTF16.compress(val, 0, length);
             if (buf != null) {
                 this.coder = LATIN1;
                 this.value = buf;
                 return;
             }
         }
         this.coder = UTF16;
         this.value = Arrays.copyOfRange(val, 0, length << 1);
     }
}

AbstractStringBuilder是一个抽象类,继承它的2个类我们也不陌生 :StringBuilder和StringBuffer。AbstractStringBuilder也有一个byte[]类型的value字段用来存储字符串。所以这个构造方法理解起来也不难,先获取道这个value的值和长度,如果isLatin1()返回true(实际上这个isLatin1()方法,同时判断了编码和是否开启压缩),则直接用Arrays.copyOfRange方法把这个AbstractStringBuilder的value赋值给String的value。否则,再判断是不是开启压缩,是的话,仍然是通过StringUTF16.compress方法来进行压缩再赋值。而几个判断条件都不满足的话,就直接把code设置喂UTF16,然后通过Arrays.copyOfRange复制,底层是通过public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length)方法实现的

String(byte[] value, byte coder) {
    this.value = value;
    this.coder = coder;
}

言简意赅,简单粗暴,这种构造方法,相信大家都喜欢O(∩_∩)O。

然后我们来看看public的构造方法:

public String() {
this.value = "".value;
this.coder = "".coder;
}

初始化一个新的空字符串String对象,很有意思的时,源码注释里提到了,由于String是immutable不可变的,一个空字符串对象是没有必要的,所以不推荐使用本构造方法。

public String(String original) {
    this.value = original.value;
    this.coder = original.coder;
    this.hash = original.hash;
}

直接把原字符串的相应字段的值赋上去,完全OK。

public String(char value[]) {
    this(value, 0, value.length, null);
}

这个就是通过String(char[] value, int off, int len, Void sig)实现了把给定char数组转成字符串的目的。

public String(char value[], int offset, int count) {
    this(value, offset, count, rangeCheck(value, offset, count));
}

仍然走String(char[] value, int off, int len, Void sig)方法,最后的rangeCheck就是检查了一下越界的问题(如果value长度只有10,你却要用它的第3到第20个字符来生成字符串,可不就出错了嘛)。

public String(int[] codePoints, int offset, int count) {
    checkBoundsOffCount(offset, count, codePoints.length);
    if (count == 0) {
        this.value = "".value;
        this.coder = "".coder;
        return;
    }
    if (COMPACT_STRINGS) {
        byte[] val = StringLatin1.toBytes(codePoints, offset, count);
        if (val != null) {
            this.coder = LATIN1;
            this.value = val;
            return;
        }
    }
    this.coder = UTF16;
    this.value = StringUTF16.toBytes(codePoints, offset, count);
}

从代码点数组中截取一部分来创建字符串,根据是否开了字符串压缩,通过StringLatin1.toBytesStringUTF16.toBytes方法来把相应的代码点转成byte,最终形成一个byte数组

所谓代码点,看得出来它是一个int整数,代表的是Unicode字符集里的位置。Unicode目前的代码点范围是0x0000-0x10FFFF,这比以前的范围(0x0000-0xFFFF)。不过目前Unicode11.0只有137374个字符,就是说实际上还有将近100万个空余地址,每年Unicode的字符集都会增加不少新字符。如果codePoints参数里有某个值,超出了Unicode有效值的范围,本构造方法就会有java.lang.IllegalArgumentException异常。

public String(byte bytes[], int offset, int length, String charsetName) throws UnsupportedEncodingException {
    if (charsetName == null)
        throw new NullPointerException("charsetName");
    checkBoundsOffCount(offset, length, bytes.length);
    StringCoding.Result ret =
    StringCoding.decode(charsetName, bytes, offset, length);
    this.value = ret.value;
    this.coder = ret.coder;
}

从给定的字符数组里截取一部分(或全部),创建一个给定字符集的字符串。

public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], int offset, int length, Charset charset)
public String(byte bytes[], String charsetName)
public String(byte bytes[], Charset charset)
public String(byte bytes[], int offset, int length)
public String(byte[] bytes)

这6个方法本质上都差不多,基本上都是用给定的字符数组生成一个指定字符集(没有指定则是系统默认字符集)的字符串,最终依赖的都是StringCoding.decode方法。

public String(StringBuffer buffer) {
    this(buffer.toString());
}

public String(StringBuilder builder) {
    this(builder, null);
}

这2个方法分别是用StringBuffer和StringBuilder对象来创建String对象。其中,String(StringBuffer buffer)方法稍微麻烦点,因为StringBuffer有一个toStringCache属性,从名字上就看得出来,它是toString的缓存,实际上每次调用StringBuffer的toString方法,就会更新一次toStringCache的值。而String(StringBuilder builder)则十分干脆的调用了String(AbstractStringBuilder asb, Void sig)方法。

String的其他方法

  • 实现CharSequence接口的方法
public int length() {
    return value.length >> coder();
}

可以看到,并不是直接返回数组的长度,而是做了一次右移操作。coder()是String类的内部方法:

byte coder() {
    return COMPACT_STRINGS ? coder : UTF16;
}

可见,如果没开启压缩,那么直接返回UTF16(值为1),如果开启了压缩,则返回coder的值(可能是LATIN1即0,也可能是UTF16即1)。如果coder()的值为1,那么右移1位就意味着value.length要除2,这是因为使用UTF16编码下,一个字符用2个byte来表示,所以除2才是字符串的长度。

public boolean isEmpty() {
    return value.length == 0;
}

判断字符串是否是空的,简单高效。

public char charAt(int index) {
    if (isLatin1()) {
        return StringLatin1.charAt(value, index);
    } else {
        return StringUTF16.charAt(value, index);
    }
}

获取相应位置的字符。String的很多方法,都依赖于StringLatin1和StringUTF16两个类,其中各自的具体实现可以以后再说。

  • 代码点(CodePoint)相关方法
public int codePointAt(int index)
public int codePointBefore(int index)
public int codePointCount(int beginIndex, int endIndex)
public int offsetByCodePoints(int index, int codePointOffset)

这4个方法,分别返回了字符串相应位置的代码点、指定位置之前的代码点、指定范围的代码点总数量、返回给定位置偏移一定数量代码点的索引。

  • 获取char数组
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
    checkBoundsBeginEnd(srcBegin, srcEnd, length());
    checkBoundsOffCount(dstBegin, srcEnd - srcBegin, dst.length);
    if (isLatin1()) {
        StringLatin1.getChars(value, srcBegin, srcEnd, dst, dstBegin);
    } else {
    StringUTF16.getChars(value, srcBegin, srcEnd, dst, dstBegin);
    }
}

其中dst参数代表了一个输入的数组,本方法会从字符串截取一定范围的字符,从dst数组的dstBegin位置开始复制。

  • 获取byte数组
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset)
public byte[] getBytes()

3个方法都依赖于StringCoding.encode方法,所不同的就是如果带字符集参数就用给定字符集,否则就用默认字符集,最终获得了字符串的字节数组。

  • 判断和比较方面的方法
public boolean equals(Object anObject)

比较当前字符串与给定对象。

public boolean contentEquals(StringBuffer sb)
public boolean contentEquals(CharSequence cs)

这两个方法都是用来比较内容的。

public boolean equalsIgnoreCase(String anotherString)

忽略大小写后进行内容比较。

public int compareTo(String anotherString)
public int compareToIgnoreCase(String str)

都是比较字符串,不过后者忽略大小写。

public boolean regionMatches(int toffset, String other, int ooffset, int len)
public boolean regionMatches(boolean ignoreCase, int toffset,
String other, int ooffset, int len)

对字符串与参数中的other字符串进行局部匹配,第二个方法的ignoreCase参数为true则忽略大小写。

public boolean startsWith(String prefix, int toffset)
public boolean startsWith(String prefix)

检测字符串是否以参数中的字符串为开头,其中toffset自然是一个偏移量,不为0 的时候,检测字符串从第toffset开始是否以prefix为开始。

public boolean endsWith(String suffix)

判断字符串是否以给定字符串为结尾。实现也很简单,就是利用startsWith来实现的,只不过偏移量给调整到了字符串长度减去给定字符串的长度。

public boolean matches(String regex)

判断字符串是否跟给定的正则表达式相匹配。

public boolean contains(CharSequence s)

判断字符串是否包含给定的字符序列。

public boolean isBlank()

判断字符串是否是空的或者只包含空格。

然后从这些方法中挑几个,看看它们的源码实现。

public boolean equals(Object anObject) {
    if (this == anObject) {
    return true;
    }
    if (anObject instanceof String) {
        String aString = (String)anObject;
        if (coder() == aString.coder()) {
        return isLatin1() ? StringLatin1.equals(value, aString.value): StringUTF16.equals(value, aString.value);
        }
    }
    return false;
}

首先,如果当前字符串跟给定对象相等,那就直接返回true,因为==是从对象级进行判断,相等则说明二者是同一个对象。然后如果给定对象是String的实例,将其强转为String,当二者的编码相同时,再去调用StringLatin1.equalsStringUTF16.equals方法来判断内容,具体实现也不麻烦,就是把数组中的每一个位置都进行比对即可。

public boolean startsWith(String prefix, int toffset) {
    // Note: toffset might be near -1>>>1.
    if (toffset < 0 || toffset > length() - prefix.length()) {
        return false;
    }
    byte ta[] = value;
    byte pa[] = prefix.value;
    int po = 0;
    int pc = pa.length;
    if (coder() == prefix.coder()) {
        int to = isLatin1() ? toffset : toffset << 1;
        while (po < pc) {
            if (ta[to++] != pa[po++]) {
                return false;
            }
        }
    } else {
        if (isLatin1()) {  // && pcoder == UTF16
            return false;
        }
        // coder == UTF16 && pcoder == LATIN1)
        while (po < pc) {
            if (StringUTF16.getChar(ta, toffset++) != (pa[po++] & 0xff)) {
                return false;
            }
        }
    }
    return true;
}

先来一波位置和长度的判断。接下来定义了2个byte数组,其中ta[]代表当前字符串的value,pa[]则是给定字符串的字节数组value。接下来判断2个字符串的编码,相同的话,定义一个起始位置to,然后2个数组的每一位进行比对。当2个字符串的编码不同,若本地字符串是LATIN1,则直接返回false,因为prefix字符串必定是UTF16的。若本地字符串是UTF16而prefix是LATIN1的,再使用StringUTF16.getChar把本地字节数组的每一位转成char,与给定字符数组进行对比。

  • 获取索引系列方法
public int indexOf(int ch)
public int indexOf(int ch, int fromIndex)

返回给定字符参数在字符串里首次出现的位置,如果使用了fromIndex参数,则查找从字符串的fromIndex位置开始。

public int lastIndexOf(int ch)
public int lastIndexOf(int ch, int fromIndex)

返回在字符串里最后一次出现给定字符参数的位置。

public int indexOf(String str)
public int indexOf(String str, int fromIndex)

返回在字符串里首次出现给定字符串参数的位置。

public int lastIndexOf(String str)
public int lastIndexOf(String str, int fromIndex)

返回当前字符串中最后一次出现给定字符串参数的位置。

  • 对字符串进行各种操作处理的方法
public String substring(int beginIndex)
public String substring(int beginIndex, int endIndex)
public CharSequence subSequence(int beginIndex, int endIndex)

都是从当前字符串中截取一部分。其中,subSequence方法的实现依赖于substring,实际上它的作用就是在于满足String实现CharSequence接口,仅此而已。

public String concat(String str)

返回一个当前字符串与给定字符串拼接到一起的新字符串。

public String replace(char oldChar, char newChar)

把字符串中的oldChar全部替换为newChar。

public String replaceFirst(String regex, String replacement)

只替换字符串中第一个匹配正则的字符串。

public String replaceAll(String regex, String replacement)

替换字符串中全部的符合正则表达式的字符串。

public String replace(CharSequence target, CharSequence replacement)

把字符串中全部的target替换为replacement。

public String[] split(String regex, int limit)
public String[] split(String regex)

把字符串按照给定的正则表达式进行分割,得到的结果放到数组中。

public static String join(CharSequence delimiter, CharSequence... elements)
public static String join(CharSequence delimiter,
Iterable<? extends CharSequence> elements)

把delimiter参数代表的字符序列,加入到给定的一组CharSequence中,返回结果。比如String.join("-", "Java", "is", "cool")的返回结果是”Java-is-cool”

public String toLowerCase(Locale locale)
public String toLowerCase()
public String toUpperCase(Locale locale)
public String toUpperCase()

把当前字符串全部转换成小写或大写,如果没有传Locale地区参数,则按照默认地区来处理。

public String trim()
public String strip()
public String stripLeading()
public String stripTrailing()

这4个方法都是去空格的,不过trim方法年代久远,能处理的字符有限,strip这3个方法是Java11才加入的,能处理的Unicode空格字符要多得多。

public Stream<String> lines()

根据行终止符来题取一行一行的字符串流。

public IntStream chars()

返回对当前字符串的char值进行零扩展的int流。

public IntStream codePoints()

返回当前字符串的字符代码点值的int流。

public char[] toCharArray()

把当前字符串转成一个char数组。

public static String format(String format, Object... args)
public static String format(Locale l, String format, Object... args)

按照给定的格式与相应的参数(可以是多个),得到符合格式的字符串。

public native String intern()

返回字符串对象的规范化表示。这个方法比较有意思,它由native修饰符,意味着它的具体实现要跑到底层的native代码,主动把字符串对象放到字符串常量池里(假如字符串池之前没有这个字符串对象的话,不同版本JDK的该方法底层实现差异不小)。

public String repeat(int count)

返回一个把当前字符串重复count次的新字符串,如果count为0,则返回空字符串。

我们再从这些方法里挑几个看看源码实现吧。

public String substring(int beginIndex, int endIndex) {
    int length = length();
    checkBoundsBeginEnd(beginIndex, endIndex, length);
    int subLen = endIndex - beginIndex;
    if (beginIndex == 0 && endIndex == length) {
        return this;
    }
    return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen): StringUTF16.newString(value, beginIndex, subLen);
}

先对各种边界条件进行一番检查。然后如果从位置0开始截取,一直截取到最终位置,那么就直接返回字符串本身。否则就根据字符串的编码分别调用StringLatin1.newStringStringUTF16.newString方法。这2个方法最终也都依赖于public static native void arraycopy(Object src,  int  srcPos, Object dest, int destPos, int length)方法。

public boolean contains(CharSequence s) {
    return indexOf(s.toString()) >= 0;
}

判断字符串是否包含某个字符序列。可以看到它的实现很简单,也很巧妙,直接调用了indexOf方法。如果indexOf的返回值不为负,则说明字符串包含这段序列。

public String replace(CharSequence target, CharSequence replacement) {
    String tgtStr = target.toString();
    String replStr = replacement.toString();
    int j = indexOf(tgtStr);
    if (j < 0) {
        return this;
    }
    int tgtLen = tgtStr.length();
    int tgtLen1 = Math.max(tgtLen, 1);
    int thisLen = length();

    int newLenHint = thisLen - tgtLen + replStr.length();
    if (newLenHint < 0) {
        throw new OutOfMemoryError();
    }
    StringBuilder sb = new StringBuilder(newLenHint);
    int i = 0;
    do {
        sb.append(this, i, j).append(replStr);
        i = j + tgtLen;
    } while (j < thisLen && (j = indexOf(tgtStr, j + tgtLen1)) > 0);
    return sb.append(this, i, thisLen).toString();
}

这个方法用来把原字符串中所有的target子字符序列,替换成给定的replacement字符序列,也是十分常用的方法。它的实现是这样的:先把2个参数转成字符串。然后变量j用来标识目标字符串在原字符串的位置(因为可能有多个符合条件的子字符串),当j为负时显然说明字符串不包含target,就直接返回原字符串。再做一次长度判断,若原字符串长度减去目标字符串长度再加上替换字符串长度,结果为负,则抛出OutOfMemoryError错误。最后把这些异常情况排除后,使用StringBuilder的append(CharSequence s, int start, int end)方法来对字符串逐渐拼接,每次在i位置上开始拼接到j位置,拼接内容则是先拼接原字符串的部分内容,再拼接替换内容,再对i和j进行改变,最后就得到了结果。

  • String的hashCode()方法
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
    hash = h = isLatin1() ? StringLatin1.hashCode(value): StringUTF16.hashCode(value);
    }
    return h;
}

继续查看源码后,发现StringLatin1和StringUTF16的hashCode方法,都使用了这样的公式:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

其中s代表了字符数组,n则代表了数组长度。

不难看出,String的hashCode最终是有冲突的(有限数量的hash值对应了无限数量的String,必定有冲突),所以hash值的存在只能告诉你,2个内容一致的字符串,hash值必定一致,但2个hash值相同的字符串,内容不一定一致。

总结

String在Java语言中是个太过于重要的类,某种意义上说,我们可以认为它也“算是”一种基本类型了(就像int、float等)。String对象一经创建,就无法再修改,所以如果你需要对字符串进行多次修改,一定要使用StringBuilder或StringBuffer(避免产生大量多余的String对象导致频繁的GC)。

自从Java9之后,Java添加了COMPACT_STRINGS,并且把原来的char[]形式的value类型改为了byte[],这一切都是为了节省空间:Java内部使用的是UTF16编码,每个char都需要占用2字节空间,而当一个字符串的字符都在ASCII集里时,它们的前8位实际上都是0,那么这个时候使用byte来存储就节省了一半的空间。据统计,字符串的内容完全在ASCII码里的情况超过了一半,所以Java的这个改动确实是很有意义的。当然这么做也带来了一些额外的性能开销,因为必须要维护coder属性,以及很多时候需要做byte和char的转换,这都是有性能代价的。据粗略测试,开启了COMPACT_STRINGS压缩后,有些时候会损失大约10%的性能,但节省的空间也更加可观。由于这个选项是可以自己配置的,所以应该根据实际需求来决定是否开启。

如果你有心,可以去对比几个版本的String,就会发现不同版本的String实际上内部源码差别相当大,但是这些区别对于开发者来说是透明的,升级JDK版本并不需要担心String的内部变动会对你的代码运行产生影响,String我们该怎么用还是怎么用,内部实现的区别并不会影响到我们的使用。

这是我写的第一篇阅读分析JDK源码的文章,也许是自己太菜了,花费了好几天的时间。而即使是如此长的文章,实际上依然无法完全把String展现出来,它还有很多地方需要注意,甚至大量的细节都足以单独成篇了。

KaelLi
  • 本文由 发表于 2019年2月26日14:32:06
  • 转载请务必保留本文链接:https://www.kaelli.com/33.html
匿名

发表评论

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: