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

Java字符串创建与存储机制揭秘

2022-02-117.7k 阅读

Java字符串的创建方式

在Java中,创建字符串主要有两种常见方式:通过字面量和使用new关键字。

通过字面量创建字符串

String str1 = "Hello";
String str2 = "Hello";

在上述代码中,str1str2都是通过字面量的方式创建的字符串。当Java编译器遇到字符串字面量时,它会先在字符串常量池中查找是否存在相同内容的字符串。如果存在,就直接返回常量池中该字符串的引用;如果不存在,则在常量池中创建一个新的字符串对象,并返回其引用。所以在这个例子中,str1str2实际上引用的是同一个位于字符串常量池中的"Hello"对象。可以通过以下代码验证:

System.out.println(str1 == str2);

上述代码输出结果为true,因为==比较的是对象的引用,这表明str1str2指向同一个对象。

使用new关键字创建字符串

String str3 = new String("World");
String str4 = new String("World");

使用new关键字创建字符串时,会在堆内存中创建一个新的字符串对象。即使内容相同,每次使用new都会创建一个新的对象。同样通过==比较来验证:

System.out.println(str3 == str4);

上述代码输出结果为false,说明str3str4虽然内容都是"World",但它们是堆内存中不同的对象,拥有不同的内存地址。

另外,使用new关键字创建字符串时,还涉及到字符串常量池的操作。当执行new String("World")时,首先会在字符串常量池中查找是否存在"World",如果不存在则在常量池中创建"World",然后在堆内存中创建一个新的String对象,其内容指向常量池中的"World"。可以通过如下代码进一步分析:

String s1 = "Java";
String s2 = new String("Java");
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));

这里System.out.println(s1 == s2)输出false,因为s1指向常量池中的"Java",而s2是堆中的新对象。System.out.println(s1.equals(s2))输出true,因为equals方法比较的是字符串的内容,它们内容相同。

Java字符串的存储机制

字符串常量池

字符串常量池是Java堆内存中的一个特殊区域,用于存储字符串字面量。它的设计目的是为了节省内存空间,提高字符串操作的效率。当通过字面量创建字符串时,字符串对象会首先在常量池中查找,避免重复创建相同内容的字符串。

在JDK 6及之前,字符串常量池位于永久代(PermGen)中。永久代是Java堆内存的一部分,主要用于存储类的元数据信息,如类的结构、方法、常量池等。由于永久代的大小在JVM启动时就已经固定,当字符串常量池中的数据不断增加,可能会导致永久代内存溢出。例如:

