Threads, performance and lag
Keep the server at 20 TPS: what is slow, what may run async, and how to measure.
A plugin that works can still make a server lag. On this page you will learn what TPS and MSPT mean, how to measure your own plugin, why most of the API may only be used on the main thread, and how to spread a big job over many ticks so nobody notices it.
Ticks, TPS and MSPT
A Minecraft server does its work in small steps called ticks. In each tick it moves mobs, grows crops, runs redstone, handles what players did, and runs your plugin's code. A healthy server runs 20 ticks every second, so each tick gets exactly 50 milliseconds. That is the tick budget.
Two numbers tell you how the server is doing:
- TPS (ticks per second): how many ticks the server finishes per second. 20.0 is perfect. 15 means the game runs at three quarters of normal speed: crops grow slower, mobs move in slow motion.
- MSPT (milliseconds per tick): how long the work of one tick really takes. Under 50 is fine. Over 50, the server cannot finish in time, and TPS drops.
MSPT is the more useful number, because TPS stays at a flat 20 until you run out of budget and then falls suddenly. A server at 45 ms is already close to trouble even though it still shows 20 TPS.
This small program plays out the rule. Each number in the list is how many milliseconds of work one tick needed. Run it and read the verdicts:
1void main() {2 int budgetMillis = 50;3 int[] workPerTick = {18, 22, 31, 49, 64, 80, 36};4 5 for (int tick = 0; tick < workPerTick.length; tick++) {6 int work = workPerTick[tick];7 int tickLength = Math.max(work, budgetMillis);8 double tps = 1000.0 / tickLength;9 String verdict = work <= budgetMillis ? "fits, the server waits " + (budgetMillis - work) + " ms" : "OVER by " + (work - budgetMillis) + " ms, the next tick starts late";10 IO.println("Tick " + (tick + 1) + ": " + work + " ms of work -> " + String.format("%.1f", tps) + " TPS, " + verdict);11 }12}- The budget of one tick.
- A tick can never be shorter than 50 ms, because the server waits. If the work takes longer, the tick lasts as long as the work.
- There are 1000 ms in a second, so a tick of 50 ms gives 20 ticks per second. A tick of 80 ms gives only 12.5.
Measuring: is my server slow?
You do not have to guess. Paper has measuring tools built in. Type these in the console or in chat if you are an operator:
> tpsTPS from last 1m, 5m, 15m: 20.0, 20.0, 20.0> msptServer tick times (avg/min/max) from last 5s, 10s, 1m:2.5/0.4/42.2, 2.5/0.4/42.2, 2.5/0.4/42.2/mspt prints three numbers for each time window: the average, the fastest and the slowest tick. The slowest one is often the interesting one: a spike up to 42 ms in the example is a tick that was almost over budget. A plugin that does a big job once an hour causes exactly this kind of spike.
Finding out which plugin is slow: spark
Paper includes a profiler called spark. A profiler records what the server is doing many times per second and tells you where the time goes. Your plugin will show up by name, and even by method.
Start the profiler
Run
/spark profiler startand play normally, or do the thing you think is slow.Stop and open the report
After a few minutes run
/spark profiler stop. spark uploads the result and gives you a link to a web page with a call tree.Read the tree from the top
Find your plugin in the list. The percentages tell you how much of the server's time each method used. A plugin that uses more than a few percent deserves a look.
For a quick health check without a full profile, /spark tps and /spark health print summaries. Paper's own guide at docs.papermc.io/paper/profiling explains how to read a report.
Timing your own code
When you suspect one particular piece of your code, time it. System.nanoTime() returns a very precise clock. Read it before and after, and subtract:
Where this file livesthreads-performance-snippetssrcmainjavacomexamplethreadsperformancesnippetsStopwatch.java
The package com.example.threadsperformancesnippets is the folder path com/example/threadsperformancesnippets inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-snippets/
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
- AsyncRoundTrip.javaHelper class
- ChunkSnippets.javaHelper class
- MoveSnippets.javaListener: reacts to events
- PlayerCache.javaListener: reacts to events
- Stopwatch.javayou are hereHelper class
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
13static void timed(Plugin plugin, String label, Runnable work) {14 long start = System.nanoTime();15 work.run();16 long micros = TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - start);17 if (micros > SLOW_THRESHOLD_MICROS) {18 plugin.getLogger().info(label + " took " + micros / 1000.0 + " ms");19 }20}- The clock reading before. The number itself means nothing, only differences between two readings do.
- Runs whatever you handed in.
Runnableis a piece of code you can pass around, usually written as a lambda like() -> doTheThing(). - Converts nanoseconds to microseconds (a millionth of a second). A nanosecond is far too small to read comfortably.
- Only talks when something is slow. A timer that logs on every call would itself spam the console and cost time.
You would use it like Stopwatch.timed(plugin, "rebuild ranking", () -> rebuildRanking());. Remove the timing before you ship, or keep it behind a debug setting.
The demo plugin on this page also has a /perf command that shows the numbers for you with one command:
Where this file livesthreads-performance-demosrcmainjavacomexamplethreadsperformancedemoPerfCommand.java
The package com.example.threadsperformancedemo is the folder path com/example/threadsperformancedemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-demo/
- src/main/
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- BigFillCommand.javaCommand (BasicCommand)
- FillJob.javaHelper class
- PerfCommand.javayou are hereCommand (BasicCommand)
- PerformanceDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
11@Override12public void execute(CommandSourceStack source, String[] args) {13 CommandSender sender = source.getSender();14 double[] tps = Bukkit.getTPS();15 double millisecondsPerTick = Bukkit.getAverageTickTime();16 17 String color = millisecondsPerTick < 40 ? "green" : millisecondsPerTick < 50 ? "yellow" : "red";18 sender.sendRichMessage("<gray>TPS over 1m / 5m / 15m: <white><one> / <five> / <fifteen>",19 Placeholder.unparsed("one", format(tps[0])),20 Placeholder.unparsed("five", format(tps[1])),21 Placeholder.unparsed("fifteen", format(tps[2])));22 sender.sendRichMessage("<gray>Average tick time: <" + color + "><ms> ms</" + color + "> <dark_gray>(the budget is 50 ms)",23 Placeholder.unparsed("ms", format(millisecondsPerTick)));24}- An array of three numbers: the averages of the last 1, 5 and 15 minutes.
- The average MSPT, in milliseconds.
- A chain of conditions: green when there is plenty of budget left, yellow when it is getting tight, red when over. The text is then used as a MiniMessage color tag.
Average tick time: 2.5 ms (the budget is 50 ms)
The main thread rule
A computer can do several things at once by using several threads. Think of threads as workers. A Minecraft server has many, but only one of them runs the game: the main thread. Mobs, blocks, players, inventories and the world all belong to it.
The rule is simple: use the Bukkit API only from the main thread. Your event handlers, commands and normal scheduled tasks already run there, so you are safe by default. Trouble starts when you move work to another thread, for example with an async task, and then touch the world from it.
The reason: the main thread keeps the world in a consistent state while it ticks. If a second worker changed a block at the same moment, two pieces of code could disagree about what the world looks like, and the server could corrupt data or crash. So Paper checks, and refuses.
The classic error
This plugin breaks the rule on purpose. It starts an async task and places a block from inside it:
Where this file livesthreads-performance-asyncbugsrcmainjavacomexamplethreadsperformanceasyncbugAsyncBugPlugin.java
The package com.example.threadsperformanceasyncbug is the folder path com/example/threadsperformanceasyncbug inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-asyncbug/
- src/main/
- java/com/example/threadsperformanceasyncbug/Package com.example.threadsperformanceasyncbug
- AsyncBugPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/threadsperformanceasyncbug/Package com.example.threadsperformanceasyncbug
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
10@Override11public void onEnable() {12 getServer().getAsyncScheduler().runNow(this, task -> {13 World world = getServer().getWorlds().getFirst();14 Block block = world.getBlockAt(0, 100, 0);15 block.setType(Material.GOLD_BLOCK);16 });17}- Starts the task on a worker thread, not the main thread.
- This line changes the world. It is not allowed on a worker thread, and this is where it fails.
The server refuses and prints this. This is real output from a Paper 26.3 server:
[12:44:30 ERROR]: Thread Folia Async Scheduler Thread #0 failed main thread check: block onPlacejava.lang.Throwable at org.spigotmc.AsyncCatcher.catchOp(AsyncCatcher.java:9) ~[paper-26.3.jar:26.3-142-1c92a6c] at org.bukkit.craftbukkit.block.CraftBlock.setType(CraftBlock.java:170) ~[paper-26.3.jar:26.3-142-1c92a6c] at threads-performance-asyncbug.jar//com.example.threadsperformanceasyncbug.AsyncBugPlugin.lambda$onEnable$0(AsyncBugPlugin.java:15) ~[?:?][12:44:30 WARN]: [AsyncBug] Async task for AsyncBug v1.0.0 generated an exceptionjava.lang.IllegalStateException: Asynchronous block onPlace!How to read it, from the bottom up of what matters:
IllegalStateException: Asynchronous block onPlace!is the real error. "Asynchronous" is the word to look for: it always means "you used the API from the wrong thread". The words after it, here "block onPlace", say what you tried to do.- The line with
AsyncBugPlugin.java:15is your code. That is where to look. The line above it names the Bukkit method you called. - The thread name in the first line,
Folia Async Scheduler Thread #0, shows that the code ran on a worker. The name says "Folia" because Paper's modern schedulers share their code with Folia. It is not an error in itself.
Other versions of the same message say things like Asynchronous entity add! or Asynchronous chunk access!. They all mean the same thing, and the fix is always the same.
The fix: do the slow part async, then come back
Split the job into two parts. The slow, safe part (files, databases, web requests, big calculations) runs async. When it is done, the result is handed back to the main thread, which then touches the world or the player.
Where this file livesthreads-performance-snippetssrcmainjavacomexamplethreadsperformancesnippetsAsyncRoundTrip.java
The package com.example.threadsperformancesnippets is the folder path com/example/threadsperformancesnippets inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-snippets/
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
- AsyncRoundTrip.javayou are hereHelper class
- ChunkSnippets.javaHelper class
- MoveSnippets.javaListener: reacts to events
- PlayerCache.javaListener: reacts to events
- Stopwatch.javaHelper class
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
15static void tellPlayerTheirNote(Plugin plugin, Player player, Path noteFile) {16 plugin.getServer().getAsyncScheduler().runNow(plugin, task -> {17 String note = readNote(noteFile);18 19 plugin.getServer().getScheduler().runTask(plugin, () -> {20 if (player.isOnline()) {21 player.sendRichMessage("<gray>Your note: <white><note>", Placeholder.unparsed("note", note));22 }23 });24 });25}- Starts a worker thread task. Everything inside the lambda runs off the main thread.
- Reading a file can take a long time compared with a tick. It is exactly the kind of work that belongs on the worker thread.
- Hops back. This runs the code inside on the main thread, in the next tick.
- The player may have left while the file was loading. Always check before you use them again.
The Scheduler page explained the two schedulers. The new rule to remember is: the async part reads and computes, the main-thread part touches Bukkit. Never pass Bukkit objects such as Block or Entity into an async task to modify them. Pass plain values (numbers, text, coordinates), not live game objects.
What is expensive?
Most plugin code is cheap. A handful of things are not, and they cause nearly all the lag that plugins cause. Learn this list.
1. Loading chunks
A chunk is a 16 by 16 column of the world. Asking for a block in a chunk that is not loaded makes the server read or even generate that chunk, right now, on the main thread. On the test server for this page, generating the chunks under a 256 by 256 area took about 2.7 seconds, all inside a single tick. That is 54 ticks' worth of freezing.
The better way is to ask for the chunk and let the server load it in the background. When it is ready, you get called on the main thread:
Where this file livesthreads-performance-snippetssrcmainjavacomexamplethreadsperformancesnippetsChunkSnippets.java
The package com.example.threadsperformancesnippets is the folder path com/example/threadsperformancesnippets inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-snippets/
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
- AsyncRoundTrip.javaHelper class
- ChunkSnippets.javayou are hereHelper class
- MoveSnippets.javaListener: reacts to events
- PlayerCache.javaListener: reacts to events
- Stopwatch.javaHelper class
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
15static void placeGoldBlockWithoutFreezing(Location target) {16 int chunkX = target.getBlockX() >> 4;17 int chunkZ = target.getBlockZ() >> 4;18 target.getWorld().getChunkAtAsync(chunkX, chunkZ)19 .thenAccept(chunk -> target.getBlock().setType(Material.GOLD_BLOCK));20}- Turns a block coordinate into a chunk coordinate. Shifting right by 4 bits divides by 16.
- Starts loading and returns a
CompletableFuture: a promise of a chunk that will arrive later. - Runs this code when the chunk has arrived. Paper promises this happens on the main thread, so touching the world is fine.
2. Doing heavy work on every movement
PlayerMoveEvent fires every time a player moves or even turns their head. With 50 players that is thousands of calls per second. Anything slow in that handler is multiplied by thousands. The first rule for such hot paths is to exit early:
Where this file livesthreads-performance-snippetssrcmainjavacomexamplethreadsperformancesnippetsMoveSnippets.java
The package com.example.threadsperformancesnippets is the folder path com/example/threadsperformancesnippets inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-snippets/
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
- AsyncRoundTrip.javaHelper class
- ChunkSnippets.javaHelper class
- MoveSnippets.javayou are hereListener: reacts to events
- PlayerCache.javaListener: reacts to events
- Stopwatch.javaHelper class
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
13@EventHandler(ignoreCancelled = true)14public void onMove(PlayerMoveEvent event) {15 if (!event.hasChangedBlock()) {16 return;17 }18 Location to = event.getTo();19 if (to.getBlockX() == GOAL_X && to.getBlockZ() == GOAL_Z) {20 event.getPlayer().sendRichMessage("<green>You reached the goal block!");21 }22}- False when the player only turned their head or moved inside the same block. Most move events are like that, so this one line throws most of the calls away.
- Comparing whole numbers is extremely cheap.
3. Scanning for entities
Methods like getNearbyEntities look at every entity around a point. One call is fine. Hundreds per second add up. Never call them from a hot path such as PlayerMoveEvent without a limit, and prefer the typed version getNearbyEntitiesByType, which only returns the kind of entity you asked for.
4. Files and databases on the main thread
Reading or writing a file, talking to a database or fetching a web page waits for something outside the computer's fast memory. A disk read that takes 20 ms freezes the entire server for 20 ms. Do this kind of work async, as in the AsyncRoundTrip example above.
5. Building text nobody reads
Strings are cheap one at a time, but not in a hot path. This logs a message on every move, even though nobody ever reads most of them:
1@EventHandler2public void onMove(PlayerMoveEvent event) {3 String text = "Player " + event.getPlayer().getName() + " moved to " + event.getTo();4 getLogger().info(text);5}The text is built every time, and the console fills up. Build text only when you need it, and put debug messages behind a setting that is off by default.
Caching, batching and spreading work
Three ideas fix most slow code.
- Do it less often. Ask: does this really have to run every tick? A scoreboard that updates once per second instead of every tick does 5% of the work.
- Cache answers. If you look up the same thing again and again (a player's rank, a coin balance, a config value), remember the answer and reuse it. Update the cache when the value really changes, and remove entries when players leave, or the cache grows forever.
- Spread big jobs. A job that needs 500 ms cannot run in one tick. Split it into pieces and do a few milliseconds of it per tick. Players see a short delay instead of a freeze.
Spreading a job: the idea
Here is the same idea in plain Java, with no Minecraft in the way. There are 5,000 jobs and each tick can do 800 of them:
1void main() {2 int totalJobs = 5000;3 int jobsPerTick = 800;4 5 int finished = 0;6 int tick = 0;7 while (finished < totalJobs) {8 tick++;9 int thisTick = Math.min(jobsPerTick, totalJobs - finished);10 finished += thisTick;11 IO.println("Tick " + tick + ": did " + thisTick + " jobs, " + finished + " of " + totalJobs + " finished");12 }13 14 IO.println("All done after " + tick + " ticks, which is " + tick / 20.0 + " seconds");15}- How much we allow ourselves per tick. The right number depends on how heavy each job is.
- Each trip around the loop stands for one tick.
- The last tick has fewer jobs left than the limit, so it only does what remains.
Spreading a job: a real plugin
The example plugin has a command /bigfill SIZE BLOCK [instant], for example /bigfill 64 stone (the word in brackets is optional). It fills a flat square of blocks under your feet. Without instant it spreads the work over many ticks. With instant it does everything in one tick, so you can compare them.
- threads-performance-demo/
- src/
- main/
- java/
- com/example/threadsperformancedemo/
- PerformanceDemoPlugin.javaRegisters /bigfill and /perf
- BigFillCommand.javaReads the size and block, then starts a FillJob
- FillJob.javaThe job itself: places blocks a little at a time
- PerfCommand.javaShows TPS and MSPT
- com/example/threadsperformancedemo/
- resources/
- plugin.ymlPlugin name, main class and the permission (operators only)
- java/
- main/
- src/
The heart of it is FillJob. It remembers how far it got in a counter called nextIndex and does as much work as it may each tick:
Where this file livesthreads-performance-demosrcmainjavacomexamplethreadsperformancedemoFillJob.java
The package com.example.threadsperformancedemo is the folder path com/example/threadsperformancedemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-demo/
- src/main/
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- BigFillCommand.javaCommand (BasicCommand)
- FillJob.javayou are hereHelper class
- PerfCommand.javaCommand (BasicCommand)
- PerformanceDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
57private void runOneTick(BukkitTask task) {58 long start = System.nanoTime();59 long deadline = start + BUDGET_NANOS_PER_TICK;60 while (nextIndex < totalBlocks && System.nanoTime() < deadline) {61 placeBlock(nextIndex);62 nextIndex++;63 }64 workNanos += System.nanoTime() - start;65 ticksUsed++;66 67 if (nextIndex >= totalBlocks) {68 task.cancel();69 reportFinished();70 } else if (player.isOnline()) {71 int percent = nextIndex * 100 / totalBlocks;72 player.sendActionBar(Component.text("Filling... " + percent + "%", NamedTextColor.YELLOW));73 }74}- The job allows itself 2 milliseconds per tick. That leaves 48 ms for everything else. Choose a small share, because other plugins need time too.
- Checks the clock before each block. The loop stops as soon as the time slice is used up, even if blocks remain.
- Remembers progress. The next tick continues exactly where this one stopped.
- When every block is done, the repeating task stops itself. Without this the task would run forever.
- The player may have logged out. The job still finishes the blocks, it just stops sending progress messages.
The counter is turned into a position with two small calculations:
Where this file livesthreads-performance-demosrcmainjavacomexamplethreadsperformancedemoFillJob.java
The package com.example.threadsperformancedemo is the folder path com/example/threadsperformancedemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-demo/
- src/main/
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- BigFillCommand.javaCommand (BasicCommand)
- FillJob.javayou are hereHelper class
- PerfCommand.javaCommand (BasicCommand)
- PerformanceDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
76private void placeBlock(int index) {77 int x = minX + index % size;78 int z = minZ + index / size;79 world.getBlockAt(x, y, z).setType(material, false);80}- The remainder of dividing by the row length. It counts 0, 1, 2 ... up to the size, then starts again at 0: this is the position within a row.
- Whole-number division. It stays 0 for the whole first row, then 1 for the second row, and so on: this is which row we are in.
- The
falseturns off block physics, so placing a block does not trigger updates in all the neighbors. Much cheaper for big fills.
The command starts it with a repeating task. The two numbers 1L, 1L mean "start in one tick, then repeat every tick":
Where this file livesthreads-performance-demosrcmainjavacomexamplethreadsperformancedemoFillJob.java
The package com.example.threadsperformancedemo is the folder path com/example/threadsperformancedemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-demo/
- src/main/
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- BigFillCommand.javaCommand (BasicCommand)
- FillJob.javayou are hereHelper class
- PerfCommand.javaCommand (BasicCommand)
- PerformanceDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/threadsperformancedemo/Package com.example.threadsperformancedemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
42void startSpread() {43 plugin.getServer().getScheduler().runTaskTimer(plugin, this::runOneTick, 1L, 1L);44}this::runOneTickhands the method to the scheduler. Because the method takes theBukkitTaskas a parameter, the job can cancel itself from the inside.
On the test server, a 256 by 256 fill (65,536 blocks) in an already loaded area printed this when run the spread way:
That is about 6.7 seconds of game time, with the plugin using only a few milliseconds of every tick. The average is a bit above the 2 ms budget because a single block that triggers extra work can run past the limit. The check only happens between blocks, so a budget is a goal, not a guarantee. Keep it small enough that one slow block cannot hurt.
Thread-safety basics
Sometimes you do want several threads to share data: an async task fills a cache while the main thread reads it. Then a second danger appears: two threads changing the same value at once. That is called a race condition, and Java's ordinary tools are not thread-safe against it.
This program makes two threads each add one to a counter a million times. Three versions of the counter take part. Run it:
1import java.util.concurrent.ConcurrentHashMap;2import java.util.concurrent.atomic.AtomicInteger;3 4int plainCounter = 0;5 6void main() throws InterruptedException {7 AtomicInteger atomicCounter = new AtomicInteger();8 ConcurrentHashMap<String, Integer> kills = new ConcurrentHashMap<>();9 10 Runnable work = () -> {11 for (int i = 0; i < 1_000_000; i++) {12 plainCounter++;13 atomicCounter.incrementAndGet();14 kills.merge("Steve", 1, Integer::sum);15 }16 };17 18 Thread first = new Thread(work);19 Thread second = new Thread(work);20 first.start();21 second.start();22 first.join();23 second.join();24 25 IO.println("Plain int: " + plainCounter + " (should be 2000000)");26 IO.println("AtomicInteger: " + atomicCounter.get());27 IO.println("ConcurrentHashMap: " + kills.get("Steve"));28}- An ordinary number.
plainCounter++looks like one step, but it is really three: read the value, add one, write it back. - An
atomic counter. ItsincrementAndGetdoes the three steps as one unbreakable action. - The map version of the same idea: read, add and write back in one safe step.
- Waits until that thread has finished before moving on, so the totals are final when printed.
The plain number comes out smaller than 2,000,000, because sometimes both threads read the same old value and one of the additions is lost. Your exact number will differ every time you run it. The atomic counter and the ConcurrentHashMap always get it right.
The practical rules for plugins:
- Data shared between the main thread and async tasks goes into thread-safe containers:
ConcurrentHashMapinstead ofHashMap, andAtomicIntegerinstead of a plain number you change from several threads. - Data only the main thread touches can stay in normal
HashMaps. This is most of your plugin, and it is simpler and faster. - Bukkit objects (players, blocks, worlds) are never thread-safe. Do not touch them from async code. Pass plain values instead.
Here is a small cache that follows the rules. It may be filled from async tasks and read from event handlers, and it forgets players who leave:
Where this file livesthreads-performance-snippetssrcmainjavacomexamplethreadsperformancesnippetsPlayerCache.java
The package com.example.threadsperformancesnippets is the folder path com/example/threadsperformancesnippets inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-snippets/
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
- AsyncRoundTrip.javaHelper class
- ChunkSnippets.javaHelper class
- MoveSnippets.javaListener: reacts to events
- PlayerCache.javayou are hereListener: reacts to events
- Stopwatch.javaHelper class
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/java/com/example/threadsperformancesnippets/Package com.example.threadsperformancesnippets
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.threadsperformancesnippets;2 3import java.util.Map;4import java.util.UUID;5import java.util.concurrent.ConcurrentHashMap;6import org.bukkit.event.EventHandler;7import org.bukkit.event.Listener;8import org.bukkit.event.player.PlayerQuitEvent;9 10final class PlayerCache implements Listener {11 12 private final Map<UUID, Integer> coinsByPlayer = new ConcurrentHashMap<>();13 14 int coinsOf(UUID playerId) {15 return coinsByPlayer.computeIfAbsent(playerId, this::loadCoinsFromDisk);16 }17 18 void addCoins(UUID playerId, int amount) {19 coinsByPlayer.merge(playerId, amount, Integer::sum);20 }21 22 @EventHandler23 public void onQuit(PlayerQuitEvent event) {24 coinsByPlayer.remove(event.getPlayer().getUniqueId());25 }26 27 private int loadCoinsFromDisk(UUID playerId) {28 return 0;29 }30}- The thread-safe map.
- If the player is already in the cache, return the stored value. If not, call
loadCoinsFromDisk, store the result and return it. The expensive load happens at most once per player. - Adds to the stored number safely, even if two threads add at the same moment.
- Cleans up when the player leaves. A cache that never removes anything slowly eats the server's memory.
Mistakes to avoid
- Touching blocks, entities or players from an async task. Do the slow part async, then hop back to the main thread.
- Reading files or databases in an event handler. Load once, cache, or load async.
- Heavy work in
PlayerMoveEventwithout an early exit. Start withhasChangedBlock(). - A cache that is never cleaned. Remove entries when players leave or when data changes.
- Using
Thread.sleepto "wait". It freezes the thread it runs on, and on the main thread it freezes the whole server. Use a delayed task instead. - Guessing instead of measuring. Run
/spark profiler startand look at the report before you rewrite anything.
Calm down the mob radar
This listener shows how many monsters are near a player. It works, but it scans for entities on every move event, which makes the server lag when many players are online:
1@EventHandler2public void onMove(PlayerMoveEvent event) {3 Player player = event.getPlayer();4 int monsters = player.getWorld().getNearbyEntitiesByType(Monster.class, player.getLocation(), 16).size();5 player.sendActionBar(Component.text("Monsters within 16 blocks: " + monsters));6}Rewrite it so that it only scans when the player has moved to a new block, and at most once per second (20 ticks) for each player. Remember to clean up when a player quits.
Hint 1
Hint 2
Map<UUID, Integer>. Bukkit.getCurrentTick() gives you the current tick number.Hint 3
HashMap is fine. Add a PlayerQuitEvent handler that removes the player.Show the solution
Two early exits cut most of the calls: one for "same block" and one for "scanned less than a second ago". The scan itself stays the same.
Where this file livesthreads-performance-exercisesrcmainjavacomexamplethreadsperformanceexerciseMobRadarListener.java
The package com.example.threadsperformanceexercise is the folder path com/example/threadsperformanceexercise inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- threads-performance-exercise/
- src/main/
- java/com/example/threadsperformanceexercise/Package com.example.threadsperformanceexercise
- MobRadarListener.javayou are hereListener: reacts to events
- MobRadarPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/threadsperformanceexercise/Package com.example.threadsperformanceexercise
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
23@EventHandler(ignoreCancelled = true)24public void onMove(PlayerMoveEvent event) {25 if (!event.hasChangedBlock()) {26 return;27 }28 Player player = event.getPlayer();29 int now = Bukkit.getCurrentTick();30 Integer lastScan = lastScanTickByPlayer.get(player.getUniqueId());31 if (lastScan != null && now - lastScan < SCAN_INTERVAL_TICKS) {32 return;33 }34 lastScanTickByPlayer.put(player.getUniqueId(), now);35 36 int monsters = player.getWorld().getNearbyEntitiesByType(Monster.class, player.getLocation(), SCAN_RADIUS).size();37 player.sendActionBar(Component.text("Monsters within 16 blocks: " + monsters, NamedTextColor.RED));38}- Skips head turns and tiny movements inside one block.
- The server's tick counter. Twenty ticks make one second.
- Too soon since the last scan for this player, so leave.
The matching quit handler removes the entry so the map does not grow. The full class is in examples/plugins/threads-performance-exercise.
Recap
- The server has 50 ms per tick. MSPT is how long a tick really takes, TPS falls when MSPT goes over 50.
- Measure with
/tps,/msptand the bundled/sparkprofiler, and time your own code withSystem.nanoTime(). - Use the Bukkit API only on the main thread. The error to know is
IllegalStateException: Asynchronous ...!. - Do slow, safe work async, then hop back to the main thread to touch the world.
- The expensive things are chunk loading, hot paths like
PlayerMoveEvent, entity scans, file and database work on the main thread, and text nobody reads. - Cache answers, do less often, and spread big jobs over many ticks with a time budget.
- Data shared between threads needs
ConcurrentHashMapor atomic classes. Bukkit objects are never thread-safe.
Quick quiz
A server shows 20.0 TPS but
/msptreports an average of 48 ms. What does that tell you?TPS stays at 20 as long as ticks finish within 50 ms, and 48 ms is just inside. That is why MSPT is the earlier warning. The two numbers use different units: milliseconds against ticks per second.You see
IllegalStateException: Asynchronous entity add!in the console. What went wrong?"Asynchronous" in these messages always means the wrong thread. Hand the work back with a main-thread task.Which task belongs on an async thread?
File reading is slow and does not touch Bukkit. The other three change the game and must run on the main thread.Why does
FillJobcheckSystem.nanoTime()before every block?The check is what turns a big job into a well-behaved one. The job keeps its place innextIndexand continues next tick.Two threads both add to a shared
HashMapcounter. What is the safest fix?A concurrent map does the read, add and write as one safe step. Sleeping only makes a collision less likely, it does not remove the bug.
Next steps
- Review the schedulers in Scheduling tasks.
- Learn how the same ideas change when the server runs many threads at once in Folia and regionized scheduling.
- Find bugs faster in Debugging.