Java Generics Wildcards and PECS
Use ? extends T for producers and ? super T for consumers, and understand why List<String> is not a List<Object>.
Core Java for backend interviews: OOP, equals/hashCode, collections internals, the JVM and GC, concurrency, streams and modern Java features.
Official reference: dev.java
Use ? extends T for producers and ? super T for consumers, and understand why List<String> is not a List<Object>.
ClassNotFoundException vs NoClassDefFoundError in Java: what each error means, why classes go missing at runtime and how to fix the classpath.
Fix java.util.ConcurrentModificationException when removing items in a loop: use removeIf or Iterator.remove, and see when concurrent lists help.
Fix java.lang.NullPointerException by reading the helpful message, finding the null in the top stack frame and validating inputs early.
Fix java.lang.OutOfMemoryError: Java heap space by checking -Xmx, capturing a heap dump and finding what retains memory in Eclipse MAT.
Fix UnsupportedClassVersionError (class file version 65.0 vs 61.0) by matching the runtime JDK to your build or compiling with javac --release.
Why Arrays.asList and List.of throw UnsupportedOperationException on add or remove, and how to choose a mutable copy or an immutable list.
How ArrayList grows its backing array by 1.5x, why add is amortized O(1), and when ensureCapacity or a different list actually helps.
Why TreeSet and TreeMap drop elements when a Comparator disagrees with equals, and how to write comparators that obey the full contract.
How exceptions travel through CompletableFuture chains, when to use exceptionally, handle or whenComplete, and why causes arrive wrapped.
Why check-then-act on ConcurrentHashMap still races, and how merge, compute and putIfAbsent make compound updates atomic per key.
The equals and hashCode contract explained: why equal objects need equal hash codes, how HashMap uses both, and how to write a correct pair.
Checked vs unchecked exceptions in Java, where to catch, when to wrap with a cause, and how to avoid swallowing failures at the wrong layer.
How shutdown, shutdownNow and awaitTermination differ in ExecutorService, and the safe pattern for stopping thread pools without losing work.
How Java memory leaks happen despite garbage collection: GC roots, reachability, retained size, and finding the reference chain in a heap dump.
How HashMap works internally: hash spreading, bucket index, collisions, load factor resizing and the Java 8 switch from chains to trees.
How to make a class immutable in Java: final fields, no setters, defensive copies of mutable inputs and outputs, and safe publication.
When to use Instant, LocalDateTime, ZonedDateTime and OffsetDateTime, and how daylight saving gaps and overlaps break naive date code.
When to return Optional in Java, why get and isPresent checks miss the point, and why Optional fields, parameters and collections are a smell.
What Java records generate, why they are only shallowly immutable, how compact constructors copy and validate, and where records do not fit.
Why Java stream intermediate operations do nothing until a terminal operation runs, how elements flow one by one, and where laziness bites.
Why reusing a Java stream throws IllegalStateException, how to share data with a Supplier or a collection, and what toList returns.
Java String equals vs ==: why == compares references, how the string pool and compile-time constants make it seem to work, and safe comparisons.
What synchronized guarantees in Java: mutual exclusion, visibility and reentrancy, plus how to guard multi-field invariants with one lock.
Why one Java thread may never see another's write, what volatile guarantees, and how happens-before rules make publication safe.
How try-with-resources closes resources in reverse order, keeps the primary exception with suppressed close failures, and beats finally.
When Java 21 virtual threads help, why they do not speed up CPU-bound work, and how pinning, pooling and resource limits trip teams up.
The four pillars are encapsulation, inheritance, polymorphism and abstraction.
private fields exposed through methods that can enforce invariants.extends. Java allows single inheritance of classes, but a class can implement many interfaces.List rather than on ArrayList.I'd also mention that composition is often preferred over inheritance, because deep class hierarchies couple classes tightly.
extends, single class inheritance onlyLikely follow-up: Why does Java not support multiple inheritance of classes? · Is encapsulation the same as abstraction?
They nest inside each other.
.class files portable: "write once, run anywhere".java.lang, java.util and so on): everything needed to run a program.javac, jar, javadoc, jshell, jlink, and diagnostic tools like jcmd.One modern nuance: since JDK 11, Oracle no longer ships a separate JRE. Instead, jlink (Java 9+) builds a trimmed runtime image containing only the modules your application needs.
javac and jarjlink builds custom runtime imagesLikely follow-up: Is the JVM itself platform-independent? · What does the JIT compiler do?
== and equals()? What does this code print?easy== compares values for primitives but references for objects: it's true only if both sides point to the very same object. equals() compares logical equality, and what that means is up to the class.
Object.equals is just ==, so a class only gets value semantics if it overrides it. String, the wrapper types, collections and records all do.
So the snippet prints false, true, false, true. s2 is a new object, so == is false while equals is true. The Integer case is the classic trap: autoboxing uses a cache that's only guaranteed for -128 to 127, so with default JVM settings 1000 produces two different boxes.
Rules of thumb: use == for primitives, enums and deliberate identity checks; use equals for everything else, or Objects.equals(a, b) when either side may be null.
String s1 = "java";
String s2 = new String("java");
System.out.println(s1 == s2); // ?
System.out.println(s1.equals(s2)); // ?
Integer x = 1000, y = 1000;
System.out.println(x == y); // ?
System.out.println(x.equals(y)); // ?== compares references for objects, values for primitivesequals compares logical content when overriddenObject.equals is identityObjects.equals for null-safe comparisonLikely follow-up: Why is == safe for enums? · What happens if you override equals but not hashCode?
String immutable in Java, and what is the string pool?easyA String can't change once created: methods like toUpperCase() or concat() return new strings, and the class is final, so no subclass can add mutability. The main reasons:
"hello" in a program can share one instance. Sharing is only safe because nobody can modify it.HashMap keys.The pool is a JVM-managed table of canonical strings. String literals and compile-time constants go there automatically, and intern() looks up or adds others. Since Java 7 it lives on the main heap rather than in PermGen, so unused pooled strings can be garbage-collected.
finalLikely follow-up: Why is a char[] sometimes preferred over a String for passwords? · What does intern() do?
HashMap work internally? Walk me through put and get.midA HashMap is an array of buckets. Each bucket holds a chain of nodes with the key, value, hash and a next pointer.
put calls key.hashCode() and spreads it by XOR-ing the high 16 bits into the low 16. The index is (n - 1) & hash, which works because the capacity n is always a power of two.equals(): a match replaces the value, else a node is appended.i or moves to i + oldCapacity.get repeats the hash and index steps, then matches with equals(). One null key is allowed.
(n - 1) & hashequalsnull key allowed; not thread-safeLikely follow-up: Why must the capacity be a power of two? · What happens if a key is mutated after insertion?
equals() and hashCode(), and what breaks if you override only one of them?midequals must be reflexive, symmetric, transitive and consistent, and x.equals(null) must return false. The link to hashCode:
equals don't change.Hash-based collections depend on this: they use the hash to find the bucket and only then call equals. In the snippet, Point overrides equals but inherits the identity-based hashCode, so two equal points almost certainly have different hash codes and contains returns false.
The fix is to override both from the same fields, for example Objects.hash(x, y), or to use a record, which generates both. And keep keys immutable: mutating a field after insertion leaves the entry filed under its old hash, where lookups can't find it.
class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
// hashCode() not overridden!
}
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
System.out.println(set.contains(new Point(1, 2))); // almost always falseLikely follow-up: Why is using getClass() versus instanceof in equals debated? · What does a record generate for you?
default, static and private methods?easySince Java 8 the line is blurrier, but the core differences remain:
public static final), never instance state.private helper methods, allowed since Java 9.default methods, so an interface can grow without breaking implementers, and static methods for utilities, which are called on the interface and not inherited by implementing classes.I use an interface for a capability or contract that unrelated classes share, like Comparable or Runnable. I use an abstract class when related classes share state and a partly implemented template, such as a base class that manages a connection and lets subclasses fill in one step.
default and static interface methods since Java 8private interface methods since Java 9Likely follow-up: What happens if a class inherits the same default method from two interfaces? · Can an interface have a constructor?
Overloading means several methods with the same name but different parameter lists. The compiler picks one at compile time from the static types of the arguments. The return type alone can't distinguish overloads.
Overriding means a subclass redefines an inherited instance method with the same signature. The JVM picks the implementation at runtime from the object's actual class: dynamic dispatch.
So the snippet prints Woof, then animal. a.speak() dispatches on the runtime type, Dog, but greet(a) was resolved at compile time, when the type of a is Animal.
Overriding rules: the return type may be covariant (a subtype), access can't be narrowed, the method can't throw broader checked exceptions, and static, private and final methods can't be overridden. A static method with the same signature hides the parent's instead. Always add @Override so the compiler catches signature mistakes.
class Animal { void speak() { System.out.println("..."); } }
class Dog extends Animal {
@Override void speak() { System.out.println("Woof"); }
}
static void greet(Animal a) { System.out.println("animal"); }
static void greet(Dog d) { System.out.println("dog"); }
Animal a = new Dog();
a.speak(); // ?
greet(a); // ?Likely follow-up: Can you overload main? · Why are fields not polymorphic?
ArrayList versus LinkedList?easyArrayList is backed by a resizable array. get(i) is O(1), appending is amortized O(1), and when the array is full it grows by about 50% and copies the elements over. Inserting or removing in the middle is O(n) because later elements shift, but that shift is a fast bulk copy over contiguous memory.
LinkedList is a doubly linked list that also implements Deque. Adding or removing at either end, or at an iterator's position, is O(1), but get(i) is O(n) because it walks from the nearer end. Every element costs a separate node object with two extra pointers, and nodes are scattered in memory, which hurts CPU cache performance.
In practice ArrayList wins almost every real workload, even ones that look insert-heavy, because finding the position in a linked list is itself O(n). For a queue or stack I'd choose ArrayDeque rather than LinkedList.
Likely follow-up: When would LinkedList actually be the better choice? · What is the default capacity of an ArrayList?
Checked exceptions are subclasses of Exception that aren't RuntimeExceptions, such as IOException or SQLException. The compiler forces you to handle them: catch them or declare them with throws. They model recoverable conditions outside the program's control.
Unchecked exceptions are RuntimeException and its subclasses, like NullPointerException, IllegalArgumentException or IndexOutOfBoundsException. They usually signal programming bugs, and the compiler doesn't require handling. Errors such as OutOfMemoryError and StackOverflowError are unchecked too; they signal serious problems that applications generally shouldn't catch.
The hierarchy has Throwable at the top, with Exception and Error under it. One rule worth knowing: an overriding method can't declare broader checked exceptions than the method it overrides. Many modern APIs and frameworks, Spring included, favour unchecked exceptions, because checked ones add boilerplate and don't compose well with lambdas.
throwsRuntimeException and its subclassesError is unchecked and usually not caughtThrowable → Exception / ErrorLikely follow-up: When would you create a custom checked exception? · How do you handle a checked exception inside a lambda?
final, finally and finalize()?easyThey're unrelated apart from the spelling.
final is a modifier. A final variable can be assigned only once; for a reference, the reference is fixed but the object it points to can still change. A final method can't be overridden, and a final class, like String, can't be subclassed.finally is a block after try/catch that runs whether the try block completes normally, returns or throws. It's meant for cleanup, though try-with-resources is usually better. It's skipped only in extreme cases, such as System.exit(), a JVM crash, or a try block that never finishes.finalize() is a method on Object that the garbage collector might call before reclaiming an object. There's no guarantee when, or even whether, it runs; it slows GC and can resurrect objects. It was deprecated in Java 9 and deprecated for removal in Java 18. Use try-with-resources or java.lang.ref.Cleaner instead.final: no reassignment, overriding or subclassingfinally runs after try/catch, for cleanupfinalize is unreliable and deprecated for removalCleanerLikely follow-up: What happens if both the try block and the finally block contain a return?
String, StringBuilder and StringBuffer?easyString is immutable, so every "modification" creates a new object. Building a string with += in a loop creates many intermediate strings and copies the characters again each time, which is O(n²) overall.
StringBuilder and StringBuffer are both mutable character buffers with the same API: append, insert, reverse, deleteCharAt and so on. The difference is synchronization:
StringBuffer (Java 1.0) has synchronized methods, so it's thread-safe but pays for locking on every call.StringBuilder (Java 5) is the unsynchronized equivalent and the right default.You almost never share a string buffer between threads, so StringBuffer is mostly legacy. For simple one-line concatenation, plain + is fine: the compiler optimizes it, and since Java 9 it compiles to an invokedynamic call backed by StringConcatFactory. It's loops where you should reach for StringBuilder explicitly.
String is immutable; += in a loop is wastefulStringBuffer is synchronized, StringBuilder is notStringBuilder+ concatenation is optimized by the compilerLikely follow-up: Why does sb1.equals(sb2) return false for two builders with the same content?
Java 8 (2014) was the biggest update since generics, and it brought a functional style to Java:
Function, Predicate, Supplier, Consumer and friends in java.util.function.String::length.forEach and stream() to existing interfaces without breaking every implementation.Optional for return values that may be absent.java.time API: immutable, thread-safe dates and times replacing Date and Calendar.CompletableFuture for composing asynchronous work.Under the hood, PermGen was replaced by Metaspace, and HashMap buckets with many collisions started converting into balanced trees.
Optional and the java.time APILikely follow-up: Why were default methods needed to add streams? · What was wrong with java.util.Date?
HashMap, Hashtable and ConcurrentHashMap. How does ConcurrentHashMap achieve thread safety?hardHashMap isn't thread-safe: concurrent writes can lose updates or corrupt its internal structure. It allows one null key and null values.Hashtable is a legacy Java 1.0 class whose methods are all synchronized on the table, so only one thread can use it at a time, even for reads. It rejects null keys and values. Collections.synchronizedMap has the same single-lock design.ConcurrentHashMap is built for concurrency. Since Java 8 it no longer uses segments: reads don't lock, inserting into an empty bucket uses a CAS, and other updates lock only the first node of that bucket. Threads even help each other with resizing. Its iterators are weakly consistent and never throw ConcurrentModificationException.ConcurrentHashMap also rejects null: a get returning null would be ambiguous between "absent" and "mapped to null", and you can't lock the map to check. For compound updates, use its atomic methods like computeIfAbsent or merge instead of get-then-put.
nullnullcompute or merge for atomic compound updatesLikely follow-up: How did ConcurrentHashMap work in Java 7? · Is size() on a ConcurrentHashMap exact?
static keyword mean, and where can you use it?easystatic means the member belongs to the class rather than to any instance.
static final constant.Math.max. They have no this, so they can't use instance fields or methods directly. They can't be overridden, only hidden, because the call is bound at compile time.import static java.lang.Math.max; lets you write max(a, b).Pitfalls: mutable static state is effectively a global variable, which makes code hard to test and needs synchronization when threads share it. And a static collection that only grows is a classic memory leak, since it lives as long as the class itself.
thisLikely follow-up: Why is main static? · Can you call a static method through an instance reference?
Java is always pass-by-value. For primitives, the value is copied. For objects, the value that gets copied is the reference, so the method receives its own copy of a pointer to the same object.
That explains both lines. In reassign, the parameter sb is pointed at a brand-new object, but that only changes the method's local copy; the caller's s still refers to the original, so it prints hello. In mutate, the copy still points at the same object, so append changes the object the caller sees, and it prints hello world.
A handy test: if Java were pass-by-reference, you could write a swap(a, b) method that swaps two of the caller's variables. You can't. People loosely say "objects are passed by reference", but the precise phrasing is "object references are passed by value".
static void reassign(StringBuilder sb) { sb = new StringBuilder("new"); }
static void mutate(StringBuilder sb) { sb.append(" world"); }
StringBuilder s = new StringBuilder("hello");
reassign(s);
System.out.println(s); // ?
mutate(s);
System.out.println(s); // ?swap(a, b) method cannot workLikely follow-up: How would you return two values from a method?
At the root is Iterable, extended by Collection, which has three main sub-interfaces:
List: ordered, index-based, allows duplicates. ArrayList, LinkedList.Set: no duplicates. HashSet (unordered), LinkedHashSet (insertion order), and TreeSet, which implements SortedSet and NavigableSet.Queue: holds elements for processing, usually FIFO. PriorityQueue orders by priority, and the Deque sub-interface, implemented by ArrayDeque and LinkedList, works at both ends.Map is a separate hierarchy: it stores key-value pairs and doesn't extend Collection. Implementations include HashMap, LinkedHashMap, and TreeMap through SortedMap and NavigableMap.
Java 21 added sequenced collections: SequencedCollection, SequencedSet and SequencedMap give ordered collections uniform methods such as getFirst(), getLast() and reversed(). Also, don't confuse Collection, the interface, with Collections, the utility class of static helpers like sort and unmodifiableList.
Iterable → Collection → List, Set, QueueMap is separate; it does not extend CollectionTreeSet and TreeMapCollections is a utility classLikely follow-up: Why does Map not extend Collection? · Which collections allow null elements?
From most to least restrictive:
private: visible only inside the top-level class that declares it, including its nested classes.protected: the same package, plus subclasses in other packages, which can reach it through inheritance.public: visible everywhere, subject to module exports since Java 9.Top-level classes can only be public or package-private, and a source file can have at most one public top-level class, whose name must match the file name. Interface methods are implicitly public unless declared private.
Two interview favourites: an overriding method can't reduce visibility, so you can't override a public method as protected; and protected is wider than package-private, not narrower.
Good practice is to start with the most restrictive level that works: private fields, and package-private classes unless they're part of your public API.
protected adds subclasses in other packagesLikely follow-up: Can a subclass in another package access a protected member through a parent-type reference?
Comparable and Comparator?easyComparable (in java.lang) defines a class's natural ordering through a single method, compareTo(T other), implemented by the class itself. String, Integer and LocalDate implement it, and it's what Collections.sort(list), TreeMap and TreeSet use by default.
Comparator (in java.util) is a separate object with compare(a, b). It lets you define any number of orderings, for classes you don't control or when there's no single natural order. Since Java 8 it has factories and combinators: comparing, comparingInt, thenComparing, reversed, nullsFirst.
Both return a negative number, zero, or a positive number. Two tips: use Integer.compare(a, b) rather than a - b, which can overflow; and keep compareTo consistent with equals, because sorted collections use the comparison, not equals, to decide whether two elements are duplicates.
record Person(String name, int age) implements Comparable<Person> {
public int compareTo(Person o) { return Integer.compare(age, o.age); }
}
List<Person> people = new ArrayList<>(List.of(new Person("Ann", 30), new Person("Bob", 25)));
Collections.sort(people); // natural order: by age
people.sort(Comparator.comparing(Person::name)); // by name
people.sort(Comparator.comparingInt(Person::age)
.reversed()
.thenComparing(Person::name)); // age desc, then namecompareTo, inside the classcompare(a, b)comparing, thenComparing, reversedInteger.compareLikely follow-up: What happens if compareTo is inconsistent with equals in a TreeSet?
Fail-fast iterators, used by ArrayList, HashMap and most java.util collections, throw ConcurrentModificationException if the collection is structurally modified after the iterator was created, by anything other than the iterator itself. They track a modCount counter and check it on each next(). The check is best-effort: it exists to catch bugs, not to provide thread safety.
The classic bug is removing inside a for-each loop, as in the snippet. The fixes are Iterator.remove() or removeIf.
Fail-safe iterators (the JDK docs say snapshot or weakly consistent) never throw. CopyOnWriteArrayList iterates over a snapshot of the array taken when the iterator was created, so it doesn't see later changes. ConcurrentHashMap iterators are weakly consistent: they may or may not reflect updates made during iteration. The costs are copying on every write for copy-on-write lists, and possibly stale views.
List<String> names = new ArrayList<>(List.of("a", "b", "c"));
for (String n : names) {
if (n.equals("a")) names.remove(n); // throws ConcurrentModificationException
}
names.removeIf(n -> n.equals("a")); // fix 1 (Java 8+)
for (Iterator<String> it = names.iterator(); it.hasNext(); ) {
if (it.next().equals("a")) it.remove(); // fix 2
}
List<String> cow = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
for (String n : cow) cow.remove(n); // no exception: iterates a snapshotConcurrentModificationExceptionmodCount check, best-effort onlyIterator.remove() or removeIfCopyOnWriteArrayListConcurrentHashMapLikely follow-up: Does removing the second-to-last element in a for-each loop throw? · When is CopyOnWriteArrayList a good fit?
An immutable object's state can't change after construction. The recipe:
final (or give it only private constructors), so no subclass can add mutable state or override methods.private final, assigned exactly once in the constructor.String and LocalDate do.List.copyOf, as in the snippet.this escape during construction.The payoff: immutable objects are inherently thread-safe, safe to share and cache, and safe as map keys because their hash code never changes. Records give you most of this for free, but they're only shallowly immutable: a record holding an ArrayList still needs a defensive copy in its compact constructor.
public final class Employee {
private final String name;
private final List<String> skills;
public Employee(String name, List<String> skills) {
this.name = Objects.requireNonNull(name);
this.skills = List.copyOf(skills); // defensive, unmodifiable copy
}
public String name() { return name; }
public List<String> skills() { return skills; } // safe to hand out
public Employee withName(String n) { return new Employee(n, skills); }
}Likely follow-up: Why is Collections.unmodifiableList alone not enough? · How would you make a record with a list field immutable?
There are several correct options:
private static final Config INSTANCE = new Config();. It's thread-safe because the JVM initializes a class exactly once, under a lock. Simple, but not lazy.Holder class isn't initialized until getInstance() first touches it, so you get laziness and thread safety with no locking on the hot path.volatile. Without it, reordering can publish the reference before the constructor finishes, so another thread may see a half-built object.A synchronized getInstance() is also correct, but it takes a lock on every call. In real applications I'd usually let a DI container such as Spring manage the single instance instead.
// Lazy and thread-safe with no locking: Holder is initialized on first use
public final class Config {
private Config() {}
private static class Holder {
static final Config INSTANCE = new Config();
}
public static Config getInstance() { return Holder.INSTANCE; }
}
// Simplest of all, and safe against reflection and serialization
public enum Registry { INSTANCE; }volatile fieldLikely follow-up: How can serialization break a classic singleton, and how does readResolve fix it?
Intermediate operations such as filter, map, flatMap, sorted, distinct and limit return a new Stream and are lazy: they only describe the pipeline. Terminal operations such as collect, forEach, reduce, count, findFirst and anyMatch trigger execution and produce a result or a side effect. Without a terminal operation, nothing runs at all.
Elements also flow through the pipeline one at a time, not stage by stage. So the snippet prints filter a, filter bb, map bb, and stops: findFirst is short-circuiting, so "ccc" and "dddd" are never touched.
Other points worth making: a stream can be consumed only once, and reusing it throws IllegalStateException. Stateful operations keep state across elements; sorted, for example, must see every element before it emits any. And pipeline lambdas should be free of side effects; the prints here are only for demonstration.
Optional<String> first = Stream.of("a", "bb", "ccc", "dddd")
.filter(s -> {
System.out.println("filter " + s);
return s.length() > 1;
})
.map(s -> {
System.out.println("map " + s);
return s.toUpperCase();
})
.findFirst();findFirst stop earlyLikely follow-up: What is the difference between map and flatMap? · Why is peek mostly for debugging?
A functional interface has exactly one abstract method. default and static methods don't count, and neither do abstract methods that redeclare public Object methods like equals. The @FunctionalInterface annotation is optional, but it makes the compiler enforce the rule.
A lambda is a concise implementation of that one method, and its type comes from the target context: s -> s.isEmpty() can be a Predicate<String> or a Function<String, Boolean>. The core interfaces in java.util.function:
Function<T, R>: takes a T, returns an RPredicate<T>: takes a T, returns a booleanConsumer<T>: takes a T, returns nothingSupplier<T>: takes nothing, returns a TBiFunction, UnaryOperator, BinaryOperator, and primitive versions like IntPredicate that avoid boxingA lambda can capture local variables only if they're effectively final. Unlike in an anonymous class, this inside a lambda refers to the enclosing instance, and lambdas are linked at runtime through invokedynamic rather than compiled to a class file each.
@FunctionalInterface is optional but enforcedLikely follow-up: Why must captured variables be effectively final? · Is Comparator a functional interface even though it declares equals?
OutOfMemoryError: Java heap space.StackOverflowError.-XX:MaxMetaspaceSize.HotSpot also keeps a code cache for JIT-compiled machine code. The one-line version: objects live on the heap, while a method's primitives and references live in its frame on that thread's stack.
StackOverflowError vs OutOfMemoryErrorLikely follow-up: What does escape analysis let the JIT do with short-lived objects? · What happens when Metaspace fills up?
The GC reclaims objects that are no longer reachable from GC roots: local variables on thread stacks, static fields, live threads and JNI references. Because it traces reachability instead of counting references, a cycle of objects pointing at each other is still collected once nothing outside reaches it.
The weak generational hypothesis says most objects die young, so generational collectors split the heap:
Collections include phases where application threads are paused (stop-the-world); modern collectors do most of their work concurrently to keep those pauses short. HotSpot offers Serial, Parallel (throughput-oriented), G1 (the default on typical server hardware since Java 9) and ZGC (low latency). System.gc() is only a hint that the JVM may ignore.
Likely follow-up: What are GC roots? · How would you tune GC for a latency-sensitive service?
Runnable and Callable?easyYou can extend Thread and override run(), or, better, pass a Runnable to a Thread, which separates the task from the thread that runs it. In real code you'd usually submit tasks to an ExecutorService rather than create threads by hand.
Runnable: void run(). It returns nothing and can't throw checked exceptions.Callable<V>: V call() throws Exception. It returns a value and may throw checked exceptions.Submitting a Callable to an executor gives you a Future<V>. future.get() blocks until the result is ready, and if the task threw, get() throws an ExecutionException that wraps the original.
A classic gotcha: call start(), not run(). start() creates a new thread, which then calls run(); calling run() directly just executes it on the current thread. And calling start() twice on the same Thread throws IllegalThreadStateException.
Runnable over extending ThreadRunnable returns nothing, no checked exceptionsCallable returns a value and can throwFuture for the resultstart() creates a thread; run() does notLikely follow-up: What are the possible states of a thread? · How do you stop a thread cleanly?
synchronized and ReentrantLock?midBoth provide mutual exclusion and memory visibility, and both are reentrant: a thread that holds the lock can acquire it again.
synchronized uses the object's built-in monitor. It's block-structured, so the lock is always released when the block exits, even on an exception, and it works with wait/notify. It's simple and hard to misuse.
ReentrantLock (java.util.concurrent.locks, Java 5) is an explicit lock that you must release in a finally block. In return it adds:
tryLock(), optionally with a timeout, to avoid waiting foreverlockInterruptibly(), so a waiting thread can be interruptedCondition objects instead of a single wait setPerformance is comparable on modern JVMs, so I default to synchronized and switch to ReentrantLock when I need those features. For read-heavy data, ReentrantReadWriteLock or StampedLock can help. One more factor: in Java 21, a virtual thread that blocks inside synchronized pins its carrier thread; Java 24 removed that limitation.
synchronized releases the lock automaticallyReentrantLock needs unlock() in finallysynchronized unless you need the extrasLikely follow-up: What is a ReadWriteLock good for? · What does "fair" mean for a lock, and what does it cost?
volatile guarantee, and what is the happens-before relationship?hardvolatile gives a field two guarantees. Visibility: a write by one thread is seen by subsequent reads in other threads, rather than lingering in a register or cache. Ordering: the compiler and CPU can't reorder memory operations around a volatile access in ways that would break that visibility. Without volatile, the loop in the snippet might never see running = false, because the JIT is allowed to hoist the read out of the loop.
It does not make compound actions atomic: count++ on a volatile field is still a read-modify-write race. Use AtomicInteger or a lock for that.
Happens-before is the Java Memory Model's visibility rule: if action A happens-before B, then B sees A's effects. Key edges are program order within a thread, unlocking a monitor before a later lock of it, a volatile write before a later read of that field, Thread.start() before the new thread's actions, and a thread's actions before another thread returns from join() on it. The relation is transitive, so everything written before a volatile write is visible to a thread that reads it.
class Worker implements Runnable {
private volatile boolean running = true; // without volatile, run() may never stop
public void run() {
while (running) {
// do work
}
}
public void stop() { running = false; } // called from another thread
}count++ is still a raceLikely follow-up: Why does double-checked locking need volatile? · When would you use AtomicInteger instead?
A deadlock is when two or more threads each hold a lock another one needs, so they all wait forever. In the snippet, one thread locks a and waits for b, while the other locks b and waits for a.
It requires four conditions at once: mutual exclusion, hold and wait, no preemption and circular wait. Prevention means breaking one of them:
safeTransfer does. This breaks circular wait and is the most common fix.ReentrantLock.tryLock(timeout, unit) lets a thread back off and retry instead of waiting forever.To diagnose one, take a thread dump with jstack or jcmd <pid> Thread.print: HotSpot reports "Found one Java-level deadlock" along with the threads and locks involved. ThreadMXBean.findDeadlockedThreads() does the same programmatically.
// Thread 1: transfer(a, b) Thread 2: transfer(b, a) -> can deadlock
void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) { from.debit(amount); to.credit(amount); }
}
}
// Fix: acquire locks in one global order, e.g. by account id
void safeTransfer(Account from, Account to, int amount) {
Account first = from.id() < to.id() ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) { from.debit(amount); to.credit(amount); }
}
}tryLock with a timeout as a fallbackLikely follow-up: What is the difference between deadlock, livelock and starvation?
ExecutorService and thread pools work, and how would you configure one for production?midAn ExecutorService decouples submitting tasks from running them: you call submit() or invokeAll(), and a pool of reusable worker threads executes the tasks. That avoids creating a thread per task and caps concurrency.
ThreadPoolExecutor is configured with a core size, a maximum size, a keep-alive time, a work queue and a rejection policy. A new task starts a thread if fewer than the core number are running; otherwise it's queued; only when the queue is full does the pool grow towards the maximum; beyond that the task is rejected (AbortPolicy by default, or CallerRunsPolicy for back-pressure).
The Executors factories hide risks: newFixedThreadPool uses an unbounded queue and newCachedThreadPool can create unbounded threads, so in production I build a ThreadPoolExecutor with a bounded queue. Size CPU-bound pools near the core count and I/O-bound pools larger. Always shut pools down with shutdown() and awaitTermination(); since Java 19, ExecutorService is AutoCloseable, so try-with-resources works too.
Executors factories can be unboundedLikely follow-up: What does CallerRunsPolicy do, and why is it useful? · How do you size a pool for I/O-bound work?
Optional for, and how should and shouldn't you use it?midOptional<T> (Java 8) is a container that holds either a value or nothing. It's designed mainly as a return type for methods that may legitimately have no result, so the absence shows up in the signature and callers are pushed to handle it instead of hitting a NullPointerException.
Idiomatic use is functional: map, flatMap and filter to transform, then orElse, orElseGet, orElseThrow or ifPresentOrElse (Java 9) to finish. A common trap is that orElse(x) always evaluates its argument, while orElseGet(supplier) only calls it when the Optional is empty, which matters when the default is expensive.
What to avoid:
get() without checking; prefer orElseThrow() (Java 10), which states the intentOptional fields, parameters or collection elements; it isn't Serializable and adds an allocationOptional<List<T>>; return an empty list insteadnull from a method declared to return OptionalString city = userRepo.findById(id) // returns Optional<User>
.map(User::address)
.map(Address::city)
.orElse("Unknown");
User user = userRepo.findById(id)
.orElseThrow(() -> new NotFoundException(id));
opt.orElse(loadDefault()); // loadDefault() runs even if opt has a value
opt.orElseGet(this::loadDefault); // runs only when opt is emptymap/flatMap/filter, then an orElse variantorElse is eager, orElseGet is lazyget(); prefer orElseThrow()Likely follow-up: What is the difference between map and flatMap on an Optional?
try-with-resources (Java 7) declares resources in parentheses after try and closes them automatically when the block exits, normally or through an exception. Each resource must implement AutoCloseable, which Closeable extends.
Key details:
out before in in the snippet.catch or finally block of the same statement runs.close() also throws, the body's exception is the one propagated, and the close exception is attached to it as a suppressed exception, available through getSuppressed(). With a hand-written finally, the close exception would silently replace the original.try (in) { ... }.It replaces the error-prone pattern of null checks and nested try/finally blocks, so it's the standard way to handle streams, JDBC connections and other resources.
try (BufferedReader in = Files.newBufferedReader(Path.of("in.txt"));
BufferedWriter out = Files.newBufferedWriter(Path.of("out.txt"))) {
String line;
while ((line = in.readLine()) != null) {
out.write(line.toUpperCase());
out.newLine();
}
} catch (IOException e) {
// runs after both resources are closed: out first, then in
System.err.println("Copy failed: " + e.getMessage());
}AutoCloseablecatch and finally runLikely follow-up: How would you write your own resource class for try-with-resources?
Generics (Java 5) let you parameterize types, like List<String>, so the compiler catches type errors and you don't need casts. They're implemented by type erasure: the compiler checks the types, then erases them, replacing each type parameter with its bound (Object if unbounded), inserting casts where needed, and generating bridge methods so overriding still works. That kept generic code compatible with older, pre-generics libraries.
Because type arguments don't exist at runtime:
List<String> and List<Integer> share one runtime class, so getClass() can't tell them apart.new T(), new T[10] or T.class.process(List<String>) and process(List<Integer>) clash, because they have the same erasure.List<int> isn't allowed, so values get boxed.The usual workaround is passing a Class<T> token, as in repository.find(User.class).
new T(), generic arrays or T.classClass<T> token when you need the typeLikely follow-up: What is a bridge method? · Why can you not create an array of List<String>?
? extends T versus ? super T?hardGenerics are invariant: a List<Integer> is not a List<Number>, even though Integer is a Number. If it were, you could add a Double to a list of integers through the Number view. Wildcards restore flexibility safely:
? extends T (upper bound): the list holds some subtype of T. You can read elements as T, but you can't add anything except null, because the exact type is unknown.? super T (lower bound): the list holds some supertype of T. You can add T values, but reading only gives you Object.?: an unknown type, for code that doesn't care, like counting elements.PECS, from Effective Java, means Producer Extends, Consumer Super: if a parameter produces values you read, use extends; if it consumes values you write, use super. The textbook example is Collections.copy(List<? super T> dest, List<? extends T> src). Put wildcards in parameters, not return types, which would force callers to deal with them.
static double sum(Collection<? extends Number> nums) { // producer: we only read
double total = 0;
for (Number n : nums) total += n.doubleValue();
return total;
}
static void fillWithInts(List<? super Integer> sink) { // consumer: we only write
for (int i = 0; i < 3; i++) sink.add(i);
}
List<Integer> ints = new ArrayList<>();
List<Number> nums = new ArrayList<>();
fillWithInts(ints); fillWithInts(nums); // both compile
sum(ints); sum(nums); // both compile, yet "nums = ints" would not? extends T: read as T, cannot add? super T: can add T, reads give ObjectLikely follow-up: Why are arrays covariant while generics are not? · Why is Collections.max declared the way it is?
HashSet work internally, and how does it compare with LinkedHashSet and TreeSet?midA HashSet is a thin wrapper around a HashMap. Each element is stored as a key, and every value is the same shared dummy object. add(e) is essentially map.put(e, PRESENT) == null: if the key already existed, put returns the old value and add returns false.
So everything about HashMap applies: average O(1) add, remove and contains, one null element allowed, no ordering guarantee, and correctness that depends entirely on the elements' hashCode and equals.
LinkedHashSet is backed by a LinkedHashMap, which threads a doubly linked list through the entries, so iteration follows insertion order at a small memory cost.TreeSet is backed by a TreeMap, a red-black tree. Elements stay sorted by natural ordering or a Comparator, operations are O(log n), and duplicates are detected with the comparison, not equals.The classic bug: mutating an element's fields after adding it changes its hash code, so contains and remove can no longer find it.
HashMap; elements are the keyshashCode and equalsLinkedHashSet keeps insertion orderTreeSet is sorted and uses compareToLikely follow-up: Why does Set.add return a boolean?
new String("hello") create? What does this code print?midnew String("hello") involves up to two String objects. The literal "hello" refers to a pooled instance, created the first time that literal is resolved and reused afterwards, and new always creates a separate object on the heap. If the literal is already pooled, only one new object is created.
So the snippet prints true, false, true, true:
a == b: identical literals refer to the same pooled instance.a == c: new always yields a distinct object.a == d: intern() returns the canonical pooled instance.a == e: part is a final variable initialized with a constant, so part + "lo" is a compile-time constant expression. It's folded to "hello" and interned. Remove final and the concatenation happens at runtime, creating a new object, so it would print false.This is exactly why strings are compared with equals(): identity depends on how each string was produced.
String a = "hello";
String b = "hello";
String c = new String("hello");
String d = c.intern();
final String part = "hel";
String e = part + "lo";
System.out.println(a == b); // ?
System.out.println(a == c); // ?
System.out.println(a == d); // ?
System.out.println(a == e); // ?new makes a separate objectintern() returns the pooled instanceLikely follow-up: When would calling intern() yourself be a good idea?
TreeMap work, and when would you choose it over HashMap?midTreeMap is a red-black tree, a self-balancing binary search tree that keeps keys sorted by natural ordering (Comparable) or by a Comparator passed to the constructor. get, put, remove and containsKey are O(log n), versus O(1) on average for HashMap.
You choose it when order matters. As a NavigableMap it offers:
firstKey(), lastKey(), pollFirstEntry()floorKey(k), ceilingKey(k), lowerKey(k), higherKey(k) for nearest-match lookupsheadMap, tailMap, subMap, descendingMapTypical uses are leaderboards, time series ("latest price at or before this timestamp"), scheduling and range queries.
Gotchas: keys are compared with compareTo or compare, not equals, so an ordering inconsistent with equals can merge keys you think are distinct. With natural ordering, a null key throws NullPointerException. And it isn't thread-safe; ConcurrentSkipListMap is the concurrent sorted alternative.
equalsConcurrentSkipListMap for concurrent sorted mapsLikely follow-up: How would you find the entry with the closest key at or below a timestamp?
A class goes through loading (finding its bytes and creating a Class object), linking (verifying the bytecode, preparing static fields with default values, and resolving symbolic references, often lazily) and initialization (running static initializers and static field assignments). Initialization is lazy: it happens on first active use, such as new, a static method call or reading a non-constant static field, and the JVM guarantees it runs once, thread-safely.
Since Java 9 there are three built-in loaders:
java.base.java.sql.With parent delegation, a loader first asks its parent and loads the class itself only if the parent can't. That stops application code from replacing core classes like java.lang.String. A class's runtime identity is its name plus its defining loader, which is how app servers and plugin systems isolate versions, and why you can get a ClassCastException between two "identical" classes.
Likely follow-up: What is the difference between ClassNotFoundException and NoClassDefFoundError? · Why would you write a custom class loader?
OrderService that also renders PDFs and sends emails should be split so each concern can change independently.PaymentMethod implementation instead of another branch in a giant switch.Square extends Rectangle, where setting the width silently changes the height.delete() just to throw UnsupportedOperationException.OrderService receives a PaymentGateway interface through its constructor, which is exactly what dependency injection in Spring does, and makes the class easy to test with a fake.I'd add that these are guidelines, not laws: applied dogmatically, they produce lots of tiny classes and needless indirection.
Likely follow-up: How does Spring support dependency inversion? · Which SOLID principle does the strategy pattern embody?
CompletableFuture, and how does it improve on a plain Future?hardCompletableFuture (Java 8) is a Future you can compose. A plain Future only lets you block with get(); a CompletableFuture lets you attach what happens next, so you build a non-blocking pipeline.
supplyAsync (returns a value) or runAsync. Without an executor argument they use the common ForkJoinPool, a poor place for blocking I/O, so pass your own pool.thenApply is like map; thenCompose is like flatMap, for a step that itself returns a future; thenAccept consumes the result.thenCombine merges two independent futures, as in the snippet; allOf and anyOf handle many.exceptionally supplies a fallback, handle sees either the result or the exception, and whenComplete observes both without changing the result.orTimeout and completeOnTimeout.join() throws an unchecked CompletionException, while get() throws checked exceptions. Non-Async stages may run on whichever thread completed the previous stage, so use the ...Async variants with an executor for heavy work.
ExecutorService pool = Executors.newFixedThreadPool(8);
CompletableFuture<User> user =
CompletableFuture.supplyAsync(() -> userClient.fetch(id), pool);
CompletableFuture<List<Order>> orders =
CompletableFuture.supplyAsync(() -> orderClient.fetch(id), pool);
CompletableFuture<Dashboard> dashboard = user
.thenCombine(orders, Dashboard::new) // both run in parallel; merge results
.orTimeout(2, TimeUnit.SECONDS) // Java 9+
.exceptionally(ex -> Dashboard.empty()); // fallback on any failure
Dashboard d = dashboard.join();FuturethenApply maps, thenCompose flat-mapsthenCombine and allOf for parallel workexceptionally and handle for errorsLikely follow-up: What is the difference between thenApply and thenApplyAsync? · How would you cancel a chain of stages?
Virtual threads (final in Java 21) are lightweight threads managed by the JVM rather than the OS. They're cheap enough to create one per task, so you can run hundreds of thousands, even millions, at once.
A virtual thread runs by being mounted on one of a small pool of platform carrier threads, a ForkJoinPool sized to the number of cores by default. When it blocks on I/O, a lock or sleep, the JVM unmounts it and the carrier picks up another virtual thread. So plain, blocking, thread-per-request code gets the scalability of async code without callbacks or reactive chains.
Things to know:
Semaphore.synchronized or native code keeps the carrier occupied. Java 24 fixed this for synchronized; on 21, prefer ReentrantLock around blocking calls.ThreadLocal use gets expensive with millions of threads; scoped values are the intended replacement.try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocking is cheap here
return id;
});
}
} // close() waits for every task to finish
Thread.ofVirtual().start(() -> System.out.println("hi"));
Thread.startVirtualThread(() -> System.out.println("hi"));SemaphoresynchronizedLikely follow-up: How do virtual threads compare with reactive programming? · What is structured concurrency?
A record (final in Java 16) is a concise, transparent carrier for immutable data. From the header record Money(BigDecimal amount, String currency) the compiler generates:
private final field for each componentamount(), not getAmount()equals, hashCode and toString based on all componentsRecords are implicitly final and extend java.lang.Record, so they can't extend another class, but they can implement interfaces and declare static fields, methods and nested types. They can't declare extra instance fields.
A compact constructor, like the one in the snippet, validates or normalizes arguments without repeating the assignments. Records are only shallowly immutable, so a List component should be copied with List.copyOf there. They're a great fit for DTOs, value objects, map keys and multiple return values, and they work with record patterns in switch (Java 21). They're a poor fit for JPA entities, which need mutability and a no-arg constructor.
public record Money(BigDecimal amount, String currency) {
public Money { // compact canonical constructor
Objects.requireNonNull(currency);
if (amount.signum() < 0) throw new IllegalArgumentException("negative");
}
public Money add(Money other) {
return new Money(amount.add(other.amount), currency);
}
}
Money m = new Money(new BigDecimal("9.99"), "USD");
m.amount(); // accessor: amount(), not getAmount()
System.out.println(m); // e.g. Money[amount=9.99, currency=USD]Likely follow-up: Can a record have a non-canonical constructor? · Why are records a poor fit for JPA entities?
transient keyword do?hardBuilt-in serialization turns an object graph into bytes with ObjectOutputStream.writeObject and rebuilds it with ObjectInputStream.readObject. A class opts in by implementing the marker interface Serializable, and every non-transient field must itself be serializable, or you get a NotSerializableException.
transient fields are skipped: passwords, caches, or things like a thread or a connection that make no sense elsewhere. After deserialization they hold default values (null, 0, false). Static fields aren't serialized either; they belong to the class.serialVersionUID identifies the class version. If you don't declare one, it's computed from the class structure, so a harmless change can cause an InvalidClassException when reading old data. Declare it explicitly.writeObject/readObject for custom formats, readResolve to keep singletons unique, and Externalizable for full control.Deserializing untrusted data is a well-known security risk, so modern code prefers JSON or Protobuf, and uses ObjectInputFilter when Java serialization is unavoidable.
Serializable marker interfacetransient fields are skipped and restored as defaultsserialVersionUID explicitlyLikely follow-up: How can serialization break a singleton? · How are records deserialized differently from ordinary classes?
Yes. The GC only frees unreachable objects, so a Java leak means objects that are still referenced but will never be used again. Typical causes:
ThreadLocal values in thread pools: pooled threads don't die, so values stay until you call remove().HashMap: once a key's hash changes, the entry can't be found or removed, but it's still referenced.To find one, check whether heap usage after full GCs keeps climbing. Then take a heap dump (jcmd <pid> GC.heap_dump heap.hprof, or -XX:+HeapDumpOnOutOfMemoryError) and analyse it in Eclipse MAT or VisualVM, looking for the objects with the largest retained size and their paths to GC roots.
Likely follow-up: How would a WeakHashMap help, and what are its pitfalls?
javac compiles source to platform-neutral bytecode. At runtime, HotSpot starts by interpreting that bytecode while profiling it: counting method calls and loop iterations, and recording which types and branches actually occur. Code that gets hot is compiled to native machine code by the Just-In-Time compiler and kept in the code cache.
HotSpot uses tiered compilation, on by default since Java 8:
Key optimizations are inlining (the biggest win), escape analysis (objects that never escape a method may avoid heap allocation entirely), loop optimizations, lock elision, and speculative devirtualization: if a call site has only ever seen one implementation, it's called directly. If an assumption later breaks, say a new subclass is loaded, the JVM deoptimizes that code back to the interpreter and can recompile it.
That's why Java needs warm-up, and why microbenchmarks need a harness like JMH.
Likely follow-up: Why can a microbenchmark without JMH be misleading? · How does ahead-of-time compilation such as GraalVM Native Image differ?
A method reference is shorthand for a lambda that just calls an existing method: list.forEach(System.out::println) means the same as list.forEach(x -> System.out.println(x)). Like a lambda, it needs a functional interface as its target type, and the compiler matches the method's signature against that interface's single abstract method.
The four kinds:
Integer::parseInt stands for s -> Integer.parseInt(s).System.out::println calls the method on one specific object, which is evaluated once, when the reference is created.String::toUpperCase takes the receiver as its first argument, s -> s.toUpperCase(). With two parameters, String::equalsIgnoreCase becomes (a, b) -> a.equalsIgnoreCase(b).ArrayList::new, or int[]::new for arrays.I use them when they read more clearly than the lambda, which is usually true for simple delegation. If the lambda does more than one call or reorders arguments, a lambda is clearer.
Function<String, Integer> parse = Integer::parseInt; // static method
Consumer<String> print = System.out::println; // bound to one object
Function<String, String> upper = String::toUpperCase; // unbound: s -> s.toUpperCase()
BiPredicate<String, String> same = String::equalsIgnoreCase; // (a, b) -> a.equalsIgnoreCase(b)
Supplier<List<String>> newList = ArrayList::new; // constructor
IntFunction<int[]> newArray = int[]::new; // array constructorLikely follow-up: What happens if the bound receiver is null when the reference is created?
parallelStream() or .parallel() splits the source with a Spliterator and processes the pieces on the common ForkJoinPool, whose default parallelism is one less than the number of cores, because the calling thread helps too. It only pays off when the work outweighs the cost of splitting, scheduling and merging.
Good candidates:
ArrayList, IntStream.range; not LinkedList or Stream.iteratesum, reduce and collectPitfalls:
ArrayList from forEach is a race; use collect instead.CompletableFuture tasks.forEach order becomes non-deterministic, and forEachOrdered, findFirst or limit on ordered streams add overhead.My rule: default to sequential, and go parallel only after measuring a real speed-up.
ForkJoinPoolArrayListLikely follow-up: Why does LinkedList parallelize poorly?
Collectors?midcollect is the terminal operation that accumulates a stream into a result, and Collectors provides the recipes:
groupingBy(classifier) builds a Map<K, List<T>>. An optional second argument is a downstream collector applied to each group: counting(), averagingDouble, summingInt, mapping, maxBy, or another groupingBy for nested maps. A three-argument form lets you pick the map type, such as TreeMap::new for sorted keys.partitioningBy(predicate) splits into a Map<Boolean, List<T>> that always has both true and false keys.toMap(keyFn, valueFn) throws IllegalStateException on a duplicate key unless you pass a merge function, as the snippet does with Double::sum. It also throws NullPointerException for a null value.joining concatenates strings with an optional delimiter, prefix and suffix.teeing (Java 12) combines the results of two collectors.For a plain list, Java 16's stream.toList() returns an unmodifiable list, while Collectors.toList() makes no promise about mutability (in practice it's an ArrayList).
record Employee(String name, String dept, double salary) {}
Map<String, List<Employee>> byDept = staff.stream()
.collect(Collectors.groupingBy(Employee::dept));
Map<String, Long> headcount = staff.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.counting()));
Map<String, Double> avgPay = staff.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.averagingDouble(Employee::salary)));
Map<Boolean, List<Employee>> seniors = staff.stream()
.collect(Collectors.partitioningBy(e -> e.salary() > 100_000));
Map<String, Double> payByName = staff.stream()
.collect(Collectors.toMap(Employee::name, Employee::salary, Double::sum));
String names = staff.stream().map(Employee::name).collect(Collectors.joining(", "));groupingBy with downstream collectorspartitioningBy always has true and false keystoMap needs a merge function for duplicate keysjoining for building stringsstream.toList() returns an unmodifiable listLikely follow-up: How would you group into a TreeMap of sets?
Builder solves constructing objects with many parameters, especially optional ones. The alternatives are poor: telescoping constructors (Pizza(size), Pizza(size, cheese), Pizza(size, cheese, olives) and so on) are hard to read and easy to call with arguments in the wrong order, while the JavaBeans style, a no-arg constructor plus setters, leaves the object mutable and possibly half-initialized.
The classic implementation, shown in the snippet, is a static nested Builder:
thisbuild() validates the combination and calls a private constructor, so the product can be immutableThe call site reads like named arguments: new Pizza.Builder("large").cheese().olives().build(). The JDK uses the idea in HttpClient.newBuilder(), HttpRequest.newBuilder() and Stream.builder(), and Lombok's @Builder generates one for you. The cost is extra code, so for two or three parameters a constructor, a static factory or a record is simpler.
public final class Pizza {
private final String size;
private final boolean cheese, olives;
private Pizza(Builder b) { size = b.size; cheese = b.cheese; olives = b.olives; }
public static class Builder {
private final String size; // required
private boolean cheese, olives; // optional
public Builder(String size) { this.size = Objects.requireNonNull(size); }
public Builder cheese() { cheese = true; return this; }
public Builder olives() { olives = true; return this; }
public Pizza build() { return new Pizza(this); }
}
}build() validates and returns an immutable objectLikely follow-up: How would you enforce required fields at compile time?
Factory is a creational pattern: it hides which concrete class gets instantiated behind a method that returns an interface type. A caller asks for "a notifier for this channel" and doesn't know or care that it got an SmsNotifier. Variants include:
List.of, Optional.of or NumberFormat.getInstance()Strategy is a behavioural pattern: a family of interchangeable algorithms behind one interface, chosen at runtime. Comparator is the JDK's classic strategy: sort stays the same while the ordering varies. Since Java 8, a strategy is often just a lambda.
The difference is what varies: a factory varies object creation, a strategy varies behaviour. They combine naturally: a factory, often a Map<PaymentType, PaymentStrategy> or a Spring-injected list of beans, picks the right strategy, and the caller just runs it. Both replace sprawling if/switch blocks and follow the open/closed principle.
Comparator is a built-in strategyLikely follow-up: How would you register strategies automatically in Spring?
Observer defines a one-to-many dependency: a subject keeps a list of observers and notifies them all when its state changes, without knowing their concrete types. It decouples the thing that changes from the things that react, and it's the foundation of event-driven code.
In Java it shows up as:
ActionListener, and java.beans.PropertyChangeListenerApplicationEventPublisher and @EventListenerjava.util.concurrent.Flow (Java 9), Project Reactor and RxJava are observer plus back-pressureThe old java.util.Observer and Observable have been deprecated since Java 9, partly because Observable is a class rather than an interface and its event model is very limited.
Pitfalls worth mentioning: the lapsed listener problem, where forgotten registrations cause memory leaks; one slow or throwing observer blocking the rest when notification is synchronous; unspecified notification order; and thread safety, commonly handled by keeping observers in a CopyOnWriteArrayList.
java.util.Observer deprecated since Java 9Likely follow-up: How is publish/subscribe different from the Observer pattern?
Default methods (Java 8) bring back a limited form of the diamond problem: if two interfaces provide the same default method, which one does the class get? Java resolves it with three rules, in order:
B extends A and both define the default, B's version is used.InterfaceName.super.method(), as Duck does.There's no ambiguity about state, because interfaces have no instance fields, which is why Java allows this but not multiple inheritance of classes.
Related details: static interface methods aren't inherited, so you always call them as Flyer.helper(); private interface methods (Java 9) let several defaults share code without exposing it; and a default method can't override an Object method such as equals or toString.
interface Flyer { default String move() { return "fly"; } }
interface Swimmer { default String move() { return "swim"; } }
class Duck implements Flyer, Swimmer {
@Override
public String move() { // required: otherwise a compile error
return Flyer.super.move() + " and " + Swimmer.super.move();
}
}
new Duck().move(); // "fly and swim"Interface.super.method()Likely follow-up: Why can a default method not implement toString()?
A sealed class or interface (final in Java 17) restricts which classes may extend or implement it, through a permits clause. It sits between final, which allows no subclasses, and fully open inheritance.
Rules:
final, sealed or non-sealed. Records are implicitly final, so they qualify automatically; non-sealed reopens a branch, as Rectangle does in the snippet.permits and the compiler infers it.The big payoff comes with pattern matching: because the compiler knows every possible subtype, a switch over a sealed type can be exhaustive without a default (Java 21). If someone later adds a Triangle, every such switch stops compiling until it's handled, instead of silently falling into a default at runtime. Sealed interfaces plus records give Java algebraic data types, a good fit for results like success-or-failure and for AST nodes.
sealed interface Shape permits Circle, Square, Rectangle {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
non-sealed class Rectangle implements Shape { // open to further subclassing
double w, h;
}
static double area(Shape s) {
return switch (s) { // exhaustive: no default needed
case Circle c -> Math.PI * c.r() * c.r();
case Square sq -> sq.side() * sq.side();
case Rectangle r -> r.w * r.h;
};
}permitsdefaultLikely follow-up: How is a sealed interface different from an enum?
switch evolved in modern Java? Explain switch expressions and pattern matching for switch.hardIt happened in steps:
switch can produce a value, and the arrow form case A, B -> has no fall-through, so there are no forgotten breaks. A block body returns its value with yield. A switch expression must be exhaustive; over an enum, covering every constant is enough.instanceof (Java 16): if (obj instanceof String s) tests and binds in one step, with no cast.switch (Java 21): cases can be type patterns such as case Integer i, refined with guards (when i > 100), and case null handles null explicitly instead of throwing NullPointerException.case Point(int x, int y) deconstructs a record in place, and patterns can nest.The compiler also checks dominance: the guarded case Integer i when ... must come before the plain case Integer i, or the code won't compile. Combined with sealed types, this gives exhaustive, type-safe branching that replaces instanceof chains and many visitor-pattern hierarchies.
record Point(int x, int y) {}
static String describe(Object obj) {
return switch (obj) {
case null -> "null";
case Integer i when i > 100 -> "big int " + i;
case Integer i -> "int " + i;
case String s -> "string of length " + s.length();
case Point(int x, int y) -> "point at " + x + "," + y; // record pattern
default -> "something else";
};
}yield returns a value from a blockwhen guards in Java 21Likely follow-up: What happens if the selector is null and there is no case null?
var in Java, and where can and can't you use it?easyvar (Java 10) is local variable type inference: the compiler infers the variable's static type from its initializer. var list = new ArrayList<String>(); declares an ArrayList<String> exactly as if you'd written the type out. Java stays statically typed; nothing becomes dynamic at runtime.
Where it's allowed:
for loop indexes and enhanced for variablesWhere it isn't: fields, method parameters, return types, variables without an initializer, var x = null;, and array initializers like var a = {1, 2};. A lambda or method reference alone can't initialize a var either, because there's no target type.
var isn't a full keyword, so old code using it as a variable or method name still compiles. I use it when the type is obvious from the right-hand side, and avoid it when it hides useful information, as in var result = service.process();.
Likely follow-up: What type does var list = new ArrayList<>(); infer?
Each LTS release bundles everything since the previous one:
var (from Java 10), the standard java.net.http.HttpClient, new String methods (isBlank, strip, lines, repeat), Files.readString and writeString, launching a single source file directly with java Main.java, and removal of the Java EE and CORBA modules from the JDK.instanceof, sealed classes, helpful NullPointerException messages that name the null variable, and strong encapsulation of JDK internals.switch, record patterns, sequenced collections, and generational ZGC.Java 25 (September 2025) is the latest LTS. Frameworks pushed upgrades too: Spring Boot 3 requires Java 17, which is why many teams jumped from 8 or 11 straight to 17 or 21. When migrating, the usual pain points are removed Java EE modules like JAXB and code that relied on JDK internals.
Likely follow-up: What would you check when migrating a service from Java 8 to 21?
G1 (Garbage-First), the default collector since Java 9, splits the heap into many equal-sized regions, each serving as Eden, survivor, old, or "humongous" space for very large objects. It marks live objects concurrently, then collects the regions with the most garbage first, hence the name. You give it a pause-time goal with -XX:MaxGCPauseMillis (200 ms by default), and it sizes each collection to try to meet that goal. Copying survivors out of regions, called evacuation, happens in stop-the-world pauses.
ZGC (-XX:+UseZGC, production-ready since Java 15) does almost all of its work concurrently, including relocating objects, using coloured pointers and load barriers so application threads keep running while objects move. Pauses are typically under a millisecond and don't grow with heap size, even for multi-terabyte heaps. Java 21 added a generational mode, which became the only mode in Java 24.
Choosing: G1 is a well-balanced default for most services. ZGC suits latency-sensitive systems and very large heaps, at the cost of some throughput and extra CPU. Parallel GC is still the pick for pure batch throughput.
Likely follow-up: What is a humongous object in G1? · What would you look for first in GC logs?
No questions match that filter.
Prefer multiple choice? All 20 Java MCQs with answers →