String源码浅析

微信扫一扫,分享到朋友圈

String源码浅析

如果问你,开发过程中用的最多的类是哪个?你可能回答是 HashMap ,一个原因就是HashMap的使用量的确很多,还有就是HashMap的内容在面试中经常被问起。

但是在开发过程中使用最多的类其实并不是HashMap类,而是“默默无闻”的String类。假如现在问你String类是怎么实现的?这个类为什么是不可变类?这个类为什么不能被继承?这些问题你都能回答么。本文就从String源代码出发,来看下String到底是怎么实现的,并详细介绍下String类的API的用法。

String源码结构

首先要说明的是本文的源码是以 JDK11为基准 ,选择JDK11的原因是JDK11是一个LTS版本(长期支持版本),没选择现阶段还在广泛使用的JDK8的原因是想在看源码的过程中学习下JDK的新特性。

还有要说下的就是:大家在看源码时一定要注意JDK的版本,因为不同版本的实现有较大的差异。比如说String的实现在高低版本中就差异比较大。如果你是一个博客主,更加要注明代码的版本了,不然读者可能会很疑惑,为什么和自己之前看的不一样。

好了,下面就言归正传来看下String在JDK11中的实现代码。

public final class String implements Serializable, Comparable<String>, CharSequence {
@Stable
//字节数组,存放String的内容,如果你看的是较低版本的源代码,这个变量可能是char[]类型,这个其实是JDK9开始对String做的一个优化
//具体是做了什么优化我们下面再讲,这边先卖个关子
private final byte[] value;
//也是和String压缩优化有关,指定当前的LATIN1码还是UTF16码
private final byte coder;
//哈希值
private int hash;
//序列化Id
private static final long serialVersionUID = -6849794470754667710L;
//优化压缩开关,默认开启
static final boolean COMPACT_STRINGS = true;
private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0];
public static final Comparator<String> CASE_INSENSITIVE_ORDER = new String.CaseInsensitiveComparator();
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
//... 下面部分代码省略
}

从实现的接口看,String类有如下特点:

  • String类被 final 关键字修饰,因此不能被继承。
  • String的成员变量value使用final修饰,因此是不可变的,线程安全;
  • String类实现了Serializable接口,可以实现序列化。
  • String类实现了Comparable,可以比较大小。
  • String类实现了CharSequence接口,String本质是个数组,低版本中是char数组,JDK9以后优化成byte数组,从String的成员变量value就可以看出来。

这边说一个看源代码的小技巧: 看一个类的源代码时,我们先看下这个类实现了哪些接口,就可以大概知道这个类的主要作用功能是什么了。

JDK9对String的优化

这边首先要讲下 JDK 9 中对String的优化,如果你不了解这块优化点的话,看String的代码时会感到非常疑惑。

背景知识

在Java中,一个字节char占用两个字节的内存空间。在低版本的JDK中,String的内部默认维护的是一个char[]数组,也就是说一个字符串中包含一个字符,这个字符串内部就包含一个相应长度的字符数组。这样就会出现下面这种情况:

String s = "ddd";
String s1 = "自由之路";

上面两个字符串内部的情况实际上是:

char[] value = ['d','d','d'];
char[] value1 = ['自','由','之','路'];

对于字符串s,我们发现其中每个字符其实都是可以用一个字节表示的,而现在使用两个字符的char类型来表示,明显就浪费了一倍的内存空间。

而且根据统计,在实际程序运行中,字符串中包含的字符大多都是可以用一个字节表示的字符,所以优化的空间很大。优化的方式就是在String内部使用byte[]数组来表示字符串,而不是使用char[]数组。当检测到,字符串中的所有字符在Unicode码集中的码值可以使用一个字节表示时,就可以节省一半的空间。

JDK6 中的Compressed Strings

其实在JDK6中就对String类做过类似的优化:在Java 6引入了Compressed Strings,对于one byte per character的字符串使用byte[],对于two bytes per character的字符串继续使用char[]。

