前言

上一篇我们学习了 ==equals 的区别,其中反复提到了 String

在 Java 面试中,String 是一个非常高频的知识点。

面试官可能会问:

  • String 为什么是不可变的?
  • String 不可变体现在哪里?
  • String 不可变有什么好处?
  • StringBuilder 为什么可以修改?
  • 字符串拼接会不会改变原来的字符串?

这一篇我们就来单独梳理 String 不可变这个问题。

一、什么叫 String 不可变?

所谓 String 不可变,意思是:

一个 String 对象一旦创建,它所表示的字符串内容就不能被修改。

比如:

public class Test {
    public static void main(String[] args) {
        String str = "hello";
        str = str + " world";

        System.out.println(str);
    }
}

输出结果:

hello world

看起来好像 str"hello" 变成了 "hello world"

但实际上,原来的 "hello" 字符串对象并没有被修改。

真正发生的是:

1. 创建字符串对象 "hello"
2. 拼接后创建新的字符串对象 "hello world"
3. 让 str 指向新的字符串对象
4. 原来的 "hello" 对象内容没有改变

也就是说,变的是变量 str 的引用指向,不是原来字符串对象的内容。

二、用代码理解不可变

看下面这个例子:

public class Test {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = s1;

        s1 = s1 + " world";

        System.out.println(s1);
        System.out.println(s2);
    }
}

输出结果:

hello world
hello

解释一下:

一开始:

String s1 = "hello";
String s2 = s1;

s1s2 都指向 "hello"

后来:

s1 = s1 + " world";

这行代码创建了一个新的字符串 "hello world",然后让 s1 指向新对象。

但是 s2 仍然指向原来的 "hello"

所以最后输出:

s1 = hello world
s2 = hello

这就说明原来的字符串对象没有被修改。

三、String 类本身是 final 的

String 类是 final 修饰的。

可以简单理解为:

public final class String {
    // 省略源码
}

final 修饰类时,表示这个类不能被继承。

也就是说,我们不能写一个类继承 String

public class MyString extends String {
}

这是不允许的。

为什么不允许继承?

因为如果允许继承,子类就可能破坏 String 原本的不可变设计。

所以 String 被设计成 final,从类继承层面防止被扩展和篡改。

四、String 底层保存字符的数组不能随便改

在不同 Java 版本中,String 的底层实现略有不同。

在 Java 8 中,String 底层主要使用 char[] 保存字符。

在 Java 9 之后,String 底层改成了 byte[] 加编码标记的方式,用来节省内存。

但是不管底层是 char[] 还是 byte[],核心思想都一样:

保存字符串内容的字段是私有的,并且不会对外提供直接修改内容的方法。

可以简单理解为:

public final class String {
    private final char[] value;
}

这里有几个重点:

  1. private:外部不能直接访问
  2. final:引用不能随便改变
  3. 没有提供修改原内容的方法

所以普通代码不能直接修改 String 对象内部保存的字符数据。

五、String 没有提供修改原对象的方法

比如我们调用:

String s = "hello";
String result = s.replace("h", "H");

System.out.println(s);
System.out.println(result);

输出结果:

hello
Hello

可以看到:

s.replace("h", "H")

并没有修改原来的 s

它返回的是一个新的字符串。

类似的方法还有:

substring()
toUpperCase()
toLowerCase()
concat()
replace()

这些方法看起来像是在修改字符串,其实都是返回新字符串。

比如:

String s = "java";
String upper = s.toUpperCase();

System.out.println(s);
System.out.println(upper);

输出结果:

java
JAVA

原来的 java 没有改变。

六、String 不可变和字符串常量池有关

String 不可变的一个重要好处是:可以安全地使用字符串常量池。

比如:

String s1 = "hello";
String s2 = "hello";

s1s2 可能指向字符串常量池中的同一个 "hello" 对象。

如果 String 是可变的,那么就会出现一个严重问题。

假设 s1 修改了 "hello" 的内容:

hello -> hi

那么 s2 指向的内容也会跟着变化。

这显然是不安全的。

正因为 String 不可变,所以多个引用可以安全地共享同一个字符串对象。

这也是字符串常量池能够存在的重要原因。

七、String 不可变有什么好处?

1. 安全性更高

字符串经常用于保存重要信息,比如:

  • 文件路径
  • 数据库连接地址
  • 用户名
  • 网络请求地址
  • 类名
  • 配置项
  • Token 的一部分

如果字符串可以随便被修改,就可能带来安全问题。

比如方法接收到一个数据库连接字符串,如果中途被其他地方修改了,就可能连接到错误地址。

String 不可变可以避免这种问题。

2. 可以安全共享

因为字符串不可变,所以多个变量可以安全地引用同一个字符串对象。

比如:

String s1 = "java";
String s2 = "java";
String s3 = "java";

它们都可以指向常量池中的同一个对象。

这样可以减少重复对象创建,节省内存。

3. 适合作为 HashMap 的 key

String 经常作为 HashMap 的 key。

比如:

Map<String, String> map = new HashMap<>();

map.put("username", "zhangsan");
map.put("password", "123456");

