collections.unmodifiablemap返回的是只读代理而非深拷贝,其内部持原始map引用,仅拦截修改操作但不阻止底层变更;需通过断链、冻结嵌套结构、锁死引用实现真正不可变。

这个问题本质不是 bug,而是设计使然:Collections.unmodifiableMap 返回的只是一个「只读代理」,它不复制数据,也不冻结原始 Map。只要底层 Map 被其他代码修改,你看到的“不可变视图”就会同步变化——就像透过玻璃看房间,玻璃本身不能阻止里面的人走动。
为什么改了原始 Map,“不可变”视图也跟着变?
unmodifiableMap 内部持有一个对原始 Map 的引用,所有 get()、containsKey() 等查询操作都直接委托过去;它只拦截 put()、remove() 这类修改方法并抛 UnsupportedOperationException。但若原始 Map 是某个 public static 字段、Spring Bean 属性或被插件通过反射/上下文获取到,第三方就能绕过代理直接改它。
- 你写了
Collections.unmodifiableMap(config),但 config 本身是类里的public static HashMap→ 插件直接调config.put("timeout", 10000) - 配置来自
Properties或 Jackson 反序列化结果(默认是 LinkedHashMap),你没做拷贝就包装 → 外部仍可修改该 LinkedHashMap 实例 - value 是 ArrayList 或自定义可变对象(如 Student),你只封了外层 Map,没冻结 value → 插件调
map.get("users").add(new User())成功
如何快速定位是否被底层透传污染?
别只盯着“能不能调 put()”,重点查三处:
- 原始引用是否还活着:在 IDE 中全局搜索该 Map 变量名,确认它没被声明为 public/static/protected,也没作为参数传给可能修改它的方法
-
下游拿到的是不是嵌套可变结构:比如
GLOBAL_CONFIG.get("features")返回的是普通 ArrayList,而非 Collections.unmodifiableList → 检查 value 封装层级 - 配置加载链是否引入可变源头:用 Spring @ConfigurationProperties 绑定时,默认生成的是可变 LinkedHashMap;用 Properties.load() 后直接包装,Properties 本身继承 Hashtable,仍可调 put()
真正切断透传的实操步骤
不是加一层 unmodifiable 就完事,关键动作是“断链 + 冻结 + 锁死”:
- 初始化时立即创建独立副本:
new HashMap(rawMap)或 Java 10+ 的Map.copyOf(rawMap)(后者自动判断是否需深拷贝) - 对所有可变 value 递归封装:List →
Collections.unmodifiableList(),嵌套 Map → 再套一层Collections.unmodifiableMap() - 设为
private static final,且不提供任何返回原始引用的 getter;构造后立刻丢弃 rawMap 引用(如局部变量、不存字段) - 更省心的做法:直接用
Map.ofEntries(map.entrySet().toArray(new Map.Entry[0]))(Java 9+)或 Guava 的ImmutableMap.copyOf(map),它们构建即不可变,无底层引用风险
验证是否修复成功的简单办法
写个测试,模拟插件篡改行为:
- 尝试调用
GLOBAL_CONFIG.put("x", "y")→ 应抛 UnsupportedOperationException - 拿到
GLOBAL_CONFIG.get("list"),对其调add()→ 若没提前封装,会成功;若已用 unmodifiableList 包装,则抛异常 - 用反射获取原始 Map 字段(如有)并修改 → 若你已切断引用,应找不到或修改无效;否则说明原始实例仍在内存中存活
不复杂但容易忽略。核心就一条:unmodifiableMap 不是保险箱,只是贴了张“禁止入内”的纸;真要防住,得把箱子搬走、锁死、再烧掉钥匙。
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