使用-XX:+UseCompressedStrings来开启上面的优化。不过由于开启这个特性后会造成一些不可知的异常,这个特性在java7中被废弃了,然后在java8被移除。

JDK9中的Compact String

Java 9 重新采纳字符串压缩这一概念。

和JDK6不同的是: 无论何时我们创建一个所有字符都能用一个字节的 LATIN-1 编码来描述的字符串,都将在内部使用字节数组的形式存储 ,且每个字符都只占用一个字节。另一方面,如果字符串中任一字符需要多于 8 比特位来表示时,该字符串的所有字符都统统使用两个字节的 UTF-16 编码来描述。因此基本上能如果可能,都将使用单字节来表示一个字符。

//占用3个字节
String ss = new String("ddd");
//占用14个字节
String s = "自由之路ddd";

现在的问题是:所有的字符串操作如何执行? 怎样才能区分字符串是由 LATIN-1 还是 UTF-16 来编码?为了处理这些问题,字符串的内部实现进行了一些调整。引入了一个 final 修饰的成员变量 coder , 由它来保存当前字符串的编码信息。

//所有的字符串都用byte数组存储
private final byte[] value;
//用coder标示字符串中所有的字符是不是都可以用一个字节表示,它的值只有两个LATIN1:1,标示所有字符都可以用一个字节表示,UTF16:标示字符串中部分字符需要两个字节表示。
private final byte coder;
//下面是两个常量
static final byte LATIN1 = 0;
static final byte UTF16 = 1;

现在,大多数的字符串操作都将检查 coder 变量,从而采取特定的实现:

public int indexOf(int ch, int fromIndex) {
return isLatin1()
? StringLatin1.indexOf(value, ch, fromIndex)
: StringUTF16.indexOf(value, ch, fromIndex);
}
private boolean isLatin1() {
return COMPACT_STRINGS && coder == LATIN1;
}

我们再看下String的一个常用方法:

public int length() {
return value.length >> coder;
}

这个方法是要计算字符串的长度,含义也很清楚。根据coder字段判断当前的字符串中一个字符使用几个字节表示,如果是coder等于0,也是LATIN1模式,那么所有字符都是用一个字节表示,直接返回byte[]数组的长度就可以。

如果coder等于1,那么标示字符串中所有字符都是用两个字节表示的,计算字符串的长度需要将byte[]数组除以2。 value.length >> coder 就是这个意思。

因为对String做了上面的优化,所以String的很多方法在操作时都需要判断现在的模式是LATIN1还是UTF16模式,具体的方法这边就不一一举例了。但是这些判断对使用String的开发者时无感的。

当然,String的这个优化特性可以关闭,使用下面的启动参数就可以。

+XX:-CompactStrings

String的常用构造方法

//构建空字符串
public String() {
this.value = "".value;
this.coder = "".coder;
}
//根据已有的字符串,创建一个新的字符串
@HotSpotIntrinsicCandidate
public String(String original) {
this.value = original.value;
this.coder = original.coder;
this.hash = original.hash;
}
//根据字符数组,创建字符串,创建的过程中有压缩优化的逻辑,具体见下面的方法
public String(char[] value) {
this((char[])value, 0, value.length, (Void)null);
}
String(char[] value, int off, int len, Void sig) {
if (len == 0) {
this.value = "".value;
this.coder = "".coder;
} else {
if (COMPACT_STRINGS) {
//如果发现这个字符数组可以压缩,就使用LATIN1方式
byte[] val = StringUTF16.compress(value, off, len);
if (val != null) {
this.value = val;
this.coder = 0;
return;
}
}
//不能进行压缩优化,还是使用UTF16的方式
this.coder = 1;
this.value = StringUTF16.toBytes(value, off, len);
}
}

String中还有很多构造方法,但是都会大同小异,大家可以自己看源代码。

String常用方法总结

