The first time you decompile a modern Java class and read the result, something feels off. The logic is recognizable, but the code does not look like anything a person would write. You see strange method names with dollar signs, string concatenation that has turned into a call to some factory class, lambdas that seem to have vanished into thin air, and methods that appear twice with slightly different signatures. Nothing is broken. What you are looking at is the honest output of a decompiler faithfully reversing what javac actually produced, which is often quite different from the source that a developer originally typed.
This guide is about reading that output. It is not about the binary layout of a class file, which we cover separately in Java bytecode structure explained, nor about the general reconstruction pipeline described in how Java class file decompilation works. Instead, we focus on a narrower and very practical question: when a decompiler shows you modern Java language features that were desugared or dynamically linked at compile time, what do those features look like on the way back, and how do you interpret them without being misled?
Why Decompiled Java Rarely Matches the Original Source
Decompilation is not the inverse of compilation. When javac compiles your code, it discards information that the runtime does not need and rewrites high-level constructs into lower-level machinery the JVM understands. Local variable names may disappear, generics are largely erased, and several convenient language features are translated into calls, helper methods, or dynamic call sites that have no direct one-to-one syntax in Java source. A decompiler reads the bytecode that survived and tries to produce valid, compilable Java that behaves the same way. It is optimizing for behavioral equivalence, not for matching the exact characters you wrote.
This is why two things are simultaneously true: the decompiled code is correct, and the decompiled code looks unfamiliar. Once you accept that the compiler is a translator that reshapes your intent, the weird output stops being a mystery. Each odd construct you see corresponds to a specific compilation decision. The rest of this article walks through the ones you will meet most often.
invokedynamic: The Instruction That Hides Its Target
The single biggest reason modern decompiled Java looks unusual is the invokedynamic instruction, introduced by JSR 292 in Java 7. The four classic invocation instructions, invokestatic, invokevirtual, invokespecial, and invokeinterface, all name a concrete target method directly in the constant pool. You can read them and immediately know what is being called. invokedynamic is deliberately different. It does not name a target method. Instead it references a bootstrap method plus a set of static arguments, and the first time that call site executes, the bootstrap runs and returns a linked call site the JVM then caches.
The consequence for reading decompiled output is that the interesting information is not in the call itself but in the BootstrapMethods attribute the call site points to. A decompiler that understands the common bootstrap patterns will translate them back into friendly syntax, such as a lambda or a string concatenation. A more literal tool, or an unfamiliar bootstrap, may leave you with a raw call site you have to interpret manually. When you see anything referencing a bootstrap method, train yourself to ask two questions: which factory is the bootstrap, and what static arguments were baked in? Those two answers usually reveal the original language feature.
StringConcatFactory: Where Did My Plus Signs Go?
A classic surprise is opening a method that clearly builds a string and finding no StringBuilder and no + operators. Instead you see an invokedynamic call whose bootstrap is java.lang.invoke.StringConcatFactory. This is the result of JEP 280, which since Java 9 compiles string concatenation through invokedynamic rather than emitting an explicit chain of StringBuilder.append calls. The compiler hands the concatenation shape to StringConcatFactory at link time, and the runtime assembles an efficient concatenation strategy.
In practice the bootstrap uses a recipe. With makeConcatWithConstants, a template string encodes the layout: a placeholder character marks each dynamic argument, and constant text is either inlined in the recipe or passed as a static bootstrap argument. So a source expression like this:
String greeting = "Hello, " + name + "! You have " + count + " messages.";
may decompile into something that surfaces the recipe rather than the original operators, roughly:
String greeting = makeConcatWithConstants<"Hello, \u0001! You have \u0001 messages.">(name, count);
The exact rendering depends on the decompiler; better ones reconstruct the original + expression, while more literal ones expose the factory call and its recipe. Either way, when you see StringConcatFactory or a makeConcatWithConstants bootstrap, mentally translate it back to plain string concatenation. The dynamic argument placeholders in the recipe map, in order, to the arguments passed at the call site.
Lambda Desugaring: The Vanishing Closures
Lambdas and method references are perhaps the most confusing feature to see after decompilation, because they do not survive as a compact lambda syntax at the bytecode level at all. When javac compiles a lambda, it does two things. First, it moves the lambda body into a private, synthetic method on the enclosing class, conventionally named something like lambda$methodName$0. Second, at the point where the lambda is used, it emits an invokedynamic call whose bootstrap is java.lang.invoke.LambdaMetafactory. At runtime the metafactory generates a small class implementing the target functional interface and wires it to that synthetic method.
So a tidy piece of source such as:
list.forEach(item -> System.out.println(item));
can decompile into a form where the body lives elsewhere and the call site references it indirectly:
list.forEach(SomeClass::lambda$process$0);
// and separately, generated by the compiler:
private static synthetic void lambda$process$0(String item) {
System.out.println(item);
}
When you spot a method whose name contains lambda$, you are almost always looking at a desugared lambda body, not something a developer wrote by hand. The number at the end is just a per-method counter distinguishing multiple lambdas. Method references behave similarly: a reference like System.out::println may be linked directly through LambdaMetafactory without generating a separate synthetic method, because the metafactory can bind straight to the referenced method. If a decompiler is good at pattern-matching these bootstraps, it will restore the original lambda or method-reference syntax; if not, you will need to reconnect the call site and its target yourself.

