A bytecode-level tool that transforms a JAR compiled with Java 9 into a
fully Java 8-compatible JAR, including a bundled runtime backport library
(j9compat) that provides Java 8 implementations of every Java 9 API used.
Compatibility focus: runs on Eclipse Temurin (Adoptium) and targets Android 11–15 (API 30–34) deployments with Java 9 bytecode input.
| Transformation | Description |
|---|---|
| Class-file version downgrade | Changes class file major version from 53 (Java 9) to 52 (Java 8). |
| Module-info retention | Downgrades module-info.class so module metadata is preserved even though Java 8 ignores JPMS. |
| Private interface methods | Java 9 allows private methods in interfaces. This tool makes them package-private so the Java 8 verifier accepts them. |
| String concatenation | Rewrites invokedynamic StringConcatFactory concatenation to StringBuilder bytecode. |
| Collection factory methods | Redirects List.of(), Set.of(), Map.of(), Map.ofEntries(), Map.entry(), and all copyOf() variants to j9compat.CollectionBackport. |
| Stream API additions | Redirects takeWhile(), dropWhile(), ofNullable(), and the three-argument iterate() to j9compat.StreamBackport. |
| Primitive Stream additions | Redirects IntStream/LongStream/DoubleStream.takeWhile(), dropWhile(), and the three-argument iterate() to j9compat.*StreamBackport. |
| Collectors additions | Redirects Collectors.filtering() and Collectors.flatMapping() to j9compat.CollectorsBackport. |
| Optional API additions | Redirects ifPresentOrElse(), or(), and stream() to j9compat.OptionalBackport. |
| Optional primitive additions | Redirects OptionalInt/Long/Double.ifPresentOrElse() and .stream() to j9compat.Optional*Backport. |
| InputStream additions | Redirects transferTo(), readAllBytes(), and readNBytes() to j9compat.IOBackport. |
| Objects additions | Redirects requireNonNullElse(), requireNonNullElseGet(), checkIndex(), checkFromToIndex(), and checkFromIndexSize() to j9compat.ObjectsBackport. |
| CompletableFuture additions | Redirects orTimeout(), completeOnTimeout(), failedFuture(), completedStage(), failedStage(), minimalCompletionStage(), newIncompleteFuture(), and copy() to j9compat.CompletableFutureBackport. |
| Process/Stack/Flow types | Remaps ProcessHandle, StackWalker, Flow, and SubmissionPublisher to Java 8-compatible j9compat implementations. |
| Module system APIs | Redirects Class.getModule() and remaps Module, ModuleLayer, and related descriptor types to j9compat backports. |
| VarHandle APIs | Redirects VarHandle lookups (including findVarHandle and arrayElementVarHandle) to j9compat.VarHandle. |
| Reflection/MethodHandle lookups | Redirects Class.getMethod and MethodHandles.Lookup lookups of Java 9 APIs to the backport implementations. |
.
├── asm-9.4.jar ASM core (bytecode library)
├── asm-commons-9.4.jar ASM commons
├── asm-tree-9.4.jar ASM tree API
│
├── src/
│ ├── desugarer/
│ │ ├── Java9ToJava8Desugarer.java Main tool – processes JARs
│ │ ├── ClassDesugarer.java ClassVisitor (version + interface priv)
│ │ ├── ClassHierarchy.java Tracks class inheritance/implements
│ │ └── MethodDesugarer.java MethodVisitor (API call remapping)
│ │
│ └── j9compat/ Runtime backport library (Java 8)
│ ├── CollectionBackport.java List/Set/Map factory methods
│ ├── StreamBackport.java Stream additions
│ ├── IntStreamBackport.java IntStream additions
│ ├── LongStreamBackport.java LongStream additions
│ ├── DoubleStreamBackport.java DoubleStream additions
│ ├── OptionalBackport.java Optional additions
│ ├── OptionalIntBackport.java OptionalInt additions
│ ├── OptionalLongBackport.java OptionalLong additions
│ ├── OptionalDoubleBackport.java OptionalDouble additions
│ ├── IOBackport.java InputStream additions
│ ├── ObjectsBackport.java Objects additions
│ ├── CollectorsBackport.java Collectors additions
│ ├── CompletableFutureBackport.java CompletableFuture additions
│ ├── ProcessHandle.java ProcessHandle backport
│ ├── StackWalker.java StackWalker backport
│ ├── Flow.java Flow (Reactive Streams) interfaces
│ └── SubmissionPublisher.java Reactive Streams publisher implementation
│
└── .github/workflows/
└── desugar-java9-to-java8.yml CI pipeline
# 1. Compile the j9compat backport library
mkdir -p build/backport
javac -source 8 -target 8 \
-cp asm-9.4.jar:asm-commons-9.4.jar:asm-tree-9.4.jar \
-d build/backport \
src/j9compat/*.java
# 2. Compile the desugarer tool
mkdir -p build/desugarer
javac -source 8 -target 8 \
-cp asm-9.4.jar:asm-commons-9.4.jar:asm-tree-9.4.jar \
-d build/desugarer \
src/desugarer/*.java
# 3. Build a fat JAR (ASM + desugarer classes in one JAR)
mkdir -p build/fatjar
cd build/fatjar
jar xf ../../asm-9.4.jar
jar xf ../../asm-commons-9.4.jar
jar xf ../../asm-tree-9.4.jar
cp -r ../desugarer/* .
cd ../..
jar cfe build/desugar9to8.jar desugarer.Java9ToJava8Desugarer -C build/fatjar .java -jar build/desugar9to8.jar [--incremental] [--cache-dir <dir>] [--class-path <path>] <input-java9.jar> <output-java8.jar> [backport-classes-dir]
| Argument | Description |
|---|---|
<input-java9.jar> |
JAR compiled with Java 9 (class version 53). |
<output-java8.jar> |
Path for the Java 8-compatible output JAR. |
[backport-classes-dir] |
Optional: directory containing compiled j9compat/*.class files. If provided, the backport classes are bundled into the output JAR. |
--incremental |
Enable incremental mode (reuse unchanged output entries). |
--cache-dir <dir> |
Cache directory for incremental mode (default: build/.desugar-cache). |
--class-path <path> |
Extra classpath entries (jar/dir, separated by your platform path separator) used to resolve InputStream subclasses. |
java -jar build/desugar9to8.jar \
my-app-java9.jar \
my-app-java8.jar \
build/backport
### Source desugaring (Java 9 → Java 8 source)
java -jar build/desugar9to8.jar --source MyApp.java --output MyApp.java8.java
To compile immediately after desugaring:
java -jar build/desugar9to8.jar --source MyApp.java --compile
The output JAR will:
- Have all class files at version 52 (Java 8).
- Contain the
j9compat.*backport classes. - Redirect every Java 9 API call to the corresponding backport method.
Incremental mode reuses the output bytes for unchanged JAR entries and writes an on-disk cache alongside the output.
java -jar build/desugar9to8.jar --incremental --cache-dir build/.desugar-cache \
my-app-java9.jar \
my-app-java8.jar \
build/backportThe com.nerdvision:reflection-java9:3.0.2 artifact is compiled for Java 9
(class version 53) and calls Class.getModule(). The steps below desugar it
and run a Java 8 smoke test.
# Download the Java 9 jar and its Java 8 dependencies
curl -L -o /tmp/reflection-java9-3.0.2.jar \
https://repo1.maven.org/maven2/com/nerdvision/reflection-java9/3.0.2/reflection-java9-3.0.2.jar
curl -L -o /tmp/reflection-api-3.0.2.jar \
https://repo1.maven.org/maven2/com/nerdvision/reflection-api/3.0.2/reflection-api-3.0.2.jar
curl -L -o /tmp/agent-api-3.0.2.jar \
https://repo1.maven.org/maven2/com/nerdvision/agent-api/3.0.2/agent-api-3.0.2.jar
# Desugar the Java 9 jar
java -jar build/desugar9to8.jar \
/tmp/reflection-java9-3.0.2.jar \
/tmp/reflection-java9-3.0.2-java8.jar \
build/backport
# Verify class version is now Java 8 (major 52)
javap -verbose -classpath /tmp/reflection-java9-3.0.2-java8.jar \
com.nerdvision.agent.reflect.java9.Java9ReflectionImpl | grep "major version"
# Use any Java 8 runtime (download Temurin 8 if needed)
curl -L -o /tmp/temurin8-jre.tar.gz \
https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u482-b08/OpenJDK8U-jre_x64_linux_hotspot_8u482b08.tar.gz
tar -xzf /tmp/temurin8-jre.tar.gz -C /tmp
cat > /tmp/Java9ReflectionSmokeTest.java << 'JAVA'
public class Java9ReflectionSmokeTest {
private static class Dummy {
private String secret = "ok";
}
public static void main(String[] args) throws Exception {
com.nerdvision.agent.reflect.java9.Java9ReflectionImpl impl =
new com.nerdvision.agent.reflect.java9.Java9ReflectionImpl();
java.lang.reflect.Field field = Dummy.class.getDeclaredField("secret");
boolean result = impl.setAccessible(Dummy.class, field);
System.out.println("setAccessible=" + result);
System.out.println("value=" + field.get(new Dummy()));
}
}
JAVA
javac --release 8 \
-cp /tmp/reflection-java9-3.0.2-java8.jar:/tmp/reflection-api-3.0.2.jar:/tmp/agent-api-3.0.2.jar \
-d /tmp/java9-smoke-test \
/tmp/Java9ReflectionSmokeTest.java
/tmp/jdk8u482-b08-jre/bin/java \
-cp /tmp/reflection-java9-3.0.2-java8.jar:/tmp/reflection-api-3.0.2.jar:/tmp/agent-api-3.0.2.jar:/tmp/java9-smoke-test \
Java9ReflectionSmokeTest
# Expected output:
# setAccessible=true
# value=okAll Java 9 immutable-collection factory methods are replaced with Java 8
Collections.unmodifiableList/Set/Map wrappers with identical null-rejection
and duplicate-rejection semantics.
// Java 9 // Desugared to
List.of("a", "b") --> CollectionBackport.listOf("a", "b")
Set.of(1, 2, 3) --> CollectionBackport.setOf(1, 2, 3)
Map.of("k", "v") --> CollectionBackport.mapOf("k", "v")
Map.ofEntries(Map.entry(k, v)) --> CollectionBackport.mapOfEntries(...)
List.copyOf(coll) --> CollectionBackport.listCopyOf(coll)stream.takeWhile(p) --> StreamBackport.takeWhile(stream, p)
stream.dropWhile(p) --> StreamBackport.dropWhile(stream, p)
Stream.ofNullable(t) --> StreamBackport.ofNullable(t)
Stream.iterate(s, hasNext, f) --> StreamBackport.iterate(s, hasNext, f)intStream.takeWhile(p) --> IntStreamBackport.takeWhile(intStream, p)
longStream.dropWhile(p) --> LongStreamBackport.dropWhile(longStream, p)
DoubleStream.iterate(s, h, f) --> DoubleStreamBackport.iterate(s, h, f)Collectors.filtering(p, c) --> CollectorsBackport.filtering(p, c)
Collectors.flatMapping(f, c) --> CollectorsBackport.flatMapping(f, c)opt.ifPresentOrElse(a, e) --> OptionalBackport.ifPresentOrElse(opt, a, e)
opt.or(supplier) --> OptionalBackport.or(opt, supplier)
opt.stream() --> OptionalBackport.stream(opt)optInt.ifPresentOrElse(a, e) --> OptionalIntBackport.ifPresentOrElse(optInt, a, e)
optLong.stream() --> OptionalLongBackport.stream(optLong)
optDouble.stream() --> OptionalDoubleBackport.stream(optDouble)in.transferTo(out) --> IOBackport.transferTo(in, out)
in.readAllBytes() --> IOBackport.readAllBytes(in)
in.readNBytes(buf, off, len) --> IOBackport.readNBytes(in, buf, off, len)Objects.requireNonNullElse(obj, def) --> ObjectsBackport.requireNonNullElse(...)
Objects.requireNonNullElseGet(obj, supplier)--> ObjectsBackport.requireNonNullElseGet(...)
Objects.checkIndex(idx, len) --> ObjectsBackport.checkIndex(...)
Objects.checkFromToIndex(from, to, len) --> ObjectsBackport.checkFromToIndex(...)
Objects.checkFromIndexSize(from, size, len) --> ObjectsBackport.checkFromIndexSize(...)cf.orTimeout(1, SECONDS) --> CompletableFutureBackport.orTimeout(cf, 1, SECONDS)
cf.completeOnTimeout(v, 1, SECONDS) --> CompletableFutureBackport.completeOnTimeout(cf, v, 1, SECONDS)
CompletableFuture.failedFuture(ex) --> CompletableFutureBackport.failedFuture(ex)
CompletableFuture.completedStage(v) --> CompletableFutureBackport.completedStage(v)
CompletableFuture.failedStage(ex) --> CompletableFutureBackport.failedStage(ex)
cf.minimalCompletionStage() --> CompletableFutureBackport.minimalCompletionStage(cf)
cf.newIncompleteFuture() --> CompletableFutureBackport.newIncompleteFuture(cf)
cf.copy() --> CompletableFutureBackport.copy(cf)ProcessHandle.current() --> j9compat.ProcessHandle.current()
StackWalker.getInstance() --> j9compat.StackWalker.getInstance()
Flow.Publisher<T> --> j9compat.Flow.Publisher<T>
SubmissionPublisher<T> --> j9compat.SubmissionPublisher<T>Class<?> c = ...;
c.getModule() --> j9compat.ModuleBackport.getModule(c)MethodHandles.lookup()
.findVarHandle(Foo.class, "field", int.class)
--> j9compat.MethodHandlesBackport.findVarHandle(...)
MethodHandles.arrayElementVarHandle(int[].class)
--> j9compat.MethodHandlesBackport.arrayElementVarHandle(int[].class)Class<?> c = Stream.class;
c.getMethod("takeWhile", Predicate.class)
--> j9compat.ReflectionBackport.getMethod(...)
MethodHandles.lookup()
.findVirtual(Stream.class, "takeWhile", MethodType.methodType(Stream.class, Predicate.class))
--> j9compat.MethodHandlesBackport.findVirtual(...)The desugarer is built on ASM 9.4 (ObjectWeb ASM bytecode library).
Java9ToJava8Desugareropens the input JAR, iterates over every entry, and passes.classfiles through the transformation pipeline.ClassDesugarer(aClassVisitor) downgrades the class-file version and stripsACC_PRIVATEfrom interface methods.MethodDesugarer(aMethodVisitor) intercepts everyvisitMethodInsn()call and rewrites matching Java 9 API calls:- Static-to-static: same descriptor, new owner/name,
isInterface=false. - Instance-to-static: the receiver is prepended to the descriptor, and
INVOKEVIRTUAL/INVOKEINTERFACEbecomesINVOKESTATIC. The operand stack already holds the receiver below the arguments, so no extra instructions are needed.
- Static-to-static: same descriptor, new owner/name,
- The optional backport directory is bundled into the output JAR so the
desugared code can resolve the
j9compat.*classes at runtime.
The workflow in .github/workflows/desugar-java9-to-java8.yml runs on every
push and pull request to main. It:
- Compiles the desugarer tool and backport library with
javac -source 8 -target 8. - Packages a fat JAR including ASM.
- Compiles a small Java 9 sample class and desugars it.
- Verifies the output class file is at major version 52 using
javap. - Exposes the fat JAR and the sample output as build artefacts.
On manual (workflow_dispatch) runs you can supply a release-asset filename to
desugar any JAR attached to the latest release.
There are currently no known limitations; all Java 9 API call sites listed
above are redirected to the j9compat backports. For status details, see
LIMITATIONS.md.