Java gives you two kinds of value, one calling convention, and a runtime that manages memory but not your invariants. Almost every bug that survives review comes from misreading one of those three: treating a reference as if it were the object, trusting a check the compiler erased before the code ran, or assuming a field one thread wrote is a field another can see. This is the shape rather than a syntax tour — what the compiler guarantees, what it deliberately does not, and which ordinary-looking operations do something other than what they read like.
Each chapter opens with the short version. Tap one to read the detail.
Values: two kinds, and the arithmetic on one of them
~2 min
Java has exactly two kinds of value: eight primitives, and references to objects. Every argument is copied on the way in — the reference included — so a method can change what an object contains, never which object the caller's variable names.
The eight primitives (boolean, byte, short, char, int, long, float, double) hold their value directly. Everything else is a reference: the variable holds a pointer to an object stored elsewhere, not the object.
Arguments are copied into fresh parameter variables, so reassigning a parameter changes only the method's own copy. What misleads people is that a copied reference still names the same object, so mutation through it is visible everywhere. final splits identically: it fixes which object a variable names, not its contents.
Boxing bridges the two worlds and carries the sharpest trap: Integer is an object, so == compares identity, and caching is guaranteed only for constants from −128 to 127.
static void reassign(Box b) { b = new Box(99); } // caller still sees 1
static void mutate(Box b) { b.n = 99; } // caller now sees 99
Integer x = 127, y = 127; // x == y -> true; at 128, false
Integer n = null; int v = n; // NullPointerException on unboxing
byte b = 127; b += 1; // -128: += hides a narrowing cast
double d = 0.1 + 0.2; // 0.30000000000000004
Primitive arithmetic is machine arithmetic. byte, short and char promote to int first, so b = b + 1 does not compile while b += 1 does: compound assignment carries an implicit cast, and the shorter form truncates where the longer refused. Overflow wraps in silence, and integer division truncates toward zero.
Rule: == asks "the same object?" — use equals for values, and neither for floating point.
Construction and dispatch: what is chosen when
~2 min
Instance methods are chosen by the object's runtime type; fields, static methods and which overload you called are chosen by the compile-time type. That asymmetry is why an overridable method called from a constructor can reach an unassigned subclass field.
A superclass constructor runs to completion before any subclass field initializer does. So if the superclass constructor calls a method the subclass overrides, the override runs against fields still at their defaults.
class Base { Base() { System.out.println(size()); } int size() { return -1; } }
class Sub extends Base {
List<String> items = new ArrayList<>(List.of("a", "b"));
@Override int size() { return items.size(); } // items is still null here
}
new Sub(); // NullPointerException, from a constructor that looks harmless
Fields do not override — they hide. Declare name in both classes and both exist in every instance; which one you read depends on the reference's type, not the object. Static methods hide the same way, and overload resolution is fixed at compile time: pass a String through an Object variable and the Object overload wins.
Interfaces give multiple inheritance of type without multiple inheritance of state — their fields are implicitly constant. A default method supplies behavior, but inheriting two unrelated defaults with one signature is a compile error you resolve with Left.super.m(). When overriding, a return type may narrow and a throws clause may only shrink.
Rule: never call an overridable method from a constructor — the object is not finished yet.
The equals and hashCode contract
~2 min
These are one contract, not two methods. Override equals alone and your object goes into a hash-based collection and cannot be found again — and mutating a key after inserting it does the same thing to a perfectly correct implementation.
equals must be reflexive, symmetric, transitive and consistent, and x.equals(null) must be false. hashCode must stay the same while the fields equals reads are unchanged, and — the load-bearing clause — equal objects must produce equal hash codes. Unequal objects may collide; that costs only performance.
Hash-based collections consult hashCode first to pick a bucket, equals only within it. An object with a good equals and the inherited identity hashCode therefore lands in an unpredictable bucket, and a lookup for an equal object searches a different one.
Set<Key> set = new HashSet<>();
Key k = new Key(1);
set.add(k);
set.contains(new Key(1)); // false when only equals() is overridden
k.id = 2; // now with BOTH overridden, but mutated:
set.contains(k); // false — and the set still reports size 1
The second failure is the more dangerous one, because the implementation is correct: the key was filed under the old hash and nothing re-files it. An entry that can never be found and never be removed is a leak with no stack trace.
Records generate both methods from their components, which makes them the cheapest correct implementation available. Writing them by hand, note that getClass() comparison rejects subclasses while instanceof accepts them — choose deliberately, since symmetry depends on it.
Rule: a hash key must be immutable in every field equals reads.
Modeling data: records, enums, sealed types, and the modern switch
~2 min
A record is a transparent carrier for its components — the compiler writes the constructor, accessors, equals, hashCode and toString. Its immutability is shallow: hand it a mutable list and callers can still change what is inside.
record Point(int x, int y) {} generates a canonical constructor, accessors named x() rather than getX(), and value-based equals, hashCode and toString; records are implicitly final. The compact constructor — the parameter list omitted — is where validation and defensive copying belong, because it runs before the fields are assigned.
record Holder(List<String> items) {
Holder { items = List.copyOf(items); } // without this line:
}
// Holder h = new Holder(new ArrayList<>(List.of("x")));
// h.items().add("smuggled"); -> Holder[items=[x, smuggled]]
Enum constants are singletons, so == is the right comparison, and each may carry its own method body. ordinal() returns declaration position, which changes the moment someone reorders the constants — never persist it or send it over a wire. A sealed interface with a permits clause closes the hierarchy, which is what lets a Java 21 pattern switch over it be checked for exhaustiveness.
Switch has two forms. The colon form falls through — matching case 1 in a three-case block runs all three bodies unless you break — while the arrow form runs one branch only. A switch used as an expression must be exhaustive, and a block-bodied arm yields its value.
Rule: copy every mutable component in the compact constructor, or the record is a promise you did not keep.
Collections: choosing by the operation you repeat
~2 min
Pick by the operation you do most: ArrayList for indexed reads, HashMap for lookup by key, LinkedHashMap when order must be insertion order, TreeMap when it must be sorted. Fail-fast iteration is a best-effort bug detector, never a guarantee.
Arrays came first and show their age. length is a field; equals compares identity, so you need Arrays.equals; toString prints a type tag and a hash. Arrays are also covariant — a String[] is usable as an Object[] — and the store check keeping that honest runs at runtime, throwing ArrayStoreException. Arrays.asList is a fixed-size view: set works, add throws.
HashMap gives constant-time lookup when hash codes disperse well and guarantees nothing about iteration order, not even that it stays the same over time; LinkedHashMap preserves insertion order and TreeMap sorts by compareTo. HashMap accepts a null key and null values, while the List.of and Map.of factories reject null with a NullPointerException and refuse every mutation.
The iterator contract burns people twice.
List<String> l = new ArrayList<>(List.of("a", "b", "c"));
for (String x : l) if (x.equals("a")) l.remove(x); // ConcurrentModificationException
for (String x : l) if (x.equals("b")) l.remove(x); // no exception at all
l.removeIf(x -> x.equals("b")); // the correct way
Removing the second-to-last element ends the loop early, so the check never runs. The documentation is explicit that fail-fast behavior is best-effort and exists only to detect bugs; never build logic on it.
One last distinction: Collections.unmodifiableList wraps a live list, so later writes to the original show through the "unmodifiable" wrapper. List.copyOf snapshots.
Rule: mutate a collection during iteration only through the iterator itself or removeIf.
Generics and erasure
~2 min
Type arguments are checked at compile time and erased before the code runs, so at runtime there is no List<String> — only List. Everything odd about generics follows: no generic arrays, no instanceof List<String>, and raw types that switch checking off.
Erasure replaces each type parameter with its bound, or Object when unbounded, and inserts the casts needed to stay type-safe. So generics cost nothing at runtime — and equally exist for nothing at runtime.
boolean same = new ArrayList<String>().getClass()
== new ArrayList<Integer>().getClass(); // true: one class
List raw = new ArrayList<Integer>();
raw.add("not an integer"); // compiles, with a warning
List<Integer> ints = raw;
int v = ints.get(0); // ClassCastException, far from the mistake
That distance is the real cost: a raw type turns off checking for every operation on that reference, and the failure surfaces at some later read, in code that did nothing wrong.
Generic types are invariant — a List<Integer> is not a List<Number> — where arrays chose the opposite and pay at runtime instead. Wildcards restore flexibility one direction at a time: read from a ? extends, write to a ? super. You cannot add to a List<? extends Number>, because the compiler cannot know which subtype it holds.
Two restrictions fall straight out of erasure: new List<String>[10] is rejected, and you cannot test a parameterized type with instanceof, because the information is gone.
Rule: use a wildcard when a parameter only produces or only consumes, and treat every raw type as unchecked code.
Failure: checked, unchecked, and the two habits that hurt
~2 min
Checked exceptions are the ones the compiler makes you catch or declare; RuntimeException, Error and their subclasses are exempt. The damage in practice comes from catching something and doing nothing with it, and from letting finally complete abruptly.
Throwable splits into Error — conditions a program is not expected to recover from — and Exception, whose subtree RuntimeException is exempt from checking. The stated reasoning is worth knowing: so many ordinary operations can raise a runtime exception that declaring them would not meaningfully help establish correctness.
finally is where control flow goes to die. If it completes abruptly — a return, a throw, even a break — its reason replaces whatever the try block was doing, and an exception in flight is discarded.
static int swallow() {
try { throw new RuntimeException("real failure"); }
finally { return 42; } // returns 42; the exception vanishes silently
}
Try-with-resources exists so you never write that block. Resources close in reverse order of declaration, and if the body fails and a close fails too, the body's exception is thrown with the close failures attached as suppressed rather than replacing it — strictly more than a hand-written finally keeps.
One smaller rule the compiler enforces: catch clauses go most-specific first, since catching a superclass before its subclass makes the later clause unreachable. And when you catch something you cannot handle, wrap it and pass the original in as the cause — a stack trace that stops at your own re-throw has destroyed the evidence.
Rule: never return or throw from finally, and use try-with-resources for anything closable.
Lambdas, streams, and Optional
~2 min
A lambda is an instance of a functional interface — one abstract method — and captures only effectively final locals. Stream pipelines are lazy and single-use: nothing runs until a terminal operation, and an operation the implementation proves unnecessary may never run.
A lambda needs a target type with exactly one abstract method; default and static methods do not count against that. java.util.function supplies the standard shapes — Function, Consumer, Supplier, Predicate — plus primitive specializations like IntPredicate that exist purely to avoid boxing. Captured locals must be effectively final: a lambda captures the value, not the variable.
A stream is a pipeline description, not a collection. Intermediate operations build it lazily, nothing traverses the source until a terminal operation runs, and afterwards the stream is spent — reusing it throws IllegalStateException. Because the implementation may skip work it can prove unnecessary, side effects in your lambdas are not merely bad style: they may never happen.
Stream.of(1, 2, 3).peek(System.out::println).count(); // prints nothing
persons.stream().collect(toMap(Person::city, Person::name));
// IllegalStateException: Duplicate key Paris — use the 3-arg merge form
count() computed the size without running the pipeline. toMap is the other everyday trap: duplicate keys throw rather than overwrite, and null values are rejected even though HashMap accepts them.
Optional is a return type for "there may be no result" — not a field or parameter, and never itself null. Its sharpest edge: orElse evaluates its argument eagerly even when a value is present, so orElse(expensiveDefault()) pays every time. orElseGet takes a supplier and runs it only when needed.
Rule: keep stream lambdas free of side effects, and prefer orElseGet whenever the fallback costs anything.
Concurrency: visibility first, then mutual exclusion
~2 min
The hard part is not interleaving, it is visibility: with no happens-before edge between two threads, a write by one may never be seen by the other — not late, never. volatile and synchronized create those edges; i++ creates none.
Two accesses to one field from different threads, at least one a write, with no happens-before relationship between them, are a data race. The edges you can rely on are few: unlocking a monitor to locking it, writing a volatile field to reading it, starting a thread to its first action, and a thread's last action to another thread that observes it via join. Without one, a reader may spin forever on a flag another thread already set.
static boolean flag = false; // not volatile
new Thread(() -> { while (!flag) { } }).start();
flag = true; // reader can loop forever
static int n = 0; // four threads, 200_000 increments each
// observed: 543_779 of 800_000. AtomicInteger gives exactly 800_000.
synchronized supplies mutual exclusion and the memory edge together, and locks are reentrant. Deadlock comes from two threads taking the same two locks in different orders, so give every lock a global order. And wait belongs inside a loop re-testing its condition: spurious wake-ups are explicitly permitted.
Prefer the concurrency library, whose collections and executors extend the same happens-before guarantees. Watch two silences: a task submitted to an executor swallows its exception until someone calls get(), and a failed CompletableFuture stays quiet unless you join or handle it. Java 21 adds virtual threads, scheduled by the runtime rather than the operating system and cheap enough to have millions — aimed at blocking work, not computation.
Rule: share nothing mutable; when you must, publish it through a lock, a volatile, or an immutable object.
The runtime: how code is found, loaded, and freed
~2 min
Each thread gets its own stack; every object lives on one shared heap reclaimed by reachability. Classes load lazily and initialize on first real use — which is why a failing static initializer reports one error the first time and a different, more confusing one afterwards.
The Java virtual machine (JVM) gives each thread a private stack of frames — exhaust it and you get StackOverflowError — and shares one heap for every object and array. Objects are never explicitly freed: a collector reclaims what is unreachable, and the specification mandates no particular algorithm. So a "memory leak" here is always a reachability leak — usually a long-lived collection or cache holding objects you are finished with.
Classes arrive in three phases — loading finds the bytes, linking verifies and resolves them, initialization runs the static initializer — and a class's identity at runtime is its binary name plus its defining loader, so one class file loaded twice gives two incompatible types.
Initialization is triggered by first real use: constructing an instance, calling a static method, reading a non-constant static field. A compile-time constant is not real use — the compiler copies its value into the caller.
class Config { static final int LIMIT = 10; } // change to 999,
// recompile Config alone, and callers still print 10 — the old value was inlined
That is a genuine deployment bug, and the same laziness explains a failing static block: the first use throws ExceptionInInitializerError with your real cause attached, and every use after throws NoClassDefFoundError, naming a class that is present and simply unusable.
Packaging draws the outer boundary: in a module exports grants normal access to a package and opens grants reflective access, and a package that is neither is unreachable from outside whatever its members declare.
Rule: read NoClassDefFoundError as "initialisation already failed", and go looking for the first exception.
Text, files and clocks: where machine defaults leak in
~2 min
Strings are immutable and literals are pooled, so == sometimes works — which is what makes it a bug. Beyond that, everything reading a machine default (the charset, the locale, the clock and zone) behaves one way on your laptop and another in production.
Strings never change; every "modification" returns a new object, and identical literals are the same pooled object while one built at run time is not. split is the most misread method in the class — its argument is a regular expression, so "a.b.c".split(".") returns an empty array. Text is stored in the UTF-16 (Unicode Transformation Format) encoding, where a character outside the basic range takes two char values: "a😀b".length() is 4 while codePointCount is 3.
Encoding is the classic silent corruption. Files text methods use UTF-8 whatever the machine says and fail loudly on invalid bytes, while a reader built without a charset takes the platform default and yields replacement characters — same input, corrupted quietly. Case conversion has the same shape: toUpperCase() uses the default locale, where Turkish turns i into a dotted İ.
Files.readString(latin1File); // MalformedInputException — loud
new InputStreamReader(in).read(); // platform default — silent "caf?"
LocalDate.of(2007, 3, 31).plusMonths(1); // 2007-04-30, clamped
// ...and .minusMonths(1) gives 2007-03-30. Not reversible.
// Europe/Paris, night of the autumn change:
// 23:00 plusDays(1) -> 23:00 next day; plusHours(24) -> 22:00
java.time types are immutable and value-based, so compare with equals. Instant is a point on the timeline, LocalDateTime a wall-clock reading with no zone, and ZonedDateTime the only one that survives a daylight-saving change. Take the current time from an injected Clock and all of it becomes testable.
Rule: pass the charset, the locale, the zone and the clock explicitly — a default is a dependency you never declared.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.