I can tell you the exact Java version every codebase I’ve worked on runs, because I wrote most of them. Java 6 for J2ME at university in 2009. Java 7 and 8 at BluLogix and AFAQ. Java 12 somewhere in the middle. Java 17 at Bytro and onward. Kotlin as a daily driver since 2020.
This is not a language-spec recap. This is what actually changed for me as a working engineer at each inflection point — and what the dark years in between felt like when they arrived.
Java 6: the J2ME era, a.k.a. pain
My first Java was J2ME — Java Micro Edition — writing mobile apps for
feature phones at university around 2009. If you have never written J2ME,
let me paint the picture: no generics in the subset you got, no full
Collections API, a screen abstraction that varied by device, and a
packaging format (JAR as MIDlet) that made you intimately familiar
with bytes and sizes in a way modern engineers never are.
The constraint was the point. A J2ME app that exceeded 64 KB was rejected by certain operators. I wrote layout code that I cannot show anyone without an explanation. I debugged on the device because emulators were wrong enough to mislead you. I shipped apps that ran on Nokia handsets with 2MB of heap.
This is where I learned that the JVM’s abstractions are not free, and that you can make them cheap if you understand exactly what you’re paying. That lesson has been useful every day since.
Java 7: try-with-resources ended three whole bug classes
Java 7 shipped in 2011. By the time I was using it professionally at
BluLogix (2013), the upgrade I immediately loved was try-with-resources.
Before this, closing resources in Java was a specific kind of tax: a finally block that itself needed a null-check, because the resource might not have been opened, and the close might throw, and if the close threw while you were already in an exception, you’d swallow the original exception, and now you’re debugging a ghost. The pattern was well-known and universally annoying:
InputStream is = null;
try {
is = new FileInputStream(path);
// do stuff
} catch (IOException e) {
// handle
} finally {
if (is != null) {
try { is.close(); } catch (IOException ignored) {}
}
}
With Java 7:
try (InputStream is = new FileInputStream(path)) {
// do stuff
}
This is not a minor convenience. This is the compiler enforcing a resource lifecycle that used to require discipline and vigilance. I estimate I fixed at least a dozen resource-leak bugs in older codebases by migrating them to try-with-resources. I have not written one since.
Diamond operator (<>) shipped in Java 7 too — you could write
new ArrayList<>() instead of new ArrayList<String>(). Laughably small
now. At the time, it removed a specific friction I encountered 30 times
a day.
Java 8: the one that actually rewrote how I think
Java 8 (2014) is the biggest inflection point in my Java career. Not
because of lambdas in the abstract. Because of what lambdas enabled in
practice: the Streams API, Optional, and method references — and the
combination of all three changing how I reason about data transformation.
Before Java 8, processing a list in Java was a for-each loop, a local accumulator, null-checks on every access, and explicit collection construction. The code was correct but verbose in a way that made the intent hard to read at a glance.
After Java 8:
orders.stream()
.filter(o -> o.getStatus() == ACTIVE)
.map(Order::getTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
The logic is in the pipeline. You read it top to bottom and the shape of the data transformation is visible. The accumulation is implicit. The filter, map, reduce are named operations with clear semantics.
What I actually noticed: my code reviews started having different conversations. Instead of “this loop looks wrong, let’s trace it,” we were having “why are you materializing here, can you stay lazy?” and “this filter + map can be a flatMap.” The primitives shifted what we argued about.
Optional was divisive. Still is. I like it for return types where
absence is semantically meaningful and you want to force the caller to
confront that absence. I do not like Optional as a field type, a
method parameter, or a collection element. This is, I think, the
correct position. It took me two years to fully arrive at it.
The Java 9 modules era: legendary chaos
Java 9 shipped the Java Platform Module System (JPMS). In theory: a way to declare explicit module boundaries, control what packages your module exposes, and build a more maintainable large-scale Java application. In practice: a six-year period of pain for anyone with dependencies.
The problem was the ecosystem. JPMS required every library to either
ship as a proper named module or get treated as an “automatic module”
with heuristically derived module names that could change between builds.
The majority of the Java ecosystem was not ready. The result: adding the
--module-path flag to your project and then spending a day resolving
split-package conflicts, unnamed modules, and --add-opens incantations
for reflection that libraries relied on.
I have written --add-opens java.base/java.lang=ALL-UNNAMED more times
than I can count. Every time I do it, a small part of my soul leaves.
Java 10, 11, 12 each improved things at the margins. Local variable type
inference (var) arrived in Java 10 and I adopted it immediately — not
because it saves characters, but because it stops you from writing the
type twice when the constructor already says what it is:
var connections = new HashMap<String, ConnectionPool>();
The module situation improved slowly. Spring, Hibernate, and the other majors got their acts together. By the time I was on Java 17, JPMS drama was mostly background noise rather than active incident.
Java 17: finally-modern-Java
Java 17 is an LTS release (September 2021) and it is the first version where I look at Java as a whole and think: this is a modern language.
Records:
record Point(double x, double y) {}
That’s it. Immutable value type, equals, hashCode, toString all
generated. No Lombok. No 40-line class. Just the declaration of what the
data is. I use records everywhere now — DTOs, command objects, event
payloads, domain value objects. The boilerplate cost of Java value types
went from “annoying” to “zero.”
Sealed classes + pattern matching:
sealed interface Shape permits Circle, Rectangle {}
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
The compiler knows Shape can only be Circle or Rectangle. If you
add a third permits type and forget to handle it in the switch, you
get a compile error. This is algebraic data types landing in Java, 25
years after ML had them. Better late than never — and the Java version
is practical, idiomatic, and integrates cleanly with the existing type
system.
Text blocks — multi-line string literals without escape madness — arrived in Java 15 and I use them constantly for SQL, JSON snippets in tests, and any string that spans more than two lines.
Kotlin’s rise alongside
I want to be honest here: by the time Java 17 arrived with records and
sealed types, I had been writing Kotlin for two years at Bytro and it
had recalibrated my expectations. Data classes. Null safety built into
the type system. Extension functions. Coroutines for async. Smart casts
that eliminate half your instanceof chains.
Kotlin is Java’s shadow self — what Java would have been if it had been designed from scratch around 2012 rather than 1995. It runs on the JVM, it interops with Java almost perfectly, and it fixes most of the things that Java takes until Java 17 to fix.
I write Kotlin when I want to write Kotlin. I write Java 17 when the project is Java and I don’t want to introduce a language dependency. Both are daily drivers. The JVM under both is the same JVM, which means the GC tuning knowledge, the heap diagnostic skills, the thread dump reading, the JMX monitoring — it all transfers. The JVM is the durable investment; Java and Kotlin are the language layers on top of it.
What 15 years on the JVM actually teaches you
The honest answer: you stop being surprised by allocations and start
being strategic about them. You develop opinions about garbage collector
pauses that you can defend with numbers. You understand why String
interning matters and exactly when it matters and when it doesn’t.
The language improvements are real. Java 17 is dramatically better to write than Java 6. But the underlying skills — understanding the cost model, reading JVM diagnostics, reasoning about thread safety, designing for testability — those don’t change as much as the syntax does.
J2ME in 2009 taught me to count bytes. Streams in 2014 taught me to think in pipelines. Records in 2021 told me the language was finally catching up to how I’d been thinking about value types for years.
I’m still here. The JVM is still here. Java is on version 21 now (LTS). I have opinions about virtual threads (Project Loom is real and good). I will have opinions about whatever ships in Java 25.
This is, as far as I can tell, just what a 15-year relationship with a platform looks like.