Synthetic Methods and Fields: Compiler Bookkeeping
The synthetic flag marks any member the compiler generated that does not correspond to something declared in source. Desugared lambda bodies are one example, but there are many more. Accessor methods that let a nested class reach a private member of its enclosing class are synthetic. Fields that capture the enclosing instance for an inner class, often named this$0, are synthetic. Assert-status fields and switch-map arrays for switching on enums also fall into this category. None of these appear in the code you wrote, yet they are all legitimate parts of the compiled artifact.
When reading decompiled output, treat synthetic members as scaffolding rather than logic. They exist so that the JVM's access rules and linking model are satisfied, and they rarely tell you anything about the program's intent. A decompiler may or may not hide them, so if you see oddly named members with dollar signs that you never declared, check whether they carry the synthetic flag before spending time trying to understand why they exist. Recognizing them quickly keeps you focused on the real behavior.
Bridge Methods: The Same Method, Twice
Bridge methods are a specific kind of synthetic method that exists because of generics and type erasure. Suppose you implement Comparable<MyType> and write compareTo(MyType other). At runtime, generics are erased, so the interface actually declares compareTo(Object). To keep polymorphism working, the compiler generates a bridge method with the erased signature that casts its argument and delegates to your real method:
public int compareTo(MyType other) { /* your code */ }
// compiler-generated bridge:
public synthetic bridge int compareTo(Object other) {
return compareTo((MyType) other);
}
In decompiled output this shows up as two methods with the same name but different parameter types, one of which is a thin cast-and-forward. Both the synthetic and bridge access flags are set on the generated one. Covariant return types produce bridges too: if a subclass overrides a method and narrows the return type, the compiler adds a bridge with the original return type. When you see a duplicated method whose body is nothing but a cast and a call to its twin, you are looking at a bridge method. It is not dead code and it is not a decompiler bug; it is the mechanism that makes erased generics and covariant overrides work at the bytecode level.
Other Constructs That Look Strange
A few more patterns are worth recognizing. Enhanced switch and pattern matching increasingly rely on invokedynamic bootstraps such as those in java.lang.runtime.SwitchBootstraps, so a clean switch expression can decompile into a dynamic dispatch you have to read through the bootstrap. Records generate synthetic accessor methods and route their equals, hashCode, and toString through an invokedynamic call to java.lang.runtime.ObjectMethods. String switches compile into a two-stage structure that first switches on hashCode and then confirms with equals. Concatenation inside loops, try-with-resources unwinding into explicit close calls in finally blocks, and the numbered local variables that replace erased names all add to the sense that the output is machine-shaped. In every case the underlying trick is the same: a high-level convenience was lowered into primitives, and the decompiler is showing you the primitives.
A Practical Reading Strategy
When you open unfamiliar decompiled code, a consistent approach keeps you from being misled. First, ignore members with dollar signs and synthetic or bridge flags on the first pass; they are almost always scaffolding. Second, whenever you see invokedynamic, jump to the bootstrap method and identify the factory, because StringConcatFactory, LambdaMetafactory, SwitchBootstraps, and ObjectMethods each tell you exactly which language feature was in play. Third, treat duplicated methods that only cast and delegate as bridges and move on. Fourth, remember that missing variable names and erased generics are expected, not signs of corruption.
Our Java decompiler is built for exactly this kind of inspection. It runs entirely in your browser, so proprietary class files never leave your device, and it surfaces the invokedynamic and StringConcatFactory details that explain why modern output looks the way it does. Pair it with the structural background in Java bytecode structure explained and the pipeline overview in how Java class file decompilation works, and the strange shapes resolve into a predictable vocabulary.
The core insight worth keeping is simple. Decompiled Java looks weird because it is faithful, not because it is wrong. Modern language features are compiled into dynamic call sites and synthetic helpers that have no direct source syntax, so a decompiler either reconstructs the sugar or shows you the machinery underneath. Once you can name each pattern, invokedynamic call sites, factory bootstraps, desugared lambdas, synthetic bookkeeping, and bridge methods, reading modern decompiled output becomes a matter of translation rather than guesswork.