public class StringPoolOOM {
    public static void main(String[] args) {
        try {
            for (int i = 0; i < Integer.MAX_VALUE; i++) {
                String str = "String" + i;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }
}

在JDK 6环境下运行上述代码,很可能会抛出java.lang.OutOfMemoryError: PermGen space错误。

从JDK 7开始,字符串常量池被移到了堆内存中,也就是通常所说的新生代和老年代所在的区域。这样做的好处是,随着堆内存的动态扩展,字符串常量池的空间不再受限于固定大小的永久代,从而减少了因字符串常量池导致的内存溢出问题。

在JDK 8中,永久代被完全移除,取而代之的是元空间(Metaspace)。元空间使用本地内存,不再占用Java堆空间,进一步避免了永久代相关的内存问题。但字符串常量池依然位于堆内存中,保持了与JDK 7一致的存储位置。

堆内存中的字符串对象

当使用new关键字创建字符串时,会在堆内存中创建一个新的字符串对象。这个对象包含了字符串的实际内容以及一些元数据信息。例如,String类在JDK中的定义包含了一个char数组来存储字符串的字符内容,以及一些用于表示字符串长度等信息的字段。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** The offset is the first index of the storage that is used. */
    private final int offset;

    /** The count is the number of characters in the String. */
    private final int count;
    // 其他方法和字段省略
}

在堆内存中创建的字符串对象,其生命周期遵循Java对象的一般生命周期规则。当没有任何引用指向该对象时,垃圾回收器会在适当的时候回收该对象所占用的内存空间。

需要注意的是,虽然字符串对象在堆内存中,但如果字符串内容是通过字面量创建的,那么其实际内容依然存储在字符串常量池中,堆内存中的字符串对象只是持有对常量池字符串的引用。例如:

String s3 = new String("Hello");

这里在堆内存中创建了String对象s3,但"Hello"本身存储在字符串常量池中,s3对象内部的value数组指向常量池中的"Hello"

字符串创建和存储机制对性能的影响

字面量创建的性能优势

通过字面量创建字符串,由于会重用字符串常量池中的对象,避免了重复创建相同内容的字符串,从而在内存使用上更加高效。特别是在处理大量相同内容的字符串时,这种优势更加明显。例如,在一个循环中创建大量相同的字符串:

for (int i = 0; i < 10000; i++) {
    String str = "example";
}

在这个例子中,虽然循环执行了10000次,但实际上在字符串常量池中只创建了一个"example"对象,大大节省了内存空间。

同时,由于字面量创建的字符串对象在常量池中,访问速度相对较快。因为常量池在内存中的位置相对固定,且在类加载时就已经确定,所以在查找和访问时不需要像在堆内存中那样进行复杂的内存寻址操作。

new关键字创建的性能劣势

使用new关键字创建字符串会在堆内存中创建新的对象,即使内容相同也不例外。这不仅会占用更多的内存空间,还会增加垃圾回收的负担。例如:

for (int i = 0; i < 10000; i++) {
    String str = new String("example");
}

在这个循环中,会在堆内存中创建10000个内容相同但引用不同的字符串对象。随着对象数量的增加,堆内存的使用量会快速上升,当达到一定程度时,垃圾回收器需要频繁地进行垃圾回收操作,这会导致应用程序的性能下降。

另外,由于通过new创建的字符串对象在堆内存中的位置是动态分配的,每次访问时需要进行相对复杂的内存寻址,相比字符串常量池中的对象,访问速度会稍慢一些。

字符串拼接与存储机制

字符串拼接的方式

在Java中,字符串拼接主要有以下几种方式:使用+运算符、使用concat方法、使用StringBuilderStringBuffer类。

使用+运算符拼接字符串

String s1 = "Hello";
String s2 = "World";
String result1 = s1 + s2;

当使用+运算符拼接字符串时,如果参与拼接的操作数都是字符串字面量,编译器会在编译期进行优化,将其直接合并为一个字符串字面量。例如:

String result2 = "Hello" + "World";

在这种情况下,result2实际上在编译期就已经被确定为"HelloWorld",并存储在字符串常量池中。

但如果参与拼接的操作数中有变量,那么+运算符的拼接操作会在运行时进行。编译器会将+运算符的拼接操作转换为StringBuilderappend方法调用。例如:

String prefix = "Hello";
String result3 = prefix + "World";

上述代码在运行时等价于:

String prefix = "Hello";
StringBuilder sb = new StringBuilder();
sb.append(prefix);
sb.append("World");
String result3 = sb.toString();

这里会在运行时创建StringBuilder对象,调用append方法进行字符串拼接,最后通过toString方法生成最终的字符串。

使用concat方法拼接字符串

String s3 = "Hello";
String s4 = "World";
String result4 = s3.concat(s4);

concat方法会创建一个新的字符串对象,将调用该方法的字符串与传入的字符串连接起来。它的实现方式类似于使用+运算符在运行时拼接字符串,也是通过创建StringBuilder对象,调用append方法进行拼接,最后通过toString方法返回结果。

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(0, len + otherLen, buf);
}

在这个实现中,先创建了一个新的字符数组buf,其长度为原字符串长度与要拼接字符串长度之和。然后将原字符串的字符复制到buf中,再将传入字符串的字符也复制到buf中,最后通过new String创建一个新的字符串对象返回。

使用StringBuilderStringBuffer类拼接字符串

StringBuilderStringBuffer类都提供了高效的字符串拼接方法。它们的主要区别在于StringBuffer是线程安全的,而StringBuilder是非线程安全的。在单线程环境下,StringBuilder的性能略高于StringBuffer

StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append("World");
String result5 = sb.toString();

StringBuilderStringBuffer类内部维护了一个可变的字符数组,通过append方法将字符串添加到数组中,当调用toString方法时,才会根据数组内容创建一个新的不可变字符串对象。这种方式避免了每次拼接都创建新的字符串对象,从而提高了性能。特别是在进行大量字符串拼接操作时,使用StringBuilderStringBuffer的优势更加明显。例如:

