JDK核心JAVA源码解析(2) - String(上)

想写这个系列很久了,对自己也是个总结与提高。原来在学JAVA时,那些JAVA入门书籍会告诉你一些规律还有法则,但是用的时候我们一般很难想起来,因为我们用的少并且不知道为什么。知其所以然方能印象深刻并学以致用。

本文从JDK 1.8 & JDK 9.0 String的组成区别开始讨论,之后以9.0为准讨论代码

2. String类

2.1. String的基本组成(1.8与9.0的区别)

Java中所有的双引号字面量代表字符串都是String这个类实现的。String一旦创建就不能更改。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence
//String类被final修饰了,表明其不可被继承。
//实现了 Serializable接口--->使其可以序列化,方便数据的传输
//实现了Comparable接口,可以调用Collections.sort() 和 Arrays.sort() 方法排序,并且String类实现 compareTo() 方法。
//实现了CharSequence接口,该接口能表示char值的一个可读序列。几个String兄弟类都实现了此接口。

2.1.1. 存储字符串

在JDK1.8中,采用了char数组来存储字符串,和C语言一样。Java的char默认是UTF16编码的,即无论是什么字符,都用两个字节表示。但是对于字符串中所有字符编码都在0xff(例如只包含ASCII编码的字符)之内的字符,相当于多耗费了一倍的空间。针对这个问题JDK9将字符串的底层存储由char数组改成了byte数组。并检查如果字符串中有没有大于0xff的字符,如果没有就用一个字节存储字符串,如果有,则所有字符用两个字节存储(这个是可以通过COMPACT_STRINGS配置的)。

JDK 1.8:

/** The value is used for character storage. */
private final char value[];

JDK 9.0:

//在BootstrapClassLoader加载的类中被Stable注解修饰的变量或者数组,从null和0变为非null和非0的值,只能有一次
@Stable
//这个属性被java虚拟机信任,如果String实例恒定,此属性也不可改变,重写会造成问题。
private final byte[] value;
//原来的char数组变成了一个byte数组和一个编码器标识coder
//此属性为用于编码字节的编码的标识符,分为 LATIN1 与 UTF16,同样也是被虚拟机信任,不可变,不重写。
private final byte coder;

这里的Stable注解是JDK内部注解,只对于被Bootstrap加载的类生效;对于被这个注解修饰的变量或者数组,值或其中所有只能被修改一次。引用类型初始为null,原生类型初始为0,他们能被修改为非null或者非0只能修改一次。

coder一般只有两个值:

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

Latin1是ISO-8859-1的别名,有些环境下写作Latin-1。ISO-8859-1编码是单字节编码,向下兼容ASCII,其编码范围是0x00-0xFF,0x00-0x7F之间完全和ASCII一致。
UTF16就是两字节编码。

很多方法需要区分是单字节编码还是双字节编码,一般通过isLatin1来区分:

static final boolean COMPACT_STRINGS;

static {
    COMPACT_STRINGS = true;
}

private boolean isLatin1() {
    return COMPACT_STRINGS && coder == LATIN1;
}

COMPACT_STRINGS为是否启用之前提到了byte数组压缩,代码中可以看出,默认就是启用。实际上这个是JVM注入的(布尔变量COMPACT_STRINGS是由命令行参数XX:-CompactStrings定义的,并且也能用该参数禁用掉),如果没有分开写,而是这么写的话:

static final boolean COMPACT_STRINGS = true;

那么像COMPACT_STRINGS && coder == LATIN1这样的判断,会被编译器优化成为等同于coder == LATIN1;为了避免这个JIT优化,需要分开写。

2.1.2. 字符串哈希值

//哈希值的缓存
private int hash; // Default to 0
//获取哈希值,如果hash为0则重新计算,不为0则直接返回
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        hash = h = isLatin1() ? StringLatin1.hashCode(value)
                              : StringUTF16.hashCode(value);
    }
    return h;
}

计算哈希值需要区分是单字节编码还是双字节编码,就通过上面的isLatin1方法判断。
String的哈希算法比较简单,就是将每一个char提取出来,并移位相加;单字节与双字节编码区别就在于取字符上面

2.1.3 标准序列化相关属性

这块我们并不深入,因为JAVA自带的序列化机制太废了,基本没人用。

//从jdk1.0.2 String就开始用这个序列化版本号
private static final long serialVersionUID = -6849794470754667710L;
//因为String是一个特殊的需要序列化的类(相当于toString就是他的序列化方式),而String里面有很多getter,例如getBytes(),这些都是不需要被序列化的,所以利用new ObjectStreamField[0]来舍弃序列化getter属性
private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];

