前言
很多Java的初学者可能都会有这样的疑问:“我常常听到String对象是不可变的,但当我每看到下面的代码时又深感矛盾,这不是可以变吗?”。
String a = "hello, world!";
a = a.replace(",", "!");
System.out.println(a);
“变量a的值确实发生了改变啊!”
这个问题的背后至少涉及到以下几方面的知识内容:
- 内存机制
- 性能考虑
- 线程安全
- 哈希一致
而Java之所以设计 String 为 不可变 (immutable),又是以上方面交织权衡后的结果。
所以很多面试官在面试一些工作经验较少的Java工程师时,会经常将该问题作为面试题之一。以此考察工程经验尚浅的开发者们的基础知识掌握程度。
接下来,我们就从一个语言设计者的角度慢慢剖析为什么要将String类型设计成不可变。
你会准备把 String 对象放在哪里?
基本上所有语言的内存空间在设计上都是由两部分组成的:栈和堆。JVM的设计也不例外,主要部分就是虚拟机栈(VM Stack)和堆(Heap)。
栈中存的是局部基本类型变量、对象引用和方法,它随着方法调用自动分配和释放。而堆中存的是Java中所有的对象及数组的实际数据。
我来举个更具体的例子,如果在一个方法中定义一个int基本类型的变量和一个Integer类型的变量:
int a = 200;
Integer b = 200;
两个200的值在内存中的表现是不同的,因为int是基本类型变量,所以这个200会和变量名a一起直接会放在栈中(栈的操作简单,性能更高)。而Integer变量对应这个200的值则会分配到堆内存中,栈中只存储变量名b和堆中这个200所在位置的引用。
同样地,如果声明一个String类型的变量并赋值:
String s = "Hello";
它会和Integer一样,在堆内存中开辟一个空间存储"Hello",变量s和内存引用则存到栈中。
如果你是设计师,为什么不把String设计成一个基本类型也放到栈中呢?
因为String的长度是不可控的。你可能可以根据业务场景确定下来关系数据库中某个varchar类型的字段的长度,但你却没办法确定一个用于任意场景的String的长度。所以这种长度不固定的类型无法存储到栈中,只能放在堆内存这片"汪洋大海"中(相比栈空间,堆空间确实是海量的)。
所以如果让你来选择存储位置——String 类型变量对应的值一定是要放在堆内存中的。
字符串常量池:性能优化的智慧
既然String对象都要存储在堆内存中,那么大量频繁地创建字符串,将会极大程度地影响程序的性能。为了解决这个问题,Java引入了**字符串常量池(String Intern Pool)**的概念,它的存在就是为了避免相同字符串的重复创建。
让我们通过一个具体例子来理解常量池的工作机制:
String s1 = "hello"; // 字面量方式
String s2 = new String("hello"); // new方式
System.out.println(s1 == s2); // false (不同引用)
System.out.println(s1.equals(s2)); // true (内容相同)
这里发生了什么?
String s1 = "hello"
: "hello" 是编译期常量,会被放进字符串常量池String s2 = new String("hello")
: 会在堆上新建一个String对象,内容拷贝自常量池里的 "hello"
常量池的演进历史:
- Java 7之前:字符串常量池位于永久代
- Java 7之后:常量池移到了堆内存中
常量池中存储的不是字符串对象本身,而是指向堆中字符串对象的引用。这个优化设计使得相同内容的字符串可以共享内存空间,大大提高了内存使用效率。
💡 为了全文的简单性,我们将字符串的存储统一描述为存储在堆内存中,但实际的内存管理比这更加精细和优化。
你会希望String可变吗?
如果现在是一名设计者,你会希望String是可变的吗?
从现实的角度来看,我会希望它可变,比如我用一个String对象来代表一篇文章的内容,我想改一下文章内容,只要改一下String对象就可以了。
但如果String在Java的世界中是可变的,会带来什么问题呢?
String a = "Hello";
现在a指向一块内存地址,如果现在有多个线程去修改a的值,即修改同一块内存内容,就一定会出现线程安全问题(这和在多线程中操作同一个List是一样的道理)。
如果要解决这个问题,又要进行加锁操作,但是一旦上升到锁操作,性能就会变差。并且String作为一个最常用的类型之一,对它加锁,那这个语言基本上可以退出市场了。
除此之外,String还经常作为哈希数据结构(HashMap)的Key,如果一个key-value加入到Hash中,这个key还能随便在其他位置改变,那么这个HashMap也就彻底乱套了。
所以有时候挺反直觉的,站在用户的角度上,我们希望String可变,但是这个可变性会造成大量的安全性问题和解决安全性带来的性能问题。
**所以从安全性来看,String不能变。**大部分语言也是这样设计String的。
可是如果不可变,又是怎么修改的呢?
就像前面说的,用户当然希望能对一个String的变量来回修改。
String a = "Hello";
a = "Hello, world!";
这在Java中当然是允许的,这方便了用户对一个变量值的改变。但实际上,这不叫修改,它不是对Hello
所在的内存内容做了修改,而是新开辟了一块内存空间,并将内存地址重新设置为a变量的引用。
让我们回到最开始的那段代码:
String a = "hello, world!";
a = a.replace(",", "!");
它实际上并没有修改原来的String,而是返回了一个新的String对象。
String是通过生成一个新的String对象,将新的对象的内存地址重新分配给原来的变量来实现“修改“
的。
如何做到不可变的?
String类底层使用的是一个char数组,使用private和final进行修饰,并且没有暴露出能够直接修改该char[]的方法,外部无法直接修改这个数组内容;并且String本身也是final修饰的,避免了Java中的继承机制来破坏这种不可变性。
从Java 9开始,String底层实现改为byte数组(byte[]),结合编码标识来存储字符,这是为了节省内存空间(Latin-1字符只需要1个字节)
频繁“修改”岂不是会导致内存浪费和性能下降?
是的,如果代码中大量存在类似下面的拼接逻辑:
String a = "xxxx";
String b = "xxxx";
String c = a + b;
确实会大量创建新对象,造成内存浪费以及伴随的性能开销。
如果你是设计者,如何避免这个问题呢?我想你一定听说过,使用StringBuilder来实现字符串拼接吧。
// StringBuilder 示例
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World");
sb.insert(5, ",");
sb.delete(6, 11);
System.out.println(sb); // Hello
使用StringBuilder就避免了每次操作都需要生成一个新的String的内存浪费和性能开销了。
实际上,现代JVM对简单的字符串拼接(如a + b)会自动优化为StringBuilder操作。
如果你是一个优秀的语言设计者,你自然也会为其创建线程安全的版本:StringBuffer。作为开发者,也应该时刻谨记,一旦涉及到并发多线程场景,一定要使用线程安全的类。
String 真的就不能变吗?
大部分语言中确实不希望String是可变的,但近几年有另一门正在流行的语言把String设计成了可变类型了!
let a = "hello";
let a = String::from("hello");
这是Rust中声明一个字符串的两种方式,看起来和Java没有什么不同。
"hello"是字面量,类似于Java中常量池中的字符串;String::from("hello")
也和Java一样,会在堆上分配一个可变的 String 对象,内容拷贝自字面量 "hello"。
但是Rust中的String对象是可变的。
fn main() {
let s1 = "hello"; // &str
let mut s2 = String::from("hello"); // String
// s1.push_str(" world"); // ❌ 报错:&str 不可变
s2.push_str(" world"); // ✅ 可以修改
println!("{}", s2); // hello world
}
那么Rust不需要考虑前面提到的安全性问题吗?
这就要讲到Rust的设计哲学之一了:所有权。
当一个值被分配给一个变量后,这个变量就是这个值的所有者,如果换了所有者,之前的所有者就都失效了。
比如在Java中,假设String是可变的,有一个去除某个字符的方法rm():
String a = "Hello";
String b = a;
a.rm('o');
b.rm('o');
那么当a、b就都可以对原来的值进行修改,上面的代码在单线程中执行一点问题没有,但一旦涉及多线程就麻烦了。
而Rust通过所有权的限定,直接在源头上阻止两个变量同时对同一个值修改:
let c = String::from("hello");
let d = c;
println!("{}", d);
println!("{}", c); // ❌ borrow of moved value
运行上面的代码后会直接出现borrow of moved value
报错:
发生了所有权转让后,别说让前面的变量去修改对应的值了,就连读一下这个变量都不被允许了。
所以Rust以另一种方式解决了String若可变带来的安全性问题。Rust因其安全性而著名,但也因为这些安全性带来了很大的复杂度。如果你是语言开发者你会如何设计呢?作为开发者,你又会选择什么语言来开发你的项目呢?
总结
不可变的抉择
通过从设计者的角度深入分析,我们揭开了Java String不可变设计背后的原因、优化:
1. 安全性的考虑
- 线程安全:避免多线程环境下的并发修改问题,无需额外同步机制
- 哈希一致性:保证HashMap等哈希数据结构的稳定性,确保对象作为key时的可靠性
- 引用安全:防止意外的数据篡改,特别是在方法参数传递中
2. 内存优化
- 字符串常量池:相同内容的字符串共享内存空间,避免重复创建
- 哈希值缓存:不可变对象的哈希值计算一次后可永久缓存
- 内存布局优化:从Java 9开始使用byte[]替代char[]进一步节省空间
3. 设计权衡的艺术
- 牺牲"直接修改"的便利性,换取整个系统的稳定性和性能
- 通过StringBuilder/StringBuffer提供高效的可变字符串操作方案
- 在易用性和安全性之间找到最佳平衡点
- Java选择不可变+工具类,Rust选择所有权系统,体现了不同的设计哲学
理解语言设计背后的思考过程,不仅能帮助我们写出更高质量的代码,更能培养系统性的工程思维。每一个看似简单的设计决策,都蕴含着深刻的技术智慧和无数工程师的经验积累。本文产生的契机也是我目前正在看Rust的相关内容,体验到了这两个语言不同的设计哲学。
两个语言在垃圾回收机制上也有很大的不同。Java开发者在面试时也常常被问到GC的原理,如果能在回答的时候再向外延伸到Rust语言是如何做内存回收的内容也一定能增分不少。如果对这方面感兴趣,请留言告诉我。
如果你喜欢这篇文章,也烦请给我留言评论 谢谢😄