|
| 1 | +# Replacing Apache Batik with Graphics2D: How We Made Our Java OG Card Generator Faster Than Python |
| 2 | + |
| 3 | +*March 2026 · [javaevolved.github.io](https://javaevolved.github.io)* |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +[Java Evolved](https://javaevolved.github.io) is a static site showcasing 112 modern Java patterns across 11 categories — each with a side-by-side "old vs modern" code comparison. For every pattern, we generate an Open Graph card: a 1200×630 PNG image used when links are shared on social media. |
| 8 | + |
| 9 | +This is the story of how we replaced Apache Batik with plain `Graphics2D`, split a monolithic script into modules using JBang, and tuned the JVM to squeeze out every last bit of performance — ending up faster than Python. |
| 10 | + |
| 11 | +## The starting point: Batik |
| 12 | + |
| 13 | +Our OG card generator was a single 400-line Java file (`generateog.java`) run via [JBang](https://www.jbang.dev). It built an SVG string for each card, then used Apache Batik's `PNGTranscoder` to rasterize it to PNG: |
| 14 | + |
| 15 | +```java |
| 16 | +//DEPS org.apache.xmlgraphics:batik-transcoder:1.18 |
| 17 | +//DEPS org.apache.xmlgraphics:batik-codec:1.18 |
| 18 | + |
| 19 | +static void svgToPng(String svgContent, Path pngPath) throws Exception { |
| 20 | + var input = new TranscoderInput(new StringReader(svgContent)); |
| 21 | + try (var out = new BufferedOutputStream(Files.newOutputStream(pngPath))) { |
| 22 | + var transcoder = new PNGTranscoder(); |
| 23 | + transcoder.addTranscodingHint(PNGTranscoder.KEY_WIDTH, (float) W * 2); |
| 24 | + transcoder.addTranscodingHint(PNGTranscoder.KEY_HEIGHT, (float) H * 2); |
| 25 | + transcoder.transcode(input, new TranscoderOutput(out)); |
| 26 | + } |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +This worked, but Batik is a heavyweight library. It brings in the full AWT/Swing graphics pipeline, XML parsers, and codec libraries. The fat JAR was over 10 MB. And it was **slower than our equivalent Python script using cairosvg** — a thin wrapper around the native Cairo C library. |
| 31 | + |
| 32 | +We had an irony on our hands: a site about modern Java patterns had a Java tool that couldn't outperform Python. |
| 33 | + |
| 34 | +## The insight: we don't need SVG→PNG |
| 35 | + |
| 36 | +Our OG cards are simple layouts — rounded rectangles, text, solid fills. We were building an SVG string by hand, then asking Batik to parse it back into a graphics model and rasterize it. That's a round trip through XML parsing for geometry we already knew. |
| 37 | + |
| 38 | +Java's `Graphics2D` API can draw all of this directly to a `BufferedImage`: |
| 39 | + |
| 40 | +```java |
| 41 | +var img = new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB); |
| 42 | +var g = img.createGraphics(); |
| 43 | +g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); |
| 44 | +g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); |
| 45 | +g.scale(SCALE, SCALE); |
| 46 | + |
| 47 | +// Draw directly — no SVG intermediary |
| 48 | +drawCard(g, snippet); |
| 49 | + |
| 50 | +g.dispose(); |
| 51 | +ImageIO.write(img, "PNG", outputPath.toFile()); |
| 52 | +``` |
| 53 | + |
| 54 | +No SVG parsing. No Batik. No external dependencies for PNG output — just the JDK. |
| 55 | + |
| 56 | +We still generate SVG files (for the web), but PNG rendering is now pure `Graphics2D`. |
| 57 | + |
| 58 | +## The refactoring: JBang multi-source |
| 59 | + |
| 60 | +The original 400-line monolith mixed colors, dimensions, content loading, syntax highlighting, SVG generation, PNG conversion, and font management all in one implicit class. We split it into 7 focused source files using JBang's `//SOURCES` directive: |
| 61 | + |
| 62 | +```java |
| 63 | +///usr/bin/env jbang "$0" "$@" ; exit $? |
| 64 | +//JAVA 25 |
| 65 | +//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.3 |
| 66 | +//DEPS com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.3 |
| 67 | +//SOURCES og/Palette.java |
| 68 | +//SOURCES og/Layout.java |
| 69 | +//SOURCES og/ContentLoader.java |
| 70 | +//SOURCES og/SyntaxHighlighter.java |
| 71 | +//SOURCES og/SvgRenderer.java |
| 72 | +//SOURCES og/PngRenderer.java |
| 73 | +//SOURCES og/FontManager.java |
| 74 | +``` |
| 75 | + |
| 76 | +Each file lives in the `og` package under `html-generators/og/`: |
| 77 | + |
| 78 | +| File | Responsibility | |
| 79 | +|------|---------------| |
| 80 | +| `Palette.java` | Color constants (hex strings + `Color.decode()` helper) | |
| 81 | +| `Layout.java` | Dimensions, font sizing, line fitting | |
| 82 | +| `ContentLoader.java` | JSON/YAML parsing, `Snippet` record | |
| 83 | +| `SyntaxHighlighter.java` | Java tokenizer → `List<Token>` (shared by SVG + PNG) | |
| 84 | +| `SvgRenderer.java` | SVG string generation | |
| 85 | +| `PngRenderer.java` | Direct `Graphics2D` PNG rendering | |
| 86 | +| `FontManager.java` | Font downloading and registration | |
| 87 | + |
| 88 | +The `SyntaxHighlighter` is the key shared abstraction. Instead of producing SVG `<tspan>` fragments directly, it returns a `List<Token>` where each token has text and an optional color: |
| 89 | + |
| 90 | +```java |
| 91 | +public record Token(String text, String color) {} |
| 92 | +``` |
| 93 | + |
| 94 | +The SVG renderer converts tokens to `<tspan>` elements. The PNG renderer draws them with `Graphics2D`: |
| 95 | + |
| 96 | +```java |
| 97 | +static void drawTokens(Graphics2D g, List<Token> tokens, int x, int y, |
| 98 | + Font codeFont, FontRenderContext frc) { |
| 99 | + float curX = x; |
| 100 | + for (var token : tokens) { |
| 101 | + g.setColor(token.color() != null |
| 102 | + ? Palette.color(token.color()) |
| 103 | + : Palette.color(Palette.SYN_DEFAULT)); |
| 104 | + g.drawString(token.text(), curX, y); |
| 105 | + curX += (float) codeFont.getStringBounds(token.text(), frc).getWidth(); |
| 106 | + } |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +## Font loading: a subtle gotcha |
| 111 | + |
| 112 | +Our first `Graphics2D` attempt rendered text with the wrong fonts. The labels showed □ rectangles instead of ✗ and ✓ symbols. |
| 113 | + |
| 114 | +The problem: Java's `Font.createFont()` registers all physical fonts with `style=0` (PLAIN), regardless of their actual weight. When you write `new Font("Inter", Font.BOLD, 24)`, Java looks for a font in the "Inter" family with `BOLD` style — but the registered "Inter Bold" has `style=0`. Java falls back to algorithmic bolding of the Regular weight, or worse, to a system font that lacks the Unicode characters you need. |
| 115 | + |
| 116 | +The fix: load fonts directly from the `.ttf` files: |
| 117 | + |
| 118 | +```java |
| 119 | +public static Font getFont(String filename, float size) { |
| 120 | + var path = FONT_CACHE.resolve(filename); |
| 121 | + return Font.createFont(Font.TRUETYPE_FONT, path.toFile()).deriveFont(size); |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +Then use exact physical fonts: |
| 126 | + |
| 127 | +```java |
| 128 | +var titleFont = FontManager.getFont("Inter-Bold.ttf", 24f); |
| 129 | +var labelFont = FontManager.getFont("Inter-SemiBold.ttf", 11f); |
| 130 | +var codeFont = FontManager.getFont("JetBrainsMono-Regular.ttf", (float) fontSize); |
| 131 | +``` |
| 132 | + |
| 133 | +## GC profiling: humongous allocations |
| 134 | + |
| 135 | +Running with `-Xlog:gc:stderr` revealed the OG generator triggered **34 GC events** with repeated "G1 Humongous Allocation" warnings: |
| 136 | + |
| 137 | +``` |
| 138 | +GC(5) Pause Young (Concurrent Start) (G1 Humongous Allocation) 238M->138M(516M) |
| 139 | +GC(7) Pause Young (Concurrent Start) (G1 Humongous Allocation) 234M->90M(516M) |
| 140 | +``` |
| 141 | + |
| 142 | +Each `BufferedImage` at 2× resolution is 2400×1260×4 bytes ≈ **12 MB** — larger than half the default G1 region size. G1 treats these as "humongous" objects requiring special allocation paths. |
| 143 | + |
| 144 | +The fix was trivially simple: **reuse a single `BufferedImage`** across all 112 cards. |
| 145 | + |
| 146 | +```java |
| 147 | +// Allocated once, reused for every card |
| 148 | +static final BufferedImage SHARED_IMG = |
| 149 | + new BufferedImage(W * SCALE, H * SCALE, BufferedImage.TYPE_INT_RGB); |
| 150 | +``` |
| 151 | + |
| 152 | +Each card clears the image with a white fill before drawing. Result: **34 GCs → 1 GC**. |
| 153 | + |
| 154 | +## JIT tuning: lowering the C2 threshold |
| 155 | + |
| 156 | +With only 112 iterations, the JVM's C2 compiler (which normally triggers after ~10,000 invocations) never kicks in. The hot methods — tokenization, font metrics, `drawString` — run in C1-compiled (or interpreted) code. |
| 157 | + |
| 158 | +Lowering the C2 threshold to 100 invocations lets the optimizing compiler engage early: |
| 159 | + |
| 160 | +``` |
| 161 | +-XX:Tier4CompileThreshold=100 |
| 162 | +``` |
| 163 | + |
| 164 | +| Threshold | Time (112 cards) | Delta | |
| 165 | +|-----------|-----------------|-------| |
| 166 | +| Default (~10K) | 10.02s | baseline | |
| 167 | +| **100** | **9.32s** | **−7%** | |
| 168 | +| 1 | 9.78s | −2% | |
| 169 | + |
| 170 | +Threshold=100 is the sweet spot: C2 compiles the hot loop just in time for the bulk of the work. Threshold=1 wastes time compiling methods before they're warm. |
| 171 | + |
| 172 | +We added this flag to all `java -jar` execution calls in our CI workflows. It doesn't affect AOT training runs (which need default JIT behavior for proper class profiling). |
| 173 | + |
| 174 | +## The results |
| 175 | + |
| 176 | +### Local benchmarks (Apple M1 Max, 112 patterns) |
| 177 | + |
| 178 | +**OG Card Generator — Steady-State (avg of 5 runs):** |
| 179 | + |
| 180 | +| Method | Time | |
| 181 | +|--------|------| |
| 182 | +| **Fat JAR (Graphics2D)** | **10.82s** | |
| 183 | +| Fat JAR + AOT | 11.04s | |
| 184 | +| JBang (from source) | 11.62s | |
| 185 | +| Python (cairosvg) | 14.10s | |
| 186 | + |
| 187 | +**HTML Generator — Steady-State (avg of 5 runs):** |
| 188 | + |
| 189 | +| Method | Time | |
| 190 | +|--------|------| |
| 191 | +| **Fat JAR** | **7.47s** | |
| 192 | +| Fat JAR + AOT | 8.00s | |
| 193 | +| JBang | 11.63s | |
| 194 | +| Python | 31.52s | |
| 195 | + |
| 196 | +### What we shipped |
| 197 | + |
| 198 | +| Before | After | |
| 199 | +|--------|-------| |
| 200 | +| Batik transcoder + codec deps | Zero rendering deps (pure JDK) | |
| 201 | +| 10+ MB fat JAR | 2.6 MB fat JAR | |
| 202 | +| 1 monolithic file (400 lines) | 7 modular source files | |
| 203 | +| 34 GC events per run | 1 GC event per run | |
| 204 | +| Slower than Python | **24% faster than Python** | |
| 205 | + |
| 206 | +### Why AOT cache doesn't help here |
| 207 | + |
| 208 | +You might notice the AOT cache (`-XX:AOTCache`) shows no improvement over plain JAR execution. That's expected: JEP 483 AOT cache pre-loads classes from a training run, eliminating class-loading overhead on subsequent runs. But for our generator, class loading is already fast (~0.5s). The bottleneck is `ImageIO.write()` — pure CPU-bound PNG compression — which no amount of class pre-loading can speed up. |
| 209 | + |
| 210 | +## What we learned |
| 211 | + |
| 212 | +1. **Don't SVG-to-PNG when you can draw directly.** If you control the layout, `Graphics2D` + `ImageIO` is simpler, faster, and dependency-free. |
| 213 | + |
| 214 | +2. **Java's `Font.createFont()` registers everything as style=0.** Load fonts from files with `deriveFont()` instead of relying on `new Font(name, style, size)`. |
| 215 | + |
| 216 | +3. **Reuse large objects.** A 12 MB `BufferedImage` per card is a humongous allocation in G1. One shared buffer, cleared each iteration, drops GC events from 34 to 1. |
| 217 | + |
| 218 | +4. **Lower `-XX:Tier4CompileThreshold` for short-lived CLI apps.** With only ~100 iterations, the C2 compiler needs a nudge to engage before the work is done. |
| 219 | + |
| 220 | +5. **Profile before tuning.** We tested Epsilon GC, Shenandoah, ZGC, Serial GC, various heap sizes, and G1 region sizes. None moved the needle. The bottleneck was always PNG compression — a CPU-bound operation that no GC or heap configuration can improve. |
| 221 | + |
| 222 | +6. **JBang `//SOURCES` makes multi-file Java scripts practical.** No build tool, no `pom.xml` — just list your source files and run. |
| 223 | + |
| 224 | +## Try it yourself |
| 225 | + |
| 226 | +```bash |
| 227 | +# Generate all 112 OG cards |
| 228 | +jbang html-generators/generateog.java |
| 229 | + |
| 230 | +# Generate a single card |
| 231 | +jbang html-generators/generateog.java language/type-inference-with-var |
| 232 | + |
| 233 | +# Build JAR + AOT for fastest execution |
| 234 | +jbang export fatjar --force --output html-generators/generateog.jar html-generators/generateog.java |
| 235 | +java -XX:AOTCacheOutput=html-generators/generateog.aot -jar html-generators/generateog.jar |
| 236 | +java -XX:Tier4CompileThreshold=100 -XX:AOTCache=html-generators/generateog.aot -jar html-generators/generateog.jar |
| 237 | +``` |
| 238 | + |
| 239 | +The full source is at [github.com/javaevolved/javaevolved.github.io](https://github.com/javaevolved/javaevolved.github.io) under `html-generators/`. |
| 240 | + |
| 241 | +CI benchmark results: [Actions run #22563953466](https://github.com/javaevolved/javaevolved.github.io/actions/runs/22563953466). |
0 commit comments