前言
Java 中 String 是面试高频考点,很多同学只记住"字符串不可变",但分不清引用不可变 和对象内容不可变,同时经常混淆「对象头identity哈希」与「String缓存的内容哈希」,再结合 HashMap 的 put/get 流程,整套知识点经常混乱。本文从原理、设计目的、底层实现、HashMap 关联、常见坑点一次性梳理清楚。
一句话核心结论:
字符串不可变,不是因为字符数组不能改,而是语言设计上,
String对象本身被设计成只读;修改字符串本质是新建一个字符串对象,原对象不会发生任何变化。
一、先分清两个核心概念:不可变 vs 变量重赋值
Immutable(不可修改):字符串对象一旦创建,对象内部的字符内容永远不能变。我们日常看到的"修改字符串",本质都是生成全新字符串对象,旧对象依旧保留在内存中。
注意区分:变量引用可以重新赋值,不等于对象内容可以修改。
变量只是一个引用地址,你可以让变量指向新的字符串对象,但原来那个字符串对象本身内容无法修改。
Java示例:
String s = "abc";
s = s + "d";
// 不是修改原来"abc"对象,而是新建"abcd"对象,s引用指向新对象
// 原来的 "abc" 对象内容依旧是 abc,没有任何改动。
二、为什么 Java 将 String 设计为不可变?四大核心原因
1. 字符串常量池(最关键)
大量字符串会放入常量池实现对象复用,多个变量引用同一个字符串实例。
假设字符串是可变的:
String a = "hello";
String b = "hello";
// a、b指向常量池同一个对象
a[0]='x';
// 如果允许修改,b 也会跟着变成 xello!会产生难以排查的隐式bug
不可变特性保证:共享的字符串不会被其他代码偷偷篡改,常量池才能安全复用,大幅节省内存。
2. 哈希值缓存,提升性能
String 的 hashCode() 只需要计算一次,缓存到对象成员变量。因为字符串内容永久不变,哈希值固定,非常适合作为 HashMap 的 key。
如果字符串可变,一旦内容改变,哈希值随之改变,哈希表存储结构直接错乱。
3. 天然线程安全
不可变对象天然线程安全。多线程同时读取同一个字符串,不需要加锁,不存在并发写竞争问题,不会出现并发修改异常。
4. 安全性
网络连接地址、文件路径、类名、密码等大量底层场景都会使用字符串。
若字符串可变,在参数传递过程中,其他代码可以偷偷篡改内容,造成严重安全漏洞。
三、String底层实现细节 & 常见误区
Java String 底层:
- JDK8:
private final char[] value - JDK9+:
private final byte[] value,引入Compact Strings紧凑字符串做内存压缩优化
Compact Strings说明:
JDK8 使用char数组,char固定占用2字节。业务里大量字符串仅包含ASCII字符,只需要1字节即可存储,会造成内存浪费。
JDK9新增coder编码标识:
coder=0:Latin1单字节编码,全ASCII字符,1byte存1字符,内存减半;
coder=1:UTF16双字节编码,包含中文等非ASCII字符,每个字符占用2字节。
创建字符串时JVM自动判断选择编码,上层业务代码无感知。
误区:final数组 = 数组元素不可修改?❌
final仅代表数组引用不可重新指向新数组,数组里面的元素理论上通过反射可以强行修改;真正实现对外不可变的关键:数组被private私有,不对外暴露任何修改方法,正常业务代码无法访问内部数组。
核心结论:不是final就代表不可变;真正不可变 = 私有数据 + 不提供对外修改API。
四、频繁字符串拼接不要直接用 +,可变容器选择
如果业务中存在大量字符串拼接,+ 会不断新建String对象,产生大量内存垃圾,性能差。
Java 提供可变字符容器:
StringBuilder:非线程安全,单线程拼接优先选择(性能更高)StringBuffer:方法加synchronized,线程安全,多线程场景使用
二者 底层结构、API、扩容规则完全一致,核心区别只有一个:线程安全。底层原理:二者内部是可变字符数组,直接在原有缓冲区追加内容,不会每次拼接都新建字符串对象。
五、两个容易混淆的哈希码:identity hash VS String缓存的内容hash
重点区分:
对象头 mark word 存储的是 identity hash ;String类成员变量存储的是内容hash,二者相互独立!
① mark word(对象头)里的 identity hash
调用 System.identityHashCode(obj) 获取。
- 和对象内容无关,和对象内存实例绑定;
- JVM生成,GC移动对象后该hash值不会变化;
- 第一次调用
identityHashCode才写入mark word,懒加载; - 对应
Object默认的hashCode(),如果类没有重写hashCode(),返回的就是identity hash;
重要:两个文本内容完全一样的String对象,identityHashCode可以不相同!
② String 自定义成员变量:private int hash
public final class String {
private final byte[] value;
private int hash; // String自己定义的成员变量,不在对象头!
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
// 根据字符内容遍历计算hash
h = ...;
hash = h; // 计算完成,缓存到成员变量
}
return h;
}
}
- 基于字符串字符内容 计算,重写后的
hashCode(); - 内容相同,不管多少个实例,hash值相同;
- 懒加载:第一次调用
hashCode()才计算,后续直接复用缓存;
代码演示:
String s1 = "abc";
System.out.println(s1.hashCode()); // 内容hash,来自成员变量hash
System.out.println(System.identityHashCode(s1)); // identity hash,来自对象头mark word
// 两个返回值大概率不一样!
为什么 String 要单独缓存内容hash?其他类大多不缓存?
- String 极高频作为
HashMap/HashSet的key,HashMap操作第一步就调用hashCode(); - String的hash计算需要遍历全部字符,字符串越长开销越大;
- String不可变,内容永远不变,hash一旦算出永久有效,可以安全缓存。
其他类不缓存的原因:
- 可变类(如User):字段可以修改,缓存hash会变成脏数据,不能缓存;
- 部分不可变类:hash计算成本极低,无需缓存。例如
Integer,hashCode()直接return value,几乎无开销; - 像BigDecimal,虽然计算hash开销高,但使用频率远低于String,JDK没有内置缓存。
拓展:自定义不可变类,也可以手动增加hash字段缓存hashCode,JDK不会默认实现。
六、HashMap put 底层源码(JDK8),理解hashCode和equals分工
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 扰动函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
put执行流程:
- 调用
key.hashCode()拿到原始哈希码; - 扰动函数:
h ^ (h >>>16),将高位混合到低位,降低哈希碰撞概率; (n -1) & hash计算数组下标,定位桶位置;- 判断桶状态:
- 桶为空,直接放入新节点;
- 桶不为空:使用
key.equals()精确比对key; - key相同:覆盖旧value;
- key不同:发生哈希碰撞,链表追加或者树化。
✅ 核心规则:
hashCode():定位桶,快速缩小候选key范围;
equals():精确判断key是否相同,解决hash碰撞。
契约:equals返回true,hashCode必须相等;hashCode相等,equals不一定相等。
细节补充:
key支持null:
hash(null)返回0,存入下标0的桶;put覆盖旧value、get查询时,同样会调用
key.hashCode()定位桶。
七、自定义对象作为HashMap Key:必须重写hashCode + equals
错误示例(不重写,直接当key)
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
HashMap<User, String> map = new HashMap<>();
map.put(new User("张三",18), "学生A");
User u2 = new User("张三",18);
System.out.println(map.get(u2)); // 返回null!
原因:
没有重写方法,使用Object原生实现。
Object.hashCode() 返回identity hash(和内存地址绑定);Object.equals()等价于==,只比较引用地址。
两个User对象内容一样,但属于两个实例,hash不同,equals返回false,无法取出value。
✅ 正确写法:重写 equals + hashCode
import java.util.Objects;
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return age == user.age && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
此时只要name和age相同,equals=true,hashCode也相同,可以正常get获取value。
八、大坑:可变对象作为HashMap Key
如果重写了hashCode,但后续修改参与hash计算的字段 ,hashCode()返回值会发生变化!
User u = new User("张三",18);
map.put(u, "value");
u.age = 99; // 修改参与hash计算的字段!
map.get(u); // 大概率拿不到value
原因:
put阶段,根据age=18算出hash,存入对应桶;修改age之后对象hashCode改变;get时使用新hash去别的桶查找,找不到原始entry。
最佳实践:HashMap的key优先使用不可变对象,例如String、Integer;自定义类做key建议设计成不可变类,所有字段加final,不提供setter。
九、总结:为什么 String 适合作为 HashMap 的 key
- 不可变:内部字段不可修改,hashCode不会变化,规避可变key导致的数据丢失风险;
- 缓存hashCode:多次调用hashCode无需重复遍历字符,性能更好;
- JDK原生重写equals与hashCode,开箱即用,满足哈希契约。
拓展:
- 自定义可变对象:不推荐作为HashMap key,存在查询丢失风险;
- 自定义不可变对象:可以作为key,需要手动重写
hashCode和equals; - 自定义不可变类,是否缓存hashCode看计算开销,字段少计算快则无需缓存。