在Java基础面试中,String、StringBuilder、字符串常量池、intern()以及Integer缓存机制经常被放在一起考察。
这些知识点的核心并不是记住几个固定结论,而是理解对象是否可变、对象创建位置、引用比较方式以及自动装箱和拆箱的底层行为。
一、String为什么不可变

所谓不可变,是指一个String对象创建以后,它所表示的字符串内容不能再被修改。
例如:
String str = "Java";
str = str + "基础";
执行第二行代码后,并不是把原来的"Java"对象修改成了"Java基础",而是生成了一个新的字符串对象,再让变量str指向新对象。
原来的字符串对象仍然保持不变。
1. String类不允许被继承
String类使用了final修饰:
public final class String {
}
因此,其他类不能继承String并通过重写方法破坏它的不可变性。
2. 内部数据不会直接暴露
在较新的JDK中,String内部主要使用字节数组保存字符串数据:
private final byte[] value;
在较早的JDK中,则主要使用字符数组:
private final char[] value;
内部数组使用private修饰,外部代码无法直接访问;引用又使用final修饰,不能在对象创建后重新指向其他数组。
不过,仅仅使用final并不能完全保证对象不可变。因为final只能保证引用不变,不能保证数组内容不被修改。
String真正实现不可变,还依赖以下设计:
-
不向外暴露内部可变数组;
-
不提供直接修改内部内容的方法;
-
构造对象时进行必要的数据复制;
-
修改字符串的方法会返回新对象。
因此,String的不可变性是通过整个类的封装设计共同保证的。
二、String不可变带来了哪些好处
1. 可以安全使用字符串常量池
多个变量可以共同引用字符串常量池中的同一个对象:
String s1 = "Java";
String s2 = "Java";
此时,s1和s2通常指向同一个字符串对象。
如果字符串可以被随意修改,那么通过s1修改内容后,s2看到的内容也会发生变化,字符串常量池就无法安全复用对象。
正因为String不可变,同一个字符串对象才能被多个位置共享。
2. 天然具有较好的线程安全性
不可变对象创建后不会发生状态变化。
多个线程同时读取同一个String对象时,不需要担心其中一个线程修改内容,也不需要额外加锁。
需要注意的是,变量的重新赋值不一定线程安全,但String对象本身的内容不会被修改。
3. 可以缓存哈希值
字符串经常作为HashMap的Key。
因为字符串内容不会改变,所以它的哈希值计算一次后可以被缓存起来。后续再次调用hashCode()时,可以直接使用已有结果,从而提高查询效率。
如果字符串内容可以修改,那么哈希值也会随之变化。对象放入HashMap后,可能无法从原来的桶中找到,导致数据结构失效。
4. 提高安全性
字符串常被用于保存类名、文件路径、网络地址、数据库连接信息等重要数据。
如果字符串内容可以在传递过程中被其他代码修改,就可能引发安全问题。不可变性可以避免对象被接收方意外或恶意修改。
三、String、StringBuilder和StringBuffer的区别

这三个类都可以表示和操作字符串,但使用场景不同。
1. String
String对象不可变。
每次进行字符串拼接或修改时,通常会创建新的字符串对象。
String str = "A";
str = str + "B";
str = str + "C";
如果只进行少量拼接,使用String没有问题。
如果在循环中进行大量拼接,则可能创建很多临时对象,增加内存占用和垃圾回收压力。
2. StringBuilder
StringBuilder内部内容可以修改。
StringBuilder builder = new StringBuilder();
builder.append("A");
builder.append("B");
builder.append("C");
String result = builder.toString();
多次调用append()时,通常是在同一个可变对象中追加内容,不会像普通字符串拼接那样频繁创建新对象。
因此,单线程下进行大量字符串拼接时,通常优先使用StringBuilder。
StringBuilder没有对主要操作进行同步控制,因此线程不安全,但执行效率通常较高。
3. StringBuffer
StringBuffer的功能与StringBuilder类似,也是可变字符串。
主要区别是StringBuffer的很多方法使用了synchronized进行同步控制,因此具有一定的线程安全性。
同步会带来额外开销,所以在不需要线程安全时,通常优先使用StringBuilder。
三者可以简单对比如下:
| 对比项 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 内容是否可变 | 不可变 | 可变 | 可变 |
| 线程安全 | 对象内容不可变 | 不安全 | 相对安全 |
| 拼接效率 | 大量拼接时较低 | 通常最高 | 低于StringBuilder |
| 适用场景 | 少量字符串操作 | 单线程大量拼接 | 多线程共享可变字符串 |
实际开发中,不建议仅仅因为代码可能运行在多线程环境中就直接使用StringBuffer。
如果每个线程都创建自己的StringBuilder局部变量,那么线程之间没有共享对象,仍然是安全的。
四、编译器会优化String拼接吗
对于简单的常量拼接,编译器通常会在编译期间直接计算结果:
String str = "Java" + "基础";
这段代码可能在编译后直接等价于:
String str = "Java基础";
但是,如果字符串拼接涉及变量,通常需要在运行期间完成:
String name = "Java";
String result = name + "基础";
编译器可能使用StringBuilder或其他字符串连接机制进行优化。
尽管编译器会做一定优化,但在循环中进行大量字符串拼接时,仍然建议显式使用StringBuilder:
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 1000; i++) {
builder.append(i);
}
五、什么是字符串常量池