JDK序列化标准,参考:https://docs.oracle.com/javase/6/docs/platform/serialization/spec/protocol.html#8299

2.2 String构造器以及String初始化的特殊机制

String的很多特性需要我们结合字节码来分析。否则很难理解。
首先我们来看他的所有构造器,其中有两个一般用不到:

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

因为String本来就是不可变的,所以这种构造器显得没啥意义

2.2.1 以字符数组为输入的构造器

public String(char value[]) {
    this.value = Arrays.copyOf(value, value.length);
}
//以字符串数组,起始偏移和个数生成字符串
public String(char value[], int offset, int count) {
    //如果offset<0,则抛异常,偏移量最小是0
    if (offset < 0) {
        throw new StringIndexOutOfBoundsException(offset);
    }
    //如果count<0,则也不合法
    if (count <= 0) {
        if (count < 0) {
            throw new StringIndexOutOfBoundsException(count);
        }
        //如果count = 0并且偏移量合理的话就是空字符串
        if (offset <= value.length) {
            this.value = "".value;
            return;
        }
    }
    //如果偏移量+count大于数组长度,则不合理
    if (offset > value.length - count) {
        throw new StringIndexOutOfBoundsException(offset + count);
    }
    //利用Arrays.copy来复制数组,高效
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

2.2.2 利用代码点初始化字符串

代码点还有代码单元
代码点和代码单元和编码有关。刚刚开始时人们认为2个字节的代码宽度足以能够对全世界各种语言的所有字符进行编码,并留下足够的空间给未来扩展,当时仅占用65535不到一半的部分,所以java的设计采用了16位的Unicode字符集。可是十分遗憾的是,经过一段时间Unicode字符超过了65535个,现在16位的char类型已经不能满足所有Unicode字符的需要了。为了解释java是怎么解决这个问题的,所有就使用了这两概念。

代码点: 是指一个编码表中的某个字符对应的代码值,也就是Unicode编码表中每个字符对应的数值。
Unicode标准中,代码点采用16进制书写,并加上前缀U+,比如字符A对于的编码值是U+0041,Unicode的代码点可以分成17个代码级别。第一个代码级别称为基本的多语言级别,代码点从U+0000到U+FFFF,其中包括了经典的Unicode代码,其余的16个附加级别,代码点从U+10000到U+10FFFF,其中包括了一些增补字符。代码单元: 组成一个字符的组成元素

//以代码点数组为输入
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) {
        //根据latin1编码
        byte[] val = StringLatin1.toBytes(codePoints, offset, count);
        //如果都是单字节字符,就以LATIN1编码
        if (val != null) {
            this.coder = LATIN1;
            this.value = val;
            return;
        }
    }
    //没有压缩的话,默认是UTF16,根据UTF16编码
    this.coder = UTF16;
    this.value = StringUTF16.toBytes(codePoints, offset, count);
}
//检查是否越界
static void checkBoundsOffCount(int offset, int count, int length) {
    if (offset < 0 || count < 0 || offset > length - count) {
        throw new StringIndexOutOfBoundsException(
            "offset " + offset + ", count " + count + ", length " + length);
    }
}

2.2.3 利用byte数组初始化字符串

//指定字符集和偏移量还有长度来构造字符串,其实主要就是按照字符集解码保存为字符串
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, Charset charset) {
    if (charset == null)
        throw new NullPointerException("charset");
    checkBoundsOffCount(offset, length, bytes.length);
    StringCoding.Result ret =
        StringCoding.decode(charset, bytes, offset, length);
    this.value = ret.value;
    this.coder = ret.coder;
}
public String(byte bytes[], String charsetName)
        throws UnsupportedEncodingException {
    this(bytes, 0, bytes.length, charsetName);
}
public String(byte bytes[], Charset charset) {
    this(bytes, 0, bytes.length, charset);
}
public String(byte bytes[], int offset, int length) {
    checkBoundsOffCount(offset, length, bytes.length);
    StringCoding.Result ret = StringCoding.decode(bytes, offset, length);
    this.value = ret.value;
    this.coder = ret.coder;
}
public String(byte[] bytes) {
    this(bytes, 0, bytes.length);
}

2.2.4 利用StringBuilder或者StringBuffer来构造

public String(StringBuffer buffer) {
    this(buffer.toString());
}
public String(StringBuilder builder) {
    this(builder, null);
}

2.2.5 字符串运算与引用字节码分析

我们来看下面一段代码:

