Folia / Luminol 插件适配完全指南
这份指南的数据来自 Luminol 1.21.11 上实际运行 ServerAPIProbe 采集的 API 签名,不是抄文档来的。 适用版本:Folia 1.21.x / Luminol 1.21.11
一、先说清楚 Folia 到底改了什么
Folia 是 PaperMC 官方做的 Paper 分支,给 Minecraft 服务端加了区域化多线程(Regionized Multithreading)。
传统服务端是这样跑的:一个主线程负责所有世界的所有 tick。TPS 上不去?加 CPU 没用,因为瓶颈就在那一个线程上。
Folia 把世界切成一个个区域(Region)——附近的已加载区块归为一组,每个区域在独立线程上并行 tick。
┌─────────────────────────────────────────┐
│ 区域 A (线程 1) │ 区域 B (线程 2) │
│ 区块 (0,0) (0,1) │ 区块 (4,0) (4,1) │
│ 玩家 P1, 实体 E1 │ 玩家 P2, 实体 E2 │
├─────────────────────────────────────────┤
│ 区域 C (线程 3) │ 区域 D (线程 4) │
│ 区块 (0,4) (0,5) │ 区块 (4,4) (4,5) │
│ 玩家 P3 │ 空区域 │
└─────────────────────────────────────────┘
关键结论一句话:Folia 没有"主线程"这个概念了。每个区域就是它自己的主线程。
线程模型大致是:
| 线程类型 | 数量 | 用途 |
|---|---|---|
| 区域线程 | 动态(每区域一个) | 执行区域的 tick 循环 |
| 全局区域线程 | 1 个 | 服务器级操作(配置、全局广播) |
| 异步线程池 | 可配置 | HTTP、IO、数据库等非世界操作 |
| 网络线程 | ~4 个 | 处理网络包 |
什么服务器适合 Folia
- 大型生存服:玩家分散在不同区域,多核并行处理
- 无政府服:高频操作分散到多个区域线程
- 高 TPS 需求:突破传统单线程瓶颈
反过来说,20 人以下的小服没必要上 Folia——单线程 Paper 完全够用,Folia 只会徒增复杂度。另外如果你的插件生态高度依赖未适配的 Bukkit 插件,也不适合。
硬件上建议至少 8 核,推荐 16 核以上。内存需求和普通 Paper 差不多,但线程开销略高。
二、Luminol:国人做的 Folia 分支
Luminol 是基于 Folia 的分支,专为生存和无政府服务器设计,在 Folia 基础上加了更多优化、可配置的原版特性和扩展 API。
| 方面 | Folia | Luminol |
|---|---|---|
| 开发方 | PaperMC 官方 | LuminolMC 社区 |
| 定位 | 通用多线程服务端 | 生存/无政府专用 |
| 原版特性配置 | 有限 | 丰富可配置 |
| 存档格式 | 标准 | linear / b_linear |
| API | Folia API | Folia API + Luminol 扩展 API |
| 插件兼容性 | 需完全适配 Folia | 同 Folia(LightingLuminol 分支可兼容部分 Bukkit 插件) |
| Minecraft 版本 | 1.21.3 - 1.21.8 | 1.21.5 - 1.21.11 |
社区资源:QQ 群 1015048616,Telegram @LuminolMinecraft,Discord discord.gg/Qd7m3V6eDx,GitHub github.com/LuminolMC/Luminol。
区域所有权
这是理解全部适配规则的前提。
每个实体、区块、位置都"属于"某个区域。只有拥有该区域的线程才能安全地操作其中的数据。
所以:
PlayerInteractEvent在玩家所在区域线程触发 → 可以安全操作该玩家BlockBreakEvent在方块所在区域线程触发 → 可以安全操作该方块- 不要在事件里操作其他区域的实体或方块
判断当前线程有没有权限:
Bukkit.isOwnedByCurrentRegion(Entity entity);
Bukkit.isOwnedByCurrentRegion(Location location);
Bukkit.isOwnedByCurrentRegion(Block block);
Bukkit.isGlobalTickThread();
三、必须改的十条
3.1 teleport() 必须换成 teleportAsync()
// ❌ 抛 UnsupportedOperationException
player.teleport(location);
// ✅
player.teleportAsync(location);
原因:teleport() 是同步的,会直接操作目标区域的数据,线程不安全。teleportAsync() 返回 CompletableFuture<Boolean>,由目标区域线程异步处理。
3.2 BukkitScheduler 不能用了
// ❌ 全部抛异常
Bukkit.getScheduler().runTask(plugin, () -> { });
Bukkit.getScheduler().runTaskTimer(plugin, () -> { }, 0, 20);
Bukkit.getScheduler().runTaskLater(plugin, () -> { }, 60);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { });
// ✅
Bukkit.getGlobalRegionScheduler().run(plugin, task -> { });
Bukkit.getRegionScheduler().runAtFixedRate(plugin, location, task -> { }, 0, 20);
Bukkit.getRegionScheduler().runDelayed(plugin, location, task -> { }, 60);
Bukkit.getAsyncScheduler().runNow(plugin, task -> { });
为什么不能用? 因为 runTask() 不知道该在哪个区域线程执行——没有主线程了,它无处可去。
3.3 BukkitRunnable 也不能用
它内部调用的就是 BukkitScheduler。
// ❌
new BukkitRunnable() {
public void run() { }
}.runTaskTimer(plugin, 0, 20);
// ✅
entity.getScheduler().runAtFixedRate(plugin, task -> { }, null, 0, 20);
3.4 runDelayed 的 delay 必须 > 0
// ❌ IllegalArgumentException: Delay ticks may not be <= 0
scheduler.runDelayed(plugin, location, task -> { }, 0);
// ✅ delay=0 用 run()
scheduler.run(plugin, location, task -> { });
// ✅ 或用 runAtFixedRate 并设 initialDelay=1
scheduler.runAtFixedRate(plugin, location, task -> { }, 1, period);
3.5 EntityScheduler 多一个 retired 参数
这一条坑过很多人——EntityScheduler 的所有方法都比别的调度器多一个参数。
// ❌ 少一个参数,编译不过
entity.getScheduler().run(plugin, task -> { });
// ✅ 第三个参数是 retired(实体失效时的回调,传 null 即可)
entity.getScheduler().run(plugin, task -> { }, null);
entity.getScheduler().runAtFixedRate(plugin, task -> { }, null, 0, 20);
3.6 共享数据必须线程安全
区域线程是真并行的。
// ❌ HashMap 非线程安全
Map<UUID, String> data = new HashMap<>();
// ✅
Map<UUID, String> data = new ConcurrentHashMap<>();
AtomicInteger counter = new AtomicInteger(0);
3.7 全局状态要谨慎
// ⚠️ 危险:多个区域线程同时读写
public static int totalKills = 0;
// ✅
public static AtomicInteger totalKills = new AtomicInteger(0);
public static synchronized void addKill() { totalKills++; }
3.8 getOnlinePlayers() 返回并发集合
// ✅ 可以遍历,但不要在遍历中修改
for (Player player : Bukkit.getOnlinePlayers()) {
player.sendMessage("Hello");
}
// ✅ 需要修改就先复制一份
List<Player> online = new ArrayList<>(Bukkit.getOnlinePlayers());
for (Player player : online) { }
3.9 不要假设执行顺序
// ❌ 假设 task1 在 task2 之前执行
scheduler.run(plugin, loc1, task -> { /* task1 */ });
scheduler.run(plugin, loc2, task -> { /* task2 */ });
如果 loc1 和 loc2 在不同区域,执行顺序是不确定的。
3.10 plugin.yml 加标记
folia-supported: true
不加这一条,服务端会直接拒绝加载——哪怕代码其实已经适配好了。
四、四大调度器 API(实测签名)
GlobalRegionScheduler
Bukkit.getGlobalRegionScheduler() — 全局任务(配置保存、全局广播、服务器统计)
public interface GlobalRegionScheduler {
ScheduledTask run(Plugin plugin, Consumer<ScheduledTask> task);
void execute(Plugin plugin, Runnable run);
ScheduledTask runDelayed(Plugin plugin, Consumer<ScheduledTask> task, long delayTicks);
ScheduledTask runAtFixedRate(Plugin plugin, Consumer<ScheduledTask> task, long initialDelayTicks, long periodTicks);
void cancelTasks(Plugin plugin);
}
RegionScheduler
Bukkit.getRegionScheduler() — 在特定位置所在的区域执行(方块操作、区域实体生成)
public interface RegionScheduler {
// 按 Location
ScheduledTask run(Plugin plugin, Location location, Consumer<ScheduledTask> task);
void execute(Plugin plugin, Location location, Runnable run);
ScheduledTask runDelayed(Plugin plugin, Location location, Consumer<ScheduledTask> task, long delayTicks);
ScheduledTask runAtFixedRate(Plugin plugin, Location location, Consumer<ScheduledTask> task, long initialDelayTicks, long periodTicks);
// 按区块坐标
ScheduledTask run(Plugin plugin, World world, int chunkX, int chunkZ, Consumer<ScheduledTask> task);
ScheduledTask runAtFixedRate(Plugin plugin, World world, int chunkX, int chunkZ, Consumer<ScheduledTask> task, long initialDelayTicks, long periodTicks);
}
EntityScheduler
entity.getScheduler() — 在实体所在区域执行(玩家操作、实体 AI、物品操作)
public interface EntityScheduler {
ScheduledTask run(Plugin plugin, Consumer<ScheduledTask> task, Runnable retired);
boolean execute(Plugin plugin, Runnable run, Runnable retired, long delayTicks);
ScheduledTask runDelayed(Plugin plugin, Consumer<ScheduledTask> task, Runnable retired, long delayTicks);
ScheduledTask runAtFixedRate(Plugin plugin, Consumer<ScheduledTask> task, Runnable retired, long initialDelayTicks, long periodTicks);
}
⚠️ 所有方法都有 retired 参数。
用 EntityScheduler 有个额外好处:任务绑定在实体上,实体跨区域移动时任务会自动跟随。对于"对某个玩家持续施加效果"这类需求,它永远是正确的选择。
AsyncScheduler
Bukkit.getAsyncScheduler() — 纯异步任务(HTTP、IO、数据库)
public interface AsyncScheduler {
ScheduledTask runNow(Plugin plugin, Consumer<ScheduledTask> task);
ScheduledTask runDelayed(Plugin plugin, Consumer<ScheduledTask> task, long delay, TimeUnit unit);
ScheduledTask runAtFixedRate(Plugin plugin, Consumer<ScheduledTask> task, long initialDelay, long period, TimeUnit unit);
void cancelTasks(Plugin plugin);
}
⚠️ 时间参数用 TimeUnit,不是 ticks。
ScheduledTask
public interface ScheduledTask {
Plugin getOwningPlugin();
boolean isRepeatingTask();
ExecutionState getExecutionState();
CancelledState cancel(); // 返回 CancelledState,不是 void
boolean isCancelled();
}
五、禁用 API 对照表
| ❌ 禁用 | ✅ 替代 |
|---|---|
Bukkit.getScheduler().runTask() | GlobalRegionScheduler.run() / RegionScheduler.run() |
Bukkit.getScheduler().runTaskTimer() | RegionScheduler.runAtFixedRate() / EntityScheduler.runAtFixedRate() |
Bukkit.getScheduler().runTaskLater() | RegionScheduler.runDelayed() / EntityScheduler.runDelayed() |
Bukkit.getScheduler().runTaskAsynchronously() | AsyncScheduler.runNow() |
new BukkitRunnable().runTask() | 对应调度器 |
Entity.teleport() | Entity.teleportAsync() |
Player.teleport() | Player.teleportAsync() |
六、代码示例
定时任务
// Bukkit
new BukkitRunnable() {
public void run() {
if (!player.isOnline()) { cancel(); return; }
player.damage(1);
}
}.runTaskTimer(plugin, 0, 20);
// Folia(注意 retired=null)
player.getScheduler().runAtFixedRate(plugin, task -> {
if (!player.isOnline()) { task.cancel(); return; }
player.damage(1);
}, null, 0L, 20L);
异步请求后回到玩家线程
// Bukkit
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
String result = fetchFromAPI(url);
Bukkit.getScheduler().runTask(plugin, () -> player.sendMessage(result));
});
// Folia
Bukkit.getAsyncScheduler().runNow(plugin, task -> {
String result = fetchFromAPI(url);
player.getScheduler().run(plugin, t -> player.sendMessage(result), null);
});
一份代码同时兼容 Bukkit 和 Folia
public static void scheduleRepeating(Plugin plugin, Entity entity, Runnable action, long period) {
try {
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
// Folia
entity.getScheduler().runAtFixedRate(plugin, t -> action.run(), null, 0L, period);
} catch (ClassNotFoundException e) {
// Bukkit
new BukkitRunnable() {
public void run() { action.run(); }
}.runTaskTimer(plugin, 0, period);
}
}
七、常见报错对照
| 错误 | 原因 | 解决 |
|---|---|---|
UnsupportedOperationException: Must use teleportAsync | 用了 teleport() | 改 teleportAsync() |
UnsupportedOperationException at CraftScheduler.handle() | 用了 BukkitScheduler | 改 Folia 调度器 |
IllegalArgumentException: Delay ticks may not be <= 0 | runDelayed 的 delay ≤ 0 | delay=0 用 run(),或 max(delay,1) |
NoSuchMethodException 反射失败 | Paper 反射重写器拦截 | 直接调 API,别用反射 |
ConcurrentModificationException | 多区域线程同时改集合 | 用 ConcurrentHashMap |
| Gradle Capability 冲突 | 同时依赖 spigot-api 和 luminol-api | 只保留 luminol-api |
NoClassDefFoundError | Folia 类在 Bukkit 上不存在 | 检查类存在后再调用 |
八、⚠️ 最容易踩的坑:编译期 API 必须与目标核心一致
这是所有问题里最常见的一个,单独拎出来说。
Paper 编译出来的 JAR,在 Luminol 上可能直接 NoClassDefFoundError。
原因:Luminol 用 Folia 定制的 PluginClassLoader,配合 Paper 的反射重写器(AbstractDefaultRulesReflectionProxy)和区域化多线程的类加载机制。用 Paper API 编译的字节码,在 Luminol 的类加载器里无法正确解析。
<!-- ❌ 用 Paper API 编译 -->
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>26.2.build.48-alpha</version>
</dependency>
<!-- ✅ 用 Luminol API 编译 -->
<dependency>
<groupId>me.earthme.luminol</groupId>
<artifactId>luminol-api</artifactId>
<version>1.21.11-R0.1-SNAPSHOT</version>
</dependency>
仓库地址:
<repositories>
<repository>
<id>luminol-repo</id>
<url>https://repo.menthamc.org/repository/maven-public/</url>
</repository>
</repositories>
用 Maven Enforcer 的话记得加白名单:<allowedRepository>luminol-repo</allowedRepository>。
⚠️ 不要同时依赖 spigot-api,会触发 Gradle capability 冲突。luminol-api 已经包含全部 API 了。
可用版本:
| 版本 | Minecraft |
|---|---|
1.21.11-R0.1-SNAPSHOT | 1.21.11 |
1.21.8-R0.1-SNAPSHOT | 1.21.8 |
26.1.2.build.707-stable | 稳定版 |
26.2.build.711-stable | 最新开发版 |
实现类名是 FoliaGlobalRegionScheduler、FoliaRegionScheduler、FoliaAsyncScheduler。
反射陷阱
不要用反射调用 Folia API。 Paper 的反射重写器会拦截。正确做法是直接调 API,把 Folia 特定代码放在单独的类里,运行时按需加载。
九、适配清单
必须修改
- 所有
Bukkit.getScheduler()→ 四大调度器 - 所有
BukkitRunnable→ 调度器 API - 所有
Entity.teleport()→Entity.teleportAsync() - EntityScheduler 方法加
retired参数 - 检查
runDelayed的 delay 是否 > 0 -
plugin.yml添加folia-supported: true
需要检查
- 共享数据使用线程安全集合
- 事件中只操作本区域数据
- 不依赖任务执行顺序
-
getOnlinePlayers()遍历时不修改
无需修改
- 命令执行(
onCommand) - 配置读取
- GUI/Inventory 操作
- 权限检查
- bStats 统计
十、快速自查
判断一个插件能不能直接在 Folia 上跑,最快的办法是搜这几个关键词:
Bukkit.getScheduler
BukkitRunnable
.teleport(
三个都搜不到,基本就成功了一半;剩下那一半是并发安全。
上面这些"实测签名"不是抄文档来的,是我写了个叫 ServerAPIProbe 的插件,用反射把当前核心全部 API 类和方法签名 dump 成一份 markdown 采出来的。想做同样的事,思路很简单:显式列一批关键类 + 整包扫描 io.papermc.paper.threadedregions,逐个 Class.forName 后反射出方法签名。
另外还有一篇讲具体适配过程的:我在 Luminol 上适配插件踩过的坑。
评论
本站已关闭评论。