字符串常量池是JVM为了减少重复字符串对象而设计的一种复用机制。
例如:
String s1 = "abc";
String s2 = "abc";
字符串字面量"abc"会被放入字符串常量池中。
当代码再次使用相同的字符串字面量时,JVM可以直接复用常量池中已有的对象,因此:
System.out.println(s1 == s2);
结果通常为:
true
因为s1和s2指向同一个常量池对象。
但是,下面的代码不同:
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s1 == s2);
结果通常为:
false
"abc"指向常量池中的字符串对象,而new String("abc")会在堆中显式创建一个新的String对象。
虽然两个对象的内容相同,但并不是同一个对象。
使用equals()比较时:
System.out.println(s1.equals(s2));
结果为true,因为String重写了equals(),比较的是字符串内容。
六、new String("abc")创建几个对象
这个问题不能脱离具体前提直接回答。
执行:
String str = new String("abc");
其中涉及两个部分:
-
字符串字面量
"abc"; -
通过
new创建的字符串对象。
如果字符串常量池中原来没有"abc",那么通常会涉及:
-
在字符串常量池中准备
"abc"对象; -
在堆中通过
new创建一个新的String对象。
从整个过程看,可以认为涉及两个字符串对象。
如果常量池中已经存在"abc",那么这条语句只会额外创建一个新的堆对象。
因此,面试时更严谨的回答是:
如果常量池中不存在
"abc",整个过程通常涉及两个对象;如果常量池中已经存在,则只新增一个堆中的String对象。无论如何,new都会创建一个新的对象。
七、intern()方法有什么作用
intern()用于获得字符串在常量池中的规范引用。
例如:
String s1 = new String("abc");
String s2 = s1.intern();
String s3 = "abc";
System.out.println(s1 == s2);
System.out.println(s2 == s3);
结果通常为:
false
true
s1指向通过new创建的堆对象。
调用s1.intern()后,JVM会检查字符串常量池:
-
如果常量池中已经存在内容相同的字符串,则返回池中对象的引用;
-
如果不存在,则将对应字符串加入常量池或记录其规范引用,再返回该引用。
因此,s2和s3通常指向同一个常量池对象。
intern()可以减少大量重复字符串占用的内存,但不应被无节制使用。维护常量池也需要成本,是否使用应根据字符串数量、重复程度和程序生命周期判断。
八、什么是Integer缓存机制

Java的基本数据类型不能直接作为泛型参数,因此Java为基本类型提供了对应的包装类。
例如:
| 基本类型 | 包装类 |
|---|---|
| int | Integer |
| long | Long |
| boolean | Boolean |
| double | Double |
| char | Character |
进行自动装箱时:
Integer number = 127;
编译器会将其转换为类似下面的形式:
Integer number = Integer.valueOf(127);
Integer.valueOf()不会每次都创建新对象。为了减少常用小整数对象的重复创建,Java会缓存一定范围内的Integer对象。
默认情况下,缓存范围通常是:
-128~127
例如:
Integer a = 127;
Integer b = 127;
System.out.println(a == b);
结果通常为true,因为a和b复用了缓存中的同一个Integer对象。
而下面的代码:
Integer a = 128;
Integer b = 128;
System.out.println(a == b);
结果通常为false,因为默认缓存范围不包含128,两次装箱通常得到两个不同的对象。
但以下比较仍然为true:
System.out.println(a.equals(b));
因为Integer.equals()比较的是整数值,而不是对象身份。
因此,比较包装类数值时应优先使用equals(),避免依赖缓存范围。
九、自动装箱和拆箱有什么风险

1. 自动拆箱可能产生空指针异常
例如:
Integer number = null;
int value = number;
编译器会把拆箱过程转换为类似:
int value = number.intValue();
由于number为null,调用intValue()时就会抛出NullPointerException。
类似问题也可能出现在条件判断中:
Boolean enabled = null;
if (enabled) {
System.out.println("启用");
}
这里会自动调用enabled.booleanValue(),同样可能产生空指针异常。
更安全的写法是:
if (Boolean.TRUE.equals(enabled)) {
System.out.println("启用");
}
2. 频繁装箱会产生性能开销
例如:
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i;
}
每次执行加法时,都可能经历:
-
将
sum拆箱为long; -
执行基本类型加法;
-
将结果重新装箱为
Long。
这会产生大量临时对象,增加内存和垃圾回收压力。
如果不需要表示null,应优先使用基本数据类型:
long sum = 0L;
3. 包装类使用==容易产生误判
下面的结果可能让初学者困惑:
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;
System.out.println(a == b);
System.out.println(c == d);
通常输出:
true
false
原因不是127和128的比较规则不同,而是==比较对象引用,结果受到缓存机制影响。
包装类进行数值比较时,应使用:
Objects.equals(a, b);
或者在确保不为null时使用:
a.equals(b);
归根结底,String不可变、字符串常量池、intern()和包装类缓存的共同目标都是复用对象、减少重复创建。但对象复用也会影响引用比较结果,因此开发时必须区分"对象是否相同"和"内容是否相等"。