这边总结下String的常用方法,一些比较简单的方法就不具体讲了。我们挑选一些比较重要的方法,具体讲下他们的使用方法。

  • codePointAt(int index):返回下标是index的字符在Unicode码集中的码点值;
  • codePoints():返回字符串中每个字符在Unicode码集中的码点值;
  • compareToIgnoreCase(String other):忽略大小写比较字符大小;
  • concat(String other):字符串拼接函数;
  • equalsIgnoreCase(String other):忽略大小写比较字符串;
  • format:字符串格式化函数,比较有用;
  • getBytes(String charSet):获取字符串在特定编码下的字节数组;
  • indexOf(String s):返回字符串s的下标,不存在返回-1;
  • intren():作用是检测常量池中是否有当前字符串,有的话就返回常量池中的对像,没有的话就将当前对像放入常量池。
  • isBlank():如果字符串为空或只包含空白字符,则返回true,否则返回false,JDK11新加的API;
  • length():返回字符长度;
  • lines():从字符串返回按行分割的Stream,行分割福包括:n ,r 和rn,stream包含了按顺序分割的行,行分隔符被移除了,这个方法会类似split(),但性能更好;这个也是JDK11新加的API
  • matchs(String regex):和某个正则是否匹配;
  • regionMatches(int firstStart, String other, int otherStart, int len):当某个字符串调用该方法时,表示从当前字符串的firstStart位置开始,取一个长度为len的子串;然后从另一个字符串other的otherStart位置开始也取一个长度为len的子串,然后比较这两个子串是否相同,如果这两个子串相同则返回true,否则返回false。
  • repeat():返回一个字符串,其内容是字符串重复n次后的结果,JDK11新加入的函数;
  • String[] split(String regex, int limit):分割字符串,注意limit参数的使用,下面会详细讲;
  • startsWith(String prefix, int toffset):判断字符串是否以prefix打头;
  • replace(char oldChar, char newChar):使用newChar替换所有的oldChar,不是基于正则表达式的;
  • replace(CharSequence target, CharSequence replacement):替换所有,基于正则表达式的;
  • replaceFirst(String regex, String replacement):替换regex匹配的第一个字符串,基于正则表达式;
  • replaceAll(String regex, String replacement):替换regex匹配的所有字符串,基于正则表达式;
  • strip() :去除字符串前后的“全角和半角”空白字符,这个函数在JDK中11才引入,注意和trim的区别,关于全角和半角的区别,可以参考这篇文章,还提供了stripLeading()和stripTrailing(),可以分别去掉头部或尾部的空格;
  • subString(int fromIndex):从指定位置开始截取到字符串结尾部分的子串;
  • subString(int fromIndex,int endIndex):截取字符串指定下标的子串;
  • toCharArray():转换成字符数组;
  • toUpperCase(Locale locale) :小写转换成大写;
  • toLowerCase(Locale locale):大写转换成小写;
  • trim():去除字符串前后的空白字符(空格、tab键、换行符等,具体的话是去除ascll码小于32的字符),注意trim和strip的区别;
  • valueof 系列方法:将其他类型的数据转换成String类型,比如将bool、int和long等类型转换成String类型。

concat字符串拼接函数

concat函数是字符串拼接函数,介绍这个函数并不是因为这个函数比较重要或者实现比较复杂。而是因为通过这个函数的源代码我们可以看出很多String的特性。

public String concat(String str) {
//如果被拼接的字符串的长度是0,直接返回自己
int olen = str.length();
if (olen == 0) {
return this;
} else {
byte[] buf;
//如果当前字符串和被拼接的字符串的编码模式相同,都是LATIN1或者都是UTF16
if (this.coder() == str.coder()) {
byte[] val = this.value;
buf = str.value;
//计算出新字符串所需字节的长度
int len = val.length + buf.length;
byte[] buf = Arrays.copyOf(val, len);
//使用系统函数拷贝
System.arraycopy(buf, 0, buf, val.length, buf.length);
//根据新的字节数组生成一个新的字符串
return new String(buf, this.coder);
} else {
//当前字符串和被拼接的字符串的编码模式相同,那么必须使用UTF16的编码模式
int len = this.length();
buf = StringUTF16.newBytesFor(len + olen);
this.getBytes(buf, 0, (byte)1);
str.getBytes(buf, len, (byte)1);
return new String(buf, (byte)1);
}
}
}

