Java基础知识梳理(三):String、字符串常量池与包装类缓存机制

在Java基础面试中,StringStringBuilder、字符串常量池、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";

此时,s1s2通常指向同一个字符串对象。

如果字符串可以被随意修改,那么通过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

因为s1s2指向同一个常量池对象。

但是,下面的代码不同:

复制代码
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",那么通常会涉及:

  1. 在字符串常量池中准备"abc"对象;

  2. 在堆中通过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会检查字符串常量池:

  • 如果常量池中已经存在内容相同的字符串,则返回池中对象的引用;

  • 如果不存在,则将对应字符串加入常量池或记录其规范引用,再返回该引用。

因此,s2s3通常指向同一个常量池对象。

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,因为ab复用了缓存中的同一个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();

由于numbernull,调用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;
}

每次执行加法时,都可能经历:

  1. sum拆箱为long

  2. 执行基本类型加法;

  3. 将结果重新装箱为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()和包装类缓存的共同目标都是复用对象、减少重复创建。但对象复用也会影响引用比较结果,因此开发时必须区分"对象是否相同"和"内容是否相等"。

相关推荐
ywl47081208719 天前
ArrayList和LinkedList内部的实现原理和区别
java·java基础
ywl47081208719 天前
常用的集合类有哪些?比如List如何排序?
list·java基础
庞轩px3 个月前
第二篇:String、StringBuilder、StringBuffer深度剖析
java·字符串·stringbuilder·string·stringbuffer·字符串常量池
无巧不成书02184 个月前
Java变量初始化全攻略:2026最新规范+新手避坑实战
java·开发语言·java基础·java变量初始化·java语法规范·var关键字
前端小雪的博客.4 个月前
Java的面向对象:方法重写(0基础入门版)
java·java基础·java入门·override·方法重写·java面向对象·方法重写与重载的区别
CSharp精选营4 个月前
字符串拼接用“+”还是 StringBuilder?别再凭感觉写了
.net·stringbuilder·字符串拼接·csharp
予枫的编程笔记4 个月前
【面试专栏|Java并发编程】拆解Java线程生命周期:从新建到终止,再讲清进程与线程的核心差异
java·多线程·java基础·java面试·进程与线程·面试干货·java线程生命周期
前端小雪的博客.4 个月前
Java for 循环详解:从基础语法到实战案例(新手友好版)
java·java基础·for循环·循环结构