ModelUpdaters.uuidLookup and ModelUpdaters.idLookup are strong-reference maps, and ModelUpdaters.tick() performs no liveness checking. Entries are only removed by explicit removeModeledEntity / forceRemoveModeledEntity.
EntityListener registers onDeath(EntityDeathEvent) but never calls removeModeledEntity. The only automatic cleanup path is WorldListener.onEntityUnload (EntitiesUnloadEvent).
As a result, a modeled entity that dies while its chunk stays loaded is never released. On a farm area that is permanently loaded, this accumulates without bound.
Evidence — heap histogram after ~9h uptime, ~28 players
ModeledEntityImpl 431,354
ActiveModelImpl 431,354
net.minecraft.world.entity.monster.Zombie 454,650 (585 MB)
The retained zombies drag their full server-side state — per leaked entity roughly:
- 15.5
WrappedGoal - 9.8
AttributeInstance - 68
pathfinder.Node - 20.1
SynchedEntityData$DataItem - 47
DataTracker - 39
StateNodelambdas
Totals in the same histogram:
pathfinder.Node 30,909,919
Object[] 47,854,531 (6.67 GB)
DataTracker 21,427,812
Impact
Heap grows ~4 GB/hour independent of player count (flat at 25–30 players, ~800–1,200 world entities throughout).
A 38 GB heap is exhausted in 8–9 hours, after which the server enters a Full GC loop:
- 899 Full GCs in one session
- 5,350 ms average pause
- 8,759 ms max pause
The watchdog then kills the server.
The server must be restarted roughly every 8 hours.
Setup that triggers it
MythicMobs mobs applying:
model{mid=...} @self ~onSpawn
spawned and killed continuously (~4–5 kills/second) in a permanently-loaded area.
Suggested fix
Release the registry entry when the entity is removed from the world, not only when its chunk unloads — e.g. hook EntityRemoveFromWorldEvent (Paper) or call removeModeledEntity from the existing EntityListener.onDeath handler.
Happy to run further queries against a heap dump or test a patched build.