String sub1 = "123";
String sub2 = "456";
String string1 = "123" + "456";
String string2 = "123456";
String string3 = new String(string1);
String string4 = string3.intern();
String string5 = sub1 + sub2;
String string6 = sub1 + sub2 + sub1 + sub2;

其中string1==string2返回true, string1==string3返回false, string1==string4返回true, string1==string5返回false

其中的原因,我们可以通过字节码解释:

 0 ldc #2 <123> //从常量池中取出"123"的引用,在编译启动过程中,字符串字面量就会被存入常量池
 2 astore_1 //保存到第一个变量,就是sub1
 3 ldc #3 <456> //从常量池中取出"456"的引用
 5 astore_2 //保存到第二个变量,就是sub2
 6 ldc #4 <123456> //从常量池中取出"123456"的引用
 8 astore_3 //保存到第三个变量,就是string1
 9 ldc #4 <123456> //从常量池中取出"123456"的引用
11 astore 4 //保存到第四个变量,就是string2,由此,我们可以看出为啥string1==string2
13 new #5 <java/lang/String> //新建String对象,返回引用
16 dup //再次将上一个返回复制压入栈,一般创建新对象后都要这样
17 aload_3 //读取本地第三个变量,就是string1
18 invokespecial #6 <java/lang/String.<init>> //调用String构造器
21 astore 5 //保存到第五个变量,就是string3
23 aload 5 //读取本地第五个变量,就是string3
25 invokevirtual #7 <java/lang/String.intern> //调用原生intern方法
28 astore 6 //保存到第六个变量,就是string4
30 new #8 <java/lang/StringBuilder> //对于字符串+运算,创建一个StringBuilder
33 dup
34 invokespecial #9 <java/lang/StringBuilder.<init>>
37 aload_1
38 invokevirtual #10 <java/lang/StringBuilder.append> //调用append方法,相当于append(string1)
41 aload_2
42 invokevirtual #10 <java/lang/StringBuilder.append> //调用append方法,相当于append(string2)
45 invokevirtual #11 <java/lang/StringBuilder.toString>
48 astore 7
50 new #8 <java/lang/StringBuilder>
53 dup
54 invokespecial #9 <java/lang/StringBuilder.<init>>
57 aload_1
58 invokevirtual #10 <java/lang/StringBuilder.append>
61 aload_2
62 invokevirtual #10 <java/lang/StringBuilder.append>
65 aload_1
66 invokevirtual #10 <java/lang/StringBuilder.append>
69 aload_2
70 invokevirtual #10 <java/lang/StringBuilder.append>
73 invokevirtual #11 <java/lang/StringBuilder.toString>
76 astore 8

字符串相加

在当前JDK版本的环境下,字符串相加运算已经被编译器优化成为StringBuilder. 之前因为一个相加就生成一个String,导致效率很低。当前已经优化。但是注意,在循环调用中,还是得用StringBuilder。如果像下面那么写的话,每次循环生成一个StringBuilder还有String,效率更低的。

String str = "";
for(int i=0;i<10;i++) {
    str = str + "1";
}

String的intern方法

这个intern方法经历了几代的优化,其作用就是,如果常量池中不存在这个字符串,就将字符串放入常量池,如果存在直接返回常量池中的这个字符串引用。利用intern方法能大大提高性能,例如在fastjson这个序列化库,就用了很多这个机制。因为字段名基本不变,所以通过将字段名动态缓存到常量池对性能提升很明显。
对于这个常量池:
- 由于 Java 6 中使用固定的内存大小(PermGen)因此不要使用 String.intern() 方法
- Java7 和 8 在堆内存中实现字符串池。这以为这字符串池的内存限制等于应用程序的内存限制。
在 Java 7 和 8 中使用 -XX:StringTableSize 来设置字符串池 Map 的大小。它是固定的,因为它使用 HashMap 实现。近似于你应用单独的字符串个数(你希望保留的)并且设置池的大小为最接近的质数并乘以 2 (减少碰撞的可能性)。它是的 String.intern 可以使用相同(固定)的时间并且在每次插入时消耗更小的内存(同样的任务,使用java WeakHashMap将消耗4-5倍的内存)。
- 在 Java 6 和 7(Java7u40以前) 中 -XX:StringTableSize 参数的值是 1009。Java7u40 以后这个值调整为 60013 (Java 8 中使用相同的值)
- 如果你不确定字符串池的用量,参考:-XX:+PrintStringTableStatistics JVM 参数,当你的应用挂掉时它告诉你字符串池的使用量信息。

2.3 字符串长度,代码点和代码单元的理解以及代码点长度

