MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java String 类的底层实现探秘

2022-12-135.2k 阅读

Java String 类的底层实现探秘

String 类的定义与不可变性

在 Java 中,String 类被广泛用于处理文本数据。它的定义如下:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // 类的主体部分
}

从定义可以看出,String 类被声明为 final,这意味着它不能被继承。这是为了保证 String 对象的不可变性。

不可变性是 String 类的一个核心特性。一旦 String 对象被创建,其内容就不能被改变。例如:

String str = "Hello";
str = str + " World";

在上述代码中,表面上看是对 str 进行了修改,但实际上并非如此。当执行 str = str + " World"; 时,会在内存中创建一个新的 String 对象 "Hello World",而原来的 "Hello" 对象依然存在于内存中,str 变量只是重新指向了新的对象。

这种不可变性带来了很多好处。首先,它使得 String 对象可以被安全地共享。例如,在字符串常量池中,相同的字符串字面量会被复用,避免了内存的浪费。其次,不可变对象是线程安全的,多个线程可以放心地使用同一个 String 对象而无需担心数据竞争问题。

String 类的底层存储结构

String 类的底层是通过字符数组来存储字符串内容的。在 JDK 9 之前,String 类的定义中有一个 char 类型的数组 value

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
    // 其他成员变量和方法
}

value 数组被声明为 private final,这保证了 String 对象的不可变性。private 修饰符防止外部直接访问和修改数组,final 修饰符确保数组引用一旦初始化就不能再改变。

从 JDK 9 开始,为了减少内存占用,String 类的底层存储结构发生了变化。value 数组从 char 类型改为了 byte 类型,并增加了一个 coder 字段来标识编码方式:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    private final byte[] value;
    private final byte coder;
    // 其他成员变量和方法
}

在大部分情况下,字符串只包含 Latin - 1 字符(即每个字符可以用一个字节表示),此时 coder 的值为 0value 数组直接存储 Latin - 1 编码的字符。如果字符串包含非 Latin - 1 字符,coder 的值为 1value 数组采用 UTF - 16 编码存储字符。

这种优化使得 String 对象在存储 Latin - 1 字符时,内存占用减少了一半。

字符串常量池

字符串常量池是 Java 中一个重要的概念,它用于存储字符串字面量。当程序中出现字符串字面量时,JVM 会首先检查字符串常量池中是否已经存在相同内容的字符串。如果存在,则直接返回常量池中该字符串的引用;如果不存在,则在常量池中创建一个新的字符串对象,并返回其引用。

例如:

String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2); // 输出 true

在上述代码中,str1str2 都指向字符串常量池中的同一个 "Hello" 对象,所以 str1 == str2 返回 true

字符串常量池的存在可以大大节省内存空间,因为相同的字符串字面量只会在常量池中存储一份。

字符串常量池的实现原理

在 HotSpot JVM 中,字符串常量池是一个 StringTable,它本质上是一个哈希表。当创建一个字符串字面量时,JVM 会计算该字符串的哈希值,并在 StringTable 中查找是否存在相同哈希值且内容相同的字符串。如果存在,则返回其引用;如果不存在,则将新的字符串插入到 StringTable 中。

字符串常量池的大小是有限的,可以通过 -XX:StringTableSize 参数来调整。默认情况下,StringTable 的大小是 60013。

字符串常量池与 intern() 方法

String 类提供了一个 intern() 方法,它的作用是将字符串对象添加到字符串常量池中。如果常量池中已经存在相同内容的字符串,则返回常量池中该字符串的引用;否则,将当前字符串对象添加到常量池中,并返回其引用。

例如:

String str1 = new String("Hello");
String str2 = str1.intern();
String str3 = "Hello";
System.out.println(str1 == str2); // 输出 false
System.out.println(str2 == str3); // 输出 true

在上述代码中,str1 是通过 new 关键字创建的字符串对象,它存储在堆内存中。调用 str1.intern() 方法后,会将 "Hello" 字符串添加到字符串常量池中,str2 指向常量池中的 "Hello" 对象。而 str3 直接指向字符串常量池中的 "Hello" 对象,所以 str2 == str3 返回 truestr1 == str2 返回 false

String 类的常用方法实现

length() 方法

length() 方法用于返回字符串的长度。在 JDK 9 之前,其实现如下:

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

在 JDK 9 之后,由于底层存储结构的变化,实现变为:

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

这里通过右移操作(>>)来根据 coder 的值计算实际的字符个数。如果 coder0(即 Latin - 1 编码),右移操作不改变值;如果 coder1(即 UTF - 16 编码),右移一位相当于除以 2,因为每个 UTF - 16 字符占用两个字节。

charAt(int index) 方法

charAt(int index) 方法用于返回指定索引位置的字符。在 JDK 9 之前:

