Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Starting a server

Verified against Minecraft 26.3 · Part III · Dropping the jar in an empty folder, typing java -jar server.jar, and waiting for the line that says Done.

The first run unpacks the libraries the jar carries, writes two files and exits: you have not agreed to the EULA. The second gets as far as Preparing level “world” and, a second or two later, Done (1.284s)! For help, type “help”. By the time the second of those lines prints, the server has opened a data pack stack, built every registry the world is made of, taken an operating-system lock on the save directory, rewritten level.dat, constructed a ServerLevel for every dimension the packs declare and started listening on 25565 — and only the levels were built between the two lines. Every other item on that list was over before the first one printed, which is why the elapsed time Done reports is so short. It is not a claim about the whole boot: query, RCON, the watchdog and JMX are all started after that line, and the last of the server’s own start-up work happens later still. And the step in the middle whose name promises the most — MinecraftServer.prepareLevels, the one whose progress line still reads Preparing spawn area — does the least: on an ordinary world it loads no chunks at all. All it does is re-arm the chunk-loading tickets the last shutdown wrote down (tickets and loading has the nine kinds), and exactly two of the nine are ever written down.

The cast

classwhat it decidesthread
server/Maineverything decided before the Server thread exists: the flags, the properties, the EULA, which directory is the world, and when to hand offServerMain, which the jar’s bundler starts
DedicatedServerSettingsthe live copy of server.properties — and the rewrite that happens every time a setting changesServerMain, then Server
LevelStorageSource.LevelStorageAccessone open world: its directory layout, its session.lock, and every read and write of level.dat while the world is openwhoever holds it
WorldLoaderthe packs, the registries and the datapack-driven resources, assembled into a WorldStemServerMain and Util.backgroundExecutor
DedicatedServerwhat a dedicated server has and singleplayer does not: the console, the port, the legacy conversions, RCON, query, the watchdogServer, but constructed on ServerMain
MinecraftServerthe levels, the saves and the loop — and MinecraftServer.spin, the line where the Server thread beginsServer
ServerLevelone dimension: its chunk source, its saved data, its ticket storeServer
LevelLoadListenerwhat boot progress looks like: log lines on a dedicated server, a progress bar on a clientthe caller’s

From the command line to the Server thread

sequenceDiagram
    participant Main as Main
    participant LSA as LevelStorageSource.<br/>LevelStorageAccess
    participant WL as WorldLoader
    participant Worker as Worker
    participant DS as DedicatedServer

    Main->>Main: the flags, Bootstrap.bootStrap, server.properties, the EULA gate
    Main->>Main: JsonRpc.create, if the management server is enabled at all
    Main->>LSA: validateAndCreateAccess, taking session.lock in the constructor
    Main->>LSA: getUnfixedDataTagWithFallback, if there is a world: level.dat, or level.dat_old restored
    Main->>WL: load, wrapped in Util.blockUntilDone, so this thread is an executor
    WL->>Main: the pack-opening stage, the reload's apply steps and the final assembly, single-threaded
    WL->>Worker: static tags, world then dimension registries, the resources
    Worker-->>WL: the background stages, finished
    WL-->>Main: a WorldStem
    Main->>LSA: saveDataTag rewrites level.dat now, upgrade or no upgrade
    Main->>DS: MinecraftServer.spin runs the factory here, on this thread
    Note over Main,DS: the thread starts only now, and main returns

The first half of the boot, and the wall at the foot of it: one thread drives every line above that note, with the worker pool’s help, and the server object does not exist for most of them.

Read the note bar as a wall. Above it one thread drives the work, handing the load’s background stages to the worker pool; below it main has returned, and everything that remains is the Server thread and the daemons arranged around it — the half drawn under The Server thread wakes up below. Anatomy draws the same hand-off from the client’s side, where the thread that spins the server is the one drawing frames.

Everything main does before there is a second thread

