PAYATHON 2026

JavaFX 应用打包为 exe 后,Selenium ChromeDriver 为何卡住?

排障赵师傅

webdriver.chrome.driver 为 null 是正常现象,不能据此判断驱动查找失败。Selenium 4.40.0 支持 Selenium Manager,通常无需手动设置驱动路径,也无需降级到 4.6.0。

目前还无法确定根因,应优先追查打包后出现的日志类加载错误。使用 org.beryx.runtime 构建的应用会携带裁剪后的 Java 运行时,其模块和依赖可能与 IntelliJ 使用的完整 JDK 不同。此外,Task 会记录后台执行中的失败。如果没有输出完整异常,就容易把已经失败的任务误认为卡住。

先弄清版本和驱动路径的关系

Selenium 的版本号按各段整数比较,4.40.0 比 4.6.0 更新。Selenium Manager 从 Selenium 4.6 开始引入,“至少需要 4.6”并不表示只能使用 4.6。

在 Selenium 4.40.0 中,以下代码可以触发自动驱动管理:

ChromeOptions options = new ChromeOptions();
WebDriver driver = new ChromeDriver(options);

前提是相关依赖和 Selenium Manager 可执行文件完整,运行环境允许执行它,并且能够找到或获取适用的驱动。

Selenium Manager 获取驱动路径后,无需将路径写回 webdriver.chrome.driver 系统属性。因此,以下调用在创建驱动前后都可能返回 null:

System.getProperty("webdriver.chrome.driver")

这个属性可以用来检查是否显式配置了路径,但无法说明 Selenium 最终使用了哪个驱动。

排查时先保留一种驱动管理方式。使用内置 Selenium Manager 时,可以移除 bonigarcia WebDriverManager 依赖及其 setup() 调用,减少不同配置之间的影响。

为什么放进 Task 后看起来没有报错?

这里使用的是 javafx.concurrent.Task,不是 javax.concurrent.Task。将 Selenium 操作放到后台 Task 中是合理的,直接在 JavaFX Application Thread 上执行会阻塞界面。

原来的 process() 捕获 Exception,打印后返回 null。这样一来,Task.call() 会正常返回,即使抓取已经失败,任务也可能进入 SUCCEEDED 状态。

下面这些类加载失败则属于 Error,不会被 catch (Exception) 捕获:

NoClassDefFoundError
ExceptionInInitializerError

Task 所用的执行机制会记录这类失败,通常可以通过 task.getException() 取得。给线程设置 UncaughtExceptionHandler 也不能代替检查任务的失败结果。

先让失败处理输出完整堆栈:

task.setOnFailed(event -> {
    Throwable failure = task.getException();

    if (failure != null) {
        failure.printStackTrace(System.err);
    }

    buttonMessages.setText(
        "Failed: " + (failure == null ? "Unknown error" : failure.toString())
    );
});

同时,让 process() 中的异常继续向上传播,不要打印后直接返回 null,以便区分任务失败和真正的阻塞。

点击直接执行按钮时出现的日志类加载错误也需要处理。如果后台任务触发了相同的类初始化过程,两种现象可能来自同一个打包问题。

优先检查打包运行时和依赖

IntelliJ 中运行正常,只能说明 IDE 的运行环境满足要求。打包应用实际使用的 JDK 模块、JAR 和工作目录,都需要另外确认。

根据缺失类确定缺失模块

先记录完整异常,包括全部 Caused by,不要只看“logging”或最外层异常。

例如,如果缺失的是:

java/util/logging/Logger

这个类属于 JDK 的 java.logging 模块。确认打包运行时遗漏了该模块后,可以在现有 runtime 配置中补充:

runtime {
    additive = true
    modules = ['java.logging']

    options = [
        '--strip-debug',
        '--compress', 'zip-6',
        '--no-header-files',
        '--no-man-pages'
    ]
}

additive = true 表示在自动推导的模块之外追加模块,避免将模块集合替换为仅包含 java.logging。

如果缺失的是:

org/slf4j/...
org/apache/logging/...

则应检查对应的第三方依赖是否进入最终应用。添加 java.logging 无法补齐这些类。

目前缺少缺失类的全名,还不能确定补上 java.logging 就能解决问题,也不应仅凭“日志错误”同时添加多套日志框架。

如果异常是:

NoClassDefFoundError: Could not initialize class ...

还需要查找同一次运行中更早出现的初始化异常。后续调用中的错误,可能只是第一次初始化失败的结果。

对比实际依赖和运行环境

先检查 Gradle 最终解析到的 Selenium 依赖:

.\gradlew.bat dependencyInsight --dependency selenium --configuration runtimeClasspath

确认 Selenium 各组件没有混用不同版本,打包目录中的 JAR 也与预期一致。

在 IDE 和打包应用中分别输出:

System.out.println("java.home = " + System.getProperty("java.home"));
System.out.println("java.version = " + System.getProperty("java.version"));
System.out.println("user.dir = " + System.getProperty("user.dir"));
System.out.println("user.home = " + System.getProperty("user.home"));
System.out.println("java.io.tmpdir = " + System.getProperty("java.io.tmpdir"));

如果镜像中保留了 bin/java.exe,还可以用它执行 --list-modules,检查实际包含的模块。