如果 String 是可变的,那么作为 key 放入 HashMap 后,内容一旦改变,hash 值也可能改变。

这样会导致原来的 key 找不到。

因为 String 不可变,所以它作为 key 更稳定。

4. hash 值可以缓存

StringhashCode 计算比较常用。

因为 String 不可变,所以它的 hash 值一旦计算出来,就可以缓存起来。

下次再调用 hashCode() 时,就不用重复计算。

如果字符串内容可以变化,那么缓存 hash 值就不安全了。

5. 线程安全

不可变对象天然更容易保证线程安全。

多个线程同时读取同一个 String 对象,不会出现一个线程修改内容影响另一个线程的问题。

所以 String 在多线程环境下可以安全共享。

八、String 拼接到底发生了什么?

看下面的代码:

String s = "hello";
s += " world";

很多初学者会以为这是在原来的 hello 后面追加内容。

其实不是。

它大致相当于创建了一个新的字符串对象:

hello world

然后让变量 s 指向这个新对象。

原来的 "hello" 对象没有改变。

如果在循环中大量拼接字符串:

String result = "";

for (int i = 0; i < 10000; i++) {
    result += i;
}

这种写法性能不太好。

因为每次拼接都会产生新的字符串对象。

更推荐使用:

StringBuilder builder = new StringBuilder();

for (int i = 0; i < 10000; i++) {
    builder.append(i);
}

String result = builder.toString();

这就是为什么后面要学习 StringBuilder

九、StringBuilder 为什么可以修改?

StringBuilder 是可变的。

比如:

StringBuilder builder = new StringBuilder("hello");

builder.append(" world");

System.out.println(builder);

输出结果:

hello world

这里的 append 会在原来的 StringBuilder 对象上追加内容。

所以 StringBuilder 适合大量字符串拼接。

但是它和 String 的定位不一样。

类型 是否可变 常见用途
String 不可变 保存字符串常量、普通字符串
StringBuilder 可变 单线程大量字符串拼接
StringBuffer 可变 多线程环境下字符串拼接

后面我们会专门写一篇讲它们的区别。

十、final 一定能保证对象不可变吗?

不一定。

这是一个容易混淆的点。

比如:

final int[] arr = {1, 2, 3};

arr[0] = 100;

System.out.println(arr[0]);

输出结果:

100

虽然 arrfinal 修饰,但是数组内容仍然可以修改。

final 修饰引用类型变量时,表示这个引用不能再指向其他对象,但不代表对象内部内容一定不能变。

所以 String 不可变不是只靠一个 final 实现的,而是多个设计共同保证的:

  1. String 类是 final
  2. 内部保存数据的字段是私有的
  3. 不对外暴露修改内部数据的方法
  4. 修改类方法时返回新字符串
  5. 底层实现避免外部直接修改内部内容

十一、面试标准回答

如果面试官问:

String 为什么是不可变的?

可以这样回答:

String 是不可变对象,一个字符串对象创建之后,它的内容就不能再被修改。

从设计上看,String 类被 final 修饰,不能被继承;内部保存字符数据的字段是私有的,外部不能直接访问;并且 String 没有提供修改原对象内容的方法。像 replacesubstringtoUpperCase 这些方法,看起来像修改字符串,实际上都是返回新的字符串对象。

String 设计成不可变有很多好处,比如可以安全使用字符串常量池,多个变量可以共享同一个字符串对象;可以提高安全性;适合作为 HashMap 的 key;hash 值可以缓存;在多线程环境下也更安全。

十二、常见问题总结

1. String 真的完全不能被改吗?

在正常 Java 代码中,不能修改。

虽然通过反射等非常规方式可能破坏内部结构,但这不是正常开发方式,面试和项目中一般不讨论这种写法。

2. s = s + “abc” 是不是修改了原字符串?

不是。

这是创建了新的字符串对象,然后让变量 s 指向新对象。

原来的字符串对象没有被修改。

3. replace 方法会修改原字符串吗?

不会。

replace 会返回一个新的字符串。

比如:

String s = "hello";
String r = s.replace("h", "H");

s 仍然是 "hello"r"Hello"

4. String 不可变是不是只因为 final?

不是。

final 只是其中一个原因。

真正的不可变是由类设计、字段封装、没有修改方法、返回新对象等多个因素共同保证的。

5. 为什么大量拼接字符串不建议用 String?

因为 String 不可变,每次拼接都可能产生新对象。

大量拼接时更建议使用 StringBuilder

十三、总结

这一篇主要学习了 String 为什么是不可变的。

String 不可变的意思是:字符串对象创建之后,它表示的内容不能被修改。变量可以重新指向新的字符串对象,但原来的字符串对象本身不会变。

String 的不可变设计和 final 类、私有字段、没有修改原内容的方法、返回新对象等机制有关。

它的好处也很明显:可以安全使用字符串常量池,可以作为稳定的 HashMap key,可以缓存 hash 值,也更适合多线程共享。

理解这一点之后,再看 String 拼接、字符串常量池、StringBuilderHashMap key 等问题,就会清楚很多。

下一篇我们继续学习 StringStringBuilderStringBuffer 的区别。

更多推荐