Main.main does not run on the thread the JVM started with: the jar opens with a bundler, which unpacks the libraries and the game’s own jar into the working directory and starts Main.main from that copy on a thread named ServerMain, and that thread stays with it for all of what follows. SharedConstants.tryDetectVersion reads version.json out of the jar first, so everything downstream knows what version it is. Then the flags are parsed — –nogui, –port, –universe, –world, –forceUpgrade, –recreateRegionFiles, –safeMode, –initSettings, –pidFile and the rest — and CrashReport.preload runs: MemoryReserve sets a block of heap aside and one throwaway report is formatted and discarded, so that the crash-report path is warm and has memory of its own even when the reason for the crash is that there is none left. Bootstrap.bootStrap and Bootstrap.validate build and freeze the static registries (identifiers and registries), and Util.startTimerHackThread starts a daemon that sleeps forever and touches nothing. The last thing Bootstrap.bootStrap does is quieter and matters later: it puts a LoggedPrintStream over System.out and System.err — a DebugLoggedPrintStream when debug logging is on — so that anything printed from here on goes through the logger, and the original stream survives only as Bootstrap.STDOUT. That is the stream a crash report uses when the logger cannot be trusted (how a server dies).

Only then does the server read its own configuration. DedicatedServerSettings parses server.properties, and DedicatedServerSettings.forceSave writes it straight back — which is why a properties file carried over from an older jar comes back with the new keys filled in. RegionFileVersion.configure takes the region compression out of it before any chunk file is ever opened (chunk storage). And Eula reads eula.txt, or writes the default and reports it missing. That is the gate: Eula.hasAgreedToEULA false means one log line and main returns. No world has been opened, no server exists, and everything main has started so far is a daemon — which does not hold the JVM open, so the process ends with the return. –initSettings returns one step earlier still, having written both files on purpose.

The management port opens before the world does

With the EULA agreed, Services.create builds the authentication services and the name cache in the universe directory, and JsonRpc.create starts a ManagementServer if management-server-enabled is set — and throws, ending the boot, if the secret it finds is not forty alphanumeric characters. A missing secret is not that failure: DedicatedServerProperties resolves management-server-secret to a freshly generated key when the file has none, and the properties rewrite above puts that key in the file — so what stops a boot is a malformed secret, not an absent one. TLS stops it too: it is on by default and wants a keystore, which by default is not configured, so enabling the management server and nothing else ends the boot. What starts is a Netty WebSocket listener with its own event-loop group named Management server IO, TLS on by default, and a JsonRpcNotificationService registered on the NotificationManager that everything later in the boot reports through. It binds before session.lock is taken, and DedicatedServer.onServerExit stops it last, so the management protocol is reachable at both ends of the server’s life — including while there is no world to ask it about (how a server dies).

Taking the lock, and fixing level.dat twice

LevelStorageSource.createDefault points at the universe directory — the working directory unless –universe says otherwise — and LevelStorageSource.validateAndCreateAccess opens the world named by –world or by level-name. The validation is a symlink check; the lock is neither optional nor deferred. DirectoryLock.create, called from the LevelStorageSource.LevelStorageAccess constructor, opens DirectoryLock.LOCK_FILE, writes a snowman into it and takes a FileChannel.tryLock on it. That is an operating-system advisory lock, not a value in the file: a second server on the same directory fails at once with DirectoryLock.LockException, a crashed JVM releases it with nothing to clean up, and a world folder copied out from under a running server carries a session.lock that means nothing. The client’s world list greys out a world that DirectoryLock.isLocked reports held.

If the directory already holds world data, main parses level.dat once and runs the result through the datafixers twice. LevelStorageSource.LevelStorageAccess.getUnfixedDataTagWithFallback reads the file and, on a parse failure, falls back to level.dat_old and restores it over the original. That raw tag goes through LevelStorageSource.LevelStorageAccess.fixAndGetSummaryFromTag for a LevelSummary, which is enough to answer LevelSummary.requiresManualConversion and LevelSummary.isCompatible — two gates that each end the boot with one explanatory line — and separately through DataFixers.getFileFixer for the fully upgraded tag the world is built from.

