perf(ui): weave @FxThread at build time, keep the agent as a safety net

The load-time Byte Buddy agent cost ~420 ms on every launch before the
splash screen could show. FxThreadPlugin now weaves @FxThread at build time
(byte-buddy-maven-plugin, process-classes) with the same inlined advice and
method matcher as the agent, and marks each woven class @FxThreadWoven.

FxThreadAgent.install() only attaches when the application's classes run
from a directory (an IDE run), where an incremental compiler may have
overwritten woven classes, and then weaves only the classes without the
marker, so nothing is woven twice; -Dpholio.fxthread.agent=always|never
overrides the choice. A Maven-built jar starts with no agent: the splash's
first paint moves from ~0.83 s to ~0.47 s.

The measurements behind this are in .claude/plans/startup-time-analysis.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xpLSeYKKHX6o16jzYLgZv
This commit is contained in:
2026-09-30 18:10:48 -04:00
co-authored by Claude Opus 5.5
parent bfa8a523f4
commit ded9907bcb
7 changed files with 242 additions and 33 deletions
+74
View File
@@ -0,0 +1,74 @@
# Startup time analysis — desktop launch (`--ui`)
Measured on 2026-09-30, macOS arm64, Zulu 26.0.1 FX, `java -jar target/pholio.jar --ui` on a real library.
Class-load milestones from `-Xlog:class+load:file=cl.log:uptime` (JVM uptime), application milestones from
the log.
## Timeline
| JVM uptime | Step | Classes loaded |
|---:|---|---:|
| 0 → 0.077 s | JVM start, Spring Boot `JarLauncher` (nested jars) | 1 368 |
| **0.077 → 0.498 s** | **`FxThreadAgent.install()`: Byte Buddy self-attach + agent setup** | **1 696** |
| 0.498 → 0.563 s | picocli: `LaunchOptions` resolves UI vs headless | 321 |
| 0.563 → ~0.85 s | JavaFX toolkit (Glass, Prism/Metal, fonts at 0.69 s), splash built and first painted (`ProgressBarSkin` 0.83 s) | ~2 180 |
| ~0.85 s | **splash on screen** | |
| 1.098 s | `init()`: Spring `SpringApplication` starts | |
| ~1.7 → 2.2 s | settings/geo databases, then the library database opened + Flyway | |
| 2.5 → 3.8 s | components: media formats, recognition (ONNX models), UI views | |
| 3.8 s | Spring context ready | |
| 3.9 → 4.2 s | `start()`: main window built and shown, splash fades out | |
Total ~4.2 s to the main window; ~11 950 classes load after the splash appears.
## Before the splash (~0.85 s)
1. **Byte Buddy agent, ~420 ms — half of it.** `main()` installs it first so `@FxThread` classes are woven
as they load. Options:
- **Install it in parallel**: start it on a thread first thing in `main()`, join it just before Spring
loads application classes (start of `PholioFxApplication.init()`, and before the headless commands run).
picocli, `GuiBootstrap`, JavaFX and the splash never use `@FxThread`. Splash would show at ~0.45 s.
To check: `PholioFxApplication` is loaded by JavaFX at 0.571 s, before the agent would be ready — it
must carry no `@FxThread` method.
- **Weave at build time** instead of at load time: removes the cost entirely (see the build-time weaving
discussion — the reason it was dropped was IDE incremental compilation overwriting woven classes).
2. **picocli, ~65 ms**: a fast path when the arguments are exactly `--ui` could skip the parse. Minor.
3. **JavaFX toolkit, ~290 ms**: mostly fixed. Only the splash image (1376×768 PNG) could be lighter
(JPEG): tens of milliseconds at most.
Nothing Spring-related runs before the splash, so there is no bean to defer at that stage.
### Done: build-time weaving, agent as a safety net (2026-09-30)
`FxThreadPlugin` (byte-buddy-maven-plugin, `process-classes`) weaves `@FxThread` at build time and marks each
woven class `@FxThreadWoven`; `FxThreadAgent.install()` now only attaches when the application's classes run
from a directory (an IDE run), and then weaves only the unmarked classes. Measured on the Maven-built jar:
Byte Buddy is no longer loaded, `GuiBootstrap` is reached at 0.197 s (was 0.563 s) and the splash's first
paint at ~0.47 s (was ~0.83 s). A run from `target/classes` still installs the agent (logged at INFO).
## AOT
- **JDK AOT cache** (Leyden, `-XX:AOTCache`, JDK 24+): pre-loads and pre-links classes, which is exactly the
pre-splash cost (JavaFX, picocli, Byte Buddy). Caveats, to verify in practice:
- needs a plain-jar class path, not the fat jar's nested jars — the extracted layout
(`java -Djarmode=tools -jar … extract`) the installers already use fits;
- a load-time transforming agent like `FxThreadAgent` can prevent cached classes from being used for the
classes it transforms;
- classes Byte Buddy generates at runtime are not cached.
So removing or reducing the agent cost stays worthwhile even with the AOT cache.
- **Spring AOT** (`spring-boot:process-aot`): speeds up the context build, which happens *after* the splash —
shortens how long the splash stays, not when it appears.
## After the splash (~3 s of Spring) — candidates to defer
- ~1 s with no log between the media formats (2.5 s) and context ready (3.8 s): the ONNX recognition models
are loaded eagerly at startup; loading them on first use would move that off the critical path.
- `jSystemThemeDetector` / OSHI logs a warning during startup (`MacOperatingSystem: Unable …`).
- Confirm with Spring's `BufferingApplicationStartup`, which times every bean.
## How to re-measure
```bash
java -Xlog:class+load:file=cl.log:uptime --enable-native-access=ALL-UNNAMED -jar target/pholio.jar --ui
grep -m1 "org.icroco.pholio.ui.GuiBootstrap " cl.log # and other milestones
```
+30 -5
View File
@@ -26,11 +26,10 @@
<atlantafx.version>2.1.0</atlantafx.version>
<!--
Runtime bytecode instrumentation for @FxThread (org.icroco.pholio.ui.common):
FxThreadAgent self-attaches a Byte Buddy agent at JVM startup rather than weaving at
build time — see FxThread's own javadoc for why (a build-time Maven plugin's weave gets
silently dropped whenever an IDE's own incremental compiler rebuilds target/classes,
which happens on every IDE-triggered run).
Bytecode instrumentation for @FxThread (org.icroco.pholio.ui.common): woven at build time
by FxThreadPlugin (byte-buddy-maven-plugin below), with FxThreadAgent as a load-time safety
net for classes run from a directory, which an IDE may have recompiled unwoven — see
FxThread's own javadoc.
-->
<byte-buddy.version>1.18.12</byte-buddy.version>
<commons-codec.version>1.22.1</commons-codec.version>
@@ -668,6 +667,32 @@
<!-- </configuration>-->
<!-- </plugin>-->
<plugin>
<!--
Weaves @FxThread at build time (org.icroco.pholio.ui.common.FxThreadPlugin) into target/classes,
at process-classes: after maven-compiler-plugin (Lombok, ErrorProne and NullAway are done), before
tests and packaging, so the tests and the repackaged jar both run woven classes. A jar-launched
application then needs no load-time agent (~420 ms off the time to splash screen); FxThreadAgent
only steps in for classes run from a directory, which an IDE may have recompiled unwoven.
-->
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-maven-plugin</artifactId>
<version>${byte-buddy.version}</version>
<configuration>
<transformations>
<transformation>
<plugin>org.icroco.pholio.ui.common.FxThreadPlugin</plugin>
</transformation>
</transformations>
</configuration>
<executions>
<execution>
<goals>
<goal>transform</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
@@ -14,7 +14,7 @@ import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import javax.imageio.ImageIO;
import java.awt.Taskbar;
import java.awt.*;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
@@ -39,7 +39,9 @@ public class PholioFxApplication extends Application {
private @Nullable ConfigurableApplicationContext context;
/** Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}. */
/**
* Feeds {@code SplashPreloader} while the context builds — see {@link StartupProgressReporter}.
*/
private final StartupProgressReporter startupProgress = new StartupProgressReporter(this::notifyPreloader);
@Override
@@ -112,7 +114,8 @@ public class PholioFxApplication extends Application {
try (InputStream in = PholioFxApplication.class.getResourceAsStream("/images/spo-macos-1024x1024.png")) {
taskbar.setIconImage(ImageIO.read(Objects.requireNonNull(in, "spo-macos-1024x1024.png missing")));
log.debug("Dock icon set");
} catch (IOException | RuntimeException e) {
}
catch (IOException | RuntimeException e) {
log.warn("Could not set dock icon", e);
}
}
@@ -10,9 +10,10 @@ import java.lang.annotation.Target;
* Marks a method — or, on a class, every method it declares — that must always run on the JavaFX
* Application Thread, replacing a hand-written {@link FxUtils#onFxThread} wrapper around the body.
*
* <p>Enforced by {@link FxThreadAgent}, a Byte Buddy agent that self-attaches at JVM startup and weaves
* every matching method as it loads — not a runtime check, not a Spring proxy, and not a build-time Maven
* plugin. None of those were arbitrary rejections:
* <p>Enforced by Byte Buddy bytecode weaving — at build time by {@link FxThreadPlugin}
* ({@code byte-buddy-maven-plugin}), and at load time by {@link FxThreadAgent} only for classes an IDE may
* have recompiled since — not a runtime check and not a Spring proxy. How it got there was no arbitrary
* choice:
*
* <ul>
* <li>AspectJ compile-time weaving was tried first and ruled out: its compiler cannot yet target this
@@ -22,17 +23,18 @@ import java.lang.annotation.Target;
* intercepts calls arriving from outside the bean. This codebase's actual candidates overwhelmingly
* self-invoke the method that needs wrapping from an {@code @EventListener} or a constructor, which a
* proxy silently cannot see — it would report success while doing nothing.
* <li>Build-time weaving via {@code byte-buddy-maven-plugin} was tried third, worked, and was reverted
* after it shipped: an IDE's own incremental compiler recompiles into {@code target/classes} on every
* run by default, silently overwriting the woven classes with plain ones the Maven plugin never
* touched. Nothing failed at build time — a scenegraph mutation just ran on the wrong thread at
* runtime, the first time the application was actually launched from an IDE.
* <li>Build-time weaving alone ({@code byte-buddy-maven-plugin}) was tried third, worked, and was
* reverted after it shipped: an IDE's own incremental compiler recompiles into {@code target/classes} on
* every run by default, silently overwriting the woven classes with plain ones. Nothing failed at build
* time — a scenegraph mutation just ran on the wrong thread at runtime.
* <li>A load-time agent alone came next: correct whichever compiler produced a class, but ~420 ms spent
* on every launch before the splash screen could show.
* </ul>
*
* <p>A JVM agent has none of these limitations: it rewrites the method itself as the class loads, so
* self-invocation, constructor-time calls, and classes that are not Spring beans are all covered the same
* way — and unlike a build-time weave, it applies identically no matter which compiler produced the
* {@code .class} file being loaded.
* <p>Both weaves rewrite the method itself, so self-invocation, constructor-time calls, and classes that are
* not Spring beans are covered the same way. Combined — build-time weaving marking each class
* {@link FxThreadWoven}, the agent installed only for classes run from a directory and weaving only the
* unmarked ones — a Maven-built jar starts with no agent, and an IDE run is still always correct.
*
* <p><strong>{@code void}-only.</strong> Off the FX thread, the wrapped call is dispatched via
* {@code Platform.runLater} — fire-and-forget, with no return value a synchronous caller could receive.
@@ -9,6 +9,10 @@ import net.bytebuddy.matcher.ElementMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import static net.bytebuddy.matcher.ElementMatchers.isAbstract;
import static net.bytebuddy.matcher.ElementMatchers.isAnnotatedWith;
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
@@ -23,16 +27,21 @@ import static net.bytebuddy.matcher.ElementMatchers.returns;
* any class of this application's own is touched — since a class can only be woven as it loads, not
* afterwards.
*
* <p><strong>Why a runtime agent, not a build-time Maven plugin.</strong> An earlier version of this
* weaved at build time via {@code byte-buddy-maven-plugin}, bound to Maven's {@code process-classes}
* phase. That worked for every build and test run driven through Maven — and broke silently the moment
* the application was actually launched from an IDE, because an IDE's own incremental compiler recompiles
* straight into {@code target/classes} on every run by default, overwriting the woven {@code .class}
* files with plain ones from a compiler that has never heard of this Maven plugin. The failure mode is not
* a build error anywhere — it is a scenegraph mutation running on the wrong thread at runtime, minutes
* later, with nothing in the stack trace to suggest {@code @FxThread} was ever involved. A JVM agent
* instruments classes as the JVM loads them, which happens identically no matter which compiler produced
* the {@code .class} file — IDE, {@code mvn spring-boot:run}, or the packaged jar.
* <p><strong>Build-time weaving first, this agent only as the safety net.</strong> {@link FxThreadPlugin}
* weaves at build time ({@code byte-buddy-maven-plugin}, {@code process-classes}) and marks every class it
* weaves {@link FxThreadWoven}. Build-time weaving alone was tried first and dropped: an IDE's incremental
* compiler recompiles straight into {@code target/classes}, overwriting woven {@code .class} files with
* plain ones — no build error anywhere, just a scenegraph mutation on the wrong thread minutes later. And an
* agent alone costs ~420 ms before the splash screen can show (measured: Byte Buddy's self-attach and its
* ~1,700 classes), on every launch, packaged ones included. So {@link #install()} now decides:
* <ul>
* <li>classes loaded from a <strong>jar</strong> — the Maven-built jar, the installers — are all woven
* already: no agent;
* <li>classes loaded from a <strong>directory</strong> (an IDE run, {@code target/classes}) may have been
* recompiled by the IDE: the agent is installed, and weaves only the classes <em>not</em> marked
* {@link FxThreadWoven} — exactly the recompiled ones, never a class twice;
* <li>{@code -Dpholio.fxthread.agent=always|never} overrides that choice.
* </ul>
*/
public final class FxThreadAgent {
@@ -49,6 +58,13 @@ public final class FxThreadAgent {
}
installed = true;
String mode = System.getProperty("pholio.fxthread.agent", "auto");
boolean fromDirectory = loadedFromDirectory();
if ("never".equalsIgnoreCase(mode) || (!"always".equalsIgnoreCase(mode) && !fromDirectory)) {
log.debug("@FxThread woven at build time; no agent");
return;
}
ByteBuddyAgent.install();
new AgentBuilder.Default()
// AgentBuilder's default InitializationStrategy (SelfInjection) registers every visited
@@ -67,11 +83,31 @@ public final class FxThreadAgent {
// Scoped to this application's own classes: matching every loaded type (JDK, Spring,
// JavaFX, every library) would run matcherFor's reflection over each one for no benefit,
// since nothing outside this codebase ever carries @FxThread.
.type(nameStartsWith("org.icroco.pholio."))
// Classes FxThreadPlugin already wove at build time are skipped: only those an IDE has
// recompiled since (so without the marker) are woven here.
.type(nameStartsWith("org.icroco.pholio.").and(not(isAnnotatedWith(FxThreadWoven.class))))
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder.visit(Advice.to(FxThreadInterceptor.class).on(matcherFor(typeDescription))))
.installOnByteBuddyAgent();
log.debug("@FxThread instrumentation installed");
log.info("@FxThread load-time weaving installed ({}): classes run from a directory may have been "
+ "recompiled without build-time weaving", fromDirectory ? "class directory" : "forced");
}
/**
* Whether this application's classes come from a directory ({@code target/classes}, an IDE run) rather
* than a jar — a nested {@code jar:} URL in the repackaged jar, a plain {@code .jar} file once extracted.
*/
private static boolean loadedFromDirectory() {
try {
var source = FxThreadAgent.class.getProtectionDomain().getCodeSource();
if (source == null || source.getLocation() == null || !"file".equals(source.getLocation().getProtocol())) {
return false;
}
return Files.isDirectory(Path.of(source.getLocation().toURI()));
}
catch (URISyntaxException | RuntimeException e) {
return true; // cannot tell: weave, the safe choice
}
}
/**
@@ -79,7 +115,7 @@ public final class FxThreadAgent {
* declared by a class that carries {@link FxThread} itself — in which case every non-abstract,
* non-static, non-constructor method of that class is woven, not just some of them.
*/
private static ElementMatcher.Junction<MethodDescription> matcherFor(TypeDescription typeDescription) {
static ElementMatcher.Junction<MethodDescription> matcherFor(TypeDescription typeDescription) {
boolean classAnnotated = typeDescription.getDeclaredAnnotations().isAnnotationPresent(FxThread.class);
return classAnnotated
? not(isConstructor()).and(not(isStatic())).and(not(isAbstract())).and(returns(void.class))
@@ -0,0 +1,49 @@
package org.icroco.pholio.ui.common;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.build.Plugin;
import net.bytebuddy.description.annotation.AnnotationDescription;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.dynamic.DynamicType;
/**
* Build-time weaving of {@link FxThread}: run by {@code byte-buddy-maven-plugin} over {@code target/classes}
* at {@code process-classes} (see {@code pom.xml}), after javac, Lombok and ErrorProne are done.
*
* <p>Applies exactly what {@link FxThreadAgent} applies at load time — the same inlined
* {@link FxThreadInterceptor} {@link Advice}, on the same methods ({@link FxThreadAgent#matcherFor}) — and
* marks each woven class {@link FxThreadWoven}. A Maven-built jar is therefore fully woven and starts with
* no agent at all; the agent only steps in for classes a compiler other than Maven's has produced (an IDE's
* incremental build), which is what made build-time weaving alone unsafe before — see {@link FxThreadAgent}.
*
* <p>Only classes that actually use {@link FxThread}, and are not already woven, are touched: every other
* class file is left byte-for-byte as javac wrote it.
*/
public class FxThreadPlugin implements Plugin {
@Override
public boolean matches(TypeDescription target) {
return target.getName().startsWith("org.icroco.pholio.")
&& !target.getDeclaredAnnotations().isAnnotationPresent(FxThreadWoven.class)
&& usesFxThread(target);
}
@Override
public DynamicType.Builder<?> apply(DynamicType.Builder<?> builder, TypeDescription typeDescription,
ClassFileLocator classFileLocator) {
return builder.visit(Advice.to(FxThreadInterceptor.class).on(FxThreadAgent.matcherFor(typeDescription)))
.annotateType(AnnotationDescription.Builder.ofType(FxThreadWoven.class).build());
}
private static boolean usesFxThread(TypeDescription type) {
return type.getDeclaredAnnotations().isAnnotationPresent(FxThread.class)
|| type.getDeclaredMethods().stream()
.anyMatch(method -> method.getDeclaredAnnotations().isAnnotationPresent(FxThread.class));
}
@Override
public void close() {
}
}
@@ -0,0 +1,20 @@
package org.icroco.pholio.ui.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Added by {@link FxThreadPlugin} to every class it weaves at build time — never written by hand.
*
* <p>Tells {@link FxThreadAgent} a class already carries its {@link FxThread} advice, so the agent, when it
* runs at all (a run from class directories, typically the IDE), only weaves the classes an incremental
* compiler has recompiled since — which lack the marker — and never weaves a class twice. The Maven plugin
* skips marked classes the same way, so re-running {@code process-classes} over already-woven output is a
* no-op.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FxThreadWoven {
}