Java String 为什么不可变?和 HashMap 到底有什么关系?

前言

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?其他类大多不缓存?

  1. String 极高频作为 HashMap/HashSet 的key,HashMap操作第一步就调用hashCode();
  2. String的hash计算需要遍历全部字符,字符串越长开销越大;
  3. 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执行流程:

  1. 调用key.hashCode()拿到原始哈希码;
  2. 扰动函数:h ^ (h >>>16),将高位混合到低位,降低哈希碰撞概率;
  3. (n -1) & hash 计算数组下标,定位桶位置;
  4. 判断桶状态:
  • 桶为空,直接放入新节点;
  • 桶不为空:使用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

  1. 不可变:内部字段不可修改,hashCode不会变化,规避可变key导致的数据丢失风险;
  2. 缓存hashCode:多次调用hashCode无需重复遍历字符,性能更好;
  3. JDK原生重写equals与hashCode,开箱即用,满足哈希契约。

拓展:

  • 自定义可变对象:不推荐作为HashMap key,存在查询丢失风险;
  • 自定义不可变对象:可以作为key,需要手动重写hashCode和equals;
  • 自定义不可变类,是否缓存hashCode看计算开销,字段少计算快则无需缓存。
相关推荐
paopaokaka_luck1 小时前
基于springboot+vue的益暖微光公益服务平台(DeepSeek公益助手、善款信息追溯、志愿服务积分闭环、ECharts运营分析、活动状态自动流转)
java·前端·javascript·spring boot·mysql·架构·echarts
文丑颜不良啊1 小时前
LeetCode1401~1420题目解析
java·leetcode
Nuanyt1 小时前
LeetCode Hot 100 链表专题2 3 题 (Java)
java·笔记·算法·leetcode·链表
文丑颜不良啊1 小时前
LeetCode1341~1360题目解析
java·leetcode
QianhangQianping2 小时前
撤销重做不是「存快照」:一个可视化编辑器的Operation 设计
java·前端·数据库·typescript·编辑器·前端架构·撤销重做
海绵宝宝转agent2 小时前
LeetCodehot100题解
java·笔记·学习·算法·leetcode·深度优先
SunnyDays10112 小时前
Java 操作 Word 脚注和尾注:添加、读取与删除
java·word
SunnyDays10112 小时前
Java 为 Word 添加页码:隐藏封面页码、从指定页开始编号、奇偶页页码设置等
java·word·页码
开源推荐官2 小时前
多门店 O2O 商城选型复盘:从开发视角对比主流开源系统
java·架构·开源·php