This benchmark result surprised even me:
| Implementation | Time |
|---|---|
| Jayway JsonPath | 143.477 ns/op |
SJF4J Runtime JsonPath
|
26.284 ns/op |
SJF4J APT-based @CompiledPath
|
0.988 ns/op |
| Native Java | 0.877 ns/op |
For this simple path:
$.store.bicycle.color
@CompiledPath is roughly 143× faster than Jayway JsonPath and nearly identical to hand-written Java code.
How?
The answer is surprisingly simple:
@CompiledPathdoes not make JSONPath interpretation faster.It removes JSONPath interpretation from the runtime entirely.
The Hidden Cost of JSONPath
Consider a typical JSONPath lookup:
String color = JsonPath.read(
document,
"$.store.bicycle.color"
);
Even when the expression is precompiled, the runtime still needs to:
- walk path segments;
- inspect object types;
- navigate containers;
- resolve properties dynamically;
- return and convert the result.
That flexibility is exactly what makes JSONPath useful.
But if the same path executes millions of times, the interpreter itself becomes part of the cost.
The JVM would much rather execute something like this:
String color = store.bicycle().color();
No parser.
No AST.
No reflection.
No path interpreter.
Just Java.
Turning JSONPath into Java Code
With SJF4J, you declare a path interface:
@CompiledPath
public interface StorePaths {
@GetByPath("$.store.bicycle.color")
String color(BookStore root);
}
And use it like any other Java API:
StorePaths paths = CompiledNodes.of(StorePaths.class);
String color = paths.color(store);
During compilation, the annotation processor generates an implementation.
Conceptually, the generated code looks like this:
@Override
public String color(BookStore root) {
if (root == null) return null;
Store store = root.store();
if (store == null) return null;
Bicycle bicycle = store.bicycle();
if (bicycle == null) return null;
return bicycle.color();
}
That's the entire trick.
There is no runtime JSONPath evaluation anymore.
The generated method is simply normal Java code.
Why It Gets So Close to Native Speed
The speedup comes from removing layers of work.
No Runtime Parser
The path expression is processed during compilation.
Nothing needs to be parsed when the application runs.
No Path Interpreter
There is no AST traversal loop.
There is no segment-dispatch mechanism.
There is no dynamic path evaluation.
Direct Property Access
The generated implementation can directly navigate:
- POJOs
- Records
- Maps
- Lists
JsonObjectJsonArray
using the most direct access strategy available.
JVM-Friendly Code
Because the generated code looks like ordinary Java methods, the JIT compiler can inline and optimize it aggressively.
From the JVM's perspective, there is nothing special happening.
Compile-Time Validation Included
Performance is only part of the story.
Because paths are analyzed during compilation, invalid paths fail the build.
Suppose your model contains:
store.book
but you accidentally write:
@GetByPath("$.store.books.price")
Instead of discovering the problem in production, compilation fails immediately:
Unknown property 'books' on Store
The path becomes part of your application's contract.
If you've used MapStruct, QueryDSL, or jOOQ, the experience will feel familiar.
Reads and Writes
@CompiledPath supports both read and write operations.
@CompiledPath
public interface StorePaths {
@GetByPath("$.store.book[{idx}].price")
Double bookPrice(BookStore root, int idx);
@PutByPath("$.store.book[{idx}].price")
Double replaceBookPrice(
BookStore root,
int idx,
Double value
);
@EnsurePutByPath("$.store.bicycle.price")
Double ensureBicyclePrice(
BookStore root,
Double value
);
}
Dynamic placeholders are mapped directly from method parameters:
@GetByPath("$.users[{idx}].profile[{key}]")
Object value(Model root, int idx, String key);
-
intparameters address array/list indexes -
Stringparameters address object keys
The generated implementation performs direct navigation without evaluating a runtime expression tree.
Benchmark Snapshot
The following benchmark was executed against the same object graph:
| Operation | Runtime JsonPath
|
@CompiledPath |
Native Java | Speedup |
|---|---|---|---|---|
get_bookPrice |
92.485 ns/op | 1.793 ns/op | 1.627 ns/op | 51.58× |
get_color |
91.491 ns/op | 0.988 ns/op | 0.877 ns/op | 92.60× |
get_price |
80.996 ns/op | 0.984 ns/op | 0.949 ns/op | 82.31× |
put_bookPrice |
99.874 ns/op | 18.911 ns/op | 18.697 ns/op | 5.28× |
put_price |
86.607 ns/op | 19.587 ns/op | 19.157 ns/op | 4.42× |
The exact numbers depend on the model, JVM, hardware, and path shape.
The architectural takeaway is more important:
@CompiledPathmoves path resolution from runtime to build time.
When Should You Use It?
@CompiledPath is a good fit when:
- path expressions are known during development;
- the same paths execute frequently;
- performance matters;
- compile-time safety is desirable.
Typical use cases include:
- validation engines;
- pricing systems;
- routing rules;
- DTO projections;
- data transformation pipelines;
- high-frequency service code.
For user-supplied or highly dynamic expressions, runtime JsonPath remains the right tool.
The two approaches are complementary.
Setup
Add the annotation processor:
dependencies {
implementation("org.sjf4j:sjf4j:{version}")
annotationProcessor("org.sjf4j:sjf4j-processor:{version}")
}
The implementation classes are generated automatically during compilation.
The Real Idea
Most JSONPath libraries focus on evaluating path expressions as efficiently as possible.
@CompiledPath takes a different route.
Instead of optimizing the interpreter, it removes the interpreter.
The path expression becomes Java code.
And once that happens, the JVM can optimize it exactly like code you wrote yourself.
That is how a JSONPath expression ends up running at nearly the same speed as native Java.
Learn More
- Home: https://sjf4j.org
- GitHub: https://github.com/sjf4j-projects/sjf4j
- Navigating (JSON Path): https://sjf4j.org/docs/navigating
- Benchmarks: https://sjf4j.org/docs/benchmarks#json-path-benchmark
Top comments (0)