fix(ticket): TicketScanService 锁内复制 key/value,修 fastutil entry 复用导致主线程 NPE 崩服

根因:上一版(scope-perthread-lock)锁内只快照 entry 引用,锁外读 entry.getValue()。
fastutil Long2ObjectOpenHashMap.long2ObjectEntrySet() 的 entry 对象是可复用的——
锁内遍历时 snapshot 列表所有元素最终指向同一 entry 实例,解锁后 DR 维度线程
(经 DistanceManagerLock) 增删 ticket 会改写/清空该 entry,导致 getValue() 返回 null。
实测崩服:crash-2026-10-13_18.36,Server thread NPE
"Cannot invoke SortedArraySet.isEmpty() because tickets is null" at TicketScanService.visitEntry:62。

修复:锁内遍历时立即把 getLongKey()/getValue() 复制成不可变 record EntrySnapshot,
存进列表;锁外遍历 Snapshot。value 为 null 时跳过。下游 visitEntry 加 tickets==null 兜底。
DR 未加载的无锁路径行为不变。
This commit is contained in:
Xiao-no-love committed 2026-10-13 18:41:51 +08:00
1 parent 3fbc956a62
commit 9c309bfed0
1 file changed
+26 -11
@@ -16,13 +16,21 @@ import java.util.concurrent.locks.ReentrantLock;
* <b>并发安全</b>:{@code DistanceManager.tickets} 是非线程安全的
* {@code Long2ObjectOpenHashMap}。DimensionalRipper 维度线程会在 tick 中并发增删 ticket,
* 与主线程此处的遍历冲突 → fastutil fail-fast 迭代器 NPE(已实测)。
* 故遍历时持 DR 的共享锁({@link CrossModLocks}),并在锁内把 entry 快照到局部 List 再遍历,
* 避免持锁期间执行 visitor(可能较慢)。DR 未加载时锁为 null,退化为原无锁行为。
* 故遍历时持 DR 的共享锁({@link CrossModLocks}),并在锁内把 key/value <b>复制</b>出来再遍历,
* 避免持锁期间执行 visitor(可能较慢)。
* <p>
* <b>注意</b>:{@code Long2ObjectOpenHashMap.long2ObjectEntrySet()} 返回的 entry 对象是可复用的,
* 锁外再读 {@code entry.getValue()} 可能已因并发写而被改写/清空(实测 null)。因此快照必须复制
* key 与 value,不能只存 entry 引用。DR 未加载时锁为 null,退化为原无锁行为。
*/
public final class TicketScanService {
private TicketScanService() {
}
/** 快照条目:chunkPos + tickets(锁内复制,与 map 解耦)。 */
private record EntrySnapshot(long chunkPos, SortedArraySet<Ticket<?>> tickets) {
}
public static void visitTicketChunks(ServerLevel level, TicketChunkVisitor visitor) {
DistanceManagerAccessor distanceManager = TicketUtils.getDistanceManager(level);
visitTicketChunks(distanceManager, visitor);
@@ -38,28 +46,35 @@ public final class TicketScanService {
}
return;
}
// DR 共存:锁内快照 entry,锁外遍历,与维度线程的 ticket 写互斥。
List<Long2ObjectMap.Entry<SortedArraySet<Ticket<?>>>> snapshot;
// DR 共存:锁内复制 key/value 快照,锁外遍历,与维度线程的 ticket 写互斥。
// 必须复制 value,不能存 entry 引用——fastutil entry 可复用,锁外读会拿到 null/错值。
List<EntrySnapshot> snapshot;
lock.lock();
try {
snapshot = new ArrayList<>();
for (Long2ObjectMap.Entry<SortedArraySet<Ticket<?>>> entry :
distanceManager.getTickets().long2ObjectEntrySet()) {
snapshot.add(entry);
SortedArraySet<Ticket<?>> value = entry.getValue();
if (value != null) {
snapshot.add(new EntrySnapshot(entry.getLongKey(), value));
}
}
} finally {
lock.unlock();
}
for (Long2ObjectMap.Entry<SortedArraySet<Ticket<?>>> entry : snapshot) {
visitEntry(entry, visitor);
for (EntrySnapshot entry : snapshot) {
visitEntry(entry.chunkPos(), entry.tickets(), visitor);
}
}
private static void visitEntry(Long2ObjectMap.Entry<SortedArraySet<Ticket<?>>> entry,
TicketChunkVisitor visitor) {
long chunkPos = entry.getLongKey();
SortedArraySet<Ticket<?>> tickets = entry.getValue();
if (tickets.isEmpty()) {
visitEntry(entry.getLongKey(), entry.getValue(), visitor);
}
private static void visitEntry(long chunkPos, SortedArraySet<Ticket<?>> tickets,
TicketChunkVisitor visitor) {
if (tickets == null || tickets.isEmpty()) {
return;
}
visitor.visitChunk(chunkPos, tickets);
@@ -69,4 +84,4 @@ public final class TicketScanService {
public interface TicketChunkVisitor {
void visitChunk(long chunkPos, SortedArraySet<Ticket<?>> tickets);
}
}
}