format函数

String的format方法是一个很有用的方法,可以用来对字符串、数字、日期和时间等进行格式化。

//对整数格式化,4位显示,不足4位补0
//超过4位,还是原样显示
int num = 999;
String str = String.format("%04d", num);
System.out.println(str);
//对日期进行格式化
String format = String.format("%tF", new Date());
System.out.println(format);

format方法还有很多用法,大家可以自己查询使用。

regionMatches

该方法的定义如下:

regionMatches(int firstStart, String other, int otherStart, int len)

当某个字符串调用该方法时,表示从当前字符串的firstStart位置开始,取一个长度为len的子串;然后从另一个字符串other的otherStart位置开始也取一个长度为len的子串,然后比较这两个子串是否相同,如果这两个子串相同则返回true,否则返回false。

该方法还有另一种重载:

str.regionMatches(boolean ignoreCase, int firstStart, String other, int otherStart, int len)

可以看到只是多了一个boolean类型的参数,用来确定比较时是否忽略大小写,当ignoreCase为true表示忽略大小写。

split函数

String的split函数我们平时也经常使用,但是估计很多人都没有注意这个函数的第二个参数:limit

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

首先,split方法的作用是根据给定的regex去分割字符串,将分割完成的字符数组返回。其中limit参数的作用是:

  • 当limit>0时,limit代表最后的数组长度,同时一共会分割limit-1次,最后没有切割完成的直接放在一起;

  • 当limit=0时(默认值),会尽量多去分割,并且如果分割完的字符数组末尾是空字符串,会去除这个空字符串;

  • 当limit<0时,会尽量多去分割,但不会去掉末尾的空字符串。

下面举个列子:

String s1 = "博客园|CSDN||";
String[] split1 = s1.split("\\|", 2);
System.out.println("split1 length:" + split1.length);
System.out.println("split1 content:" + Arrays.toString(split1));
String[] split2 = s1.split("\\|", 0);
System.out.println("split2 length:" + split2.length);
System.out.println("split2 content:" + Arrays.toString(split2));
String[] split3 = s1.split("\\|", -1);
System.out.println("split3 length:" + split3.length);
System.out.println("split3 content:" + Arrays.toString(split3));
System.out.println("---换一个复杂点的字符串---");
s1 = "|博客园||CSDN|自由之路ddd|";
split1 = s1.split("\\|", 2);
System.out.println("split1 length:" + split1.length);
System.out.println("split1 content:" + Arrays.toString(split1));
split2 = s1.split("\\|", 0);
System.out.println("split2 length:" + split2.length);
System.out.println("split2 content:" + Arrays.toString(split2));
split3 = s1.split("\\|", -1);
System.out.println("split3 length:" + split3.length);
System.out.println("split3 content:" + Arrays.toString(split3));

下面是输出结果,对照着这个结果大家就应该能明白split方法的使用了

split1 length:2
split1 content:[博客园, CSDN|自由之路ddd|]
split2 length:3
split2 content:[博客园, CSDN, 自由之路ddd]
split3 length:4
split3 content:[博客园, CSDN, 自由之路ddd, ]
---换一个复杂点的字符串---
split1 length:2
split1 content:[, 博客园||CSDN|自由之路ddd|]
split2 length:5
split2 content:[, 博客园, , CSDN, 自由之路ddd]
split3 length:6
split3 content:[, 博客园, , CSDN, 自由之路ddd, ]

总结

  • String类被 final 关键字修饰,因此不能被继承;
  • String的成员变量value使用final修饰,因此是不可变的,线程安全;
  • String中的方法对字符串的操作都会生成一个新的String对象;
  • JDK9开始对String进行了优化,内部彻底使用byte[]数组来代替char数组。

参考

公众号推荐

欢迎大家关注我的微信公众号「程序员自由之路」

6个关于编程的笑话

上一篇

教你在几分钟内构建一个Python包

下一篇

你也可能喜欢

String源码浅析

长按储存图像,分享给朋友