修改配置后,清理并重新构建应用镜像。先验证镜像中的启动器,再验证安装后的程序,避免测试到旧文件。也可以用完整 JDK 运行同一套打包后的应用依赖,判断差异是否来自裁剪运行时。

用最小代码获取启动日志

先移除与启动无关的 Chrome 参数,只保留无头模式。等基本启动正常后,再逐项恢复反自动化参数、禁用扩展、调试管道等设置。

以下代码使用 Selenium Manager,并指定 ChromeDriver 日志文件:

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.chrome.ChromeOptions;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class SeleniumLoader {

    protected String process(String url) throws IOException {
        Path logDirectory = Path.of(
            System.getProperty("user.home"),
            "MySeleniumFX",
            "logs"
        );
        Files.createDirectories(logDirectory);

        ChromeDriverService service = new ChromeDriverService.Builder()
            .withVerbose(true)
            .withLogFile(
                logDirectory.resolve("chromedriver.log").toFile()
            )
            .build();

        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new");

        System.err.println("About to create ChromeDriver");

        WebDriver driver = new ChromeDriver(service, options);
        try {
            System.err.println("ChromeDriver created");
            driver.get(url);
            return driver.getTitle();
        } finally {
            driver.quit();
        }
    }
}

控制器继续使用后台任务,并记录完整失败信息:

private void startBackgroundTask() {
    Task<String> task = new Task<>() {
        @Override
        protected String call() throws Exception {
            return new SeleniumLoader().process(
                "https://www.selenium.dev/documentation/"
            );
        }
    };

    task.setOnSucceeded(event ->
        buttonMessages.setText("Succeeded: " + task.getValue())
    );

    task.setOnFailed(event -> {
        Throwable failure = task.getException();

        if (failure != null) {
            failure.printStackTrace(System.err);
        }

        buttonMessages.setText(
            "Failed: " + (failure == null ? "Unknown error" : failure.toString())
        );
    });

    Thread worker = new Thread(task, "selenium-worker");
    worker.start();
}

这些代码用于定位失败位置,打包问题仍需根据异常排查。finally 中的 quit() 也可能失败,分析堆栈时要留意清理阶段的异常是否遮盖了前面的错误。

将日志放在用户目录下,可以避开安装目录的写入限制和相对路径变化。已有的 --win-console 可以保留,再从终端启动应用,收集标准输出和标准错误。

没有生成 ChromeDriver 日志,只能说明日志尚未成功写入。失败可能发生在 Java 类加载、Selenium Manager 执行或驱动进程启动之前,也可能是日志路径不可写。因此,不能据此判断 ChromeDriver 已经启动后卡住。

如果任务确实仍在运行,再定位阻塞阶段

new ChromeDriver() 包含驱动定位、进程启动和浏览器会话建立等操作。如果最后一条日志停在构造函数之前,还无法确定具体阻塞在哪一步。

先检查任务状态和 Windows 进程:

现象优先检查
Task 已进入 FAILEDtask.getException() 的完整堆栈
没有 chromedriver.exe 进程类加载、Manager 执行、驱动路径、文件权限
有 chromedriver.exe,没有 Chrome浏览器定位、驱动兼容性、浏览器启动失败
Chrome 和 ChromeDriver 都存在会话握手、进程通信、浏览器日志

这些现象可以帮助缩小排查范围,但还不足以单独确定根因。

如果任务一直处于 RUNNING,用完整 JDK 中的 jcmd 获取应用 JVM 的线程堆栈。将下面的 12345 替换成实际 PID:

jcmd 12345 Thread.print

查看名为 selenium-worker 的线程,并结合其他相关线程,判断它是在等待子进程、网络响应、类初始化还是锁。线程处于 WAITING 状态,并不意味着发生了死锁。

还可以显式指定已确认可用且与 Chrome 匹配的驱动,做一次对照:

ChromeDriverService service = new ChromeDriverService.Builder()
    .usingDriverExecutable(
        new java.io.File("C:\\dev\\chromedriver.exe")
    )
    .build();

WebDriver driver = new ChromeDriver(service, options);

如果这样可以成功,就继续调查自动驱动发现和 Selenium Manager 所在阶段;如果仍然失败,则继续检查 Java 依赖、进程启动和浏览器会话。这个测试用于隔离变量,发布时无需因此硬编码路径。

需要注意的边界

  • 页面加载超时无法处理构造函数阻塞。driver.manage().timeouts().pageLoadTimeout(...) 需要先取得 driver,无法约束之前的 new ChromeDriver()。
  • 取消 Task 或中断线程,不保证 Chrome、ChromeDriver 等子进程立即退出,应用仍需管理这些资源。
  • 关闭防火墙不能排除代理、下载连接、证书或子进程执行限制。应根据实际阻塞阶段检查,并恢复防火墙。
  • 只有当异常或对照结果指向线程上下文类加载器时,才考虑相关调整。现有信息不足以将问题归因于 Task 的类加载器。

下一步先取得日志类加载错误的完整堆栈,并让 Task.setOnFailed 输出完整失败信息。确认后台任务是已经失败还是仍在等待后,再决定补充运行时模块或依赖,还是继续追查 Selenium Manager 和 Chrome 启动过程。

备注:内容仅供参考。