ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TreeSet/TreeMap内容被覆盖?根因藏在可变对象和比较器里

TreeSet/TreeMap内容被覆盖?根因藏在可变对象和比较器里 做Java开发这些年TreeSet和TreeMap我用得不算少。排序去重、排行榜维护、区间查询这些场景它们都是非常趁手的工具。但我得坦白说前阵子在一套内部排行榜模块里我竟然被这两个容器搞得焦头烂额——业务方反馈榜单上的人变少了排名错乱还有玩家的积分数据被顶掉。第一反应是并发问题查了一圈锁也没什么收获。后来写了个最小复现用例跑了一遍才发现真正的问题是TreeSet内容被覆盖而且根因不是集合本身是我把对象放进去之后又改了它。这篇文章就把这次排查的完整过程记录下来包括底层原理、导致覆盖的几种典型写法以及怎么改才能避开这些坑。如果你也习惯把可变对象丢进TreeSet或者塞进TreeMap当key那这篇文章大概率能帮你省下半天排查时间。1. 问题现场排行榜数据莫名“缩水”当时的需求很简单某个内部活动排行榜玩家实时得分需要维护一个按分数排序的榜单支持快速插入、更新、查询名次。第一版代码用的是TreeSet玩家对象实现Comparable比较器按分数从高到低排列。看起来人畜无害上线后就开始出幺蛾子。1.1 第一版代码长这样Player对象和排行榜逻辑大致如下public class Player implements ComparablePlayer { private String playerId; private int score; public Player(String playerId, int score) { this.playerId playerId; this.score score; } public void setScore(int score) { this.score score; } public int getScore() { return score; } public String getPlayerId() { return playerId; } Override public int compareTo(Player other) { // 分数高的排前面分数相同按ID排 int scoreCompare Integer.compare(other.score, this.score); if (scoreCompare ! 0) { return scoreCompare; } return this.playerId.compareTo(other.playerId); } }TreeSetPlayer rankSet new TreeSet(); Player playerA new Player(A001, 100); rankSet.add(playerA); Player playerB new Player(B002, 200); rankSet.add(playerB); Player playerC new Player(C003, 150); rankSet.add(playerC);这段代码插入三个玩家后TreeSet内部会按分数降序排列B002(200) C003(150) A001(100)。这个阶段一切正常。1.2 复现树结构没变排序却错了问题出在玩家分数变化之后。玩家A又完成了一个成就积分从100涨到180。当时的代码是直接改字段然后重新addplayerA.setScore(180); rankSet.add(playerA);你觉得这段代码会有问题吗我告诉你线上症状是榜单顺序开始随机漂移有时候少人有时候名次不对。我用最小用例复现后看到了非常典型的现象。我把这三个玩家按顺序插入后修改playerA的分数为180再遍历输出B002 - 200 C003 - 150 A001 - 180按分数降序正确输出应该是B002 - 200 A001 - 180 C003 - 150但TreeSet输出的结果是C003排在A001前面。原因很简单TreeSet的树结构是在playerA分数还是100的时候构建的它内部记录的大小关系是“playerA playerC playerB”。我把playerA的分数改成180后这个大小关系已经变了但红黑树的物理结构完全没动。于是遍历结果、first()、last()、contains()等一系列操作全部开始出错。这还算是温和的。更诡异的是如果你这时候去remove掉C003会发现remove返回false——明明元素还在集合里但顺着当前比较器走搜索路径根本找不到那个节点。这一瞬间你的第一反应一定是见鬼了。1.3 一开始怀疑的方向说实话这种问题第一次遇到任何人的直觉都是往并发上靠。我当时排查步骤是这样的先看有没有多个线程同时读写rankSet。线上确实有并发操作但加锁后问题依旧。再看是不是使用了ArrayList之类的非线程安全结构。检查发现排行榜确实是TreeSet。再怀疑是不是玩家对象里的playerId重复了导致TreeSet去重把“同一个人”顶掉了。查了数据ID没有重复。折腾了大半天才想到去检查“对象被放入TreeSet之后是不是被外部改过字段”。一查果然。所以真正的问题不是集合选错了而是我在用可变对象的时候完全没有意识到TreeSet的排序依赖的是对象在“放入那一刻”的比较结果。2. 底层机制TreeSet和TreeMap是怎么判定“重复”和“覆盖”的要彻底搞懂这个坑得先把TreeSet和TreeMap的底层机制掰开揉碎。这两个容器不像ArrayList那样“按顺序存”也不像HashMap那样“按哈希散列”。它们的核心是一棵红黑树每个节点的位置都是通过比较器逐层比较算出来的。2.1 红黑树的位置由比较器决定TreeMap内部维护一棵红黑树TreeSet内部其实就是一个TreeMap只是把元素作为TreeMap的key存进去value统一是一个固定对象。红黑树是一种自平衡二叉搜索树插入节点时会从根节点开始用key的比较器Comparator或Comparable决定往左走还是往右走。你可以把它理解成一个按身高排队的队伍。新来一个人从队头开始比比他矮的往左比他高的往右一直走到底找到自己的位置。队伍里每个人站在哪里是由“当时的比较结果”决定的而且这个位置会被固化下来。所以当元素被插入TreeSet/TreeMap之后这个元素在集合里的“位置”就已经定死了。如果之后这个对象的内容变了导致它和别人之间的比较结果也变了队伍里的位置并不会自动跟着变——它就那么错位地站在那里。2.2 比较器返回0的两种含义这是理解“内容被覆盖”最关键的一点。在红黑树插入过程中如果比较器返回0意味着“树里已经有一个和你相等的节点”。对于TreeSetadd方法会直接返回false新元素不会被加入对于TreeMapput方法会用新value覆盖旧value并返回旧value。很多人只记得TreeMap的value覆盖却忘了TreeSet同样有“覆盖”逻辑——新元素被判定重复后直接被丢弃。如果一个业务上完全不同的对象因为比较器写得太宽松而被判成重复那这个对象的信息就相当于被“覆盖”掉了。比如你只按分数比较两个玩家都得了100分TreeSet会认为它们是同一个元素后面的那个玩家的完整信息直接不存。TreeMap则会把value覆盖成后put的那个。这属于“比较器设计不当导致的覆盖”和改字段是两条完全不同的路径。2.3 “错位”是怎么发生的再回到改字段的场景。你将对象放入TreeSet后修改了它的排序字段树的物理结构没有变但对象的内容已经变了。此时从根节点开始搜索某个元素时每次比较的结果都基于新值但树的分支结构却基于旧值。两者一旦对不上搜索路径就会偏离实际节点位置。结果就是明明存在的元素查不到明明不存在的元素可能被误判size比预期小first()和last()取错对象删除操作指向错误的节点极端情况下还可能破坏红黑树的平衡修正逻辑。我自己平时喜欢用一个比喻红黑树是一本电话簿每个人按姓名拼音排列。查一个人的时候你按照“新的读音”去找他但电话簿里的位置是按照“老的读音”登记的。读音变了你还按老位置找当然找不到。3. 四个根因从代码到现象逐个拆解排查过后我把市面上常见的“TreeSet/TreeMap内容被覆盖”问题归纳成了四类。每一类我都写了对应的复现代码也完整分析了背后的逻辑。你可以对照自己的代码看看属于哪一种。3.1 根因一可变对象改动了排序字段这是最隐蔽、也最常见的一类。对象放进集合后外部通过setter修改了compareTo依赖的字段。比如上面的Score排行榜。Player playerA new Player(A001, 100); rankSet.add(playerA); // 错误操作直接修改对象分数 playerA.setScore(180); rankSet.add(playerA);现象是集合的size不会增加因为playerA已经在树里但树里那个节点的比较逻辑已经失效。后续任何依赖比较器的操作都会出现异常。前面我已经展示过具体输出了这里不再重复。强调一句size没变不代表没出问题恰恰是因为size没变很多人才忽略了严重性。3.2 根因二比较器把不同对象判成同一个这种覆盖和对象可变性无关纯粹是compareTo/compare写得不严谨。比如只按分数比较ComparatorPlayer byScoreOnly Comparator.comparingInt(Player::getScore); TreeSetPlayer set new TreeSet(byScoreOnly); set.add(new Player(A001, 100)); set.add(new Player(B002, 100)); System.out.println(set.size()); // 1B002直接被丢弃对于TreeMap表现方式更直白——value被覆盖TreeMapPlayer, Integer map new TreeMap(byScoreOnly); map.put(new Player(A001, 100), 1); map.put(new Player(B002, 100), 2); System.out.println(map.size()); // 1 System.out.println(map.values()); // [2]原来的value 1被覆盖注意这两个Player的playerId完全不同业务上是两个人。但TreeMap不认equals它只认比较器。只要比较器判定为0就被当成同一个key。这种问题在排行榜、优惠券、库存这类场景特别致命因为同分的元素极其常见。3.3 根因三compareTo和equals不一致Java文档里明确要求如果一个类实现了Comparable那compareTo和equals应该保持一致的判定结果。但实际项目里很少有人真的遵守这条规范。举个例子public class Item { private String sku; // 商品编码 private int price; // 价格 Override public boolean equals(Object o) { // 按商品编码判断相同 return this.sku.equals(((Item) o).sku); } Override public int compareTo(Item o) { // 但排序只按价格 return Integer.compare(this.price, o.price); } }同一个商品价格修改后你在TreeSet里用contains判断它是否存在走的是compareTo逻辑在普通List里用contains判断走的是equals逻辑。两套结果可能完全相反。更麻烦的是TreeSet的remove操作依赖compareTo如果一个对象的equals相同但compareTo不同你remove的时候可能删不掉或者删掉了别的对象。3.4 根因四多个集合共享同一个可变引用还有一种情况容易被忽略同一个对象实例同时被多个集合持有任何一边修改都会影响其他集合。Player shared new Player(A001, 100); TreeSetPlayer setA new TreeSet(); TreeSetPlayer setB new TreeSet(); setA.add(shared); setB.add(shared); // 某个业务逻辑通过setA拿到的对象改了一下 Player p setA.first(); p.setScore(500); // setB也“同步变化”了因为底层是同一个对象如果setB是另一个排行榜或者另外一个模块的数据索引那这个共享对象被修改后所有集合全部错乱。这种问题往往是多个服务之间传递对象引用导致的内存共享跟并发无关但比并发更难查。4. 正确修改方案让TreeSet和TreeMap不再“丢数据”搞清楚根因之后解决方案其实已经很清晰了。核心思路就一句话不要让集合里已有的对象“自己改变在比较器眼中的身份”。4.1 首选方案让排序字段不可变最好的做法是把参与compareTo/compare的字段设计成不可变。Java里可以用final字段实现public final class Player implements ComparablePlayer { private final String playerId; private final int score; public Player(String playerId, int score) { this.playerId playerId; this.score score; } public String getPlayerId() { return playerId; } public int getScore() { return score; } Override public int compareTo(Player other) { int scoreCompare Integer.compare(other.score, this.score); if (scoreCompare ! 0) { return scoreCompare; } return this.playerId.compareTo(other.playerId); } }去掉setter之后分数只能在构造Player时确定。要更新分数就得new一个新对象。这个设计从根上杜绝了“修改字段导致树结构错位”的可能。代价是更新时需要多做一次对象创建但对绝大多数业务来说这点开销完全可以忽略。4.2 更新分数的正确姿势如果因为历史原因对象确实做成可变了那更新排序相关字段时必须严格按这个顺序操作先移除再修改再加回。// 正确操作 rankSet.remove(playerA); playerA.setScore(180); rankSet.add(playerA);顺序不能反这是血泪教训。如果先改字段再removeremove在查树的时候会拿“新值”走搜索路径大概率找不到旧位置上的节点remove直接返回false。等于没删掉然后add又因为对象已经在树里而拒绝插入。最终集合里保留的还是旧结构、旧内容问题不但没解决反而更难排查。如果你用的不是原对象引用而是想根据ID找到树里的对象再改可以这样Player target rankSet.ceiling(new Player(A001, Integer.MIN_VALUE)); rankSet.remove(target); target.setScore(180); rankSet.add(target);这里通过构造一个“比较器意义上相等”的查询对象先定位到树中的真实节点再执行移除操作。4.3 比较器的兜底设计在编写compareTo/compare时除了核心排序字段建议加一个唯一标识作为兜底。这个唯一标识可以是数据库主键、UUID、自增ID等确保任何两个业务上不同的对象比较结果永远不为0。Override public int compareTo(Player other) { int scoreCompare Integer.compare(other.score, this.score); if (scoreCompare ! 0) { return scoreCompare; } return this.playerId.compareTo(other.playerId); }这样排行榜还是按分数排序分数相同的玩家按ID排位而且不会互相覆盖。同理TreeMap的key也不会因为比较器过于宽松而被错误替换。这里还要注意另一件事compareTo返回0equals必须返回true。如果做不到也要保证逻辑上“比较器认为是同一个对象的对象确实就是同一个对象”。否则后续的contains、remove、put这些操作都会出现前后矛盾。4.4 需要频繁更新时换一种结构如果你的业务场景就是高频更新排序值比如实时排行榜每秒都有分数变化那其实不太适合用TreeSet/TreeMap硬扛。因为无论如何设计只要频繁改动排序字段就免不了频繁remove再add性能和维护成本都很高。我建议的替代方案是“双索引”用一个TreeMap按不可变ID存对象再用一个TreeSet按分数排序更新时两个容器同步操作TreeMapString, Player byId new TreeMap(); TreeSetPlayer byScore new TreeSet(); // 更新分数 Player old byId.remove(playerId); byScore.remove(old); Player updated new Player(playerId, newScore); byId.put(playerId, updated); byScore.add(updated);byId的key是String天然不可变不会出现任何树结构错位问题byScore只负责排序每次更新走一遍“移除再插入”流程逻辑清晰。如果对并发要求也高可以考虑ConcurrentSkipListMap/ConcurrentSkipListSet但要注意跳表和红黑树一样同样不能容忍可变key。换数据结构只解决并发和安全问题解决不了“对象身份变化”的问题。下面用一个表格汇总一下不同场景下的推荐做法业务场景推荐方案原因需要按分数排序分数低频更新TreeSet 不可变对象简单直接性能好分数高频更新需要实时准确双索引TreeMapString, Player TreeSet查询和排序解耦更新可控并发环境下维护有序集合ConcurrentSkipListMap/Set 不可变key线程安全且仍要求key不可变只求最大/最小不关心中间顺序PriorityQueue复杂度更低但同样禁止修改排序字段数据量大且需要复杂查询外部存储的有序集合内存结构难处理持久化和扩容5. 排查清单与踩坑经验这个问题排查起来不难但如果没经验真的会绕远路。我把这次经历整理成了一套速查方法和几条实操规矩希望对你有用。5.1 问题定位速查表遇到以下现象直接对照排查方向现象优先排查方向TreeSet的size比预期小比较器/compareTo是否对多个不同对象返回了0TreeMap的value被自动覆盖key对象的equals/compareTo是否过于宽松遍历顺序/首尾元素不符合预期是否在add之后修改了对象排序字段contains/remove找不到明明存在的元素对象被放入集合后是否被外部修改多个集合同时出现数据错乱是否多个集合共享了同一个可变对象引用并发环境偶发错乱加锁无效检查是否有代码路径在集合外部修改了元素字段5.2 三条必须守住的规矩这条是我踩了坑之后给自己定的规矩写代码的时候强制遵守第一进TreeSet/TreeMap的对象默认按不可变来设计。真要更新排序字段new一个新对象不要指望集合“智能重排”。第二任何更新排序字段的操作必须走“移除-修改-插入”完整流程而且顺序绝不能错。先remove再set再add少一步都会出问题。第三写单元测试时一定要专门覆盖三类用例修改后重新add、同分不同对象的去重、remove一个之前已经add且后续被修改过的对象。这三个用例能防住大部分比较器相关的坑。我再补充一个现场排查看法如果你在代码里看到有人对TreeSet里的对象直接调用setter不管他后面有没有跟着一个add先停下来好好想一想。因为add并不会触发重排它只会安静地返回false让你误以为已经更新成功了。这种“安静”才是最可怕的。另外调试时可以用一个小技巧在对象被修改前后分别打印一下TreeSet的toString或者做一次完整遍历对比一下顺序变化。如果顺序没有按你的预期更新那基本就是可变key问题不需要再往并发、缓存、序列化这些方向瞎猜了。最后再分享一个扩展思路如果你维护的是类似排行榜这种需要实时刷新的数据除了考虑Java集合本身也可以考虑把排序逻辑放到数据库或者专门的排序服务里应用层只做缓存。这样即便多个节点并发更新也有一个统一的权威顺序来源。当然这是架构层面的取舍了这里不展开。总之我不是劝你放弃TreeSet和TreeMap而是想提醒你这两个容器很强大但它们对被存放的对象有一种隐形要求——身份必须稳定。理解这个前提很多莫名其妙的“内容被覆盖”问题其实都不会发生。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进