Tip:笔者马上毕业了,准备开始 Java 的进阶学习计划。于是打算先从 String 类的源码分析入手,作为后面学习的案例。这篇文章寄托着今后进阶系列产出的愿望,希望能坚持下去,不忘初心,让自己保持那份对技术的热爱。
因为学习分析源码,所以借鉴了 HollisChuang 成神之路 的大部分内容,并在此基础上对源码进行了学习,在此感谢。
关于 String 字符串,对于 Java 开发者而言,这无疑是一个非常熟悉的类。也正是因为经常使用,其内部代码的设计才值得被深究。所谓知其然,更得知其所以然。
举个例子,假如想要写个类去继承 String,这时 IDE 提示 String 为 final 类型不允许被继承。
此时最先想到的肯定是 java 中类被 final 修饰的效果,其实由这一点也可以引出更多思考: 比如说 String 类被设计成 final 类型是出于哪些考虑?
在 Java 中,被 final 类型修饰的类不允许被其他类继承,被 final 修饰的变量赋值后不允许被修改
查看 String 类在 JDK 7 源码中的定义:
- public final class String implements java.io.Serializable, Comparable<String>, CharSequence{
- ...
- }
可以看出 String 是 final 类型的,表示该类不能被其他类继承,同时该类实现了三个接口:
Comparable
- java.io.Serializable
对于 Sting 类,官方有如下注释说明:
- /*
- Strings are constant;
- their values can not be changed after they are created.
- Stringbuffers support mutable strings.
- Because String objects are immutable they can be shared. Forexample:
- */
String 字符串是常量,其值在实例创建后就不能被修改,但字符串缓冲区支持可变的字符串,因为缓冲区里面的不可变字符串对象们可以被共享。(其实就是使对象的引用发生了改变)
- /**
- The value isused for character storage.
- */
- private final char value[];
这是一个字符数组,并且是 final 类型,用于存储字符串内容。从 fianl 关键字可以看出,String 的内容一旦被初始化后,其不能被修改的。
看到这里也许会有人疑惑,String 初始化以后好像可以被修改啊。比如找一个常见的例子:
其实这里的赋值并不是对 str 内容的修改,而是将 str 指向了新的字符串。另外可以明确的一点:String 其实是基于字符数组 char[] 实现的。
- String str = "hello"; str = "hi"
下面再来看 String 其他属性: 比如缓存字符串的 hash Code,其默认值为 0:
- /**
- Cache the hashcode for the string
- */
- private int hash; //Default to 0
关于序列化 serialVersionUID:
- /**
- use serialVersionUID from JDK 1.0.2 for interoperability
- */
- private static final long serialVersionUID = -6849794470754667710L;
- /**
- Class String is special cased with in the Serialization Stream Protocol.
- */
- privates tatic final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0]
因为 String 实现了 Serializable 接口,所以支持序列化和反序列化支持。Java 的序列化机制是通过在运行时判断类的 serialVersionUID 来验证版本一致性的。在进行反序列化时,JVM 会把传来的字节流中的 serialVersionUID 与本地相应实体(类)的 serialVersionUID 进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常 (InvalidCastException)。
空的构造器
- public String() {
- this.value = "".value;
- }
该构造方法会创建空的字符序列,注意这个构造方法的使用,因为创造不必要的字符串对象是不可变的。因此不建议采取下面的创建 String 对象:
- String str = new String()
- str = "sample";
这样的结果显而易见,会产生了不必要的对象。
使用字符串类型的对象来初始化
- public String(String original) {
- this.value = original.value;
- this.hash = original.hash;
- }
这里将直接将源 String 中的 value 和 hash 两个属性直接赋值给目标 String。因为 String 一旦定义之后是不可以改变的,所以也就不用担心改变源 String 的值会影响到目标 String 的值。
使用字符数组来构造
- public String(char value[]) {
- this.value = Arrays.copyOf(value, value.length);
- }
- public String(char value[], int offset, int count) {
- if (offset < 0) {
- throw new StringIndexOutOfBoundsException(offset);
- }
- if (count <= 0) {
- if (count < 0) {
- throw new String IndexOutOfBoundsException(count);
- }
- if (offset <= value.length) {
- this.value = "".value;
- return;
- }
- }
- //Note:offset or count might be near-1>>>1.
- if (offset > value.length - count) {
- throw new StringIndexOutOfBoundsException(offset + count);
- }
- this.value = Arrays.copyOfRange(value, offset, offset + count);
- }
这里值得注意的是:当我们使用字符数组创建 String 的时候,会用到 Arrays.copyOf 方法或 Arrays.copyOfRange 方法。这两个方法是将原有的字符数组中的内容逐一的复制到 String 中的字符数组中。会创建一个新的字符串对象,随后修改的字符数组不影响新创建的字符串。
使用字节数组来构建 String
在 Java 中,String 实例中保存有一个 char[] 字符数组,char[] 字符数组是以 unicode 码来存储的,String 和 char 为内存形式。
byte 是网络传输或存储的序列化形式,所以在很多传输和存储的过程中需要将 byte[] 数组和 String 进行相互转化。所以 String 提供了一系列重载的构造方法来将一个字符数组转化成 String,提到 byte[] 和 String 之间的相互转换就不得不关注编码问题。
- String(byte[] bytes, Charset charset)
该构造方法是指通过 charset 来解码指定的 byte 数组,将其解码成 unicode 的 char[] 数组,构造成新的 String。
这里的 bytes 字节流是使用 charset 进行编码的,想要将他转换成 unicode 的 char[] 数组,而又保证不出现乱码,那就要指定其解码方式
同样的,使用字节数组来构造 String 也有很多种形式,按照是否指定解码方式分的话可以分为两种:
- public String(byte bytes[]) {
- this(bytes, 0, bytes.length);
- }
- public String(byte bytes[], int offset, int length) {
- checkBounds(bytes, offset, length);
- this.value = StringCoding.decode(bytes, offset, length);
- }
如果我们在使用 byte[] 构造 String 的时候,使用的是下面这四种构造方法(带有 charsetName 或者 charset 参数)的一种的话,那么就会使用 StringCoding.decode 方法进行解码,使用的解码的字符集就是我们指定的 charsetName 或者 charset。
- String(byte bytes[])
- String(byte bytes[], int offset, int length)
- String(byte bytes[], Charset charset)
- String(byte bytes[], String charsetName)
- String(byte bytes[], int offset, int length, Charset charset)
- String(byte bytes[], int offset, int length, String charsetName)
我们在使用 byte[] 构造 String 的时候,如果没有指明解码使用的字符集的话,那么 StringCoding 的 decode 方法首先调用系统的默认编码格式,如果没有指定编码格式则默认使用 ISO-8859-1 编码格式进行编码操作。主要体现代码如下:
- static char[] decode(byte[] ba, int off, int len) {
- String csn = Charset.defaultCharset().name();
- try { //use char set name decode() variant which provide scaching.
- return decode(csn, ba, off, len);
- } catch(UnsupportedEncodingException x) {
- warnUnsupportedCharset(csn);
- }
- try {
- return decode("ISO-8859-1", ba, off, len);
- } catch(UnsupportedEncodingException x) {
- //If this code is hit during VM initiali zation, MessageUtils is the only way we will be able to get any kind of error message.
- MessageUtils.err("ISO-8859-1 char set not available: " + x.toString());
- // If we can not find ISO-8859-1 (are quired encoding) then things are seriously wrong with the installation.
- System.exit(1);
- return null;
- }
- }
使用 StringBuffer 和 StringBuilder 构造一个 String 作为 String 的两个 "兄弟",StringBuffer 和 StringBuilder 也可以被当做构造 String 的参数。
- public String(StringBuffer buffer) {
- synchronized(buffer) {
- this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
- }
- }
- public String(StringBuilder builder) {
- this.value = Arrays.copyOf(builder.getValue(), builder.length());
- }
当然,这两个构造方法是很少用到的,因为当我们有了 StringBuffer 或者 StringBuilfer 对象之后可以直接使用他们的 toString 方法来得到 String。
关于效率问题,Java 的官方文档有提到说使用 StringBuilder 的 toString 方法会更快一些,原因是 StringBuffer 的 toString 方法是 synchronized 的,在牺牲了效率的情况下保证了线程安全。
StringBuilder 的 toString() 方法:
- @Override
- public String toString(){
- //Create a copy, don't share the array
- return new String(value,0,count);
- }
StringBuffer 的 toString() 方法:
- @Override
- public synchronized String toString(){
- if (toStringCache == null){
- toStringCache = Arrays.copyOfRange(value, 0, count);
- }
- return new String(toStringCache, true);
- }
一个特殊的保护类型的构造方法 String 除了提供了很多公有的供程序员使用的构造方法以外,还提供了一个保护类型的构造方法(Java 7),我们看一下他是怎么样的:
- String(char[] value, boolean share) {
- // assert share : "unshared not supported";
- this.value = value;
- }
从代码中我们可以看出,该方法和 String(char[] value) 有两点区别:
为什么 Java 会提供这样一个方法呢?
在 Java 7 之前有很多 String 里面的方法都使用上面说的那种 "性能好的、节约内存的、安全" 的构造函数。 比如:substring replace concat valueOf 等方法
实际上它们使用的是 public String(char[], ture) 方法来实现。
但是在 Java 7 中,substring 已经不再使用这种 "优秀" 的方法了
- public String substring(int beginIndex, int endIndex) {
- if (beginIndex < 0) {
- throw new StringIndexOutOfBoundsException(beginIndex);
- }
- if (endIndex > value.length) {
- throw new StringIndexOutOfBoundsException(endIndex);
- }
- intsubLen = endIndex - beginIndex;
- if (subLen < 0) {
- throw new StringIndexOutOfBoundsException(subLen);
- }
- return ((beginIndex == 0) && (endIndex == value.length)) ? this: newString(value, beginIndex, subLen);
- }
为什么呢? 虽然这种方法有很多优点,但是他有一个致命的缺点,对于 sun 公司的程序员来说是一个零容忍的 bug,那就是他很有可能造成内存泄露。
看一个例子,假设一个方法从某个地方(文件、数据库或网络)取得了一个很长的字符串,然后对其进行解析并提取其中的一小段内容,这种情况经常发生在网页抓取或进行日志分析的时候。
下面是示例代码:
- String aLongString = "...averylongstring...";
- String aPart = data.substring(20, 40);
- return aPart;
在这里 aLongString 只是临时的,真正有用的是 aPart,其长度只有 20 个字符,但是它的内部数组却是从 aLongString 那里共享的,因此虽然 aLongString 本身可以被回收,但它的内部数组却不能释放。这就导致了内存泄漏。如果一个程序中这种情况经常发生有可能会导致严重的后果,如内存溢出,或性能下降。
新的实现虽然损失了性能,而且浪费了一些存储空间,但却保证了字符串的内部数组可以和字符串对象一起被回收,从而防止发生内存泄漏,因此新的 substring 比原来的更健壮。
length() 返回字符串长度
- public int length() {
- return value.length;
- }
isEmpty() 返回字符串是否为空
- public boolean isEmpty() {
- return value.length == 0;
- }
charAt(int index) 返回字符串中第(index+1)个字符(数组索引)
- public char charAt(int index) {
- if ((index < 0) || (index >= value.length)) {
- throw new StringIndexOutOfBoundsException(index);
- }
- return value[index];
- }
转化成字符数组 trim() 去掉两端空格 toUpperCase() 转化为大写 toLowerCase() 转化为小写
- char[] toCharArray()
需要注意
拼接字符串
- String concat(String str)
将字符串中的 oldChar 字符换成 newChar 字符
- String replace(char oldChar, char newChar)
以上两个方法都使用了
concat 方法和 replace 方法,他们不会导致元数组中有大量空间不被使用,因为他们一个是拼接字符串,一个是替换字符串内容,不会将字符数组的长度变得很短,所以使用了共享的 char[] 字符数组来优化。
- String(char[] value, boolean share)
判断字符串是否匹配给定的 regex 正则表达式
- boolean matches(String regex)
判断字符串是否包含字符序列 s
- boolean contains(CharSequence s)
按照字符 regex 将字符串分成 limit 份
- String[] split(String regex, int limit)
按照字符 regex 将字符串分段
- String[] split(String regex)
getBytes
在创建 String 的时候,可以使用 byte[] 数组,将一个字节数组转换成字符串,同样,我们可以将一个字符串转换成字节数组,那么 String 提供了很多重载的 getBytes 方法。
- public byte[] getBytes() {
- return StringCoding.encode(value, 0, value.length);
- }
但是,值得注意的是,在使用这些方法的时候一定要注意编码问题。比如:
这段代码在不同的平台上运行得到结果是不一样的。由于没有指定编码方式,所以在该方法对字符串进行编码的时候就会使用系统的默认编码方式。
- String s = "你好,世界!"; byte[] bytes = s.getBytes();
在中文操作系统中可能会使用 GBK 或者 GB2312 进行编码,在英文操作系统中有可能使用 iso-8859-1 进行编码。这样写出来的代码就和机器环境有很强的关联性了,为了避免不必要的麻烦,要指定编码方式。
- public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
- if (charsetName == null) throw new NullPointerException();
- return StringCoding.encode(charsetName, value, 0, value.length);
- }
比较对象
- boolean equals(Object anObject);
与字符串比较内容
- boolean contentEquals(String Buffersb);
与字符比较内容
- boolean contentEquals(Char Sequencecs);
忽略大小写比较字符串对象
- boolean equalsIgnoreCase(String anotherString);
比较字符串
- int compareTo(String anotherString);
忽略大小写比较字符串
- int compareToIgnoreCase(String str);
局部匹配
- boolean regionMatches(int toffset, String other, int ooffset, int len)
可忽略大小写局部匹配
- boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len)
字符串有一系列方法用于比较两个字符串的关系。 前四个返回 boolean 的方法很容易理解,前三个比较就是比较 String 和要比较的目标对象的字符数组的内容,一样就返回 true, 不一样就返回 false,核心代码如下:
- int n = value.length;
- while (n-- ! = 0) {
- if (v1[i] != v2[i])
- return false;
- i++;
- }
v1 v2 分别代表 String 的字符数组和目标对象的字符数组。 第四个和前三个唯一的区别就是他会将两个字符数组的内容都使用 toUpperCase 方法转换成大写再进行比较,以此来忽略大小写进行比较。相同则返回 true,不想同则返回 false
equals 方法:
- 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;
- }
该方法首先判断 this == anObject ?,也就是说判断要比较的对象和当前对象是不是同一个对象,如果是直接返回 true,如不是再继续比较,然后在判断 anObject 是不是 String 类型的,如果不是,直接返回 false,如果是再继续比较,到了能终于比较字符数组的时候,他还是先比较了两个数组的长度,不一样直接返回 false,一样再逐一比较值。 虽然代码写的内容比较多,但是可以很大程度上提高比较的效率。值得学习!!!
StringBuffer 需要考虑线程安全问题,加锁之后再调用
contentEquals 有两个重载:
,另外一种是参数是 String 类型。具体比较方式几乎和 equals 方法类似,先做 "宏观" 比较,在做 "微观" 比较。
- cs instanceof AbstractStringBuilder
下面这个是 equalsIgnoreCase 代码的实现:
- public boolean equalsIgnoreCase(String anotherString) {
- return (this == anotherString) ? true: (anotherString != null) && (anotherString.value.length == value.length) && regionMatches(true, 0, anotherString, 0, value.length);
- }
看到这段代码,眼前为之一亮。使用一个三目运算符和 && 操作代替了多个 if 语句。
- 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;
- }
hashCode 的实现其实就是使用数学公式:s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-1]
所谓 "冲突",就是在存储数据计算 hash 地址的时候,我们希望尽量减少有同样的 hash 地址。如果使用相同 hash 地址的数据过多,那么这些数据所组成的 hash 链就更长,从而降低了查询效率。
所以在选择系数的时候要选择尽量长的系数并且让乘法尽量不要溢出的系数,因为如果计算出来的 hash 地址越大,所谓的 "冲突" 就越少,查找起来效率也会提高。
现在很多虚拟机里面都有做相关优化,使用 31 的原因可能是为了更好的分配 hash 地址,并且 31 只占用 5 bits。
在 Java 中,整型数是 32 位的,也就是说最多有 2^32 = 4294967296 个整数,将任意一个字符串,经过 hashCode 计算之后,得到的整数应该在这 4294967296 数之中。那么,最多有 4294967297 个不同的字符串作 hashCode 之后,肯定有两个结果是一样的。
hashCode 可以保证相同的字符串的 hash 值肯定相同,但是 hash 值相同并不一定是 value 值就相同。
substring 前面我们介绍过,java 7 中的 substring 方法使用 String(value, beginIndex, subLen) 方法创建一个新的 String 并返回,这个方法会将原来的 char[] 中的值逐一复制到新的 String 中,两个数组并不是共享的,虽然这样做损失一些性能,但是有效地避免了内存泄露。
replaceFirst、replaceAll、replace 区别
- String replaceFirst(String regex, String replacement)
- String replaceAll(String regex, String replacement)
- String replace(Char Sequencetarget, Char Sequencereplacement)
- public String replace(char oldChar, char newChar) {
- if (oldChar != newChar) {
- int len = value.length;
- int i = -1;
- char[] val = value;
- /*avoid get field opcode*/
- while (++i < len) {
- if (val[i] == oldChar) {
- break;
- }
- }
- if (i < len) {
- char buf[] = new char[len];
- for (intj = 0; j < i; j++) {
- buf[j] = val[j];
- }
- while (i < len) {
- char c = val[i];
- buf[i] = (c == oldChar) ? newChar: c;
- i++;
- }
- return new String(buf, true);
- }
- }
- return this;
- }
比如可以通过 replaceAll ("\d", "*") 把一个字符串所有的数字字符都换成星号;
相同点是都是全部替换,即把源字符串中的某一字符或字符串全部换成指定的字符或字符串,如果只想替换第一次出现的,可以使用 replaceFirst(),这个方法也是基于规则表达式的替换。另外, 如果 replaceAll() 和 r eplaceFirst() 所用的参数据不是基于规则表达式的,则与 replace() 替换字符串的效果是一样的,即这两者也支持字符串的操作。
copyValueOf 和 valueOf String 的底层是由 char[] 实现的,早期的 String 构造器的实现呢,不会拷贝数组的,直接将参数的 char[] 数组作为 String 的 value 属性。字符数组将导致字符串的变化。
为了避免这个问题,提供了 copyValueOf 方法,每次都拷贝成新的字符数组来构造新的 String 对象。
现在的 String 对象,在构造器中就通过拷贝新数组实现了,所以这两个方面在本质上已经没区别了。
valueOf() 有很多种形式的重载,包括:
- public static String valueOf(boolean b) {
- return b ? "true": "false";
- }
- public static String valueOf(char c) {
- char data[] = {
- c
- };
- return new String(data, true);
- }
- public static String valueOf(int i) {
- return Integer.toString(i);
- }
- public static String valueOf(long l) {
- return Long.toString(l);
- }
- public static String valueOf(float f) {
- return Float.toString(f);
- }
- public static String valueOf(double d) {
- return Double.toString(d);
- }
可以看到这些方法可以将六种基本数据类型的变量转换成 String 类型。
intern() 方法
该方法返回一个字符串对象的内部化引用。 String 类维护一个初始为空的字符串的对象池,当 intern 方法被调用时,如果对象池中已经包含这一个相等的字符串对象则返回对象池中的实例,否则添加字符串到对象池并返回该字符串的引用。
- public native String intern();
我们知道,Java 是不支持重载运算符,String 的 "+" 是 java 中唯一的一个重载运算符,那么 java 使如何实现这个加号的呢?我们先看一段代码:
- public static void main(String[] args) {
- String string = "hello";
- String string2 = string + "world";
- }
然后我们将这段代码的实际执行情况贴出来看看:
- public static void main(String args[]) {
- String string = "hollo";
- String string2 = (new StringBuilder(String.valueOf(string))).append("world").toString();
- }
看了反编译之后的代码我们发现,其实 String 对 "+" 的支持其实就是使用了 StringBuilder 以及他的 append、toString 两个方法。
String.valueOf 和 Integer.toString 的区别 接下来我们看以下这段代码,我们有三种方式将一个 int 类型的变量变成呢过 String 类型,那么他们有什么区别?
- int i = 5;
- String i1 = "" + i;
- String i2 = String.valueOf(i);
- String i3 = Integer.toString(i);
第三行和第四行没有任何区别,因为 String.valueOf(i) 也是调用 Integer.toString(i) 来实现的。 第二行代码其实是 String i1 = (new StringBuilder()).append(i).toString();
首先创建了一个 StringBuilder 对象,然后再调用 append 方法,再调用 toString 方法。
还是先上代码:
- public class switchDemoString {
- public static void main(String[] args) {
- String str = "world";
- switch (str) {
- case "hello":
- System.out.println("hello");
- break;
- case "world":
- System.out.println("world");
- break;
- default:
- break;
- }
- }
- }
对编译后的代码进行反编译:
- public static void main(String args[]) {
- String str = "world";
- String s;
- switch ((s = str).hashCode()) {
- case 99162322:
- if (s.equals("hello")) System.out.println("hello");
- break;
- case 113318802:
- if (s.equals("world")) System.out.println("world");
- break;
- default:
- break;
- }
- }
看到这个代码,你知道原来字符串的 switch 是通过 equals() 和 hashCode() 方法来实现的。记住,switch 中只能使用整型,比如 byte,short,char(ackii 码是整型) 以及 int。还好 hashCode() 方法返回的是 int 而不是 long。
通过这个很容易记住 hashCode 返回的是 int 这个事实。仔细看下可以发现,进行 switch 的实际是哈希值,然后通过使用 equals 方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。
因此性能是不如使用枚举进行 switch 或者使用纯整数常量,但这也不是很差。因为 Java 编译器只增加了一个 equals 方法,如果你比较的是字符串字面量的话会非常快,比如 "abc" =="abc" 。如果你把 hashCode() 方法的调用也考虑进来了,那么还会再多一次的调用开销,因为字符串一旦创建了,它就会把哈希值缓存起来。 因此如果这个 siwtch 语句是用在一个循环里的,比如逐项处理某个值,或者游戏引擎循环地渲染屏幕,这里 hashCode() 方法的调用开销其实不会很大。
其实 swich 只支持一种数据类型,那就是整型,其他数据类型都是转换成整型之后在使用 switch 的。
特别要注意的是,String 类的所有方法都没有改变字符串本身的值,都是返回了一个新的对象。
否则会有大量时间浪费在垃圾回收上,因为每次试图修改都有新的 String 对象被创建出来。
来源: https://juejin.im/post/5a3522f8f265da43052eb265