From tree-walking interpreter to compiled templates

FreshMarker 3.0.0 will introduce an ahead-of-time (AOT) and just-in-time (JIT) template compiler. This first post in a three-part series explains what changes under the hood, what you gain, and what you give up compared to the classic interpreter.

A short recap: what FreshMarker is

FreshMarker is an embedded Java 21 template engine, inspired by FreeMarker and sharing most of its basic concepts and directive syntax (${…​}<#if><#list><#macro>, …​). It was built to process templates at runtime with enough programming power to be useful — conditionals, loops, macros, imports, output-format-aware escaping — without requiring a build step. Templates can be loaded, changed and re-rendered on the fly, which makes FreshMarker a good fit for tools, code generators and applications that manage large amounts of user- or admin-editable templates.

The initial motivation for a separate engine was the lack of modern JDK data types in FreeMarkerjava.time.LocalDate and friends are first-class citizens in FreshMarker, and the type system is deliberately open to new data types, built-ins and providers.

Up to and including the 2.x line, FreshMarker was a pure tree-walking interpreter. That is what version 3.0.0 changes — and it is worth noting up front that this is not the first attempt at a FreshMarker compiler. Earlier experiments explored bytecode generation and direct code emission, but each of them either broke language semantics in some corner (custom providers, output-format escaping, macro dispatch, TemplateSetting stacks) or made the runtime harder to reason about than the interpreter it was supposed to replace. The 3.0.0 compiler is the first design that ships: it keeps the interpreter as the reference implementation, generates plain Java source (not bytecode), and falls back to the interpreter for anything it cannot lower yet — so no template feature regresses.

The status quo: a tree-walking interpreter

Until 3.0.0, every FreshMarker template was executed by walking two parallel trees:

  • fragment tree rooted in org.freshmarker.core.fragment.Fragment. Every statement — a constant string, <#if><#list><#switch>, an interpolation — is a Fragment and knows how to render itself via void process(ProcessContext context).
  • An expression tree rooted in org.freshmarker.core.model.TemplateObject. Every expression node (variables, literals, dot access, arithmetic, built-ins) evaluates to another TemplateObject via evaluateToObject(ProcessContext ctx).

Executing a template is then simply: Template#process creates a ProcessContext, hands it to rootFragment.process(ctx), and each node polymorphically dispatches to its children.

That design is easy to reason about, easy to extend and easy to reduce partially (Fragment#reduce), but it pays two recurring taxes on every render:

  1. Dispatch overhead — every fragment and every expression is a virtual call. The JIT eventually inlines the hot ones, but the megamorphic call sites in the loops that dominate real templates are exactly the ones the JIT struggles with.
  2. Boxing — every intermediate value is wrapped in a TemplateObject subclass (TemplateStringTemplateNumberTemplateBoolean, …​). That is fine for correctness but it allocates, and the allocator is on the hot path.

What the compiler does

Instead of interpreting the fragment tree, the compiler emits Java source code that carries out the same work. The generated class implements CompiledFragment:

public interface CompiledFragment {
    void process(ProcessContext ctx);
}

There are two flavours of code generator:

  • Stage 1 — Untyped (JavaSourceCodeGenerator): straight-line Java that inlines directive dispatch but still delegates every expression back to the interpreter.
  • Stage 2 — Typed (TypedJavaSourceCodeGenerator): uses a TemplateTypeSchema to emit code that talks directly to the Java data model, no TemplateObject in sight.

Both generators produce a Java source string that is either compiled in memory through javax.tools.JavaCompiler or written to disk during your build and compiled once with javac. At runtime you call Template#processCompiled(compiled, dataModel, writer) instead of Template#process.

The user-facing entry point for the JIT path is the CompiledTemplateProcessor facade:

Configuration configuration = new Configuration(CompilerFeature.ENABLED);
Template template = configuration.builder().getTemplate("hello",
        "Hello ${name}! <#if active>Active</#if>");

// JIT: generate source, invoke javac in-memory, load class, instantiate.
CompiledFragment compiled = CompiledTemplateProcessor.of(template);

StringWriter out = new StringWriter();
template.processCompiled(compiled, Map.of("name", "World", "active", true), out);

For AOT, the workflow is inverted: the .java file is produced at build time (e.g. by the new CLI compile command), javac runs during the build, and at runtime you only need to hand the pre-loaded class its captured constructor arguments via captureOnly.

Numbers first

The manual ships with a benchmark chart based on the classic com.mitchellbosecke:template-benchmark project, ported to Java 21. Throughput, higher is better:

Variantops/sRelative to interpreter
Interpreter50 9811.00×
Compiler (Phase 1)51 3041.01×
Interpreter with pooling55 6861.09×
Compiler (Phase 2)129 3582.54×

Two things stand out:

  • The Phase 1 compiler is essentially a wash against the interpreter on this benchmark. The evaluations in Stage 1 are still performed using TemplateObject, and the savings from the fragment calls alone are not enough to achieve a truly better runtime. But the Phase 1 compiler reveals an important insight: Falling back to the interpreter does not degrade performance.
  • The Phase 2 variant is ~2.5× faster than the interpreter. All the compile cost is paid at build time; at runtime you get a straight-line Java method with typed field access, ready for the JVM to inline and vectorise.

Pros

  • Throughput. Up to ~2.5× on realistic list-heavy templates once AOT is enabled.
  • No template-feature regressions. The compiler transparently falls back to captured interpreter fragments for anything it can’t lower yet, so semantics stay identical.
  • Same runtime plumbing. Locale stacks, formatters, output-format escaping, TemplateSetting — all of this keeps working because Template#processCompiled reuses the ordinary ProcessContext. A helper (CompiledMarkup.markup) bridges raw Java values back into the correct formatter chain for interpolations, so ${amount} still respects a number_format from a <#setting>.
  • Opt-in. You need Configuration(CompilerFeature.ENABLED) to read additional Extensions only used by the compilers. Existing code paths are untouched.
  • AOT-friendly. captureOnly returns the constructor arguments without regenerating source, so a class built by your Maven build can be loaded and instantiated with zero javax.tools activity at runtime.

Cons and trade-offs

  • Experimental status. The generated source format is not part of the stable public API.
  • JIT warm-up. If you compile a template on the first request, that request pays for source generation and javax.tools.JavaCompiler. If you throw templates away after one render, stay with the interpreter.
  • Build complexity for AOT. You need to wire exec-maven-plugin (running the FreshMarker CLI).
  • Schema maintenance for Stage 2. Peak performance requires a TemplateTypeSchema that mirrors the shape of your data model. Every new field is one more line in the schema.
  • Type binding to schema. The schema enforces a type-safe data model. However, the interpreter has the ability to handle similar types in the expressions.
  • Fallback surface. Some template features are still delegated to the interpreter
FeatureFallback reason
<#macro> / <@user/>Dynamic dispatch, delegated via captured Fragment fields
Imported macro calls (<@lib.x/>)Same — resolved at call time
Lambda-based built-ins (?filter?map?count)Lambda closure semantics
?string("…​")?until?sinceComplex formatter/temporal logic
Custom `TemplateObjectProvider`sNo schema hook yet
${x} inside <#outputformat>Delegates to interpreter’s escaper stack
<#nested> inside macrosBound to macro semantics
<#list hash as k, v> with sorted / filter / offset / limitComplex iterator plumbing
Mixed-type arithmetic (LocalDate + Integer)Operator overloading dispatch
Custom extensionsNo SPI hook yet

None of these break your template — they just miss the optimisation.

When to pick which mode

SituationRecommendation
One-shot rendering (script, CI job, test)Interpreter. Nothing to gain from compilation.
Long-running server, moderate reuse of each templateCompiler (JIT). Amortises over many renders; no build changes.
High-throughput hot path, stable data model, build pipeline OKCompiler (AOT) + Stage 2 schema. Best throughput.
Template full of macros, lambdas and complex temporal built-insInterpreter or Compiler (JIT). Most work would fall back anyway.

What comes next

The next two posts drill into the two stages:

  • Blog 2 walks through the Stage 1 compiler — how JavaSourceCodeGenerator turns a fragment tree into Java, how CompiledFragment is instantiated, and how the meta-template compiled-fragment.fmt is (fittingly) rendered by FreshMarker itself.
  • Blog 3 explains the Stage 2 pipeline — TemplateTypeSchema, type inference, raw expression resolution, the compiled built-in SPI and the CLI/Maven integration that unlocks the AOT numbers above.

Leave a Comment