上面我们已经提到了代码点的概念,这里我们需要注意下获取字符串长度在广义理解上是获取代码点长度而不是调用length方法。
首先我们看一下length方法:

public int length() {
    return value.length >> coder();
}
byte coder() {
    return COMPACT_STRINGS ? coder : UTF16;
}

通过上面我们知道默认字符串是utf16编码的,所以相当于value.length >> 1也就是字节数除以2,代表两个字节表示一个字符。通过之前的分析我们也知道,一般的字符双字节就足够表示了,所以一般的字符,用这个length()方法,返回的就是我们广义理解上的字符串长度:

System.out.println("你好".length()); //输出2
System.out.println("hello".length()); //输出5

但是对于一些特殊字符,例如数学符号��(代码点表示为\uD835\uDD6B),这样的字符是四字节表示,利用length可能获取的结果就不对了:

System.out.println("\uD835\uDD6B"); //输出z
System.out.println("\uD835\uDD6B".length()); //输出2,应该是1

这时候,我们就要用codePointCount这个方法:

public int codePointCount(int beginIndex, int endIndex) {
    if (beginIndex < 0 || beginIndex > endIndex ||
        endIndex > length()) {
        throw new IndexOutOfBoundsException();
    }
    //如果是单字节编码,就直接返回字节长度
    if (isLatin1()) {
        return endIndex - beginIndex;
    }
    //其他的一律按照UTF16编码检验有效字符长度
    return StringUTF16.codePointCount(value, beginIndex, endIndex);
}

至于如何判断是多字节字符,参考下面这篇文章:https://www.colabug.com/2136639.html

其他相关的String方法:

//判断字符串是否为空
public boolean isEmpty() {
    return value.length == 0;
}
//获取下标从0开始第index个字符
//这里需要考虑编码,但是取字符还是对于双字节编码的字符无效
public char charAt(int index) {
    if (isLatin1()) {
        return StringLatin1.charAt(value, index);
    } else {
        return StringUTF16.charAt(value, index);
    }
}

2.4 获取某个编码格式的字节数组

getBytes的重载方法很多,我们这里指分析最核心的一个:

public byte[] getBytes(Charset charset) {
    if (charset == null) throw new NullPointerException();
    return StringCoding.encode(charset, coder(), value);
}

String.getBytes(Stringdecode)方法会根据指定的decode编码返回某字符串在该编码下的byte数组表示,如:

byte[] b_gbk = "中".getBytes("GBK");
byte[] b_utf8 = "中".getBytes("UTF-8");
byte[] b_iso88591 = "中".getBytes("ISO8859-1");

将分别返回”中”这个汉字在GBK、UTF-8和ISO8859-1编码下的byte数组表示,此时

b_gbk的长度为2,

b_utf8的长度为3,

b_iso88591的长度为1。

而与getBytes相对的,可以通过new String(byte[], decode)的方式来还原这个”中”字,这个new String(byte[],decode)实际是使用指定的编码decode来将byte[]解析成字符串.

String s_gbk = new String(b_gbk,"GBK");
String s_utf8 = new String(b_utf8,"UTF-8");
String s_iso88591 = new String(b_iso88591,"ISO8859-1");

通过输出s_gbk、s_utf8和s_iso88591,会发现s_gbk和s_utf8都是”中”,而只有s_iso88591是一个不被识别的字符(可以理解为乱码),为什么使用ISO8859-1编码再组合之后,无法还原”中”字?原因很简单,因为ISO8859-1编码的编码表根本就不包含汉字字符,当然也就无法通过”中”.getBytes(“ISO8859-1”);来得到正确的”中”字在ISO8859-1中的编码值了,所以,再通过newString()来还原就更是无从谈起。
因此,通过String.getBytes(Stringdecode)方法来得到byte[]时,一定要确定decode的编码表中确实存在String表示的码值,这样得到的byte[]数组才能正确被还原。

有时候,为了让中文字符适应某些特殊要求(如httpheader要求其内容必须为iso8859-1编码),可能会通过将中文字符按照字节方式来编码的情况,如:
String s_iso88591 = newString("中".getBytes("UTF-8"),"ISO8859-1"),这样得到的s_iso8859-1字符串实际是三个在ISO8859-1中的字符,在将这些字符传递到目的地后,目的地程序再通过相反的方式Strings_utf8 = newString(s_iso88591.getBytes("ISO8859-1"),"UTF-8")来得到正确的中文汉字”中”,这样就既保证了遵守协议规定、也支持中文。

猜你喜欢

转载自blog.csdn.net/zhxdick/article/details/80803507
今日推荐