public char charAt(int index) {
    if ((index < 0) || (index >= value.length)) {
        throw new StringIndexOutOfBoundsException(index);
    }
    return value[index];
}

在 JDK 9 之后:

public char charAt(int index) {
    if ((index < 0) || (index >= (value.length >> coder))) {
        throw new StringIndexOutOfBoundsException(index);
    }
    return isLatin1()? StringLatin1.charAt(value, index) : StringUTF16.charAt(value, index);
}

这里先检查索引是否越界,然后根据 coder 的值判断编码方式,调用相应的 StringLatin1.charAt()StringUTF16.charAt() 方法来获取字符。

substring(int beginIndex, int endIndex) 方法

substring(int beginIndex, int endIndex) 方法用于返回从 beginIndexendIndex - 1 的子字符串。在 JDK 9 之前:

public String substring(int beginIndex, int endIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    if (endIndex > value.length) {
        throw new StringIndexOutOfBoundsException(endIndex);
    }
    int subLen = endIndex - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return ((beginIndex == 0) && (endIndex == value.length))? this :
        new String(value, beginIndex, subLen);
}

在 JDK 9 之后:

public String substring(int beginIndex, int endIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    if (endIndex > value.length >> coder) {
        throw new StringIndexOutOfBoundsException(endIndex);
    }
    int subLen = endIndex - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    byte[] v = (beginIndex == 0 && endIndex == value.length) ? value : Arrays.copyOfRange(value, beginIndex << coder, endIndex << coder);
    return new String(v, coder);
}

这里同样先进行边界检查,然后根据 coder 的值计算子字符串的字节范围,并通过 Arrays.copyOfRange() 方法复制相应的字节数组,最后创建一个新的 String 对象。

equals(Object anObject) 方法

equals(Object anObject) 方法用于比较两个字符串的内容是否相等。其实现如下:

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;
}

首先检查两个对象是否是同一个引用,如果是则直接返回 true。然后检查 anObject 是否是 String 类型,如果不是则返回 false。接着比较两个字符串的长度,如果长度不同则返回 false。最后逐字符比较两个字符串的内容,如果都相同则返回 true,否则返回 false

字符串拼接的实现原理

在 Java 中,字符串拼接是一个常见的操作。有多种方式可以实现字符串拼接,不同方式的底层实现原理也有所不同。

使用 + 运算符

当使用 + 运算符进行字符串拼接时,在编译期,Java 编译器会将其转换为 StringBuilder 对象的 append() 方法调用。例如:

String str1 = "Hello";
String str2 = " World";
String result = str1 + str2;

上述代码在编译后等价于:

String str1 = "Hello";
String str2 = " World";
StringBuilder sb = new StringBuilder();
sb.append(str1);
sb.append(str2);
String result = sb.toString();

StringBuilder 类是一个可变的字符串类,它通过 append() 方法将多个字符串追加到内部的字符数组中,最后通过 toString() 方法将其转换为不可变的 String 对象。

使用 StringBuilder 和 StringBuffer

StringBuilderStringBuffer 类都提供了 append() 方法用于字符串拼接。它们的主要区别在于 StringBuffer 是线程安全的,而 StringBuilder 是非线程安全的。

StringBuilderappend() 方法实现如下:

public StringBuilder append(String str) {
    super.append(str);
    return this;
}

它调用了父类 AbstractStringBuilderappend() 方法:

public AbstractStringBuilder append(String str) {
    if (str == null)
        return appendNull();
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}

首先检查 str 是否为 null,如果是则调用 appendNull() 方法。然后获取 str 的长度,通过 ensureCapacityInternal() 方法确保内部字符数组有足够的容量来存储追加的字符串。接着通过 getChars() 方法将 str 的字符复制到内部字符数组中,并更新 count(已使用的字符数)。

StringBufferappend() 方法实现类似,但在方法声明上添加了 synchronized 关键字,以保证线程安全:

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

由于线程安全的开销,StringBuffer 的性能通常比 StringBuilder 低,在单线程环境下,应优先使用 StringBuilder 进行字符串拼接。

总结

通过对 Java String 类底层实现的深入探讨,我们了解了其不可变性、底层存储结构、字符串常量池以及常用方法和字符串拼接的实现原理。这些知识对于编写高效、健壮的 Java 程序非常重要。在实际开发中,我们应该根据具体需求合理使用 String 类及其相关的操作,充分利用其特性,避免潜在的性能问题和内存浪费。同时,随着 JDK 的不断发展,String 类的实现可能会继续优化,我们也需要关注这些变化,以更好地适应新的特性和改进。

希望通过本文的介绍,读者对 Java String 类有了更深入的理解,能够在实际编程中更加得心应手地使用字符串相关的功能。