“new JSONObject(original) 是浅拷贝”,这句话最早是在一次代码评审里被前辈点出来的。当时我正准备用一行构造器,把一个 JSONObject 变成“独立副本”,然后放心地往里面塞业务字段。评审意见只有一句:这是浅拷贝,小心影响原对象。后来它果然在上线后变成了一次“改A坏B”的事故。
如果你也在用 JSONObject 做配置缓存、接口透传或者对象转换,这篇值得读完。你会搞明白为什么明明 new 了一个新对象,数据却还是共享的;也会拿到一套从复现、验证到深拷贝改造的完整方案。文章不会只停留在“要小心浅拷贝”的提醒层面,而是会带你从源码、内存、工程实践三个角度,把这个坑彻底填平。
1. 一个“改A坏B”的线上事故:new JSONObject(original) 的浅拷贝真相
1.1 事故现场:只给“副本”加了一个字段,缓存里的原对象却跟着变了
先说那次事故。我们有一个配置服务,启动时把一份权限配置加载到内存里,存在一个 JSONObject 中,后面所有请求都来读它。某个版本需要给前端返回“剩余有效天数”,实现的同学不想改缓存结构,就写了这么一行:
JSONObject view = new JSONObject(cachedConfig); view.put("expireTip", "还剩3天");逻辑上看,view 是 cachedConfig 的拷贝,往拷贝里加字段,原对象应该不受影响。但上线后出现了奇怪现象:第一个请求带来的 expireTip,后面所有用户都看得到,而且不同用户请求越频繁,提示越乱。最诡异的是,有人直接打印缓存对象,发现 cachedConfig 里居然也有了 expireTip。
排查链路大致是这样:
- 一开始怀疑并发问题,给读写都加了锁,问题仍在。
- 打日志对比两个对象,println 出来的内容几乎一样。
- 用
copy != original判断,结果是 false 成立:两个对象确实不是同一个。 - 打印 copy 修改前后的 original.size(),发现 original 的 size 也跟着变。
到这里基本上可以确认,new JSONObject(cachedConfig)并没有把 cachedConfig 的数据真正复制出来,至少没有把内部存储结构复制出来。查了项目里那个 JSON 库的源码之后,结论就是标题那句话:这是浅拷贝,而且某些实现里,浅得连顶层容器都是共享的。
1.2 最小复现 Demo:三行代码让共享引用现出原形
为了让你能自己验证,我写一个最小复现。这里统一以“实现了 Map 接口的 JSONObject”为例,不同库行为有差异,但核心观察方法是一样的。
JSONObject original = new JSONObject(); original.put("name", "zhang"); JSONObject copy = new JSONObject(original); copy.put("extra", "added"); System.out.println(original); System.out.println(copy);如果你用的库是“直接把传入 Map 当作内部存储”的实现,println 的结果会让你吓一跳:original 也会输出 extra 字段。我用过的某个国产高性能 JSON 库 1.x 就是这种实现,它内部字段直接指向传入的 map,连新建一个 HashMap 都省了。
另一个更常见的情况是“顶层复制了,嵌套没有复制”。这种实现下,直接往 copy 里 put 根节点字段,original 不会变;但只要动嵌套对象,原对象立刻失效:
JSONObject original = new JSONObject(); JSONObject nested = new JSONObject(); nested.put("count", 1); original.put("config", nested); JSONObject copy = new JSONObject(original); copy.getJSONObject("config").put("count", 99); System.out.println(original.getJSONObject("config").getInt("count")); // 99第一种是“连壳都共用”,第二种是“壳换了,瓤还是同一个”。两种都符合“new JSONObject(original) 是浅拷贝”这句话。你在自己的项目里跑一下这两个 Demo,马上就能判断出依赖的库属于哪种。
1.3 为什么直觉上容易把它当成深拷贝
很多同学第一反应是:我都 new 了,怎么还会共享?
问题出在“new”带来的安全感上。new 一个 JSONObject,确实会创建一个新对象,新对象有自己的对象头、类型信息。但这个对象内部保存数据的方式,往往是一个 Map 字段。构造器拿到 original 后,如果只是把这个 Map 字段赋给新对象,那么两个 JSONObject 对象本身不同,数据却指向同一个 Map。
打个比方:JSONObject 是一个“名片夹”,new 一次等于拿了一个新名片夹外壳,但构造器直接把 original 里面的名片倒进了新壳。你看到的是两个外壳,打开一看,名片是同一批。想要真正独立的副本,得把名片一张张重新抄一遍,也就是递归复制整棵树——这才是深拷贝的本质。
2. 从“对象”“引用”到“拷贝”:三个概念一次讲清
2.1 Java 里到底拷贝了什么:值传递和引用传递的边界
很多读者可能基础不太牢,这里必须先拉齐一个底层概念。Java 中基础类型(int、long、boolean 等)变量里保存的是值本身,赋值时做的是值拷贝。而对象类型变量里保存的是“到对象的地址”,赋值时复制的是地址,不是对象。
Map<String, Object> a = new HashMap<>(); Map<String, Object> b = a;这段代码执行后,a 和 b 是两个不同的变量,但地址相同,修改 b 的内容就是在修改 a 指向的那个 HashMap。JSONObject 也一样,它本质上是 Map 的一层壳。构造器如果直接把 original 的内部 Map 赋值给新对象,效果就等同于上面的b = a。
有些库稍微“厚道”一点,会 new 一个 HashMap 然后把 original 的键值对逐个 put 进去。这能让根节点独立,但 put 进去的 value 如果是另一个对象(比如嵌套 JSONObject、数组),仍然只复制了地址。所以无论哪种实现,只要没有递归,结论就是浅拷贝。
2.2 浅拷贝、深拷贝和“原地引用”三者的区别
工程里经常把“浅拷贝”和“深拷贝”挂在嘴边,但我发现很多人理解得不够精确。这里用表格把这几个层次说清楚:
| 对比维度 | 原地引用 | 浅拷贝 | 深拷贝 |
|---|---|---|---|
| 外层对象是否为新建 | 否 | 是 | 是 |
| 内部容器是否为新建 | 否 | 看具体实现 | 是 |
| 嵌套子对象是否为新建 | 否 | 否 | 是 |
| 修改根节点 key 是否影响原对象 | 是 | 取决于实现 | 否 |
| 修改嵌套节点是否影响原对象 | 是 | 是 | 否 |
从表格可以看出一条关键规律:浅拷贝和深拷贝的分界线,不在“是否 new 了对象”,而在“递归复制到了哪一层”。new JSONObject(original)这条语句,任何库都会 new 一个新对象,但都不保证递归复制嵌套层。所以标题这句话的正确理解是:它给了你一个新外壳,没有给你一棵新树。
2.3 JSON 树形结构里,共享引用会沿着哪条路径传染
JSON 数据天然是一棵树:根是 JSONObject,value 可能是字符串、数字、布尔值、JSONObject、JSONArray;JSONArray 里又可能装着一串 JSONObject。浅拷贝通常只处理根这一层,往下的分支全部保留原引用。
实际业务中,配置数据很少是扁平的。以我遇到的那个配置缓存为例,结构大致是:
{ "channelConfigs": [ { "channel": "app", "retryTimes": 3, "rules": { "enabled": true } } ] }如果只做浅拷贝,然后在copy.getJSONArray("channelConfigs").getJSONObject(0).put("retryTimes", 5),这条修改会直接穿过浅拷贝的“保护层”,影响到 original。越深的数据,越容易让开发者放松警惕,因为从代码上看,你操作的是 copy 的方法链,但底层引用早就回到了 original 的内存区域。
这也是为什么我后来在团队里反复强调:判断浅拷贝危害,不要看“我改的是哪个对象”,要看“我改的是哪一层”。JSON 的层级越深,浅拷贝能保护的就越少。
3. 那个构造器在源码层面做了什么:不同 JSON 库实现差异与验证方法
3.1 同一行代码,在不同库里的实现差异很大
需要先说明:并不是所有 JSON 库的new JSONObject(original)实现都一样。我曾经至少拆过四种常见库的源码,差异可以概括成三类:
第一类是“老牌官方库风格”。它的new JSONObject(Map)会新建一个 HashMap,然后遍历传入的 Map,逐项 put 到新容器。看起来是复制了,但 put 时会对 value 做一层 wrap:如果 value 已经是 JSONObject 或 JSONArray,wrap 会直接返回原对象;如果 value 是普通对象,会尝试转成 JSONObject。结果就是:根节点独立,嵌套层共享,属于标准浅拷贝。
第二类是“国产高性能库 1.x 风格”。它的new JSONObject(Map)直接把传入的 Map 赋值给内部字段。这意味着连根节点容器都共享,修改新对象的根 key,原对象跟着变。这种实现性能很好,但安全性最差,很容易让人踩坑。
第三类是“新版本改进风格”。有的新版本已经改成先复制顶层 Map,再逐项放入,但仍然不对嵌套对象做深拷贝。比起第二类安全了一点,本质上还是浅拷贝。
所以如果你在网上搜到“new JSONObject 就是浅拷贝”,从结论上说是对的,但背后的机制可能不同。最稳妥的做法是打开你项目里实际依赖的库源码,找到对应的构造器,看它有没有递归创建子对象。
3.2 验证工具:三种办法快速判断你依赖的库是哪种行为
第一种办法,直接观察根节点字段:
JSONObject original = new JSONObject(); original.put("name", "test"); JSONObject copy = new JSONObject(original); copy.put("rootKey", "newField"); System.out.println(original.containsKey("rootKey"));如果输出 true,说明连根节点容器都共享,属于最危险的那一类。
第二种办法,观察嵌套节点字段。无论库是哪种实现,只要它是浅拷贝,下面这段一定能测出来:
JSONObject original = new JSONObject(); JSONObject child = new JSONObject(); child.put("v", 1); original.put("child", child); JSONObject copy = new JSONObject(original); copy.getJSONObject("child").put("v", 2); System.out.println(original.getJSONObject("child").getInt("v"));输出是 2,就证明嵌套节点是共享的。
第三种办法更直观:用反射直接比较两个对象内部持有的 Map 是否同一个。不同库的内部字段名不一样,有的叫 map,有的直接继承自抽象 Map,但思路是通用的:
Field field = JSONObject.class.getDeclaredField("map"); field.setAccessible(true); Map<?, ?> mapInOriginal = (Map<?, ?>) field.get(original); Map<?, ?> mapInCopy = (Map<?, ?>) field.get(copy); System.out.println(mapInOriginal == mapInCopy);如果输出 true,说明两个 JSONObject 用的是同一个容器;输出 false,则只说明容器不同,嵌套 value 仍然可能共享,需要配合第二种办法一起判断。
3.3 对象本身不同、数据却共享:常见的判断误区
不少同学验证时会用copy != original,甚至用!copy.equals(original)来证明两个对象独立。这两条都不能证明“数据独立”。
copy != original只能说明栈上的引用变量不同,不能说明堆上的对象不同,更不能说明对象内部的数据结构不同。!copy.equals(original)就更不靠谱了,equals 比较的是内容,而共享引用的两个对象,内容通常是一样的,equals 往往返回 true,但这恰恰说明它们数据相关。
真正有效的验证手段,永远是“改一处,看另一处”。只要修改 copy 的任何一个节点,能在 original 上观测到变化,就说明两者之间存在共享引用。这也是测试用例里最值得写的一条断言,后面章节会给出具体写法。
4. 真正需要深拷贝的场景与实现方案:附可直接抄的工具方法
4.1 先判断:什么时候必须深拷贝
不是所有场景都要深拷贝。深拷贝有成本,无脑复制反而会让代码变慢、变啰嗦。我在实际项目里总结了一套“三层判断法”:
第一层,你拿到 JSONObject 后只读不改。这种情况直接传原对象都行,浅拷贝都是多余的。
第二层,你只往根节点加字段,不会动嵌套子节点。如果依赖的库属于“根节点容器共享”的那一类,连这层都不能幸免,必须深拷贝;如果库属于“顶层复制、嵌套共享”,这层恰好够用。但问题在于,你很难保证以后不会有人去改嵌套节点,所以保守一点为好。
第三层,你会修改嵌套节点,或者要把对象传给下游、存入缓存、提交到异步线程。这种情况没有任何商量余地,必须深拷贝。
用表格看更清楚:
| 业务场景 | 建议做法 |
|---|---|
| 只读打印、只读透传 | 直接用原对象 |
| 只加根节点字段,且库已复制顶层容器 | 浅拷贝可用,但需谨慎 |
| 修改嵌套对象后作为独立数据返回 | 必须深拷贝 |
| 对象进入缓存、队列、线程池 | 必须深拷贝 |
| 对外接口返回 JSONObject 且调用方可改内容 | 必须深拷贝 |
4.2 五种深拷贝方案对比
方案一:JSON 字符串往返。也就是把对象序列化成字符串,再解析成新对象。优点是实现最简单,一行代码:
JSONObject copy = new JSONObject(original.toString());缺点是性能较差,而且类型信息会丢失,这个问题下面专门讲。
方案二:递归遍历复制。用代码手动遍历 Map、List、JSONObject、JSONArray,遇到对象递归,遇到基础类型直接返回。性能好,可控性强,是实现深拷贝的主流方式。
方案三:调用库自带的深拷贝 API。有个别 JSON 库提供了独立的deepCopy()方法,例如库 D(一个由大厂维护的 JSON 库)的 JsonObject 就有这个方法。如果项目依赖支持,优先用官方方案,维护成本最低。
方案四:使用序列化框架。比如把对象通过二进制序列化再反序列化回来。通用性强,但对 JSONObject 这类轻量结构来说有点重,而且不是所有类都支持序列化。
方案五:手工构造目标类。把 JSONObject 里的字段逐个读到目标业务对象再返回。这是最“土”但最不会出错的方式,适合字段明确、层级不深的场景。
四种方案的横向对比:
| 方案 | 实现成本 | 性能 | 风险点 |
|---|---|---|---|
| JSON 字符串往返 | 最低 | 低 | 类型丢失、大对象耗内存 |
| 递归遍历复制 | 中 | 高 | 需要自己处理所有容器类型 |
| 库自带 deepCopy | 最低 | 高 | 依赖库版本 |
| 二进制序列化 | 中 | 中 | 类型需支持 Serializable |
| 手工构造目标类 | 高 | 最高 | 字段多时易漏 |
4.3 一个不依赖特定库的通用深拷贝工具方法
因为不同 JSON 库的 API 略有差异,这里提供一个以“JSONObject 实现了 Map 接口、JSONArray 是 List 实现”为基础的通用递归方案,你可以直接搬到项目里,再按自己依赖的库做少量调整:
public static JSONObject deepCopyJsonObject(JSONObject source) { if (source == null) { return null; } JSONObject result = new JSONObject(); for (String key : source.keySet()) { result.put(key, deepCopyValue(source.get(key))); } return result; } private static Object deepCopyValue(Object value) { if (value == null) { return null; } if (value instanceof JSONObject) { return deepCopyJsonObject((JSONObject) value); } if (value instanceof JSONArray) { JSONArray sourceArray = (JSONArray) value; JSONArray targetArray = new JSONArray(); for (int i = 0; i < sourceArray.size(); i++) { targetArray.add(deepCopyValue(sourceArray.get(i))); } return targetArray; } if (value instanceof Map) { Map<?, ?> sourceMap = (Map<?, ?>) value; Map<String, Object> targetMap = new LinkedHashMap<>(); for (Map.Entry<?, ?> entry : sourceMap.entrySet()) { targetMap.put(String.valueOf(entry.getKey()), deepCopyValue(entry.getValue())); } return targetMap; } if (value instanceof List) { List<?> sourceList = (List<?>) value; List<Object> targetList = new ArrayList<>(sourceList.size()); for (Object item : sourceList) { targetList.add(deepCopyValue(item)); } return targetList; } return value; }有几个细节需要说明:
判断顺序很重要。JSONObject 通常同时实现了 Map 接口,所以必须先判断instanceof JSONObject,否则会被 Map 分支先接住,导致 JSONObject 被复制成普通 HashMap,行为发生变化。JSONArray 同理,要放在 List 分支前面。
目标 Map 我用 LinkedHashMap,是为了尽量保持原始数据的插入顺序。很多配置类数据是讲究先后顺序的,复制完顺序乱了,也会引出隐蔽问题。
基础类型、String、BigDecimal 这类不可变对象直接返回原引用是没有问题的。但如果 value 是一个可变的 JavaBean,这个工具不会深拷贝 Bean 内部字段。遇到这种场景,更合适的做法是先序列化成 JSON 字符串,再解析成目标对象的 JSONObject,或者手工构造目标类。
4.4 字符串往返的坑:日期、浮点和类型丢失
有些同学图省事,用new JSONObject(original.toString())做深拷贝。这种方案在纯 JSON 数据上勉强可用,但一旦遇到下面这些情况就会踩坑:
日期类型会变成字符串。original 里的Date经过 toString 再解析,会变成某个格式的字符串,下游再用getDate或getLong去取,就会类型转换失败。
Long 精度可能丢失。JSON 字符串里的数字,在解析时可能被当作 Integer 或 Double,超过一定位数的 Long 会被转成 double,精度受损。这类问题在订单号、雪花 ID 上特别常见。
BigDecimal 可能变成 double。金额计算场景下,这会造成严重的精度错误。
所以我的建议是:纯展示类 JSON 可以用字符串往返,但涉及业务字段类型、金额、ID、日期的对象,优先使用递归复制或手工构造目标类。深拷贝省下的几毫秒,远不够排查一次因为类型丢失导致的数据错乱。
5. 代码审查与防御:把浅拷贝隐患挡在上线之前
5.1 看到new JSONObject(xxx)时,先问三个问题
代码评审阶段,我给自己定了一个条件反射:只要看到new JSONObject(某个已存在的 JSONObject 或 Map),立即问三个问题:
第一个问题:后续会不会对这个新对象执行 put、remove、getJSONObject 后再 put?如果会,就必须确认它是否来自深拷贝。
第二个问题:新对象会不会被传给别的线程、异步任务、消息队列?一旦跨线程,即使当前代码没有修改嵌套节点,别的线程也可能修改,必须深拷贝。
第三个问题:原对象是缓存对象、配置对象还是共享状态?如果原对象本身承担着“全局数据源”的角色,任何共享引用都可能污染全局状态。
这三个问题只要有一个答案是“是”,基本就要把浅拷贝改成深拷贝。如果全是“否”,那这行代码可以保留,但最好还是加个注释说明为什么不需要深拷贝。
5.2 用统一复制工具类替代裸构造器
比“逐行审查”更有效的办法,是禁止业务代码直接写new JSONObject(existing),统一走项目里的复制工具类。
我在团队里就是这么干的。定义一个JsonCopies类,提供deepCopy(JSONObject)方法,内部实现就是上一节的递归复制逻辑。代码评审时看到业务代码直接 new,就要求替换成工具方法;工具方法本身只有一份,即使有 bug,也只需要修一处。
这样做的另一个好处是,新来的同学不需要一开始就理解浅拷贝原理。他只需要知道:要独立副本,用JsonCopies.deepCopy;不需要独立副本,直接用原对象。认知负担小很多。
5.3 回归测试:一条断言防止共享引用再次出现
深拷贝工具写好了,如果不加测试,没人能保证下一次重构不会把它改回浅拷贝。我会在项目里放两个最基础的测试:
第一个测试,验证根节点修改不互相影响:
@Test void copyShouldIsolateRootField() { JSONObject original = new JSONObject(); original.put("name", "zhang"); JSONObject copy = JsonCopies.deepCopy(original); copy.put("extra", "added"); assertFalse(original.containsKey("extra")); }第二个测试,验证嵌套节点修改不互相影响,这是浅拷贝最容易翻车的地方:
@Test void copyShouldIsolateNestedObject() { JSONObject original = new JSONObject(); JSONObject nested = new JSONObject(); nested.put("count", 1); original.put("nested", nested); JSONObject copy = JsonCopies.deepCopy(original); copy.getJSONObject("nested").put("count", 2); assertEquals(1, original.getJSONObject("nested").getInt("count")); }如果哪天有人把工具方法里的递归逻辑删了,或改成new JSONObject(original),这两个测试会立刻变红。不要小看这两条断言,它能挡住绝大多数回归风险。
5.4 更安全的架构习惯:可变对象不出接口
最后聊一个更上层的习惯。浅拷贝问题之所以反复出现,本质是“可变对象被多个方共享”的问题。JSONObject 是一种非常灵活但也非常容易变的数据结构,谁拿到引用都能改。
后来我们在服务内部定了一条原则:内存缓存、配置共享这类长期存活的对象,默认按“不可变数据”对待,不出接口,不进线程,不落队列。如果下游需要基于它做修改,必须经过一次明确的深拷贝。对外返回的 HTTP 响应,序列化生成的字节流其实天然隔离了修改源,但如果是 RPC 调用直接传 JSONObject,就要特别小心,尽量在返回前做一次拷贝。
这个原则不需要额外引库,也不需要复杂框架,就是在代码里多坚持一步:该复制的时候不要省。省下的那一行new JSONObject(original),往往会在某个深夜变成一次数据污染的排查噩梦。
那次事故之后,我养成了几个习惯:凡是 JSONObject 作为方法参数传出去,默认别人会改它;凡是缓存里的数据要返回,先过一遍深拷贝;凡是代码评审里看到裸new JSONObject(existing),多问一句为什么要这么写。很多人觉得深拷贝是性能损耗,但和线上“改A坏B”的排查成本比起来,那零点几毫秒几乎可以忽略。如果你也写过那行代码,建议现在就去项目里搜一搜,在它真正变成事故之前,把它改成深拷贝。