Written for someone who already knows how languages work and wants Java’s particular semantics: what the compiler guarantees, what erasure takes away, what the memory model actually promises, and where the sharp edges are. The first half is a guide to the language; the second is a searchable index of the language and the core API. Every API row was checked against a real JDK — type in the filter box to narrow it, hover any entry for the full declaration.
Guide synthesised from the Java Language Specification and API documentation, Herbert Schildt’s Java: The Complete Reference, Joshua Bloch’s Effective Java and Goetz et al, Java Concurrency in Practice. API signatures generated from openjdk 21.0.2 2024-01-16 LTS.javac does remarkably little: it type-checks, desugars and emits a class file
whose version number is the only compatibility gate. Everything else — verification,
linkage, optimisation, memory — happens in the VM, which is why the guide ends with a section
on it. Class files are forward-compatible and never backward: build with --release set
to the oldest runtime you must support, or the deploy fails with
UnsupportedClassVersionError.
| Tool | What it does |
|---|---|
javac | Compiler. -d out output dir, -cp classpath, --release N compile against N’s API, -Xlint:all every warning |
java | Launcher. -cp, -jar app.jar, -Dk=v system property, -X/-XX VM options, --enable-preview |
jshell | REPL. /list /vars /methods /edit /save /open /exit; /env -class-path x.jar |
jar | Archive. jar cfe app.jar Main -C out . creates an executable jar with Main-Class |
javadoc | API docs from /** … */ comments |
jdeps / jlink / jpackage | Dependency analysis · custom trimmed runtime image · native installer |
javap | Disassembler. javap java.lang.String prints the real signatures; -c prints bytecode |
jcmd / jfr | Live JVM control — thread dumps, heap dumps, Flight Recorder |
The classpath is a flat search list of directories and jars. The module path
(-p, Java 9+) carries named modules that declare what they need and what they expose.
Ordinary application code still runs happily on the classpath — it lands in the
unnamed module, which reads everything.
Homebrew installs JDKs under /opt/homebrew/opt/openjdk*; jenv puts shims
on PATH ahead of them, so java -version and a build tool can disagree if
JAVA_HOME was never exported. jenv enable-plugin export fixes that.
A source file is a sequence of Unicode characters holding, classically, one public top-level
type whose name matches the file name. Everything executable lives inside a type; there are no
free functions. Statements end in ;, blocks are { }, whitespace is
insignificant, and names are case-sensitive.
The launcher looks for public static void main(String[]) —
String... is the same erasure and works. The exit status comes from
System.exit, never from main. Java 25 finalised the alternative the
scripting world wanted: an implicitly declared class with an instance void main(), no
public, no static, no class header at all.
An identifier starts with a letter, _ or $ and continues with those plus
digits — Unicode letters count, so a variable named δ compiles. A lone
_ is a keyword since 9 and the unnamed variable since 21. $ is reserved by
convention for generated code.
| Kind | Convention | Example |
|---|---|---|
| package | all lower, reversed domain | com.example.util |
| class / interface / record / enum | UpperCamelCase, a noun | HttpClient |
| method / field / local | lowerCamelCase, verb for methods | parseHeader |
| constant | UPPER_SNAKE | MAX_VALUE |
| type parameter | one capital letter | T E K V R |
50 reserved words, plus three reserved literals (true false null). None may be used
as an identifier. Contextual keywords — var record sealed permits yield when
module requires exports opens uses provides — are keywords only where the grammar
expects them, so old code with a variable called record still compiles. All of them
are in the index.
goto and const are reserved but unimplemented: they exist only
so the compiler can reject them with a useful message.
Java has exactly eight primitive types. They are not objects, live on the stack or inside
an object, are never null, and their sizes are fixed by the specification on every
platform — unlike C, there is no implementation-defined width.
| Type | Bits | Range | Default | Wrapper |
|---|---|---|---|---|
byte | 8 | −128 … 127 | 0 | Byte |
short | 16 | −32,768 … 32,767 | 0 | Short |
int | 32 | −2,147,483,648 … 2,147,483,647 | 0 | Integer |
long | 64 | ±9.22 × 1018 | 0L | Long |
float | 32 | IEEE 754, ~7 digits | 0.0f | Float |
double | 64 | IEEE 754, ~15 digits | 0.0d | Double |
char | 16 | UTF-16 code unit, 0 … 65,535 — unsigned | '\0' | Character |
boolean | — | true / false only | false | Boolean |
Defaults apply to fields and array elements only. A local variable has no default: reading one before assignment is a compile error, not garbage.
Trap: 017 is 15, not 17. And unicode escapes are processed before
tokenising, so a
written inside a // comment really does end the
line and break the program.
Every arithmetic operand narrower than int is promoted to int first, so
byte a=1, b=2; byte c = a + b; does not compile — the sum is an
int. Compound assignment hides an implicit cast: c += 1 compiles where
c = c + 1 does not.
var — local type inference (10+)var is not dynamic typing and not a keyword — the type is fixed
at compile time. It is legal only for locals, for indices, try-with-resources
and lambda parameters; never for fields, parameters or return types.
Highest precedence first. Everything on one row binds equally and associates as shown. When in doubt, parenthesise — the compiler does not reward cleverness.
| Level | Operators | Assoc |
|---|---|---|
| postfix | x++ x-- | — |
| unary | ++x --x +x -x ~x !x | right |
| cast / new | (Type) x · new T() | right |
| multiplicative | * / % | left |
| additive | + - (and String +) | left |
| shift | << >> >>> | left |
| relational | < <= > >= instanceof | left |
| equality | == != | left |
| bitwise | & then ^ then | | left |
| logical | && then || — short-circuit | left |
| ternary | c ? a : b | right |
| assignment | = += -= *= /= %= &= ^= |= <<= >>= >>>= | right |
| lambda | -> — lower than everything above | right |
instanceof with a pattern (16+)The binding is flow-scoped: it exists exactly where the compiler can prove the test
succeeded — including everywhere after if (!(o instanceof String s)) return;.
C’s statements, minus the implicit conversions: a condition must be a genuine
boolean, so if (x = 5) and if (list) are compile errors
rather than idioms. Definite assignment is also checked — every path must assign a local
before it is read.
outer: — Java’s answer to gotocontinue outer;next iteration of the labelled loopreturn v;leave the method — finally still runsThe enhanced for hides the iterator, so you cannot get the index, cannot remove
elements (ConcurrentModificationException — use Iterator.remove() or
Collection.removeIf), and cannot walk two collections in lock-step. It works on any
array and on anything implementing Iterable, including your own classes.
There is no goto, no comma operator outside for, and no
statement-level unless. An empty statement is a bare ; —
while (c); is a legal infinite loop and a classic typo.
switch has grown three times. The old statement falls through and needs
break. The arrow form (14+) does not fall through. The expression form
produces a value and must be exhaustive. Pattern matching (21+) lets the labels be types
rather than constants.
| Rule | Detail |
|---|---|
| Selector types | Classic: byte short char int and their wrappers, String, enum. Pattern switch: any reference type |
| Labels | Must be compile-time constants in the classic form — not variables, not null unless you write case null |
| Exhaustive | Required for every expression switch and every pattern switch. An enum or sealed selector can be exhaustive with no default |
| Dominance | A broader pattern placed before a narrower one is a compile error — order from specific to general |
| Null | Without case null a null selector throws NullPointerException — in every form |
default alone never matches null; to catch both write
case null, default -> ….
An array is an object with a fixed length, created at run time, with covariant typing and a
bounds check on every access. The length is a final field, not a method:
a.length, but s.length() for a String and list.size() for a
collection — three spellings, one of Java’s oldest irritations.
String names[], age; is not — keep [] on the typeGenerics were deliberately made invariant to avoid exactly this:
List<Object> l = new ArrayList<String>() does not compile. Arrays kept
covariance for pre-generics compatibility, and pay for it with a type check on every store.
You cannot create an array of a generic type — new T[n] and
new List<String>[10] are errors, because erasure would leave the store check with
nothing to check. Use (T[]) new Object[n] with a suppressed warning, or a
List.
String is immutable and final. Every method that appears to change
one returns a new object and leaves the original untouched. That is what makes strings safely
shareable across threads, usable as map keys, and cacheable.
Never build a string with += inside a loop: every pass allocates a new
builder, copies everything and throws it away — O(n²). One StringBuilder
outside the loop is O(n).
The opening """ must be followed by a line terminator. Incidental whitespace
— the indentation common to every line and to the closing delimiter — is stripped,
so move the closing """ to control the left margin. Trailing spaces go too;
\s keeps one and a trailing \ suppresses the newline. No escaping of
" is needed.
A char is one UTF-16 code unit, not a character. Anything above U+FFFF —
emoji, many CJK extensions, historic scripts — is a surrogate pair and occupies two of them,
so an emoji has length() == 2. Use codePointAt, codePoints()
and codePointCount when correctness matters.
Single inheritance of state, multiple of interface, everything on the heap, everything by
reference. The parts worth knowing precisely are the initialisation order, what this
may safely do before construction finishes, and the fact that Java has no destructor at all.
| # | What runs |
|---|---|
| 1 | Memory allocated, every field set to its default (0 / false / null) |
| 2 | The superclass constructor — explicitly via super(…), or an implicit no-arg super() |
| 3 | Instance field initialisers and { } instance blocks, in source order |
| 4 | The rest of the constructor body |
Static fields and static { } blocks run once, in source order, when the class
is first initialised — not when it is loaded. Calling an overridable method from a
constructor is a classic bug: the subclass override runs before the subclass fields are
initialised, and sees nulls.
Only one varargs parameter, and it must be last. Overload resolution prefers a fixed-arity
match, so sum(1,2) picks sum(int,int) if it exists. Passing a generic
varargs array warns about heap pollution — answer it with @SafeVarargs.
The garbage collector reclaims unreachable objects at a time of its choosing. finalize()
is deprecated for removal and must not be used; System.gc() is a hint the JVM may
ignore. For anything holding a file handle, socket or lock, implement
AutoCloseable and let try-with-resources close it deterministically.
Dispatch is virtual by default — the opposite of C++ — for every instance method
that is not private, static or final. Fields and
static methods are the exception: they are hidden, resolved against the static
type, and that asymmetry is the source of most inheritance surprises.
| What it is | Chosen | |
|---|---|---|
| Override | Same name, same parameters, in a subclass | at run time, by the object’s class |
| Overload | Same name, different parameters | at compile time, by the static types |
| Hide | static methods and all fields | at compile time, by the reference type |
An override may widen access, must not narrow it, may return a subtype (covariant return), and
may throw fewer or narrower checked exceptions — never more. Always write
@Override: it turns a silent typo into a compile error.
The equals/hashCode contract is the single most consequential rule in the language: break it
and objects vanish from hash-based collections. Let the IDE generate the pair, use
Objects.equals and Objects.hash, or use a record, which
generates both correctly for you.
An interface is a contract: a set of method signatures a class promises to provide. A class may implement any number of them, which is how Java gets the useful part of multiple inheritance without inheriting state twice.
| Member | Implicitly | Since |
|---|---|---|
| method with no body | public abstract | 1.0 |
| field | public static final — a constant, never state | 1.0 |
default method | public, inherited, overridable | 8 |
static method | public, not inherited — call it on the interface | 8 |
private / private static | helper for the above, invisible outside | 9 |
default existsAdding a method to a published interface used to break every implementation on earth. Java 8
needed to add forEach to Iterable and stream() to
Collection, so it gained method bodies in interfaces. The rule when two supertypes
supply the same method: a class beats an interface, a more specific interface beats a
less specific one, and anything still ambiguous is a compile error you resolve by overriding
and calling Iface.super.method().
| Interface | Abstract class | |
|---|---|---|
| How many | many per class | exactly one |
| State | constants only | any fields, including mutable |
| Constructor | none | yes (called via super) |
| Members | public (plus private helpers) | any access level |
| Use it for | a capability: can be compared, can be closed | shared implementation among close relatives |
An interface with exactly one abstract method is functional and can be the target of a
lambda. @FunctionalInterface is optional but makes the compiler enforce it.
default, static and Object methods do not count toward the
one.
Four kinds, and the difference that matters is whether an instance of the outer class is needed.
| Kind | Declared | Holds outer this |
|---|---|---|
| static nested | static class N inside a class | no — just a namespaced class |
| inner | class N inside a class | yes — needs an outer instance |
| local | inside a method or block | yes, plus captured locals |
| anonymous | new Iface() { … } | yes, plus captured locals |
A local, anonymous or lambda body may read a local variable only if it is final or
effectively final — never reassigned after initialisation. The value is copied into the
object; there is no closure over the variable itself, so a later change could not be seen and the
compiler refuses rather than lie. Fields are captured through this, so they may
change freely.
A lambda is shorter, has no this of its own (this means the enclosing
instance) and creates no extra class file. An anonymous class can implement an interface with
several methods, can extend a class, and can hold state. Use a lambda for a functional interface;
reach for the anonymous class only when a lambda cannot express it.
A non-static inner class keeps a hard reference to its outer instance, which is a real
memory-leak source in long-lived listeners and in Runnables handed to an executor.
Make it static unless it genuinely needs the outer object.
An enum is a class whose instances are a fixed, named set created once by the JVM.
That makes it type-safe, ordered, serialisable, usable in switch, and the correct
way to write a singleton.
Each constant with a body is an anonymous subclass, which is why getClass() may
report Op$1 and why an enum with bodies is not final. An enum may
implement interfaces; it may never extend a class, because it already extends
java.lang.Enum.
Enums make the best singletons: enum Registry { INSTANCE; … } is
thread-safe on first use, immune to reflection, and correct across serialization — none of
which the double-checked-locking idiom guarantees on its own.
A record (16+) is a transparent carrier for immutable data. You declare the state;
the compiler writes the constructor, accessors, equals, hashCode and
toString. It is the answer to the 60-line data class.
| A record… | |
|---|---|
is final | and implicitly extends java.lang.Record — so it cannot extend anything else |
| may implement interfaces | and may be generic, nested, local (16+) or a member of a sealed hierarchy |
| has no other instance fields | only the components. Static fields are allowed |
| is shallowly immutable | a component of type List is still a mutable list — copy it in the compact constructor |
Deconstruction gives you the components as named variables in one step, and nests as deep
as the data. With a sealed hierarchy the compiler proves the switch is exhaustive and
tells you the day you add a fourth shape and forget a case — the one thing a chain of
instanceof never did.
sealed (17+) is controlled inheritance: the type names exactly which types may extend
or implement it. That closes the hierarchy, which lets the compiler reason about it —
exhaustive switches with no default, and a guarantee no stranger will add a fourth
case at run time.
| Every permitted subtype must be… | Meaning |
|---|---|
final | the hierarchy stops here (a record always qualifies) |
sealed | it names its own permitted subtypes and the closure continues |
non-sealed | a deliberate escape hatch: from here down, anyone may extend |
Permitted subtypes must be in the same module, or in the same package if the code is on the
classpath. permits may be omitted entirely when every subtype sits in the same source
file — the compiler infers the list.
This is the sum type ML and Haskell have had for decades, and it changes how you model data: closed set of shapes, open set of operations — the opposite trade-off from classic polymorphism, and the better one whenever the data is fixed and the operations keep arriving.
A package is a namespace and an access boundary; its name maps to a directory path. A module (9+) is a named set of packages that declares what it needs and what it exposes.
| Modifier | Class | Package | Subclass | World |
|---|---|---|---|---|
private | yes | — | — | — |
| (none) package-private | yes | yes | — | — |
protected | yes | yes | yes | — |
public | yes | yes | yes | yes |
The default is not public — it is package-private, and it is the right
default for anything that is not deliberately API. protected also grants package
access, which surprises people; and a subclass in another package may use protected
members only through a reference of its own type.
java.lang is imported automatically. Two star-imports offering the same simple name
is not an error until you use it; then qualify it or import the type explicitly.
Strong encapsulation is the point: a package that is not exportsed is
unreachable from outside the module even if its classes are public, and reflection
into a package that is not opensed fails. That is what closed off
sun.misc.Unsafe and friends.
An exception is an object thrown up the call stack until a matching catch handles it.
Java is nearly alone in having checked exceptions: the compiler insists you either handle
them or declare them.
Any AutoCloseable works. If the body and close() both throw, the
body’s exception propagates and close()’s is attached to it — retrievable
with getSuppressed(). The hand-written finally { in.close(); } loses the
original exception instead, which is exactly why this syntax exists.
Checked or unchecked? The rule that has aged well: checked when a careful caller can recover (a missing file, a refused connection), unchecked when the call itself was wrong (a null argument, a bad index). Never make a checked exception you expect nobody to catch.
Generics move a whole class of ClassCastExceptions from run time to compile time.
List<String> is a list that can only hold strings, and get returns a
String with no cast. The catch is erasure: the type arguments exist for the
compiler and are gone in the bytecode.
PECS — Producer Extends, Consumer Super. A method that reads from a collection
takes ? extends T; one that writes into it takes ? super T; one that does
both takes plain T. That is the whole of
Collections.copy(List<? super T> dst, List<? extends T> src).
List<String> is not a List<Object>, even though
String is an Object. If it were, you could add an Integer
through the wider reference. Arrays chose covariance and pay with a run-time check; generics chose
invariance and give you a compile error instead.
instanceof List<?> isvoid f(List<String>) / f(List<Integer>)same erasure — will not compile as an overload pairT.class · catch (T e)not allowed; a generic class may not extend Throwablestatic T field;no static member may use the class’s type parameterlist.getClass()ArrayList — the element type is not there at run timeA raw type (List with no argument) turns every check off and infects
the whole expression — it exists only for pre-2004 code. List<?> is the safe
way to say "some list". Generic varargs create an array of a non-reifiable type: the compiler warns
about heap pollution, and @SafeVarargs is your promise not to store into it.
A lambda is a functional-interface instance written as an expression, with parameter types
inferred from the target type. It is deliberately not sugar for an anonymous class: the
compiler emits an invokedynamic and lets LambdaMetafactory decide the
representation, so a non-capturing lambda is created once and there is no class file per
occurrence.
| Form | Example | Equivalent lambda |
|---|---|---|
Type::staticMethod | Integer::parseInt | s -> Integer.parseInt(s) |
object::instanceMethod | System.out::println | x -> System.out.println(x) |
Type::instanceMethod | String::toUpperCase | s -> s.toUpperCase() |
Type::new | ArrayList::new | () -> new ArrayList<>() |
Type[]::new | String[]::new | n -> new String[n] |
| Interface | Method | Shape |
|---|---|---|
Supplier<T> | get | () → T |
Consumer<T> | accept | T → void |
Function<T,R> | apply | T → R |
Predicate<T> | test | T → boolean |
UnaryOperator<T> | apply | T → T |
BiFunction<T,U,R> | apply | (T,U) → R |
BinaryOperator<T> | apply | (T,T) → T |
Runnable / Callable<V> | run / call | () → void · () → V throws |
Each has primitive specialisations — IntPredicate,
ToLongFunction, ObjIntConsumer and friends — purely to avoid boxing
in hot code. Most compose: f.andThen(g), f.compose(g),
p.and(q).negate(), Predicate.not(String::isBlank).
Interfaces are the contract, classes the trade-off. The implementation details that actually
change behaviour: ArrayList grows by half and copies; HashMap resizes at
a 0.75 load factor and converts a bucket to a red-black tree past eight entries, so a hash-collision
attack degrades to O(log n) rather than O(n); and every non-concurrent collection is
fail-fast — a modCount check that throws
ConcurrentModificationException on a best-effort basis, which makes it a bug detector,
never a synchronisation mechanism.
| Need | Use | Because |
|---|---|---|
| indexed list | ArrayList | O(1) get, amortised O(1) append. The default |
| queue / stack | ArrayDeque | faster than LinkedList and than the legacy Stack |
| set membership | HashSet | O(1). LinkedHashSet to keep insertion order |
| sorted set / map | TreeSet / TreeMap | O(log n), plus first/last/headSet/tailSet/floor/ceiling |
| key to value | HashMap | O(1). Keys need equals + hashCode |
| enum keys | EnumMap / EnumSet | array-backed, tiny and fast |
| shared across threads | ConcurrentHashMap, CopyOnWriteArrayList | never Collections.synchronizedX unless you also lock while iterating |
| get | add | contains | remove | |
|---|---|---|---|---|
ArrayList | O(1) | O(1)* | O(n) | O(n) |
LinkedList | O(n) | O(1) | O(n) | O(1) at a known node |
HashMap / HashSet | O(1) | O(1) | O(1) | O(1) |
TreeMap / TreeSet | O(log n) | O(log n) | O(log n) | O(log n) |
* amortised: appending grows the backing array by half when it fills.
HashMap degrades to O(log n) per bucket, not O(n), since 8 — long chains become
red-black trees.
A stream is a pipeline over a source, not a data structure: it stores nothing, does not modify the source, is lazy until a terminal operation runs, and can be consumed only once. It describes what to compute; the library decides how.
| Stage | Operations |
|---|---|
| Source | collection.stream() · Arrays.stream(a) · Stream.of(…) · IntStream.range(0,n) · Files.lines(p) · Stream.iterate / generate (infinite — must be limited) |
| Intermediate | filter map flatMap mapMulti peek distinct sorted limit skip takeWhile dropWhile |
| Terminal | forEach toList toArray collect reduce count min max anyMatch allMatch noneMatch findFirst findAny |
| Collectors | toList toSet toMap joining counting summingInt averagingDouble groupingBy partitioningBy mapping teeing |
Traps. A stream throws IllegalStateException if reused. peek
is for debugging and may be skipped entirely. Lambdas must not mutate shared state —
forEach writing into a list is how a parallel stream corrupts data; collect instead.
parallelStream() pays off only for large, CPU-bound, side-effect-free work on a
splittable source, and it shares one common ForkJoinPool with the rest of the process.
Collectors.toMap throws on a duplicate key unless you pass a merge function.
Java has had threads since 1.0 and a formal memory model since 5. The model is the part that matters: without a happens-before relationship between a write in one thread and a read in another, there is no guarantee the read ever sees the write — whatever the hardware seems to do in testing.
| Tool | For |
|---|---|
ExecutorService | a pool that outlives the tasks. newFixedThreadPool, newCachedThreadPool, newScheduledThreadPool, newVirtualThreadPerTaskExecutor (21+) |
CompletableFuture | chaining async work: supplyAsync thenApply thenCompose thenCombine exceptionally allOf |
ConcurrentHashMap | the default shared map. compute, merge and putIfAbsent are atomic |
AtomicInteger / LongAdder | lock-free counters; LongAdder wins under heavy contention |
ReentrantLock / ReadWriteLock | when you need tryLock, a timeout, fairness or several conditions |
CountDownLatch / Semaphore / CyclicBarrier | one-shot gate · permit counting · repeated rendezvous |
BlockingQueue | producer/consumer without writing a single wait |
A virtual thread is scheduled by the JVM onto a small pool of carrier threads. Blocking on IO
parks it and frees the carrier, so ordinary blocking code scales like async code without the
callback rewrite. Do not pool them — create one per task. Their weak point is
synchronized blocks that block while held, which pin the carrier; use a
ReentrantLock in that hot path instead.
The classics still bite: check-then-act is not atomic; i++ is three
operations; a lock taken in different orders deadlocks; Thread.stop and
suspend are gone; and InterruptedException means somebody asked you to
stop — either propagate it or restore the flag with
Thread.currentThread().interrupt().
Two generations coexist. java.io is streams and readers — still the way bytes
and characters actually move. java.nio.file (7+) is Path and
Files, and it is what you should be calling to touch the filesystem.
Everything in java.time is immutable and thread-safe, which is precisely what
java.util.Date, Calendar and SimpleDateFormat were not —
the last of those is a famous source of production corruption when shared between threads. Treat
all three as legacy; convert at the boundary with Date.toInstant().
The last Caused by is the root cause; frames elided as
… 23 more are shared with the trace above it. A trace can also lie by omission:
the JIT drops frames it inlined, and after enough throws from one site HotSpot replaces the
exception with a preallocated one that has no stack trace at all —
-XX:-OmitStackTraceInFastThrow turns that off when a production NPE arrives naked.
Since 14 the helpful NullPointerException names the exact sub-expression, which is why
-g (or at least -g:lines) matters in a deployed build.
The compiler is the smaller half of Java. Nearly everything that determines how a program actually behaves — when a class initialises, when a method gets compiled, whether an object is allocated at all — happens in the VM, at run time, and can be observed.
Initialisation is triggered by first active use — new, a static method
call, a non-constant static field read — and not by Class.forName(x, false, cl)
or by reading a static final compile-time constant, which was inlined into the caller
and does not touch the class at all. That inlining is a genuine deployment hazard: recompile the
constant’s owner alone and callers keep the old value.
A lambda is not an anonymous class. javac emits an invokedynamic whose
bootstrap (LambdaMetafactory) spins the implementing class on first execution, so
non-capturing lambdas are allocated once and reused, and there is no class file per lambda. String
concatenation with + compiles the same way since 9 (StringConcatFactory)
rather than to a StringBuilder chain — which is why the old “concatenation
is always slow” advice now applies only inside loops.
This is why a Java microbenchmark that does not warm up measures the interpreter, and why one that does may measure a loop the compiler deleted. Use JMH, which exists because getting this right by hand is genuinely hard.
Reachability has four strengths: strong, soft (cleared under memory pressure — a
cache), weak (cleared at the next GC — WeakHashMap) and phantom
(post-mortem notification, the supported replacement for finalisers, and what
Cleaner uses). None of them is a destructor: nothing guarantees a reference is ever
cleared, so anything scarce still needs close().
The classic production leaks are all reachability, not allocation: a static collection
that only grows, a ThreadLocal never removed on a pooled thread, a listener never
unregistered, an inner class pinning its outer instance, and a redeployed application whose old
ClassLoader stays reachable through one stray thread.
Since 9, a feature release every six months, and a long-term-support release every two
years — 8, 11, 17, 21, 25. Nearly all production Java sits on an LTS. A preview feature
needs --enable-preview at both compile and run time and may change or vanish in the
next release.
| Version | Year | What it brought |
|---|---|---|
| 5 | 2004 | Generics, enums, annotations, autoboxing, for-each, varargs |
| 7 | 2011 | try-with-resources, multi-catch, diamond, strings in switch, underscores in literals, NIO.2 |
| 8 LTS | 2014 | Lambdas, streams, method references, default methods, Optional, java.time |
| 9 | 2017 | Modules (JPMS), jshell, List.of, private interface methods |
| 10 | 2018 | var for locals |
| 11 LTS | 2018 | Standard HttpClient, String.strip/isBlank/lines/repeat, Files.readString, single-file source launch |
| 14 | 2020 | Switch expressions final, helpful NullPointerException |
| 15–16 | 2020–21 | Text blocks final · records and instanceof patterns final, Stream.toList |
| 17 LTS | 2021 | Sealed classes final, pattern matching groundwork, new pseudo-random generators |
| 18 | 2022 | UTF-8 as the default charset, everywhere |
| 21 LTS | 2023 | Virtual threads, pattern matching for switch, record patterns, sequenced collections |
| 22 | 2024 | Unnamed variables and patterns (_), Foreign Function & Memory API final |
| 23–24 | 2024–25 | Markdown javadoc, generational ZGC by default · stream gatherers and the Class-File API final |
| 25 LTS | 2025 | Compact source files and instance main, module import declarations, scoped values, flexible constructor bodies |
Class files are forward-compatible, never backward: a JDK 21 build will not load on a
JDK 17 runtime (UnsupportedClassVersionError). Compile with --release set
to the oldest runtime you must support.