☰
TreeSet / ConcurrentSkipListSet 排行榜的两个坑:改了属性 remove 失效,同分的进不来
2026/10/10 3:00:20 网站建设 项目流程

TreeSet / ConcurrentSkipListSet 排行榜的两个坑:改了属性 remove 失效,同分的进不来

战力榜、积分榜、竞技场排名——排行榜是游戏服里 TreeSet/ConcurrentSkipListSet 的最高频舞台,也是两个经典坑的事发地:元素改了属性之后 remove 失效,以及两个玩家同分时后加入的进不去。这两个坑同根同源,都来自"有序集合用比较器判等"——TreeSet 和 ConcurrentSkipListSet 都一样。这篇把原理、复现、正确姿势一次讲清,最后给一个能直接用的排行榜骨架。

一、根源:TreeSet 的排序和判等是同一套东西

先立一个认知,后面所有坑都从这里长出来:

TreeSet 是 TreeMap 的马甲(ConcurrentSkipListSet 是 ConcurrentSkipListMap 的马甲),底层分别是红黑树和跳表。树/跳表里元素的位置由 compare 结果决定,并且:

compare(a, b) == 0,就被判定为"同一个元素"。

注意这和 HashSet 完全不同——HashSet 靠hashCode+equals判等,你重写的 equals 在 TreeSet 里一个字都用不上。有序集合的世界里,比较器就是法律:它决定顺序、决定相等、决定 add/remove/contains 的一切行为。

二、坑一:先改属性再删,删不掉

排行榜的日常:玩家战力变了,要更新榜单。元素是可变的,参与排序的字段一改,事故就来了:

