This page is for anyone facing a Java round, from a first job to a senior backend role. Most Java interviews start with the JVM and memory, move to strings, equals and hashCode, and collections internals, then test exceptions, generics and threads, and finish with lambdas, streams, Optional and records. Senior rounds add a production story and a judgement call. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then change the stories to your own.
Search all questions by round, difficulty and level, or save the ones you want to practise.
JVM: runs bytecode; loads classes, verifies them, interprets and JIT-compiles hot code.
JRE: the JVM plus the standard class libraries, enough to run a program.
JDK: everything needed to build: compiler, jar, debugger and other tools on top of the runtime.
"The JVM is the engine. It loads compiled class files, checks the bytecode is safe, and runs it, first by interpreting and then by compiling the hot parts to native code with the JIT. The JRE is the JVM plus the standard libraries, like java.lang and java.util, so it's what you need to run a Java program. The JDK is what developers install: it has the runtime plus tools like javac to compile, jar to package, and a debugger. The flow is: I write a .java file, javac turns it into bytecode, and any JVM on any operating system can run that bytecode. That's where 'write once, run anywhere' comes from: the bytecode is portable, the JVM is the platform-specific part."
Saying Java is platform-independent because the JVM is, when it's the bytecode that is portable and the JVM that is built per platform.
Stack: one per thread; each method call gets a frame holding local primitives and references.
Heap: shared by all threads; every object and array lives here and is cleaned up by the garbage collector.
Errors: deep recursion gives StackOverflowError; too many live objects give OutOfMemoryError.
"Every thread gets its own stack. Each time a method is called, a frame is pushed that holds its local variables: primitives are stored right there, and for objects the frame only holds a reference. The objects themselves always go on the heap, which all threads share, and the garbage collector frees them once nothing reachable points to them. So if I write Person p = new Person() inside a method, p is on the stack and the Person object is on the heap. This matters in practice. Local variables are naturally thread-safe because no other thread can see my stack, but objects on the heap can be shared. And the two errors tell you which area ran out: StackOverflowError usually means runaway recursion, OutOfMemoryError means the heap is full."
Saying objects created inside a method live on the stack, or that primitives are always on the stack even when they are fields of an object.
Reachability: start from GC roots such as thread stacks and static fields; anything not reachable is garbage, cycles included.
Generations: most objects die young, so new objects go to a young area that is collected often and cheaply.
Promotion and pauses: survivors move to the old generation, collected less often; some phases stop the application.
"The collector doesn't count references. It starts from GC roots, things like local variables on thread stacks, static fields and a few JVM internals, and marks everything it can reach. Whatever isn't marked is garbage, even if those objects point at each other in a cycle. The heap is split into generations because most objects die very young, like a temporary string built for a log line. So new objects go into the young generation, which is collected often and quickly because it's mostly garbage. Objects that survive several young collections get promoted to the old generation, which is bigger and collected less often. Some phases pause the application threads, and that's what shows up as latency spikes. Modern collectors like G1 and ZGC are designed to keep those pauses short."
Saying Java uses reference counting, or that an object is freed the moment its variable goes out of scope.
Stabilise: restart or scale out, roll back a recent deploy if the timing fits, protect the users.
Keep evidence: read the exact error message, capture a heap dump and GC logs before they're lost.
Root cause: analyse the dump, fix the leak or the load, add monitoring and an alert on heap use.
"First I protect users: restart the failing instances or add capacity, and if the crashes started right after a deploy, I roll it back. At the same time I read the exact message, because OutOfMemoryError has several kinds. Java heap space means the heap is full. Metaspace points at class loading. Unable to create native thread means too many threads, not too much data. I make sure we capture a heap dump, turning on dump-on-OOM if it isn't already on, and I keep the GC logs. Raising the maximum heap might buy time, but I treat it as a stopgap, not a fix. Once it's stable, I open the dump and look at what's filling the heap and what's holding on to it. Then I fix the cause, add an alert on heap use after GC, and write up what happened."
Going straight to doubling the heap size and closing the incident without a heap dump or root cause.
Interface: a contract; a class can implement many; default and static methods allowed, but no instance state.
Abstract class: can hold fields, constructors and protected members; a class can extend only one.
Rule of thumb: interface for a capability, abstract class for shared state and a partial implementation.
"Since Java 8, interfaces can have default and static methods, and later private methods too, so the old line 'interfaces have no code' isn't true any more. The real difference now is state and inheritance. An interface can't hold instance fields, only constants, and a class can implement as many interfaces as it likes. An abstract class can have fields, constructors and protected helpers, but a class can extend only one. So I use an interface to describe a capability, like Comparable or a PaymentGateway contract, especially when unrelated classes need it. I'd pick an abstract class when several subclasses share real state and setup logic, for example a base report class that holds a title and a date range and implements the common steps, leaving one method abstract for the part each report does differently."
Saying interfaces can't contain any method bodies, which has been wrong since Java 8.
Overloading: same name, different parameter list, in one class; chosen at compile time from the declared types.
Overriding: a subclass replaces an inherited method with the same signature; chosen at runtime from the actual object.
Rules: an override can't reduce visibility or throw broader checked exceptions; static and private methods aren't overridden.
"Overloading is having several methods with the same name but different parameters, like print(int) and print(String). The compiler picks which one to call based on the declared types of the arguments, so it's decided at compile time. Overriding is when a subclass provides its own version of a method it inherited, with the same signature. Which version runs is decided at runtime from the real object, not the variable type. So if I have Animal a = new Dog() and call a.speak(), Dog's speak runs. That's runtime polymorphism. An override has rules: it can't make the method less visible, it can't throw broader checked exceptions, and it can return a more specific type. Static methods can't be overridden, only hidden, and private or final methods can't be overridden at all."
class Animal { String speak() { return "..."; } }
class Dog extends Animal {
@Override String speak() { return "Woof"; } // overriding
String speak(int times) { return "Woof".repeat(times); } // overloading
}
Animal a = new Dog();
a.speak(); // "Woof": chosen at runtime
Mixing the two up, or saying overloading is decided at runtime.
Immutable: once created a String never changes; every 'change' returns a new String.
Why: safe to share and cache, safe across threads, the hash code can be cached, and values like file paths can't be altered after a check.
Pool: string literals are stored once and shared; new String() creates a separate object.
"A String can't change after it's created. Methods like toUpperCase or concat give you a new String and leave the original alone. That design has real benefits. Because nobody can change a String, the JVM can safely share one copy of each literal, which is the string pool: if two places in my code use the literal "hello", they point to the same object. It also makes Strings thread-safe for free, lets the hash code be cached so they're fast as HashMap keys, and stops someone changing a file path or class name after it's been checked. The side effect people trip on is ==. Two literals compare equal with == because they're the same pooled object, but new String("hello") is a separate object, so == is false. That's why I always compare content with equals."
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true: same pooled object
System.out.println(a == c); // false: c is a new object
System.out.println(a.equals(c)); // true: same content
System.out.println(a == c.intern()); // true: intern returns the pooled copy
Using == to compare string content, or saying immutability exists only to save memory.
Contract: equal objects must return the same hash code; unequal objects may share one.
Failure: HashMap and HashSet look in the bucket chosen by hashCode first, so equal objects end up in different buckets.
Good practice: use the same fields in both, prefer immutable fields, generate or use a record.
"The contract says that if two objects are equal by equals, they must return the same hashCode. The reverse isn't required: different objects can collide. If I override equals and forget hashCode, each object keeps the default identity-based hash code. Then HashMap and HashSet break in a quiet way. Say I add a Point(1, 2) to a HashSet, then ask contains(new Point(1, 2)). equals would say they match, but the set first uses hashCode to pick a bucket, looks in a different bucket, and returns false. I get duplicates in sets and lookups that miss. So I build both methods from the same fields, usually with Objects.equals and Objects.hash, or I use a record, which generates both correctly. I also avoid mutable fields in the hash, because changing one after insertion strands the object in the wrong bucket."
final class Point {
private final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
Saying equal hash codes mean equal objects, or not being able to name the collection bug.
Lock it down: final class, private final fields, no setters.
Defensive copies: copy mutable inputs in the constructor and never hand out the internal object.
Change means new: methods that 'modify' return a new instance.
"I make the class final so nobody can subclass it and add mutable behaviour. All fields are private and final, and there are no setters. The part people miss is mutable fields. If my class holds a List or a Date and I just store the reference passed into the constructor, the caller can still change it from outside. So I copy it on the way in, for a list with List.copyOf, which also gives me an unmodifiable list, and I never return the internal mutable object from a getter. Any method that looks like a change, like withSalary, returns a new object instead. The payoff is that the object is safe to share between threads without locks, and once I add equals and hashCode it makes a safe map key. For simple data carriers I'd reach for a record now, but I still have to copy mutable components myself."
public final class Team {
private final String name;
private final List<String> members;
public Team(String name, List<String> members) {
this.name = name;
this.members = List.copyOf(members); // defensive, unmodifiable copy
}
public String name() { return name; }
public List<String> members() { return members; } // safe: cannot be modified
public Team withMember(String m) {
List<String> next = new ArrayList<>(members);
next.add(m);
return new Team(name, next);
}
}
Stopping at 'final fields and no setters' while storing a caller's mutable list directly.
Bucket choice: hashCode is spread and masked to an index in an internal array.
Collision: entries sharing a bucket form a linked list, which becomes a balanced tree when it grows large.
Lookup and resize: get finds the bucket, then uses equals; past the load factor the array doubles.
"Internally a HashMap is an array of buckets. On put, it calls the key's hashCode, mixes the high bits into the low bits so poor hash codes still spread out, and uses that to pick a bucket index. If the bucket is empty the entry goes straight in. If another key is already there, it walks the entries and uses equals: a match replaces the value, otherwise the new entry is added. That's a collision. When one bucket gets crowded, Java converts its list into a red-black tree, so a bad hash doesn't make lookups linear. On get, it computes the same bucket and uses equals to find the key. When the number of entries passes capacity times the load factor, 0.75 by default, it doubles the array and redistributes. So lookups are constant time on average, not guaranteed."
Claiming get is always constant time, or not mentioning equals at all.
Old options: Hashtable and synchronizedMap lock the whole map on every call, so threads queue up.
ConcurrentHashMap: reads mostly don't lock; writes lock only one bin; iterators don't throw ConcurrentModificationException.
The trap: get then put is still a race; use putIfAbsent, computeIfAbsent or merge.
"Hashtable and Collections.synchronizedMap make every method synchronized on one lock, so only one thread can touch the map at a time, even for reads. ConcurrentHashMap was built for concurrency. In modern versions, reads usually take no lock at all, and a write locks only the single bin it's changing, or uses a compare-and-swap if the bin is empty. So many threads can read and write different keys at once. Its iterators are weakly consistent, meaning they won't throw ConcurrentModificationException. It also refuses null keys and values, so a null from get always means 'absent'. The catch is compound actions. If I write 'if the key is missing, put a value', that's two calls, and another thread can slip in between. I use the atomic methods instead: computeIfAbsent, putIfAbsent or merge for counters."
ConcurrentHashMap<String, Integer> hits = new ConcurrentHashMap<>();
// Race: two threads can both read 0 and both write 1
Integer old = hits.get(page);
hits.put(page, old == null ? 1 : old + 1);
// Atomic per key
hits.merge(page, 1, Integer::sum);
Saying any code that uses ConcurrentHashMap is automatically thread-safe.
ArrayList: backed by an array; fast index access; appending is cheap on average; middle inserts shift elements.
LinkedList: doubly linked nodes; cheap at the ends, but reaching an index means walking the list.
Practice: ArrayList by default; ArrayDeque for queues and stacks.
"ArrayList stores elements in an array, so get by index is constant time. Adding at the end is cheap on average; now and then it grows the array and copies, but that cost spreads out. Inserting or removing in the middle means shifting everything after that point. LinkedList is a chain of nodes, each pointing to the next and previous, so adding or removing at either end is constant time, but get(i) has to walk the chain. People say LinkedList is better for inserting in the middle, but you still have to walk to that position first, unless you're already there with an iterator. Each node also costs extra memory and is scattered in memory, which the CPU cache doesn't like. So I use ArrayList almost always, and when I need a queue or a stack I use ArrayDeque rather than LinkedList."
Saying LinkedList is always faster for inserts without mentioning the cost of reaching the position.
Cause: the list counts structural changes; the iterator notices the count changed behind its back and fails fast.
Not about threads: one thread is enough to trigger it.
Fixes: Iterator.remove, removeIf, or build a new filtered list.
"A for-each loop uses an iterator under the hood. ArrayList keeps a modification count, and the iterator remembers the value it started with. If I call list.remove inside the loop, the count changes without the iterator knowing, so on the next step it notices and throws ConcurrentModificationException. It's a fail-fast check, and despite the name it happens in a single thread. The safe ways are to use the iterator's own remove method, which keeps the two in sync, or, much cleaner since Java 8, removeIf with a condition. If I'd rather not change the original list, I stream and filter into a new one. And if many threads really do share the collection, I'd use a concurrent collection instead of relying on this check, because fail-fast behaviour is only best effort."
List<String> names = new ArrayList<>(List.of("ana", "bob", "al"));
// Throws ConcurrentModificationException
// for (String n : names) if (n.startsWith("a")) names.remove(n);
Iterator<String> it = names.iterator();
while (it.hasNext()) if (it.next().startsWith("a")) it.remove();
// Or, simpler
names.removeIf(n -> n.startsWith("a"));
Saying the exception only happens with multiple threads, or fixing it with a try-catch.
Shortcut: LinkedHashMap in access order, overriding removeEldestEntry.
By hand: a HashMap for lookup plus a doubly linked list for recency, both constant time.
Limits: not thread-safe; say how you'd make it safe.
"The quickest correct answer is LinkedHashMap. It keeps a linked list through its entries, and if I pass true for access order in the constructor, every get moves that entry to the end. So the head of the list is always the least recently used. Then I override removeEldestEntry to return true when the size goes over my limit, and the map drops the least recently used entry after each insert. That's a working LRU cache in a few lines. If the interviewer wants it by hand, I'd keep a HashMap from key to node plus a doubly linked list: get moves the node to the front, put adds at the front, and when full I drop the tail. Both give constant-time operations. Neither version is thread-safe, so for shared use I'd wrap it with a lock or use a proper caching library."
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LruCache(int capacity) {
super(16, 0.75f, true); // true = access order
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
Scanning a list to find the oldest entry on every call, which makes each operation linear.
Checked: subclasses of Exception outside RuntimeException; the compiler makes you catch or declare them.
Unchecked: RuntimeException and its subclasses, plus Error; not enforced by the compiler.
Choice: checked when the caller can reasonably recover; unchecked for programming mistakes.
"Checked exceptions are the ones the compiler makes me deal with: anything that extends Exception but not RuntimeException, like IOException. I either catch it or declare it with throws. Unchecked exceptions extend RuntimeException, like NullPointerException or IllegalArgumentException, and the compiler doesn't force anything. Errors, like OutOfMemoryError, are also unchecked and usually mean the JVM itself is in trouble, so I don't catch them. For my own code, I throw a checked exception when the caller can realistically do something about it, like retry or ask for a different file. I throw an unchecked one when it's a bug or a broken precondition, like a null argument, where the right fix is to change the code, not catch it. Many teams lean unchecked because checked exceptions don't fit well with lambdas and streams."
Saying unchecked exceptions can't be caught, or catching Throwable as a habit.
Almost always: finally runs after a return or an exception, but not if the JVM exits or dies first.
Trap: returning or throwing from finally hides the original exception.
try-with-resources: closes every AutoCloseable in reverse order and keeps close errors as suppressed exceptions.
"Finally runs whether the try block finishes normally, returns early or throws, which is why it's used for clean-up. It won't run if the JVM stops first, like System.exit, a crash, or the process being killed, or if the try block never finishes. One trap: if finally has its own return, or throws, it silently replaces whatever exception was on its way out, so I never return from finally. For closing things like streams, connections or locks that implement AutoCloseable, I use try-with-resources. It closes each resource automatically, in the reverse order they were opened, even when an exception is thrown. And if close itself throws while another exception is already in flight, the original stays the main exception and the close failure is attached as a suppressed one, so nothing is lost. The old hand-written finally version got that wrong constantly."
try (var in = Files.newBufferedReader(path);
var out = Files.newBufferedWriter(target)) {
String line;
while ((line = in.readLine()) != null) out.write(line.toUpperCase() + "\n");
} // out closed first, then in, even on exception
Saying finally runs no matter what, or returning a value from inside finally.
Erasure: the compiler checks generic types, then removes them; at runtime a List<String> is just a List.
Limits: no new T(), no runtime check that an object is a List<String>, no generic arrays, no overloads that differ only by type argument.
Workarounds: pass a Class<T> token; use bounded wildcards for flexible APIs.
"Generics in Java are checked by the compiler and then erased, which kept old compiled code working when generics arrived. At runtime, a List<String> and a List<Integer> are the same class; the type argument is gone and the compiler inserts casts where values come out. That leads to real limits. I can't write new T() because the JVM doesn't know what T is. I can't ask at runtime whether an object is a List<String>. I can't create a generic array like new T[10] directly. And I can't have two overloads that take List<String> and List<Integer>, because after erasure they have the same signature. The usual workaround is to pass a Class<T> in, so the method can create instances or check types. For flexible APIs I use wildcards: extends when I only read from a collection, super when I only write into it."
// Does not compile: same erasure
// void save(List<String> xs) {}
// void save(List<Integer> xs) {}
<T> T create(Class<T> type) throws ReflectiveOperationException {
return type.getDeclaredConstructor().newInstance();
}
double sum(List<? extends Number> xs) {
double total = 0;
for (Number n : xs) total += n.doubleValue();
return total;
}
Saying generic type information is fully available at runtime, or not knowing why new T() fails.
volatile: every read sees the latest write; no reordering across it; no locking and no atomicity.
synchronized: one thread at a time in the block, plus visibility of changes when the lock is released and taken.
count++: read, add, write; volatile doesn't make that atomic; use AtomicInteger or a lock.
"volatile is about visibility. Without it, one thread can keep reading a stale copy of a field and never see another thread's update, which is how a stop flag loop can spin forever. Marking the field volatile means every read sees the most recent write, and the JVM won't reorder other reads and writes around it in harmful ways. But it doesn't lock anything. synchronized gives mutual exclusion, so only one thread runs the block at a time for a given lock, and it also gives visibility: whatever one thread wrote before releasing the lock, the next thread sees after taking it. count++ is three steps: read, add one, write back. Two threads can both read five and both write six, even with volatile. So for a counter I use AtomicInteger, which does it atomically with compare-and-swap, or a lock."
class Worker implements Runnable {
private volatile boolean running = true; // visibility is enough
private final AtomicInteger done = new AtomicInteger(); // needs atomicity
public void run() {
while (running) { doTask(); done.incrementAndGet(); }
}
void stop() { running = false; }
void doTask() { }
}
Saying volatile makes operations atomic or thread-safe in general.
What: two or more threads each hold a lock and wait for one the other holds, forever.
Prevent: always take locks in one global order, hold them briefly, or use tryLock with a timeout.
Detect: take a thread dump; the JVM reports Java-level deadlocks and the locks involved.
"A deadlock is when threads wait on each other in a cycle and none can move. The textbook case is a money transfer. Thread one transfers from A to B, so it locks A then B. Thread two transfers from B to A, so it locks B then A. If each grabs its first lock at the same moment, both wait forever for the second. The most reliable fix is a consistent lock order: always lock the account with the smaller ID first, whatever the direction of the transfer. Other tools are keeping locked sections short, never calling unknown code while holding a lock, and using ReentrantLock.tryLock with a timeout so a thread can back off. To detect one in a running service, I take a thread dump with jstack or jcmd. It reports Java-level deadlocks directly and shows which thread holds which lock."
void transfer(Account from, Account to, long amount) {
Account first = from.id() < to.id() ? from : to; // one global order
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
Only listing the four textbook conditions without a concrete example or a practical fix.
Why pools: threads are expensive; a pool reuses them and caps how many run at once.
Sizing: near the core count for CPU work; more for tasks that mostly wait on I/O; bounded queue plus a rejection policy.
Hygiene: name threads, check Futures for exceptions, shut the pool down.
"Creating a thread per task is costly, and under load it can create thousands of threads and bring the service down. An ExecutorService keeps a pool of reusable threads and a queue of waiting tasks, so I control how much runs at once. For CPU-heavy work I size the pool close to the number of cores. For tasks that mostly wait on I/O I can go larger. I'm careful with the Executors shortcuts: newFixedThreadPool uses an unbounded queue, so if tasks arrive faster than they finish, the queue grows until memory runs out. In production I'd rather build a ThreadPoolExecutor with a bounded queue, named threads and a clear rejection policy. Two more habits: if I use submit, an exception is stored in the Future and is lost if nobody calls get, and I always shut the pool down on exit."
AtomicInteger n = new AtomicInteger();
ThreadFactory named = r -> new Thread(r, "report-worker-" + n.incrementAndGet());
ExecutorService pool = new ThreadPoolExecutor(
8, 8, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(500), // bounded queue
named, // readable names in thread dumps
new ThreadPoolExecutor.CallerRunsPolicy()); // back-pressure when full
Future<Report> f = pool.submit(() -> buildReport(id));
Report r = f.get(5, TimeUnit.SECONDS); // a task failure arrives as ExecutionException
pool.shutdown();
Using Executors.newFixedThreadPool everywhere without knowing its queue is unbounded, or never shutting a pool down.
Double-checked locking: check, lock, check again; the field must be volatile.
Why volatile: without it another thread can see the reference before the constructor's writes are visible.
Simpler options: the holder class idiom, or an enum singleton.
"With double-checked locking I check whether the instance is null, and only if it is do I lock and check again before creating it, so the lock is taken only on the first calls. The field must be volatile. Creating an object is really several steps: allocate memory, run the constructor, publish the reference. Without volatile, those steps can be reordered from another thread's point of view, so that thread might see a non-null reference to an object whose fields aren't set yet, skip the lock and use a half-built object. volatile forbids that. Honestly, I'd rather not write it at all. The holder idiom is lazy and thread-safe because the JVM initialises a class only once, when it's first used. And if I need protection against serialization and reflection creating copies, an enum with one value is the simplest singleton."
class Config {
private static volatile Config instance;
private Config() { }
static Config get() {
Config c = instance;
if (c == null) {
synchronized (Config.class) {
c = instance;
if (c == null) instance = c = new Config();
}
}
return c;
}
}
// Simpler: lazy and thread-safe through class initialisation
class Settings {
private Settings() { }
private static class Holder { static final Settings INSTANCE = new Settings(); }
static Settings get() { return Holder.INSTANCE; }
}
Writing double-checked locking without volatile, or synchronizing the whole getter without knowing its cost.
Name the risk: concurrent writes can lose updates and corrupt the map's internal structure.
Why tests pass: unit tests are single-threaded; the race shows only under real load.
Fix and tone: suggest ConcurrentHashMap with atomic methods, or no shared state; explain, don't just block.
"I wouldn't approve it as is, even though the tests are green. HashMap isn't thread-safe. If two request threads write at once, updates can be lost, and a write during a resize can leave the map in a broken state that's very hard to debug later. The tests pass because they run on one thread, so they can't show the problem. I'd leave a clear comment explaining the risk, and suggest ConcurrentHashMap. I'd also look at how it's used: if the code does a get and then a put, I'd suggest computeIfAbsent or merge so the whole update is atomic. And I'd ask whether the state needs to be shared at all; maybe it belongs in a proper cache or per request. I'd offer to pair on it, because the goal is a correct service and a colleague who spots it next time."
Approving it because the tests pass, or rejecting it with no explanation of the risk.
Functional interface: exactly one abstract method; default and static methods don't count; @FunctionalInterface makes the compiler check it.
Lambda: a short way to implement that one method; the interface it's assigned to decides what it means.
Everyday ones: Function, Predicate, Supplier, Consumer; a method reference like String::length is a shorter lambda.
"A functional interface is an interface with exactly one abstract method. Default and static methods don't count, so Comparator is one even though it has lots of default methods. Runnable and Callable are older examples, and java.util.function adds the everyday ones: Function takes a value and returns one, Predicate returns a boolean, Supplier takes nothing and returns something, and Consumer takes something and returns nothing. A lambda is just a compact way to implement that single method. On its own a lambda has no type; the interface it's assigned to decides what it means. A method reference like String::length is a shorter lambda when all I do is call an existing method. On my own interfaces I add @FunctionalInterface, so the compiler complains if someone adds a second abstract method. And a lambda can only use local variables that are effectively final."
Predicate<String> blank = s -> s.isBlank();
Function<String, Integer> length = String::length; // method reference
Supplier<List<String>> fresh = ArrayList::new;
Consumer<String> log = System.out::println;
@FunctionalInterface
interface RetryRule { boolean shouldRetry(int attempt); }
RetryRule upToThree = attempt -> attempt < 3;
Saying a functional interface can't have default methods, or not being able to name any interface from java.util.function.
Code: groupingBy with a counting downstream collector.
Laziness: intermediate steps like filter and map only describe work; nothing runs until a terminal operation.
Rules: a stream is used once; lambdas shouldn't change shared state.
"I'd stream the list and collect with groupingBy, using the word length as the key and Collectors.counting as the downstream collector. That gives a map from length to how many words have it. If I want the lengths sorted, I pass a TreeMap supplier. On laziness: operations like filter, map and sorted are intermediate. They just build a pipeline and return a new stream; nothing actually runs. The work happens only when a terminal operation like collect, count or forEach is called, and then each element flows through the whole pipeline. That's why a findFirst after a filter can stop early without touching the rest of the list. Two rules I keep: a stream can be consumed only once, and the lambdas inside shouldn't modify outside variables or collections. If I need a result, I let a collector build it."
List<String> words = List.of("java", "heap", "gc", "stream", "jvm");
Map<Integer, Long> byLength = words.stream()
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
// {2=1, 3=1, 4=2, 6=1}
Map<Integer, Long> sorted = words.stream()
.collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.counting()));
Adding to an outside list from inside forEach instead of collecting, or not knowing that nothing runs without a terminal operation.
Purpose: a return type that says 'there may be no value' so the caller must handle it.
Use well: map, filter, orElse, orElseGet, orElseThrow; avoid isPresent followed by get.
Avoid: fields, method parameters, collections of Optionals, and Optional of a collection.
"Optional is meant for return types where 'no result' is normal, like findUserByEmail. Returning Optional makes that visible in the signature, so the caller has to decide what happens when it's empty instead of getting a NullPointerException later. I use it with map and filter to chain steps, and finish with orElse, orElseGet or orElseThrow. There's a subtle difference there: orElse always evaluates its argument, even when a value is present, so if the default is expensive, like a database call, I use orElseGet with a lambda. What I avoid: calling get without checking, because it throws when empty, and using Optional for fields or parameters, where it adds overhead and noise. For collections I return an empty list, never an Optional of a list. And an Optional variable itself should never be null."
String city = repo.findByEmail(email) // Optional<User>
.map(User::address)
.map(Address::city)
.orElse("Unknown");
User u = repo.findByEmail(email)
.orElseThrow(() -> new NotFoundException(email));
Config c = cache.find(key).orElseGet(() -> loadFromDb(key)); // lazy default
Using isPresent followed by get everywhere, or putting Optional on every field and parameter.
What: a compact, implicitly final class for carrying data; standard since Java 16.
Generated: private final fields, a canonical constructor, accessors, equals, hashCode and toString.
Limits: can't extend a class, fields can't be reassigned, immutability is shallow; validate in a compact constructor.
"A record is a class whose job is to carry data. I write record Point(int x, int y) and Java generates private final fields, a constructor that takes both values, accessor methods named x() and y(), and equals, hashCode and toString based on the fields. So a data class that used to take forty lines of boilerplate is one line, and equals and hashCode can't drift out of sync. Records became a standard feature in Java 16. They're implicitly final, can't extend another class, but can implement interfaces and have extra methods. If I need validation, I add a compact constructor. One thing to watch: they're only shallowly immutable. If a component is a List, the caller can still change that list unless I copy it in the constructor. I use them for DTOs, keys in maps and results returned from methods."
record Money(long amountInCents, String currency) {
Money { // compact constructor
if (amountInCents < 0) throw new IllegalArgumentException("negative");
Objects.requireNonNull(currency);
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(amountInCents + other.amountInCents, currency);
}
}
Saying records are fully immutable no matter what they contain, or that accessors are named getX().
Shared pool: parallel streams run on the common ForkJoinPool, shared across the whole application.
When it helps: large data, CPU-heavy work, no blocking, easy-to-split sources, no shared mutable state.
Response: measure one real case, keep it where it wins, not everywhere.
"I'd say it isn't a free speed-up, and suggest we measure instead of switching everything. Parallel streams split the work across the common ForkJoinPool, which the whole application shares. In a web service that already handles many requests at once, the CPU is often busy anyway, so splitting each request's work mostly adds overhead. For small collections, the cost of splitting and merging is bigger than the work itself. If a stream does blocking calls, like HTTP or database calls, it can tie up the shared pool and slow down unrelated code. And any lambda that touches shared state becomes a race. Where it does help is large, CPU-heavy work on data that splits easily, like an ArrayList. So I'd pick the one or two pipelines we think are slow, benchmark sequential against parallel, and switch only where the numbers show a clear win."
Agreeing to make everything parallel, or refusing flatly without giving the reasons or offering to measure.
Symptom: what went wrong, and why it was intermittent.
Hunt: how you narrowed it down: logs, thread dumps, a stress test.
Fix and lesson: the root cause, the fix and what you changed so it doesn't recur.
"At my last company a reporting service sometimes produced wrong dates, and now and then threw a NumberFormatException, but never in testing. It only showed up under load, which already pointed at threads. I went through the classes on that path and found a static SimpleDateFormat shared by every request. SimpleDateFormat keeps parsing state inside the object, so two threads parsing at once corrupt each other. To prove it, I wrote a small test that parsed dates from many threads at the same time, and it failed within seconds. The fix was to switch to DateTimeFormatter from java.time, which is immutable and safe to share. The test then passed every run. Afterwards I added a check to our code review list for mutable objects in static fields, and we found two more similar cases."
A story where the fix was adding synchronized everywhere or a retry, without ever naming the root cause.
Measure first: profiler or timing, not guessing.
Cause: the specific hot spot and why it was slow.
Result: the change, the before and after, and how you proved nothing broke.
"At my last company, a nightly job that matched incoming records against an existing list was taking far too long. Instead of guessing, I ran it with a profiler, and almost all the time was inside a loop that called list.contains for every record. On a list with hundreds of thousands of items that's a linear scan each time, so the whole job was quadratic. I built a HashSet of the keys once before the loop, so each lookup became constant time on average. The same profile showed String.matches inside the loop, which compiles the regex again on every call, so I compiled one Pattern into a constant and reused it. The job went from most of an hour to a couple of minutes. I compared the output files from the old and new versions to make sure the results were identical."
Optimising by instinct with no measurement, or a result with no before and after.
Why: the reason for the upgrade, such as support ending or needed features.
What broke: removed JDK modules, libraries using JDK internals, build plugins.
Risk control: dependency upgrades first, full test run, staged rollout, watch GC and latency.
"At my last company I led moving a group of services from Java 8 to Java 17, mainly because Java 8 support was ending for us and we wanted records and better garbage collectors. The first breakages were at compile time: we used JAXB for XML, and those modules were deprecated in Java 9 and removed from the JDK in Java 11, so we had to add them back as normal dependencies. Then a couple of old libraries broke at runtime because they reached into JDK internals with reflection, which newer versions block by default. We upgraded those libraries first, one pull request at a time, while still on Java 8. After that the switch itself was small. We rolled it out to one service at a time, compared GC pauses and latency with the old version, and kept the old build ready to roll back. None of them needed it."
Claiming the upgrade needed no changes at all, or upgrading everything in one release with no rollback plan.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.