StringBuilder sb2 = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb2.append(i);
}
String result6 = sb2.toString();

在这个循环中,如果使用+运算符进行拼接,会创建大量的中间字符串对象,导致内存开销增大和性能下降。而使用StringBuilder,只在最后调用toString时创建一个最终的字符串对象,大大提高了拼接效率。

字符串拼接与存储机制的关系

无论是哪种字符串拼接方式,最终生成的字符串对象都会遵循Java字符串的存储机制。如果拼接后的字符串可以在编译期确定,那么它会存储在字符串常量池中。例如:

String s5 = "Hello" + "World";

s5会存储在字符串常量池中。

如果是在运行时通过+运算符、concat方法或StringBuilder/StringBuffer类拼接生成的字符串,且该字符串在常量池中不存在,则会在堆内存中创建一个新的字符串对象。例如:

String prefix2 = "Hello";
String result7 = prefix2 + "World";

result7是在堆内存中创建的新字符串对象。但如果通过intern方法调用,该字符串对象会尝试将自己加入到字符串常量池中。例如:

String prefix3 = "Hello";
String result8 = prefix3 + "World";
String interned = result8.intern();

这里interned指向的是字符串常量池中的"HelloWorld"对象(如果常量池中已存在该字符串),或者是将result8加入常量池后返回的引用。

字符串的不可变性与存储机制

字符串的不可变性

在Java中,String类被设计为不可变的。一旦一个String对象被创建,其内容就不能被修改。例如:

String str = "Java";
str = str + " Programming";

这里看似修改了str的内容,但实际上是创建了一个新的字符串对象"Java Programming",而原来的"Java"字符串对象并没有改变。str变量只是重新指向了新创建的字符串对象。

字符串的不可变性是通过String类的内部实现来保证的。String类中的value数组被声明为final,这意味着一旦数组被初始化,其引用就不能再改变。同时,String类没有提供任何方法来修改value数组的内容。

不可变性与存储机制的关联

字符串的不可变性与字符串常量池和堆内存的存储机制密切相关。由于字符串不可变,字符串常量池中的字符串对象可以被多个引用共享,而不用担心其内容会被意外修改。例如:

String s6 = "Hello";
String s7 = "Hello";

s6s7都指向字符串常量池中的同一个"Hello"对象,因为该对象的内容不会改变,所以可以安全地共享。

在堆内存中,即使通过new关键字创建的字符串对象,其内容也是不可变的。这使得字符串对象在内存中的状态更加稳定,便于垃圾回收器进行管理。当一个字符串对象不再被引用时,垃圾回收器可以安全地回收其占用的内存空间,因为不需要考虑该对象内容是否会被其他部分的代码修改。

另外,字符串的不可变性也有助于提高缓存效率。例如,在哈希表中使用字符串作为键时,由于字符串不可变,其哈希值在创建时就已经确定,并且不会改变。这样可以保证在哈希表中查找和存储的一致性,提高哈希表的性能。

总结字符串创建与存储机制的要点

  1. 创建方式:通过字面量创建字符串会在字符串常量池中查找或创建对象,相同内容的字面量会共享常量池中的对象;使用new关键字创建字符串会在堆内存中创建新对象,即使内容相同也不例外。
  2. 存储机制:字符串常量池在JDK 6及之前位于永久代,JDK 7移到堆内存,JDK 8永久代被元空间取代但常量池仍在堆内存。堆内存中的字符串对象,若内容来自字面量则引用常量池对象,否则是全新内容。
  3. 性能影响:字面量创建在内存使用和访问速度上有优势,new关键字创建会增加内存和垃圾回收负担,访问稍慢。
  4. 字符串拼接+运算符在编译期和运行时有不同行为,concat方法与运行时+类似,StringBuilderStringBuffer适合大量拼接,最终生成的字符串遵循存储机制。
  5. 不可变性String类不可变,保证常量池对象可共享,利于内存管理和缓存效率。

深入理解Java字符串的创建与存储机制,对于编写高效、稳定的Java程序至关重要。在实际开发中,应根据具体场景选择合适的字符串创建和操作方式,以优化程序性能。