staticclassPlayer{finallongid;intscore;// 参与排序的字段Player(longid,intscore){this.id=id;this.score=score;}}TreeSet<Player>rank=newTreeSet<>(Comparator.comparingInt(p->p.score));Playera=newPlayer(1001,60);Playerp=newPlayer(1002,50);Playerc=newPlayer(1003,70);rank.add(a);// 60 成为根rank.add(p);// 50 < 60,挂到左子树rank.add(c);// 70 > 60,挂到右子树p.score=80;// 直接改属性:50 → 80rank.remove(p);// 返回 false,删不掉!System.out.println(rank.size());// 还是 3

为什么删不掉?remove的定位过程和add一样:从根节点开始,用比较器决定往左还是往右走。这棵树当初按"score=50"把 p 放在了左子树;现在 p 的 score 是 80,remove 拿着"80"从根 a(60) 开始找:80 > 60 往右到 c(70),80 > 70 继续往右,右边为空,到头了——p 明明躺在左边 50 的位置上,找的人却一路往 80 的方向(右边)走,两条路永远碰不上。找不到,返回 false。

连带的伤害不止 remove:

  • contains(p)同样失效(同一个定位逻辑);
  • 迭代顺序已经乱了:p 物理上还挂在旧位置,但它的比较值是新分数——中序遍历出来的顺序不再有序;
  • 之后任何 add/remove 都可能被这棵"已经不合法"的树带偏,错误具有传染性。

这个坑还有两处阴险的地方值得点破:① 至少要两个元素才会显现——如果树里只有 p 一个节点,remove(p)比较的是 p 和它自己,compare(p, p)恒为 0,反而一定删得掉;② 它时灵时不灵——如果被改的元素恰好落在新分数的查找路径上(比如它就是根节点),remove 又会"碰巧"成功。正是这种不确定性让它极难排查:本地用例偶尔能过,上了线才炸。

正确姿势:先摘下来,再改,再放回去。(下面是完整的正确写法,不是接着上文"删失败"的状态往下写)

rank.remove(p);// ① 按旧分数定位并移除p.score=80;// ② 安全地修改rank.add(p);// ③ 按新分数放回

三步的顺序不能乱——本质是保证元素在树里的每一刻,比较结果和它的位置都是一致的。ConcurrentSkipListSet 底层是跳表,定位同样依赖比较器,这个坑一模一样。

设计层面的教训:参与有序集合排序的字段,要么把元素做成不可变的(改分 = 换一个新对象),要么用"摘下—修改—放回"的封装。裸露的可变字段 + 裸调用 add/remove,迟早出事。

三、坑二:同分的玩家,后加入的进不去

第二个坑更隐蔽,因为它的触发条件是业务数据撞车:

TreeSet<Player>rank=newTreeSet<>(Comparator.comparingInt(p->p.score));rank.add(newPlayer(1001,100));rank.add(newPlayer(1002,100));// 两人同分System.out.println(rank.size());// 1 —— 1002 没进去!

原理就是第一节的规则:两个玩家 score 相同 →compare == 0→ TreeSet 认为是"同一个元素" → 第二个 add 直接拒绝(返回 false)。玩家 1002 凭空从榜上消失,而且没有任何报错——榜单人数悄悄少一个,直到玩家投诉"我打完分怎么没上榜"才暴露。

正确姿势:比较器必须落到一个唯一的字段上,同分时用玩家 ID 决胜:

TreeSet<Player>rank=newTreeSet<>(Comparator.comparingInt((Playerp)->p.score).reversed()// 分数降序.thenComparingLong(p->p.id));// 同分按 ID 决胜

这样任何两个玩家的 compare 永远不为 0,"相等"只可能发生在"同一个玩家"身上——Set 的去重语义从"bug"变回了"防重复添加"的本意。

两个业务变体:

  • 同分先到先得:tie-break 不用 ID,用"达到该分数的时间戳"或一个自增序号(ID 设计篇里的发号器产出的自增序号正好可用)——谁先打到这个分谁在前;
  • 同分并列展示:排名计算时把"compare 相同分数"的相邻元素算同名次(1、2、2、4那种),这是展示层的逻辑,不影响比较器仍然要落到唯一字段。

再强调一遍:这里重写equals/hashCode毫无作用。TreeSet 不看它们——很多人在坑二里折腾半天 equals,方向从第一步就错了。

换成 ConcurrentSkipListSet 呢?一模一样。它同样用比较器判等,同分照样compare == 0、照样被判成"同一个元素":

NavigableSet<Player>rank=newConcurrentSkipListSet<>(Comparator.comparingInt(p->p.score));rank.add(newPlayer(1001,100));rank.add(newPlayer(1002,100));// 同分,跳表照样进不去System.out.println(rank.size());// 1 —— 和 TreeSet 分毫不差

所以别指望"换个并发容器就能绕开这两个坑"——换容器解决的是线程安全,不是比较器语义;上面的 tie-break 解法(reversed().thenComparingLong(id))原样平移过去即可。

四、连带要知道的三件事

1)比较器违约的下场:静默错乱,不是报错。JDK 的排序方法(TimSort)发现比较器不满足反对称/传递性会抛Comparison method violates its general contract!,但TreeMap/TreeSet 不做这个检查——违约的树不抛异常,只是悄悄地错:加得进去删不出来、顺序不对、contains 时灵时不灵。坑一其实就是"比较字段在树里变了 = 比较器违约"的一种形式。排查这类问题的第一反应永远是:审比较器,以及比较器读到的字段稳不稳定。

2)并发视角。TreeSet 线程不安全;换 ConcurrentSkipListSet 解决并发安全(弱一致迭代、非阻塞),但今天这两个坑一个都不少——它只是跳表版的同一套语义。选型一句话:榜单能锁在单逻辑线程里就用 TreeSet,确有多线程并发读写才上 ConcurrentSkipListSet;但无论哪个,比较器都得落到唯一字段。游戏服更常见的做法是釜底抽薪:排行榜归属场景逻辑线程单线程访问(延时任务篇讲过的单线程模型),压根没有并发问题,TreeSet 就够。