The world load turns the main thread into an executor

ServerPacksSource.createPackRepository builds the repository over the world’s datapacks/ folder and WorldLoader.load does the rest: the staged load that the resource system describes, run here for server data. WorldLoader.PackConfig.createResourceManager selects and opens the packs, TagLoader.loadTagsForExistingRegistries collects tags for the static registries, RegistryDataLoader loads RegistryDataLoader.WORLD_REGISTRIES and then RegistryDataLoader.DIMENSION_REGISTRIES, the WorldLoader.WorldDataSupplier turns the fixed level.dat into a PrimaryLevelData through LevelStorageSource.getLevelDataAndDimensions — or, with no world data at all, Main.createNewWorldData builds one out of server.properties. Last, ReloadableServerResources.loadResources loads the rest: RegistryDataLoader.RELOADABLE_REGISTRIES, the loot tables, recipes and advancements among them, through ReloadableServerRegistries.reload, and then the functions. What comes back is a WorldStem.

The threading is the part worth noticing. WorldLoader.load takes two executors: Util.backgroundExecutor for the work, and a main-thread executor for the stages that must be single-threaded. Util.blockUntilDone supplies the second by handing the loader a queue’s add method and then draining that queue until the future completes. For the length of the world load ServerMain is an event loop, running the pack-opening stage, the reload’s apply steps and the final assembly itself between bouts of waiting on the workers.

Two things then happen before the server object exists. With –forceUpgrade or –recreateRegionFiles, a WorldUpgrader rewrites the chunks that need it (an older version’s, those holding a cache –eraseCache clears, or every chunk with –recreateRegionFiles) while main polls it once a second and logs a percentage. And either way LevelStorageSource.LevelStorageAccess.saveDataTag writes level.dat back out, by the same replace every later save uses (level data and rules). That is unconditional: a server started and killed one second later has already rewritten its world data.

MinecraftServer.spin, and the last thing main does

MinecraftServer.spin takes a factory rather than a server, and its order is the point. It builds the Thread object first, sets priority 8 on a machine with more than four processors, calls the factory on the calling thread, and only then starts the thread — so the whole of the DedicatedServer constructor runs on ServerMain, and the new thread cannot begin before there is a server for it to run.

That constructor is real work. MinecraftServer’s own opens the server-wide SavedDataStorage, takes the WorldData and WorldGenSettings out of the stem (creating a world is where the stem was built, by a screen or by server.properties), builds the ServerConnectionListener, the PlayerDataStorage, the GameRules, the StructureTemplateManager and the PacketProcessor, finalises recipe loading, and refuses outright a stem whose LevelStem registry has no overworld. DedicatedServer’s adds the ServerTextFilter, the ServerLinks built from the bug-report property, and — when enable-code-of-conduct is set — every .txt file under the codeofconduct folder, which throws if that folder is missing. A misconfigured code of conduct kills the server on ServerMain, before a Server thread exists to be killed.

Back in main, the factory has already applied –port, –demo and –serverId and, unless –nogui or a headless JVM says otherwise, opened the Swing window through DedicatedServer.showGui. A Server Shutdown Thread hook goes on the runtime — its whole body is one MinecraftServer.halt call — and main returns.

The Server thread wakes up, and can still fail twice

sequenceDiagram
    participant DS as DedicatedServer
    participant MS as MinecraftServer
    participant SL as ServerLevel

    Note over DS,SL: the other side of the wall: the Server thread
    MS->>DS: runServer calls initServer
    DS->>DS: the console daemon thread, the properties into fields, the key pair
    DS->>DS: startTcpServerListener binds the port, then convertOldUsers
    DS->>MS: loadLevel, which is createLevels, forceDifficulty and prepareLevels
    MS->>SL: the overworld first, then one level per LevelStem on DerivedLevelData
    MS->>SL: the persisted tickets re-armed, then a wait in 10 ms slices
    SL-->>MS: on an ordinary world, nothing pending: nothing was asked for
    MS-->>DS: loadLevel returns
    DS->>DS: Done is logged here, before the loop is ever entered
    DS->>DS: the optional listeners, each if its property is set
    DS-->>MS: initServer returns true
    MS->>MS: the icon and the status response, then the tick loop

