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 FreeMarker: java.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:
- A fragment tree rooted in
org.freshmarker.core.fragment.Fragment. Every statement — a constant string,<#if>,<#list>,<#switch>, an interpolation — is aFragmentand knows how to render itself viavoid 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 anotherTemplateObjectviaevaluateToObject(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:
- 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.
- Boxing — every intermediate value is wrapped in a
TemplateObjectsubclass (TemplateString,TemplateNumber,TemplateBoolean, …). 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 aTemplateTypeSchemato emit code that talks directly to the Java data model, noTemplateObjectin 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:
| Variant | ops/s | Relative to interpreter |
|---|---|---|
| Interpreter | 50 981 | 1.00× |
| Compiler (Phase 1) | 51 304 | 1.01× |
| Interpreter with pooling | 55 686 | 1.09× |
| Compiler (Phase 2) | 129 358 | 2.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.
Localestacks, formatters, output-format escaping,TemplateSetting— all of this keeps working becauseTemplate#processCompiledreuses the ordinaryProcessContext. A helper (CompiledMarkup.markup) bridges raw Java values back into the correct formatter chain for interpolations, so${amount}still respects anumber_formatfrom a<#setting>. - Opt-in. You need
to read additional Extensions only used by the compilers. Existing code paths are untouched.Configuration(CompilerFeature.ENABLED) - AOT-friendly.
returns the constructor arguments without regenerating source, so a class built by your Maven build can be loaded and instantiated with zerocaptureOnlyjavax.toolsactivity 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
TemplateTypeSchemathat 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
| Feature | Fallback 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, ?since | Complex formatter/temporal logic |
| Custom `TemplateObjectProvider`s | No schema hook yet |
${x} inside <#outputformat> | Delegates to interpreter’s escaper stack |
<#nested> inside macros | Bound to macro semantics |
<#list hash as k, v> with sorted / filter / offset / limit | Complex iterator plumbing |
Mixed-type arithmetic (LocalDate + Integer) | Operator overloading dispatch |
| Custom extensions | No SPI hook yet |
None of these break your template — they just miss the optimisation.
When to pick which mode
| Situation | Recommendation |
| One-shot rendering (script, CI job, test) | Interpreter. Nothing to gain from compilation. |
| Long-running server, moderate reuse of each template | Compiler (JIT). Amortises over many renders; no build changes. |
| High-throughput hot path, stable data model, build pipeline OK | Compiler (AOT) + Stage 2 schema. Best throughput. |
| Template full of macros, lambdas and complex temporal built-ins | Interpreter 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
JavaSourceCodeGeneratorturns a fragment tree into Java, howCompiledFragmentis instantiated, and how the meta-templatecompiled-fragment.fmtis (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.