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

  • A+
所属分类:Java

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

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

String的类声明

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

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

用来记录String的hash值。

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

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

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

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

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

String的构造方法

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

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

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)方法实现的

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

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

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

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

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

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

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

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

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

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

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

String的其他方法

  • 实现CharSequence接口的方法

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

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

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

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

  • 代码点(CodePoint)相关方法

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

  • 获取char数组

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

  • 获取byte数组

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

  • 判断和比较方面的方法

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

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

 

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

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

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

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

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

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

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

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

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

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

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

  • 获取索引系列方法

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

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

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

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

  • 对字符串进行各种操作处理的方法

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • String的hashCode()方法

继续查看源码后,发现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

发表评论

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