The second half: the play port is bound two messages before the levels exist, and the word Done is logged three messages before the loop it claims is running.

MinecraftServer.runServer is the Server thread’s body and its first act is DedicatedServer.initServer, which begins with the console: a daemon thread named Server console handler reading System.in line by line. It runs nothing itself. Each line becomes a ConsoleInput — the text plus a CommandSourceStack built there on the console thread — appended to a synchronized list that DedicatedServer.handleConsoleInputs drains from DedicatedServer.tickConnection, so a typed command executes inside a tick, in its connection phase.

Then the properties become fields: online mode, the local IP, the default game type, the port. MinecraftServer.initializeKeyPair generates the RSA pair that login encryption uses (protocol phases), and ServerConnectionListener.startTcpServerListener binds the port with a Netty server bootstrap. Two things can end the boot at this point, and they end it identically.

the failurewhat the console saysthe check
the port is already takenFAILED TO BIND TO PORT! and the exceptionServerConnectionListener.startTcpServerListener throws, and DedicatedServer.initServer returns false
a legacy user list survived conversionFAILED TO START THE SERVER AFTER ACCOUNT CONVERSION! and the files to delete by handOldUsersConverter.areOldUserlistsRemoved looks for banned-players.txt, banned-ips.txt, ops.txt and white-list.txt, and is false if any of the four is still there

The conversion itself is DedicatedServer.convertOldUsers, which attempts five migrations — the two ban lists, the op list, the whitelist and the player save files — retrying a failed one up to twice more, five seconds apart, and reporting true unless all five failed; a missing legacy file counts as a success, so an ordinary server reports true on every boot. The gate is the separate check above, so what stops the boot is the file nobody could convert. Either failure returns false out of DedicatedServer.initServer, and a false there is not a quiet exit: MinecraftServer.runServer calls it inside its own try and throws an IllegalStateException on false, so the boot failure lands in the loop’s own catch, writes a crash report and falls into the same finally that /stop reaches — a server that failed to bind still walks the whole shutdown path and releases session.lock on the way out (how a server dies).

Past them, a DedicatedPlayerList is built, offline names are resolved in the cache, and the log says Preparing level “world”.

Building the levels

MinecraftServer.loadLevel is three calls: MinecraftServer.createLevels, MinecraftServer.forceDifficulty — empty in the base class, and overridden only by DedicatedServer.forceDifficulty, which pushes server.properties’ difficulty onto the world on every boot — and MinecraftServer.prepareLevels.

MinecraftServer.createLevels builds the overworld first and by name, and it is the only level that is special. It gets the custom spawners — the PhantomSpawner, the PatrolSpawner, the CatSpawner, the VillageSiege and the WanderingTraderSpawner — and its ServerLevelData is the real PrimaryLevelData out of level.dat. With it in MinecraftServer.levels (a LinkedHashMap, so it stays first for every later walk over the levels), the scoreboard, the CommandStorage and the Stopwatches come out of the server-wide saved data. Every other LevelStem in the registry then gets a ServerLevel over a DerivedLevelData, which forwards the overworld’s game time, difficulty, game mode and hardcore flag and swallows every setter but the spawn. That is why the difficulty is one number every dimension shares. The other world-wide numbers a reader would expect to find here are not its doing: the time of day belongs to ServerClockManager, the weather to one server-wide WeatherData, and the spawn a level reports to MinecraftServer.effectiveRespawnData (level data and rules).

