This Maven plugin may optimize your Bytecode after compilation,
to make it work faster.
Just add this to your pom.xml file (you must have Docker installed too):
<project>
[..]
<build>
<plugins>
<plugin>
<groupId>org.eolang</groupId>
<artifactId>hone-maven-plugin</artifactId>
<version>0.27.6</version>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>optimize</goal>
<goal>rmi</goal>
</goals>
<configuration>
<rules>streams/*</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>The plugin will do exactly the following:
- Take Bytecode
.classfiles from thetarget/classes/directory and copy all of them to thetarget/classes-before-hone/directory (as a backup). - Using jeo-maven-plugin,
transform
.classfiles to.xmirformat, which is EO in XML, and place them into thetarget/hone/jeo-disassemble/directory. - Using phino,
convert
.xmirfiles to.phifiles with π-calculus expressions, and place them into thetarget/hone/phi/directory. - Using phino,
apply a number of optimizations to π-calculus expressions in the
.phifiles and place new.phifiles into thetarget/hone/phi-optimized/directory. - Using phino,
convert
.phifiles back to.xmirfiles and place them into thetarget/hone/unphi/directory. - Using jeo-maven-plugin,
transform
.xmirfiles back to Bytecode and place.classfiles into thetarget/classes/directory.
The effect of the plugin should be performance-positive (your code should work faster) along with no functionality degradation (your code should work exactly the same as it worked before optimizations). If any of these is not true, submit a ticket, we will try to fix.
To make it work faster, you may install phino on your machine beforehand.
The most interesting step of the pipeline is the phi-to-phi rewriting,
where phino turns one π-calculus expression into another
by applying a fixed set of rules to every .phi file
in target/hone/phi/ and writing the result into target/hone/phi-optimized/.
Each rule lives in its own .phr file under
src/main/resources/org/eolang/hone/rules/
and has the same three-part shape:
pattern describes the sub-expression to look for,
result describes what to put in its place,
and an optional where block defines auxiliary metavariables
computed by small string functions such as concat, sed, join, and tau.
Inside the π-calculus body,
identifiers prefixed with π΅- capture whole groups of bindings,
those prefixed with π- capture a single binding name,
and those prefixed with π- capture an atomic sub-expression,
so that a single rule can match an entire family of concrete expressions.
When the plugin is configured with <rules>streams/*</rules>,
the Rules class scans the classpath under that directory,
collects every .phr (and .yml) file that matches the pattern,
and sorts the resulting names alphabetically before passing them on
(see Collections.sort(names) in Rules.discover()).
Each filename begins with a numeric prefix
(for example 101-, 111-, 121-, ..., 701-, 702-),
so the alphabetical sort produces the exact sequence
in which the rules are intended to fire,
one by one.
The prefixes also reveal the logical phases of the pipeline:
the 1xx group prepares the input
(it removes self-referencing labels and lowers invokedynamic to a lambda),
the 2xx group recognises stream operations
(filter, map, their primitive variants, unbox and box),
the 3xx group folds every recognised operation
into a uniform internal distill node,
the 4xx group fuses adjacent distill nodes
into a single combined one,
the 5xx group rewrites the fused chain into one mapMulti call,
the 6xx group lowers mapMulti back into a lambda,
and the 7xx group re-introduces invokedynamic
so that the bytecode emitted by JEO is shaped the way the JVM expects.
In the default "big-steps" mode,
the rewrite.sh script invokes phino rewrite once per .phi file
and passes every selected rule on the same command line
as a sequence of --rule= arguments in their alphabetical order.
Phino itself then walks through that list,
trying each rule against every position in the expression tree
and re-trying until no rule matches anymore,
capped by the maxDepth and maxCycles parameters.
The "small-steps" mode (<smallSteps>true</smallSteps>)
is meant for debugging the rules:
it invokes phino rewrite separately for each rule
and saves the intermediate result as Foo.phi.01, Foo.phi.02, and so on,
so that a diff between two adjacent files
reveals exactly which rule changed what.
The .phi file that arrives from jeo-maven-plugin
contains the bytecode of one Java class
re-encoded as a single π-expression:
every instruction (iload, imul, invokedynamic, ...)
becomes a small formation
whose Ο attribute dispatches to a name under Ξ¦.jeo.opcode,
and the metadata of the class
(its name, access flags, methods, line numbers, ...)
sits alongside as ordinary bindings.
The rules in streams/ then walk this expression through
seven stages of rewriting,
turning a sequence of low-level instructions
into a single fused stream operation
while keeping the resulting expression
re-assemblable back to valid .class bytes by jeo.
The pipeline below mirrors the one described in the hone paper.
Stage 1 (rules 101- to 141-): clean up and lower invokedynamic.
The Java compiler emits a stream pipeline as a chain of
invokedynamic+invokeinterface pairs
plus a synthetic static method per lambda;
before anything else can be recognised,
the noise around those instructions must go.
Rule 101-remove-self-reference-labels deletes labels and line-number
entries that no longer point anywhere meaningful,
111-invokedynamic-to-lambda rewrites the invokedynamic formation
into a higher-level Ξ¦.hone.lambda pragma that exposes
the interface, the target method, and the captured arguments,
121- and 131- normalise the static-vs-instance shape
of the produced lambda,
and 141-set-opcode-in-lambda records the original opcode
on the pragma so that later stages can reverse the lowering.
Stage 2 (rules 201- to 282-): recognise stream operations as pragmas.
A Ξ¦.hone.lambda immediately followed by an invokeinterface
on a java.util.stream class is, semantically, one operation:
rules 201-lambda-to-filter, 202-lambda-to-map,
203-lambda-to-primitive-filter, 204-lambda-to-primitive-map,
and 205-lambda-to-unbox
match exactly that pair and replace it with a single
Ξ¦.hone.filter / Ξ¦.hone.map / Ξ¦.hone.unbox pragma.
The paper calls these synthetic formations pragmas:
they look like bytecode instructions
but carry the information needed to reconstruct one later.
Rules 206- through 261- then tidy up the boxing and primitive
conversions that the compiler inserted around the lambda
(for example moving an Integer.valueOf call from outside the lambda
into a Ξ¦.hone.box pragma),
and 271-, 272- remove the now-useless CHECKCAST and
object-to-primitive conversions that the pragma made redundant.
Rules 281- and 282- insert a DUP in front of every filter
so the value can be both tested and forwarded
without re-running the predicate.
Stage 3 (rules 301- to 311-): fold every operation into distill.
Mapping and filtering still look different at this point:
map rewrites a value, filter drops one.
The third stage erases that difference
by rewriting every Ξ¦.hone.map, Ξ¦.hone.filter,
and their primitive variants
into a uniform Ξ¦.hone.distill pragma
whose body is a piece of bytecode that accepts an item
and either falls through (keep), returns early (drop),
or replaces the local variable (transform).
Rules 301-dup-to-distill, 302-transform-to-distill,
303-type-to-distill, 304-primitive-filter-to-distill,
305-object-filter-to-distill, and 306-map-to-distill
handle the individual cases,
and 311-load-this-in-pre-distill prepares the stack
for instance-method lambdas.
After this stage,
a pipeline of .map().filter().mapToInt()...
has been reduced to a flat list of distill pragmas
inside the same method.
Stage 4 (rules 401- to 431-): fuse adjacent distill pragmas.
This is the optimization that actually saves work at runtime.
Rule 401-fuse looks for two consecutive distill pragmas
inside the same method body
and concatenates their lambda bodies into a single distill,
so that the JVM has to traverse the stream pipeline only once
instead of pumping every element through several Consumer objects.
The rule fires repeatedly under phino's fixed-point evaluation:
if a method had five chained operations,
401-fuse will fire four times,
collapsing all of them into one.
Concatenation only works when the type that flows between
two pragmas is the same;
when one pragma returns a primitive and the next expects a wrapper
(or vice versa),
rule 411-box-distill-unbox-to-primitive-distill
unifies the boundary
by pushing the boxing/unboxing inside the body,
and 421-/422- align the head and tail types
with the surrounding bytecode.
Rule 431-dup-before-filter-distill re-applies the DUP
fix-up after a fusion changed which value is being filtered.
Stage 5 (rules 501- and 511-): emit a single mapMulti call.
At this point each fused distill is one big anonymous function
that consumes an item.
The JDK already offers an idiomatic shape for exactly that,
Stream.mapMulti(BiConsumer),
whose second argument is the downstream sink.
Rule 501-distill-to-mapMulti rewrites the distill pragma
into a Ξ¦.hone.mapMulti pragma
by appending a c.accept(x) call to the body
(where c is the BiConsumer argument),
and 511-distill-lambda-to-method extracts the body
into a real private static method on the class
so that it can be invoked through a method handle.
Stage 6 (rules 601- to 603-): pragmas back to lambdas and invokeinterface.
Once the optimizer has done its job,
the Ξ¦.hone.* pragmas have to disappear:
jeo doesn't know about them and can't translate them to bytecode.
Rule 601-mapMulti-to-lambda rewrites the Ξ¦.hone.mapMulti pragma
back into a Ξ¦.hone.lambda formation paired with an invokeinterface
call on Stream.mapMulti,
reversing the recognition that stages 2 and 5 performed.
Rule 602-box-to-boxed rewrites the Ξ¦.hone.box pragma
directly into an invokeinterface on Integer.valueOf (and its siblings),
since boxing is just a static method call and does not need a lambda.
Rule 603-unbox-to-lambda rewrites the Ξ¦.hone.unbox pragma
back into a Ξ¦.hone.lambda formation,
matching the shape that 205-lambda-to-unbox originally consumed.
After this stage, every pragma is gone;
what remains are Ξ¦.hone.lambda formations and ordinary bytecode opcodes.
Stage 7 (rules 701- and 702-): lambdas back to invokedynamic.
This stage is the inverse of stage 1 and the final bytecode-level fixup.
Rule 701-static-lambda-to-invokedynamic handles
the lambdas whose body is a static method,
and 702-nonstatic-lambda-to-invokedynamic
handles the ones that capture a this reference.
Both rules lower a Ξ¦.hone.lambda formation
into a full Ξ¦.jeo.opcode.invokedynamic formation,
reconstructing the LambdaMetafactory.metafactory call site,
the bridge and target method handles,
and the method-type descriptors
that the JVM expects to find on the constant pool side of an invokedynamic.
The result is a .phi file
that contains regular bytecode instructions again,
just fewer of them and arranged for a single pass through the stream,
ready to be converted back to .xmir by phino
and then to .class by jeo.
This pipeline never invents bytecode that the JVM cannot run.
Even when the fusion stage produces a method body
that the Java compiler would reject
(for example one local variable holding values of two unrelated
reference types in succession),
the JVM itself does not type-check locals at runtime,
so the resulting .class file still verifies and executes.
The hone paper discusses this property,
along with the cases (mixing primitives and wrappers across a fuse)
where an explicit boxing distill must be inserted to keep the JVM happy.
The pipeline deliberately fuses only non-short-circuiting operations
(filter, map, peek, distinct, skip, dropWhile, flatMap,
the primitive conversions, and mapMulti itself);
the two short-circuiting intermediate operations of the JDK,
limit() and takeWhile(), are left untouched.
The reason is structural:
everything the fusion produces is one Stream.mapMulti(BiConsumer) stage,
and a mapMulti body may keep, drop, or transform an element,
but it has no access to the Sink.cancellationRequested() channel β
there is no way for the fused body to ask upstream to stop traversal.
A stateful guard inside the body
(a countdown for limit, exactly like the one 308-skip-to-distill
builds for skip but with the branch inverted,
or a sticky latch for takeWhile)
would reproduce the values of the original pipeline
but not its traversal contract:
the source keeps being pulled after the cut-off.
On an unbounded source such as Stream.iterate(1, n -> n + 2).limit(5)
the rewritten program would simply never terminate,
and on a bounded one it would traverse the entire source β
list.stream().limit(3) would visit every element of the list β
pessimizing the very behavior limit exists to provide.
A semantics-preserving variant
(keep the native Stream.limit(n) right after the fused mapMulti
so that upstream cancellation still happens)
is only exact when no element-dropping operation is fused after the guard,
and would save a single invokedynamic in that narrow shape β
not worth the rule complexity.
Instead, limit and takeWhile act as barriers:
the chains of operations on either side of them
fuse into their own mapMulti calls independently,
while the native short-circuiting call stays in place.
The same blind spot reaches flatMap from the other direction (#736).
A flatMap is fusable β 455-flatMap-to-mapMulti and its primitive siblings
rewrite it into a mapMulti whose adapter drains each inner stream with
result.sequential().forEach(c) β but that adapter, being just a mapMulti
body, cannot see cancellationRequested() either.
The JDK's ReferencePipeline.flatMap does forward cancellation:
once a downstream short-circuit has fired it stops part-way through an inner
stream, so Stream.of(1L).flatMap(x -> Stream.iterate(x, i -> i + 1)).limit(5)
yields five elements and terminates; the fused forEach adapter, by contrast,
runs to completion for every source element and hangs forever on the
unbounded inner stream.
Because a mapMulti cannot honour cancellation, the cure mirrors the
revert-on-parallel guard above rather than patching the adapter:
454-flatMap-revert-on-short-circuit reverts the lifted flatMap back to its
native call whenever the same method body reaches a limit, takeWhile,
findFirst, findAny, anyMatch, allMatch, or noneMatch downstream of
it, so those flatMaps stay unfused.
skip(n) is fused (it lowers to a countdown counter inside the
mapMulti body, see 308-skip-to-distill), but only when the pipeline
provably stays sequential.
The fused counter is correct only if arrival order equals encounter order:
on a parallel stream several ForkJoin workers drive the one captured
counter concurrently, so it both races and counts in arrival order rather
than encounter order, dropping the wrong elements
(this is the order-dependent analogue of the parallel distinct race
fixed in #715, except that no thread-safe data structure can recover
encounter order once it is lost).
The JDK's native Stream.skip(n) honours the ordered/parallel contract,
so when a method contains a parallel() or parallelStream() call the
rules 222-parallel-reverts-skip and 223-parallel-reverts-skip-after
revert the recognised skip back to the native call before 308 can fold
it (#719, #717).
The same revert-on-parallel guard applies verbatim to a future dropWhile
lifting, which is order-dependent for the same reason.
distinct() is fused (it lowers to a seen-set check inside the mapMulti
body, see 307-distinct-to-distill), but only when the pipeline provably
stays sequential.
The fused seen-set is a thread-safe ConcurrentHashMap.newKeySet(), which
#715 introduced to close the data race on parallel streams; making add()
atomic, however, only fixes whether duplicates leak, not which element
survives among equals.
The JDK's distinct() is documented stable on an ordered stream: among
equal elements it keeps the one first in encounter order.
A single shared set populated by several ForkJoin workers instead keeps
whichever equal element wins the race to add() β arrival-order-first,
which is neither encounter-order-first nor deterministic across runs.
For primitives and substitutable-equals values this is unobservable, but
for equal-but-distinguishable objects (an equals/hashCode that collapses
instances differing in a payload field) the surviving object changes from
run to run, silently violating the stability contract.
The JDK's native Stream.distinct() honours the ordered/parallel contract,
so β exactly as for skip β when a method contains a parallel() or
parallelStream() call the rules 224-parallel-reverts-distinct and
225-parallel-reverts-distinct-after revert the recognised distinct back
to the native call before 307 can fold it (#738).
You can use this plugin with Gradle too, but it requires
some additional steps. You need to add the following to your build.gradle file:
task hone(type: Exec, dependsOn: compileJava) {
commandLine 'mvn',
"-Dhone.target=${buildDir}",
"-Dhone.classes=${buildDir.toPath().relativize(sourceSets.main.output.classesDirs.singleFile.toPath())}",
'-Dhone.rules=streams/*',
'org.eolang:hone-maven-plugin:0.0.0:build',
'org.eolang:hone-maven-plugin:0.0.0:optimize'
}
compileJava.finalizedBy hone
classes.dependsOn honeSee how it works in this example.
If you ran hone-maven-plugin in a Docker container, you might face issues if
your Docker image doesn't have phino
installed. In this case, hone-maven-plugin
will try to run a Docker container inside your Docker container (DinD).
This might lead to problems with
volume mounting.
While it's technically possible to implement this (by specifying correct volumes on your host machine), we highly recommend avoiding such a setup.
Here is the result of the latest processing of a large Java class from JNA:
Input: com/sun/jna/Pointer.class
Size of .class: 22Kb (22Kb bytes)
Size of .xmir after disassemble: 2Mb (2Mb bytes, 52996 lines)
Size of .phi: 617Kb (617Kb bytes, 14843 lines)
Size of .xmir after unphi: 2Mb (2Mb bytes, 52981 lines)
Optimization time: 10s (9727 ms)
The results were calculated in this GHA job on 2026-06-09 at 08:04, on Linux with 4 CPUs.
Here is the result of running the plugin against a number of mid-size,
mature open-source Java projects from GitHub. Each project is pinned to
a specific commit (rather than its moving master head) so that the
table stays reproducible across runs. For each pinned commit, the
workflow shallow-fetches that revision, runs mvn clean test to record
a baseline, applies hone-maven-plugin to every target/classes/
directory, and then re-runs the tests to check that the bytecode still
passes the project's own test suite. The number of modified .class
files is computed by comparing MD5 checksums before and after. The
Edits column reads as NN/MM: NN is that count of modified classes
and MM is the number of compiled .class files whose bytecode
references Stream, IntStream, LongStream, or DoubleStream β that
is, how many of the stream-using classes the plugin actually rewrote.
| Repository | Forks | LoC | Classes | Before | Edits | Hone | After |
|---|---|---|---|---|---|---|---|
| commons-configuration | 148 | 52534 | 400 | 30s | 1/27 | 45s | 30s |
| vavr | 665 | 68282 | 388 | 81s | 0/24 | 96s | 80s |
| commons-compress | 312 | 79083 | 619 | 110s | 0/19 | 74s | 82s |
| mybatis-3 | 12908 | 71445 | 486 | 43s | 1/14 | 50s | 43s |
| json-schema-validator | 341 | 31282 | 312 | 14s | 0/12 | 52s | 13s |
| commons-collections | 517 | 73500 | 614 | 25s | 0/12 | 66s | 25s |
| jsoup | 2290 | 39031 | 317 | 14s | 0/8 | 38s | 13s |
The results were calculated in this GHA job on 2026-06-13 at 04:32, on Linux with 4 CPUs.
Fork repository, make changes, then send us a pull request.
We will review your changes and apply them to the master branch shortly,
provided they don't violate our quality standards. To avoid frustration,
before sending us your pull request please run full Maven build:
mvn clean install -PquliceYou will need Maven 3.3+, Java 11+, and Docker installed.
The versions of EO and
JEO,
that we use, are defined in the pom.xml file.