Java 中的 String

最近发现了 Java 中字符串的两个比较有意思的特性,由于有长达 2 个月没有更新博客了,所以这里记录一下以凑数(并不是)。


一、1 个字节与 2 个字节

在上大学的时候,在 Java 的课上,老师有一句话让我印象深刻:“在 Java 中,字符串中的一个字符占用两个字节。”然而从 Java 9 开始,这句话就不一定正确了。

在给 Java 的程序做内存分析的时候,老夫们经常发现字符相关的(比如字符数组)会占到 50% 以上的内存(有时候甚至到 70%)。程序中的字符串很多都是有英文字母或数字组成,在这种情况下每个字符占用 2 个字节,有点太奢侈。

一个简单的Spring Boot的程序内存分布示意。

从 Java 9 开始,String 类增加一个 byte 类型的名为“coder”的成员变量,用来记录此字符串是 LATIN1 类型的编码还是 UTF16 类型的编码。如果该字符串的字符全部为 LATIN1 类型的字符,则 JVM 将以每个字符 1 个字节的形式存储,否则每个字符为 2 个字节(只要有一个字符不是 LATIN1 类型)。与此对应的,存储字符的数组由 char[] 类型修改为 byte[] 类型。

显然,在字符串的诸多操作过程中,就需要判断这个 coder 的类型来处理不同的字符长度,例如:

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

由于 String 是一个不可变类型,所以在初始化以后,coder 在“正常”情况下就不会再变化了。但是对于 StringBuilder 之类的类,就必须要考虑到原本是一个 LATIN1 类型的字符串添加一个非 LATIN1 类型字符的情况。例如 AbstractStringBuilder 的 appendChars 方法:

private final void appendChars(char[] s, int off, int end) {
    int count = this.count;
    if (isLatin1()) {
        byte[] val = this.value;
        for (int i = off, j = count; i < end; i++) {
            char c = s[i];
            if (StringLatin1.canEncode(c)) {
                val[j++] = (byte)c;
            } else {
                this.count = count = j;
                inflate();
                StringUTF16.putCharsSB(this.value, j, s, i, end);
                this.count = count + end - i;
                return;
            }
        }
    } else {
        StringUTF16.putCharsSB(this.value, count, s, off, end);
    }
    this.count = count + end - off;
}

如果要往 LATIN1 类型里塞入非 LATIN1 字符,就会调用 inflate 这个方法,将原本每个字符一个字节的数组“膨胀”成每个字符两个字节:

// inflatedCopy byte[] -> byte[]
public static void inflate(byte[] src, int srcOff, byte[] dst, int dstOff, int len) {
    // We need a range check here because 'putChar' has no checks
    checkBoundsOffCount(dstOff, len, dst);
    for (int i = 0; i < len; i++) {
        putChar(dst, dstOff++, src[srcOff++] & 0xff);
    }
}

由此可见,为了省这部分内存,是以牺牲一部分性能为代价的。不过考虑到目前各种设备的实际情况,CPU 的性能已经非常强了,反倒是内存经常不够用(比如国产安卓机都堆到 12G 甚至 16G 内存了,连最新的 iPad Pro 都是 8G 内存起步),为此各大系统还都支持了“内存压缩”,以损失部分性能还换取更多的可用内存,因此 JDK 做出这样的优化也是合情合理的。

当然,如果乃很在意这部分性能,Java 也支持关掉这个优化。


二、String 的 intern 方法

在通常情况下,字符串的“复用”只会在编译阶段,即代码中如果有相等的字符串常量,则会在编译阶段指向常量池的同一个地址,所以在运行时,会是同一个对象。但在运行过程中,产生的新字符串即使和现有的某个字符串的 equals 返回 true,也是一个新的对象。

但 String 类提供了一个 intern 方法,这个方法是 native 的,实际上调用的是 C++ 的 StringTable::intern 方法。当调用此方法时,如果常量池中已经有相同的字符串,则返回常量池中现有的对象否则将此字符串添加到常量池中并返回引用。这里有一点要注意,在 Java 6 和以前的版本,常量池是在永久代(方法区)分配的,而从 7 开始,常量池存储在堆中。

这个东东设计的初衷看上去也是为了节省内存,但是由于一不小心就会引入 bug 或负面效果,所以实际使用得貌似不多。但是最近在同事那里学到了它的一个“黑科技”用法。那就是调用字符串的 intern 方法作为 synchronized 的对象。

设想我们要按一个 id 来控制并发,那么对于同一个 id,我们要采用同一个对象给 synchronized 使用。比较容易想到的方法是把要同步的对象放到一个 ConcurrentHashMap 里,但怎么回收这些空间是一个问题。或许可以考虑使用 WeakHashMap,但也得控制好插入时的并发问题。

那么我们可以考虑把要控制的对象得类别和 id 组合成一个字符串,调用 intern 方法使得相同元素每次同步的字符串的对象是同一个:

synchronized (("Book" + book.getId()).intern()) {
    ......
}

不过,如果 intern 的字符串太多,会导致 String Pool 的冲突变得很多(不过可以调整这个哈希表的大小,具体参见 http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6962930),导致性能大幅下降。

所以,貌似还是自己去维护这些对象比较好。不过,要自己造轮子并不是很容易的事情,虽然逻辑上不复杂,但是自己写的东东由于没有经过各种边界检测和考验,很容易搞出想不到的 bug。

Google 搞的 guava 包里有一个 Interner,内部基于 ConcurrentHashMap 实现,可以设置引用类型(不过注意如果没有设置成强引用,GC 回收掉了 Interner 中的 String,就会返回不同的对象)。

在使用的时候,建议直接采用 Google 的 Guava,具体可以参见这里:https://guava.dev/releases/21.0/api/docs/com/google/common/collect/Interners.html

✏️ 有任何想法?欢迎发邮件告诉老夫:daozhihun@outlook.com