A brand-new world takes one detour, as soon as the overworld exists and before the other dimensions do, and it is the detour that actually generates terrain at boot. When ServerLevelData.isInitialized is false, MinecraftServer.setInitialSpawn asks the generator for its origin chunk (ChunkGenerator.getOrigin), reads the generator’s spawn height, and walks a spiral over the chunks five in each direction — the value MinecraftServer.SPAWN_POSITION_SEARCH_RADIUS names — calling PlayerSpawnFinder.getSpawnPosInChunk until one of them offers a standable block. The bonus chest is placed here if –bonusChest asked for one. Then the flag is set, and no later boot of that world looks for a spawn again.

Preparing the levels, which prepares nothing

MinecraftServer.prepareLevels replays a list. For each level a ChunkLoadCounter records which chunks are already ChunkStatus.FULL, calls TicketStorage.activateAllDeactivatedTickets, runs the distance manager again and counts what is new. The tickets it replays are the persisting ones the last shutdown parked rather than dropped, and two of the nine TicketTypes carry TicketType.FLAG_PERSIST — TicketType.FORCED, from /forceload, and TicketType.PORTAL — so those two are the only entries a chunk_tickets file contains (tickets and loading has the nine). The world spawn is kept by nothing.

Zero chunks are loaded by MinecraftServer.prepareLevels on a world with no forceloads and no live portal ticket.

What follows is MinecraftServer.waitUntilNextTick with the deadline set MinecraftServer.PREPARE_LEVELS_DEFAULT_DELAY_NANOS — 10 ms — out, repeated while ChunkLoadCounter.pendingChunks is above zero: the Server thread pumping its own queue in slices, the same way a tick waits for a chunk (the server tick). With a total of zero it runs once and leaves. MinecraftServer.updateMobSpawningFlags and the effective respawn data are recomputed, and the step is over.

What the console prints while nothing is prepared

The output is worth reading literally. LoggingLevelLoadListener logs Loading N persistent chunks… when LevelLoadListener.Stage.LOAD_INITIAL_CHUNKS starts and Time elapsed: N ms when it finishes, with Preparing spawn area: N% — still the menu.preparingSpawn string, its percentage computed by LevelLoadProgressTracker — printed at most twice a second in between. On an ordinary world there is no in between: N is zero, and the percentage line prints at most once, on the loop’s single pass. Of the four values LevelLoadListener.Stage declares, LevelLoadListener.Stage.PREPARE_GLOBAL_SPAWN fires only on a world’s first boot, LevelLoadListener.Stage.LOAD_PLAYER_CHUNKS belongs to a player joining rather than to boot — PrepareSpawnTask, in the configuration phase (players and sessions) — and LevelLoadListener.Stage.START_SERVER is declared and fired by nothing.

Done comes before the loop

DedicatedServer.initServer logs Done (1.284s)! For help, type “help” the moment MinecraftServer.loadLevel returns — and only then starts the optional listeners. QueryThreadGs4.create runs if enable-query, RconThread.create if enable-rcon, a Server Watchdog thread if DedicatedServer.getMaxTickLength is above zero, JMX if enable-jmx-monitoring. After those come one MinecraftServer.saveEverything with flush and force and NotificationManager.serverStarted on the JSON-RPC feed. DedicatedServer.initServer returns true, MinecraftServer.runServer loads server-icon.png — the server directory’s, or failing that the world’s, and it must be 64 by 64 — builds the first ServerStatus, and enters the tick loop.

So Done is a claim about the world being ready rather than about the server being reachable in every sense. The play port has been open since before the level loaded, and RCON is still not listening when the line prints. MinecraftServer.isReady — read by the singleplayer client’s loading screen and by the JSON-RPC status method — is set at the bottom of the loop’s first iteration, one tick later still.

The threads startup leaves behind

Boot creates every thread on this list and then hands the process to one of them; the column that matters here is daemon, because it decides which of them can hold the JVM open. What each may touch is the thread reference’s, which has the complete set including the pools and the situational ones (threads).

