Java 运行时管理
启动器要在几十毫秒内告诉用户「这台机器上有哪些 Java」。找不全,用户就得自己去翻目录;找太多,列表就没法看。这页讲我们怎么找 Java,以及找到之后怎么装一个。
六级探测,顺序不是随便排的
JavaService.ListRuntimesCoreAsync 按六级依次收集候选:
- 启动器自己下载的 ——
<数据根>/java/<major>/bin/java。目录名就是主版本号,不用探测就知道是几。 - 官方启动器的运行时 —— 调用方给的游戏目录下的
runtime,加上.minecraft/runtime(java-runtime-*)。第二个是兜底:只装了官方启动器、自己没下过 Java 的机器上,只扫数据根一个都找不到。 - PATH 与 JAVA_HOME —— PATH 按
Path.PathSeparator切,逐项找。 - Windows 注册表 —— 各厂商登记的安装位置。
- 厂商默认安装根 ——
%ProgramFiles%\Java、%ProgramFiles%\Eclipse Adoptium、%LOCALAPPDATA%\Programs\Eclipse Adoptium等 18 个固定根,找它们的一级子目录。 - 各盘根目录名里带关键词的文件夹 ——
JavaFolderKeywords里有 java / jdk / jre / jvm / jbr / temurin / zulu / corretto / adoptium / graalvm / sapmachine / semeru / bellsoft / liberica / dragonwell。
前五级是「系统会告诉你 Java 在哪」的正规渠道。第六级是专门补 PCL 能扫到、但既不登记注册表也不写 PATH 的绿色版(D:\Java\jdk-17)。
第六级只看两层
EnumerateDriveJavaExes 只扫盘根的一级子目录及其一级子目录。整盘递归会把「搜索 Java」拖成几十秒的磁盘遍历,而搜索 Java 是启动器里被调用频率最高的操作之一——用户在版本详情页每点一次「检测 Java」就触发一次。
每个候选都要真正跑一次 java -version 才收进结果:AddAsync 里解析不出主版本号就直接丢。宁可少列一个也不要列一个跑不起来的。
为什么用 reg.exe 而不是 Microsoft.Win32.Registry
Microsoft.Win32.Registry 在 .NET Core 里要额外引 NuGet 包,而 Starcloud.Core 的目标框架是 net8.0——不是 Windows 专用工程,为了读一次注册表多拉一个包不值。所以我们直接起 reg.exe query "HKLM\SOFTWARE\JavaSoft" /s,解析 stdout。
代价是厂商的值名不统一(JavaHome / InstallationPath / Path 各家各叫),所以不按名字取值,而是在输出里找类型标记为 REG_SZ / REG_EXPAND_SZ 的行,再判断那个路径下是不是真有 java.exe。reg.exe 键不存在时返回退出码 1,当作没查到即可。
查的根键有 11 个厂商(JavaSoft、Eclipse Adoptium、Microsoft\JDK、Azul Zulu、Amazon Corretto、BellSoft、SapMachine、IBM Semeru、GraalVM、AdoptOpenJDK、Eclipse Foundation),HKLM 与 HKCU 都查。
NormalizeExe:为什么 Oracle 的 javapath 会列两次
去重的键不是字符串本身,而是解析过符号链接后的真实路径:
private static string NormalizeExe(string exe)
{
var full = Path.GetFullPath(exe);
return File.ResolveLinkTarget(full, returnFinalTarget: true)?.FullName ?? full;
}Oracle 装完 Java 会在 C:\Program Files\Common Files\Oracle\Java\javapath 下放一排符号链接指向真正的 java.exe。不带解析的话,这条 javapath 和 PATH 里那一份是两个不同的字符串,同一个运行时会在列表里出现两次,用户会以为装了两份。
ParseMajorVersion:1.8.0_402 和 21.0.4
if (v.StartsWith("1."))
return int.TryParse(v.AsSpan(2, 1), out var legacy) ? legacy : 0;
return int.TryParse(v.Split('.')[0], out var major) ? major : 0;Java 8 之前的主版本号带 1. 前缀(1.8.0_402),Java 9 之后没有(21.0.4)。两条规则必须同时在:只按 1. 分支判,21 会被算成 1;只按点分取首位,1.8 会被算成 1。ParseMajorVersion 返回 0 的候选一律不收进结果——宁可少列。
下载与解压:两个平台两套归档
DownloadRuntimeAsync 从 Adoptium 取地址,Windows 下 .zip、unix 下 .tar.gz,落到 <数据根>/java/<major>/。
归档根不一定就是 JAVA_HOME:Windows 的包是 jdk-21.0.4+y/bin/...,macOS 的包还多两层 Contents/Home。所以解压后要走 PromoteJavaHome:
var staging = dest + ".promote";
Directory.CreateDirectory(staging);
// 把 JAVA_HOME 里的内容整体搬进 staging
Directory.Delete(dest, recursive: true);
Directory.Move(staging, dest);为什么不直接就地搬?因为 dest 就在归档根下面,搬的过程中源和目标互相嵌套,删旧目录会连带删掉刚搬进去的文件。用一个同级 staging 目录中转,最后整体替换,就不会。
定位 JAVA_HOME 的规则是「哪层目录里有 bin/java」,最多下探三层(jdk-x/Contents/Home 布局刚好三层)。
unix 上 tar 不带执行位
TarFile.ExtractToDirectory 不保留文件权限,解压出来的 bin/* 全是 644,java 根本起不来——表现为「明明装了 Java,双击没反应」。所以解压后必须 MakeRuntimeExecutable:给 bin/ 下所有文件和 lib/jspawnserver 置 755(UserRead|UserWrite|UserExecute|GroupRead|GroupExecute|OtherRead|OtherExecute)。jspawnserver 是 JVM 用来 fork 子进程的辅助程序,权限不对的话游戏启不动。
选哪个运行时
GameLauncher.ResolveJavaAsync 在设置没指定 Java 时,按版本 JSON 的 javaVersion.majorVersion(1.20.1 是 17,1.16.5 是 8)从探测结果里挑:精确匹配优先,其次最小超集。一个都没有就从 Adoptium 自动下一个。
相关链接:从点击到游戏起来 · 安装 Forge 与 NeoForge · 版本隔离与实例档案