3)跨服/全服榜:平移到 Redis ZSET。全服百万人的榜不该放单机内存,用 Redis ZSET——而 ZSET 底层同样是跳表,同分时按 member 的字典序排列,和今天讲的"tie-break 落到唯一字段"完全同构(member 天然唯一)。内存 TreeSet 的这套经验可以原样平移过去,只是"先删后改再加"变成一条ZADD(服务端原子更新 score)。

五、一个能直接用的排行榜骨架

综合两个坑的正确姿势,一个单逻辑线程内的迷你排行榜:

publicclassRankBoard{privatefinalMap<Long,Player>index=newHashMap<>();// id → 实体privatefinalTreeSet<Player>board=newTreeSet<>(Comparator.comparingInt((Playerp)->p.score).reversed().thenComparingLong(p->p.id));// tie-break:唯一 ID/** 更新分数:不存在则上榜,存在则"摘下—改—放回" */publicvoidupdate(longid,intnewScore){Playerp=index.get(id);if(p==null){p=newPlayer(id,newScore);index.put(id,p);board.add(p);return;}board.remove(p);// ① 先按旧分数移除p.score=newScore;// ② 再修改board.add(p);// ③ 按新分数放回}/** Top N:榜单本身有序,取前 n 个即可 */publicList<Player>top(intn){List<Player>result=newArrayList<>(n);for(Playerp:board){if(result.size()>=n)break;result.add(p);}returnresult;}/** 查某玩家名次(1-based)。注意这步是 O(n),原因见下文 */publicintrankOf(longid){Playerp=index.get(id);if(p==null)return-1;returnboard.headSet(p).size()+1;// 排在他前面的人数 + 1}}

设计要点都在注释和前文里了:index负责按 ID 找到实体(O(1)),board只负责序。复杂度:更新 O(log n),TopN O(n),查名次的rankOf也是 O(n)。

把查名次的代价说清楚。红黑树没有记录"每棵子树有多少个节点"(它不是顺序统计树),所以要知道"某玩家排第几",headSet(p).size()只能把 p 前面那一段线性数一遍——实测 30 万元素的 TreeSet 上,一次headSet().size()比一次contains慢几千倍。偶尔查几个无所谓;但"每个玩家打开榜单都想看自己排第几"这种高频需求,它就是个性能坑。两条出路:平移到 Redis ZSET 用ZRANK/ZREVRANK(跳表实现,O(log n));或内存版、分数是固定范围内的整数时,改用树状数组(Fenwick Tree),更新和查名次都是 O(log n)。这也呼应上一节——内存 TreeSet 适合"中小规模、单逻辑线程、主要拿 TopN"的场景,一旦要频繁按 id 查名次,就该换工具了。

六、复盘

现象直觉反应真正的原因与解法
remove/contains 失效集合有 bug?重写 equals?排序字段变了,元素在树里"站错位置"——先摘下再改再放回
同分玩家上不了榜加锁?换容器?compare==0 被判等——比较器用唯一 ID 做 tie-break
顺序偶尔错乱数据问题比较器违约(含字段可变),TreeMap 静默错乱不报错
多线程更新榜单上 ConcurrentSkipListSet并发安全了但坑还在;归入单逻辑线程更彻底
频繁查"某玩家排第几"很慢给 TreeSet 加索引?红黑树不存子树大小,headSet().size()是 O(n)——高频查名次改用 Redis ZRANK 或树状数组

三条可复用的经验:

  1. 有序集合的元素,参与排序的字段要么不可变,要么改前先摘——这条纪律消掉 90% 的坑;
  2. 比较器必须收敛到唯一字段:排序业务字段 + 决胜唯一键,compare==0 只应意味着"同一个元素";
  3. 在 TreeSet 的世界里忘掉 equals/hashCode,审问题先审比较器。

七、写在最后

两个坑,一句话总结:TreeSet 把"排序"和"判等"绑在了同一个比较器上——这个设计让它高效,也让它对可变元素和"逻辑相等"天然苛刻。理解了这一句,两个坑就不用背了,自己能推出来。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询