Bukkit & Folia builds

The two builds are feature-identical. Every difference is about one question: which thread does this task belong to?

Why two builds

Folia splits the world into independent regions, each ticked by its own thread. There is no main thread at all, and Bukkit.getScheduler() does not exist — not "behaves differently", but the class is absent.

So every "do this later" has to answer a new question: which region owns it?

NeedBukkit wayFolia way
Detonate a firework one tick laterrunTaskLater(plugin, task, 1L)Run delayed on the firework entity's region
Open a GUIrunTask(plugin, this::open)Run on the viewer entity's region
Return to the "main" thread after async workrunTask(plugin, ...)Run on the global region thread
Register command aliasesTouch CommandMap directlyHop to the global region thread first

On top of that, Folia only loads plugins that declare folia-supported: true in plugin.yml. That alone forces a separate jar.

i

The reverse is also true: the Folia build cannot run on plain Paper. It depends on the entity and region schedulers from folia-api, which Paper does not ship.

Code differences

A file-by-file diff comes down to nine places:

FileDifference
util/Schedulers.javaFolia only — wraps every scheduling entry point; no other class touches a scheduler
CreeperConfettiPro.javaAdds volatile caches for confettiChance and debugEnabled, plus reloadCachedSettings()
events/CreeperExplodeListener.javaReads the chance from the cache; detonates through runOnEntityLater
effects/FireworkUtil.javaspawnAndDetonate schedules on the firework entity's region
effects/FireworkStyleManager.javaEvery public method is synchronized
LanguageManager.javacurrentLanguage becomes volatile; message accessors become synchronized; the GeoIP callback uses runGlobal
commands/sub/ReloadCommand.javaRefreshes the caches first, moves alias registration to the global region thread
gui/BaseGui.javaopenLater() opens on the viewer's region
plugin.ymlapi-version: '1.19' and folia-supported: true

Nothing else changed. Same config file, same commands, same GUI, same language files — configuration files can be copied between the two builds. The jars themselves are not interchangeable: pick the one that matches your server.

The scheduler wrapper

Schedulers is a small static utility that turns "where should this run" into six methods:

MethodRuns onTypical use
runOnEntity(plugin, entity, task)The entity's region, next tickOpening a GUI, entity work
runOnEntityLater(plugin, entity, task, delay)The entity's region, delayedDelayed firework detonation
runAtLocation(plugin, loc, task)The region owning the locationPosition-based tasks
runAtLocationLater(plugin, loc, task, delay)Same, delayedSame, with a delay
runGlobal(plugin, task)The global region threadCommand registration, non-region state
runAsync(plugin, task)Any async threadNetwork, file IO
runAsyncDelayed(plugin, task, delay, unit)Any async thread, delayedThe delayed update check on startup

The core is a handful of lines, but it handles the Folia traps:

public static void runOnEntity(Plugin plugin, Entity entity, Runnable task) {
    entity.getScheduler().run(plugin, scheduled -> task.run(), () -> { });
}

public static void runOnEntityLater(Plugin plugin, Entity entity, Runnable task, long delayTicks) {
    entity.getScheduler().runDelayed(plugin, scheduled -> task.run(), () -> { },
            Math.max(1L, delayTicks));   // Folia requires a delay of at least 1 tick
}

public static void runGlobal(Plugin plugin, Runnable task) {
    Bukkit.getGlobalRegionScheduler().run(plugin, scheduled -> task.run());
}
  • Math.max(1L, delayTicks) — Folia rejects a delay of 0, so it is clamped to 1;
  • the third argument is the cancellation callback, left empty: the plugin has no delayed tasks to clean up;
  • once an entity is removed its scheduler is invalidated automatically, so there is no "operating on a dead entity" error.

Thread-safety strategy

Swapping schedulers is not enough. On Folia, players in different regions can trigger explosions simultaneously while an admin edits the config on another thread — shared state needs protection. The plugin uses two mechanisms.

1. Volatile caches: a safe snapshot for region threads

Bukkit.getConfig() returns a FileConfiguration that is not thread-safe. The listener used to read getConfig().getDouble("confetti_chance") directly, which on Folia means several region threads reading a map that may be mid-write.

The Folia build reads the values into volatile fields on enable and reload; region threads only touch the fields:

private volatile double confettiChance = 100.0D;
private volatile boolean debugEnabled = false;

public void reloadCachedSettings() {
    this.confettiChance = getConfig().getDouble("confetti_chance", 100.0D);
    this.debugEnabled = getConfig().getBoolean("debug", false);
}

/cc chance and /cc reload both refresh the cache, so changes apply immediately without a restart.

2. synchronized: guarding the collections