threadmade bydaemonwhat it may touch
Server threadMinecraftServer.spin, from mainnoeverything — it is the only thread allowed to change the world
Server console handlerDedicatedServer.initServer, first statementyesSystem.in and the ConsoleInput list. The command itself runs on the Server thread
Server WatchdogDedicatedServer.initServer, only if DedicatedServer.getMaxTickLength is above zeroyesreads MinecraftServer.getNextTickTime, the game rules and ServerLevel.getWatchdogStats off-thread, mid-tick
RCON Listener, plus one RCON Client per connectionRconThread.create, after DonenoTCP accept. DedicatedServer.runCommand hops the command onto the Server thread with BlockableEventLoop.executeBlocking
Query ListenerQueryThreadGs4.create, after Donenoa UDP status protocol, read-only
Management server IOJsonRpc.create, back in mainyesthe JSON-RPC socket. ManagementServer.tick runs from DedicatedServer.tickServer
Timer hack threadUtil.startTimerHackThread, in mainyesnothing. It sleeps, and nothing wakes it

Two rows carry a consequence for the other end of the story. RconThread and QueryThreadGs4 are the only non-daemon threads in this table besides the Server thread itself — Util.ioPool’s IO-Worker threads, made outside it and squarely in the boot path, are non-daemon too — so boot is where the process acquires the three things that will later have to be stopped by hand rather than abandoned, and what that costs at the other end belongs to how a server dies. Why those two poll, and the conditions that can leave RCON unstarted with enable-rcon set, are per-thread facts and live with the rest of them (threads).

One more thing outlives boot without being a thread: server.properties stays live. Nineteen DedicatedServerProperties fields are Settings.MutableValues, and Settings.MutableValue.update rebuilds the whole properties object and DedicatedServerSettings.update writes the file back, so /whitelist, the spawn-protection setter and the JSON-RPC settings calls all edit server.properties on disk while the server runs. /difficulty does not: it changes the world, and DedicatedServer.forceDifficulty puts the file’s difficulty back at the next boot.

Singleplayer boots the same server with a shorter list

IntegratedServer uses the same MinecraftServer.spin, MinecraftServer.runServer and MinecraftServer.loadLevel, and almost nothing else on this page. IntegratedServer.initServer turns authentication on, generates the key pair, calls MinecraftServer.loadLevel, sets the MOTD from the host’s name and the level name, saves once and returns true: no console thread, no TCP bind, no legacy conversion, no RCON, no query, no watchdog and no Done line. Its player list is built in the constructor rather than in DedicatedServer.initServer, and its LevelLoadListener is a LevelLoadTracker composed with the logging one, so the client’s progress bar and a dedicated server’s log lines are two readings of the same callbacks. The whole of it is constructed from Minecraft.doWorldLoad on the Render thread, by a caller that goes back to drawing frames rather than returning from main — anatomy is that hand-off, and its diagram is the one to read for how the two loops meet.

For a 1.21-era reader. There is no spawnChunkRadius game rule: GameRuleRegistryFix deletes it out of any save that still carries one, and the chunks around the world spawn are kept alive by whoever stands in them and by nothing else. spawn-protection in server.properties survives and is unrelated — it is a permission check in DedicatedServer.isUnderSpawnProtection, not a loader.

Where to look

In boot order. server/Main · Bootstrap.bootStrap · Eula · DedicatedServerSettings · JsonRpc.create · LevelStorageSource.validateAndCreateAccess · LevelStorageSource.LevelStorageAccess · DirectoryLock.create · WorldLoader.load · Util.blockUntilDone · WorldStem · MinecraftServer.spin · MinecraftServer.runServer · DedicatedServer.initServer · MinecraftServer.loadLevel · MinecraftServer.createLevels · MinecraftServer.prepareLevels · LevelLoadListener · IntegratedServer.initServer


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.