FireworkStyleManager keeps two LinkedHashMaps, and reads (drawing a style) can happen on a different thread than writes (add, remove, chance change). Every public method is synchronized in the Folia build:

public synchronized List<FireworkEffect> getRandomEffects() { ... }
public synchronized boolean setChance(String name, double value) { ... }
public synchronized void save() { ... }

The same applies to LanguageManager: its messages map is rebuilt wholesale on reload, so loadMessages(), getMessage() and getConsoleMessage() are locked, and currentLanguage is volatile.

v

The locks are tiny — one map operation or one string build — never held across methods, and never wrap IO or scheduling. So they cannot cause noticeable stalls on a region thread.

3. Ordering: coming back to the right thread

Language detection needs the network, so it must be async; but the result touches Bukkit state, so it must hop back. That is the one place the two builds differ structurally:

// Bukkit build
runTaskAsynchronously(plugin, () -> {
    String country = resolver.resolve();
    runTask(plugin, () -> applyCountry(country));
});

// Folia build
Schedulers.runAsync(plugin, () -> {
    String country = resolver.resolve();
    Schedulers.runGlobal(plugin, () -> applyCountry(country));
});

Porting checklist

If you extend this project, or port another plugin to Folia, run through these:

  1. Replace every Bukkit.getScheduler() with a region / global / async scheduler;
  2. before touching an entity or block, work out which region owns it, then use entity.getScheduler() or getRegionScheduler();
  3. never pass 0 as a delay — one tick minimum;
  4. never assume "the current thread is the main thread"; handlers can run on any region thread;
  5. shared mutable state needs a lock or a volatile snapshot;
  6. add folia-supported: true to plugin.yml and raise api-version to 1.19+;
  7. compile for Java 17 and depend on dev.folia:folia-api.

Bukkit 与 Folia 双分支

两个分支功能完全一致,代码差异只集中在「任务跑在哪个线程」这一件事上。理解这点,就能明白为什么不能只发一个 jar。

为什么需要两个分支

Folia 把世界切成多个互相独立的区域(region),每个区域由自己的线程按 tick 推进,根本不存在「主线程」。Bukkit.getScheduler() 在 Folia 上直接不存在——不是行为不同,而是那个类压根没有。

于是所有「等一会儿再做」的需求都必须回答一个新问题:这件事属于哪个区域?

需求Bukkit 写法Folia 写法
延迟 1 tick 引爆烟花runTaskLater(plugin, task, 1L)在烟花实体所属区域上延迟执行
打开 GUIrunTask(plugin, this::open)在玩家实体所属区域上执行
异步网络请求后回主线程runTask(plugin, ...)回到全局区域线程
注册指令别名直接改 CommandMap必须先切到全局区域线程

另外,Folia 只加载 plugin.yml 里声明了 folia-supported: true 的插件,否则会拒绝启用。这也是 Folia 分支必须单独打包的原因之一。

i

反过来,Folia 分支在普通 Paper 上也无法运行:它依赖 folia-api 里的实体调度器与区域调度器,而这些在 Paper 上不存在。

代码差异对照

逐文件对比后,改动集中在九处:

文件差异
util/Schedulers.java仅 Folia 分支存在,封装全部调度入口,其余类不再直接调用调度器
CreeperConfettiPro.java新增 volatile 缓存的 confettiChance 与 debugEnabled,提供 reloadCachedSettings()
events/CreeperExplodeListener.java概率从缓存读取;引爆改用 runOnEntityLater
effects/FireworkUtil.javaspawnAndDetonate 改为在烟花实体所属区域延迟引爆
effects/FireworkStyleManager.java全部公开方法加 synchronized
LanguageManager.javacurrentLanguage 改为 volatile;消息读写方法加 synchronized;GeoIP 回调走 runGlobal
commands/sub/ReloadCommand.java先刷新 volatile 缓存,别名注册丢到全局区域线程
gui/BaseGui.javaopenLater() 改为在查看者实体所属区域打开
plugin.ymlapi-version: '1.19' + folia-supported: true

除此之外,业务逻辑一行没改:同样的配置文件、同样的指令、同样的界面、同样的语言文件,配置可以在两个分支之间直接拷贝。但 jar 本身不能互换,必须选与服务端匹配的那个。

调度器封装

Schedulers 是一个纯静态工具类,把「在哪里执行」抽象成六个方法:

方法执行位置典型用途
runOnEntity(plugin, entity, task)实体所属区域,下一 tick打开 GUI、操作实体
runOnEntityLater(plugin, entity, task, delay)实体所属区域,延迟 N tick延迟引爆烟花
runAtLocation(plugin, loc, task)坐标所属区域,下一 tick按位置判定归属的任务
runAtLocationLater(plugin, loc, task, delay)坐标所属区域,延迟 N tick同上,带延迟
runGlobal(plugin, task)全局区域线程注册指令、非区域相关的 Bukkit 状态
runAsync(plugin, task)任意异步线程网络请求、文件 IO
runAsyncDelayed(plugin, task, delay, unit)任意异步线程,延迟启动后延迟执行的更新检查

核心实现只有几行,但把 Folia 的几个坑都处理掉了:

public static void runOnEntity(Plugin plugin, Entity entity, Runnable task) {
    entity.getScheduler().run(plugin, scheduled -> task.run(), () -> { });
}

public static void runOnEntityLater(Plugin plugin, Entity entity, Runnable task, long delayTicks) {
    entity.getScheduler().runDelayed(plugin, scheduled -> task.run(), () -> { },
            Math.max(1L, delayTicks));   // Folia 要求延迟至少 1 tick
}

public static void runGlobal(Plugin plugin, Runnable task) {
    Bukkit.getGlobalRegionScheduler().run(plugin, scheduled -> task.run());
}
  • Math.max(1L, delayTicks):Folia 的延迟调度不接受 0,传 0 会抛异常,这里统一兜到 1;
  • 第三个参数是取消回调,留空即可——插件没有需要清理的延迟任务;
  • 实体被移除后调度器会自动失效,不会出现「对已死实体操作」的报错。

线程安全策略

光换调度器还不够。Folia 下玩家可能在不同的区域线程上同时触发爆炸,而管理员在另一个线程执行指令改配置——共享状态必须自己保护。这个插件用了两种手段。

1. volatile 缓存:给区域线程一个安全快照

Bukkit.getConfig() 返回的 FileConfiguration 不是线程安全的。原来爆炸监听里直接读 getConfig().getDouble("confetti_chance"),在 Folia 上相当于多个区域线程同时读一个可能正被写入的 Map。

Folia 分支改为在启用与重载时把值读进 volatile 字段,区域线程只读这个字段:

private volatile double confettiChance = 100.0D;
private volatile boolean debugEnabled = false;

public void reloadCachedSettings() {
    this.confettiChance = getConfig().getDouble("confetti_chance", 100.0D);
    this.debugEnabled = getConfig().getBoolean("debug", false);
}

/cc chance 与 /cc reload 都会同步更新缓存,因此改完立刻生效,不需要重启。

2. synchronized:给集合操作加锁

FireworkStyleManager 内部是两个 LinkedHashMap,读取(抽样式)与写入(增删样式、改概率)可能发生在不同线程。Folia 分支给所有公开方法加了 synchronized:

public synchronized List<FireworkEffect> getRandomEffects() { ... }
public synchronized boolean setChance(String name, double value) { ... }
public synchronized void save() { ... }

同理,LanguageManager 的 messages 是 HashMap,重载语言时整个表会被重建,因此 loadMessages()、getMessage()、getConsoleMessage() 都加了锁,currentLanguage 则声明为 volatile。

v

这些锁的粒度都很小(只包住一次 Map 操作或一次字符串拼接),不存在跨方法持锁,也不会在锁内做 IO 或调度,因此不会在区域线程上造成可感知的卡顿。

3. 顺序问题:异步之后必须回到正确的线程

语言自动检测需要访问网络,只能在异步线程做;但拿到结果后要改的是 Bukkit 状态,必须切回来。两个分支的区别就在这里:

// Bukkit 分支
runTaskAsynchronously(plugin, () -> {
    String country = resolver.resolve();
    runTask(plugin, () -> applyCountry(country));
});

// Folia 分支
Schedulers.runAsync(plugin, () -> {
    String country = resolver.resolve();
    Schedulers.runGlobal(plugin, () -> applyCountry(country));
});

把插件迁到 Folia 的检查清单

如果你要基于这个项目做二次开发,或者把别的插件搬过来,按这几条过一遍基本不会踩坑:

  1. 把 Bukkit.getScheduler() 全部替换成区域/全局/异步调度器;
  2. 凡是操作实体或方块,先确定它属于哪个区域,再用 entity.getScheduler() 或 getRegionScheduler();
  3. 延迟参数不要传 0,最少 1 tick;
  4. 不要假设「当前线程就是主线程」,事件处理器可能跑在任意区域线程;
  5. 共享可变状态要么加锁,要么改成 volatile 快照;
  6. plugin.yml 加上 folia-supported: true,api-version 提到 1.19 以上;
  7. 编译目标提到 Java 17,依赖换成 dev.folia:folia-api。