Java interview questions for 5 years experience test whether we know how Java works inside, for example how HashMap finds a key or why a @Transactional method sometimes runs without a transaction. At this level, a correct definition is only half an answer. The interviewer wants the reason behind it and the trade-off, ideally with a case from a real project where it mattered.
We use these questions to revise before a mid-level or senior Java interview in 2026. Most loops start with collections and concurrency, and move on to Java 21 and Java 25 features, the JVM, Spring Boot and a small system design task.
Ten questions appear in almost every list of Java questions for experienced developers, so we learn their short answers by heart.
| Most asked question | Short answer |
|---|---|
| How does HashMap work internally? | It hashes the key to a bucket index, stores entries as a linked list or a red-black tree, and doubles the table above 75% load. |
| What is the equals() and hashCode() contract? | Equal objects must return the same hash code, otherwise HashMap and HashSet lose them. |
| How is ConcurrentHashMap thread-safe? | It writes to an empty bucket with CAS and locks only the first node of a non-empty bucket. Reads take no lock. |
| What does volatile guarantee? | Visibility and ordering, not atomicity, so count++ on a volatile field still loses updates. |
| What are virtual threads? | Cheap threads managed by the JVM, good for blocking I/O tasks and useless for CPU-bound work. |
| What problem do sealed classes solve? | They fix the list of subtypes, so a switch over them is checked for completeness by the compiler. |
| How do we make a class immutable? | A final class, private final fields, no setters, and defensive copies of mutable inputs. |
| How does G1 differ from ZGC? | G1 balances throughput and pauses (target 200 ms). ZGC keeps pauses under a millisecond at some throughput cost. |
| Why does a self-call skip @Transactional? | The transaction starts in the Spring proxy, and this.method() never goes through the proxy. |
| How do we find a memory leak? | Take a heap dump on OutOfMemoryError and look at which objects keep the biggest retained size. |
Each answer below starts with that short version and explains the reason after it, mostly with a small snippet that runs on Java 25. The code for every answer is in the example project on GitHub. For the basics, the site also has separate core Java interview questions and Java OOP interview questions.
1. Core Java Interview Questions for 5 Years Experience
Core Java questions at the 5-year level are rarely about syntax. Interviewers use them, like the classic String interview questions, to check whether we can predict what the JVM does.
1.1. How Do We Make a Class Immutable?
We declare the class final, make every field private final, skip the setters, and copy every mutable input and output. An immutable object never changes after construction, so we can share it between threads without locks and use it as a map key safely.
The copy is the part most candidates forget. In the following snippet, Playlist stores List.copyOf(songs) in its constructor, so a change to the caller’s list does not reach the playlist. A “change” method such as withSong() returns a new object.
List<String> songs = new ArrayList<>(List.of("one", "two"));
Playlist playlist = new Playlist("road trip", songs);
songs.add("three"); // the caller changes its own list
int size = playlist.songs().size(); // 2, the playlist kept its copy
Playlist longer = playlist.withSong("three"); // new object with 3 songs
boolean added = playlist.songs().add("four"); // UnsupportedOperationException
1.2. Checked or Unchecked Exceptions, Which One Do We Throw?
We throw checked exceptions when the caller can do something useful about the failure, such as a missing file, and unchecked exceptions for programming errors and for failures nobody can recover from. The compiler forces callers to catch or declare a checked exception, which is why most modern APIs, including Spring, wrap them in unchecked ones.
The 5-year follow-up is what try-with-resources does when both the body and close() fail. The exception from the body wins, and the exception from close() is attached to it as a suppressed exception, so the log shows both.
String main;
String suppressed;
AutoCloseable resource = () -> {
throw new IllegalStateException("close failed");
};
try (resource) {
throw new RuntimeException("body failed");
} catch (Exception e) {
main = e.getMessage(); // body failed
suppressed = e.getSuppressed()[0].getMessage(); // close failed
}
2. Java 17 to 25 Features
Java 25 is the current LTS release (September 2025), and Java 27 reached general availability on 15 September 2026. Interviewers expect us to have used at least Java 17 or 21 at work, so they ask about records, sealed classes, pattern matching and virtual threads.
2.1. What Is a Record, and When Should We Not Use One?
A record is a final class whose state is a fixed set of components, and the compiler generates the constructor, accessors, equals(), hashCode() and toString() for us. We use records for DTOs, API responses, map keys and values returned from methods. We validate the input in a compact constructor.
public record Fruit(String name, int count) {
public Fruit {
Objects.requireNonNull(name, "name");
if (count < 0) {
throw new IllegalArgumentException("count must be >= 0");
}
}
}
Fruit apple = new Fruit("apple", 5);
String text = apple.toString(); // Fruit[name=apple, count=5]
boolean same = apple.equals(new Fruit("apple", 5)); // true
int count = apple.count(); // 5
Fruit bad = new Fruit("banana", -1); // IllegalArgumentException: count must be >= 0
We do not use a record for a JPA entity, because Hibernate needs a no-argument constructor and mutable fields for lazy loading and dirty checking. A record with a List component is also only shallowly immutable unless we copy the list in the compact constructor.
2.2. What Problem Do Sealed Classes Solve?
A sealed class or interface lists the only classes that may extend it in its permits clause. Before sealed types, any class could implement our interface, so the compiler could not tell whether a switch covered all cases. With a sealed hierarchy, it can, and a switch over it needs no default branch.
The following snippet models three payment types. If we add a fourth record to permits later, every switch that does not handle it stops compiling, which is the main reason to use sealed types.
public sealed interface Payment permits Card, Cash, Voucher {}
record Card(int amount) implements Payment {}
record Cash(int amount) implements Payment {}
record Voucher(int amount, boolean expired) implements Payment {}
Payment payment = new Card(500);
int fee = switch (payment) {
case Card(int amount) -> amount * 2 / 100;
case Cash _ -> 0;
case Voucher(int amount, boolean expired) when expired ->
throw new IllegalStateException("voucher expired");
case Voucher _ -> 0;
}; // 10
2.3. How Does Pattern Matching for switch Work?
Since Java 21, a switch can test the type of its value and deconstruct records, as case Card(int amount) does in the previous snippet. Each case can add a condition with when, and case Cash _ uses an unnamed pattern, final since Java 22, for a value we do not read.
Interviewers follow up with two rules.
- The order of the cases matters. The compiler rejects a case that an earlier case already covers, such as case Voucher _ before the guarded case Voucher(…) when expired.
- A switch without case null throws NullPointerException for a null value, as it always did. With case null, we handle null inside the switch.
2.4. What Are Virtual Threads, and When Should We Not Use Them?
A virtual thread is a java.lang.Thread that the JVM schedules on a small pool of platform threads, called carrier threads. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier, so the carrier can run another virtual thread. That makes one thread per task affordable even for 10,000 concurrent tasks.
In the following snippet, 10,000 tasks each block for one second, and on virtual threads they all finish in under 2 seconds. A fixed pool of 200 platform threads runs the same tasks in 50 rounds of one second, so they take about 50 seconds.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Integer>> futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> {
LockSupport.parkNanos(Duration.ofSeconds(1).toNanos()); // blocks like an I/O call
return i;
}))
.toList();
} // all 10,000 finish in under 2 seconds
We do not use virtual threads in these cases.
- CPU-bound work, such as image resizing, gets no speedup, because the number of carrier threads equals the number of CPU cores.
- We never pool virtual threads. We create one per task, and we limit access to a scarce resource, such as a database with 20 connections, with a Semaphore or the connection pool.
- Large objects in ThreadLocal multiply by the number of threads, so a million virtual threads with a 1 MB cache each run out of memory.
Before Java 24, a virtual thread that blocked inside a synchronized block stayed pinned to its carrier. JEP 491 removed that limit in Java 24, so on Java 25 only a few rare cases still pin, such as blocking inside a class initializer or inside Java code called back from a native method. In a Spring Boot application, one property turns them on for Tomcat, as shown in Spring Boot virtual threads.
2.5. Are Structured Concurrency and Scoped Values Final?
Scoped values are final since Java 25, and structured concurrency is still a preview API in Java 27. Interviewers ask this to see whether we follow the releases.
| Feature | Status | What it does |
|---|---|---|
| Virtual threads | Final in Java 21 (JEP 444) | One cheap thread per task |
| No pinning in synchronized | Final in Java 24 (JEP 491) | Virtual threads unmount inside synchronized |
| Scoped values | Final in Java 25 (JEP 506) | Immutable per-call data, a safer ThreadLocal |
| Structured concurrency | Fifth preview in Java 25, seventh preview in Java 27 | Treats a group of subtasks as one unit that succeeds or fails together |
A ScopedValue is bound for the duration of one call and is visible to every method that call runs. Outside the call, it is unbound, so we cannot leak it the way we can leak a ThreadLocal in a thread pool.
static final ScopedValue<String> USER = ScopedValue.newInstance();
String greeting = ScopedValue.where(USER, "Lokesh")
.call(() -> "Hello " + USER.get()); // Hello Lokesh
boolean bound = USER.isBound(); // false, outside the scope
Using a preview API such as StructuredTaskScope needs –enable-preview at compile time and at run time. For the smaller final features, such as sequenced collections with getFirst() and reversed(), a one-line answer is enough, and the Java 21 features list covers them.
3. Collections Internals
Collections questions decide many interviews at this level, because they show whether we understand hashing and thread safety. Out of the long list of Java collections interview questions, these four come up most often for experienced developers.
3.1. How Does HashMap Work Internally?
HashMap stores entries in an array of buckets. On put(), it takes the key’s hashCode(), mixes the high bits into the low bits with h ^ (h >>> 16), and picks the bucket with (n – 1) & hash, where n is the table size. Inside the bucket, it compares the stored hash first and then calls equals(), and either replaces the value or appends a new node.

Interviewers expect the numbers too. The default capacity and load factor are in the HashMap Javadoc, and the tree thresholds are constants in the JDK 25 source.
| Setting | Value | Effect |
|---|---|---|
| Default capacity | 16 buckets | The table is created on the first put() |
| Load factor | 0.75 | The table doubles when size exceeds 12 of 16, 24 of 32, and so on |
| Treeify threshold | 8 | A bucket with more than 8 entries becomes a red-black tree, so lookup is O(log n) instead of O(n) |
| Minimum capacity for trees | 64 buckets | Below 64, the map resizes instead of building a tree |
| Untreeify threshold | 6 entries | A tree turns back into a list |
| null key | Allowed, one | Stored in bucket 0 |
More follow-ups are in HashMap interview questions.
3.2. What Is the equals() and hashCode() Contract?
If two objects are equal according to equals(), they must return the same hashCode(). The reverse is not required, so two unequal objects may share a hash code, which is a collision. When we override equals() without hashCode(), HashMap puts equal keys in different buckets and get() returns null.
The same failure happens when a key changes after we put it in the map. In the following snippet, Person computes its hash code from name, and we rename the person after the put().
Map<Person, Integer> ages = new HashMap<>();
Person lokesh = new Person("Lokesh");
ages.put(lokesh, 37);
lokesh.setName("Alex"); // hashCode() changes
Integer age = ages.get(lokesh); // null
boolean present = ages.containsValue(37); // true, the entry is still there
The entry is still in the map, but in the bucket for the old hash code. So we use immutable keys, such as String, Integer, records or enums.
3.3. How Is ConcurrentHashMap Thread-Safe?
Since Java 8, ConcurrentHashMap writes to an empty bucket with a CAS (compare-and-swap) operation and locks only the first node of a non-empty bucket with synchronized. Two threads that write to different buckets never wait for each other, and get() takes no lock at all. The old design with 16 fixed segments is gone.
The usual follow-up is how to count with it. Separate get() and put() calls are not atomic together, so we use merge() or compute(), which run atomically per key.
Map<String, Integer> counts = new ConcurrentHashMap<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 1_000; i++) {
pool.submit(() -> counts.merge("apple", 1, Integer::sum));
}
}
int apples = counts.get("apple"); // 1000
Integer old = counts.put("banana", null); // NullPointerException
Notice that ConcurrentHashMap rejects null keys and values, because get() returning null must mean “no mapping” without a second, racy containsKey() call. The older Collections.synchronizedMap() wrapper allows null but locks the whole map on every call.
3.4. What Is the Difference Between Fail-Fast and Fail-Safe Iterators?
A fail-fast iterator, such as the one from ArrayList or HashMap, throws ConcurrentModificationException when the collection changes during iteration by any means other than the iterator itself. A fail-safe iterator, such as the one from CopyOnWriteArrayList or ConcurrentHashMap, works on a snapshot or tolerates changes and never throws. The Javadoc calls the ConcurrentHashMap iterators weakly consistent, because they may or may not show changes made after the iterator was created.
List<String> fruits = new ArrayList<>(List.of("apple", "banana", "cherry"));
for (String fruit : fruits) {
if (fruit.startsWith("a")) {
fruits.remove(fruit); // ConcurrentModificationException
}
}
List<String> more = new ArrayList<>(List.of("apple", "banana", "cherry"));
boolean removed = more.removeIf(fruit -> fruit.startsWith("a")); // true, more = [banana, cherry]
The safe version is removeIf() or an explicit Iterator with iterator.remove(). Removing the second-to-last element in the loop above does not throw, because hasNext() returns false before the check runs, which is a favorite trick question.
4. Concurrency and Multithreading
Concurrency questions separate 5-year candidates most clearly, because the bugs show up only under load. These six come up in nearly every loop, and the full list is in Java concurrency interview questions.
4.1. What Does volatile Guarantee, and What Does It Not?
A write to a volatile field is visible to every thread that reads the field afterwards, and the JVM does not reorder reads and writes around it. The volatile keyword does not make compound actions atomic. The statement hits++ reads, adds and writes in three steps, so two threads can read the same value and one increment gets lost.
The following snippet runs one million increments in each of two threads, once on a volatile int and once on an AtomicInteger.
private volatile boolean running = true; // visibility: readers see the latest write
private volatile int hits = 0; // hits++ is still read-modify-write, not atomic
private final AtomicInteger safeHits = new AtomicInteger();
// 2 threads x 1,000,000 increments each
// hits -> 1,913,354 (lost updates, a different number on every run)
// safeHits -> 2,000,000
So volatile fits a stop flag or a reference that one thread publishes and others read. For counters we use AtomicInteger or LongAdder, and for several fields that change together we use a lock.
4.2. What Is the happens-before Relationship?
Happens-before is the rule from the Java Memory Model that decides when one thread is guaranteed to see another thread’s writes. If action A happens-before action B, B sees everything A wrote. Without a happens-before edge, the JVM and the CPU may reorder or cache writes, and the other thread may never see them.
The edges we name in an interview are these.
- An unlock of a monitor happens-before every later lock of the same monitor.
- A write to a volatile field happens-before every later read of that field.
- Thread.start() happens-before any action in the started thread, and every action in a thread happens-before another thread returns from join() on it.
- Submitting a task to an ExecutorService happens-before the task runs, and the task’s actions happen-before Future.get() returns.
- The rule is transitive, so A before B and B before C means A before C.
4.3. When Do We Use ReentrantLock Instead of synchronized?
We use synchronized by default, because it is shorter and the JVM releases the lock even when an exception is thrown. We switch to ReentrantLock when we need a feature synchronized does not have, such as a timeout, an interruptible wait, a fair queue or several wait conditions.
int balance = 100;
ReentrantLock lock = new ReentrantLock();
boolean acquired = lock.tryLock(500, TimeUnit.MILLISECONDS); // true, nobody holds the lock
if (acquired) {
try {
balance += 10; // balance = 110
} finally {
lock.unlock();
}
}
The unlock() call always goes in finally, because a ReentrantLock is never released on its own.
4.4. How Do We Shut Down an ExecutorService Correctly?
Since Java 19, ExecutorService implements AutoCloseable, so we create it in try-with-resources. Its close() method calls shutdown() and waits until all submitted tasks finish. On older versions, we call shutdown() and wait with awaitTermination(). If the tasks do not finish in time, we call shutdownNow().
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
Future<Integer> future = pool.submit(() -> 6 * 7);
int answer = future.get(1, TimeUnit.SECONDS); // 42
} // close(): shutdown() + wait for tasks
ExecutorService workers = Executors.newFixedThreadPool(2);
workers.submit(() -> 6 * 7);
workers.shutdown(); // no new tasks
if (!workers.awaitTermination(10, TimeUnit.SECONDS)) {
workers.shutdownNow(); // interrupt the stragglers
}
An executor that is never shut down keeps its threads alive, so the JVM does not exit.
4.5. How Do We Combine Results With CompletableFuture?
We start each call with supplyAsync() on our own executor, join two results with thenCombine(), add a deadline with orTimeout(), and turn failures into a fallback with exceptionally(). Without an executor argument, supplyAsync() uses the common ForkJoinPool, which is sized for CPU work and is shared by the whole JVM.
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 100, pool);
CompletableFuture<Integer> tax = CompletableFuture.supplyAsync(() -> 18, pool);
int total = price.thenCombine(tax, Integer::sum)
.orTimeout(2, TimeUnit.SECONDS)
.join(); // 118
int fallback = CompletableFuture.supplyAsync(() -> Integer.parseInt("abc"), pool)
.exceptionally(ex -> 0)
.join(); // 0
The ex passed to exceptionally() is a CompletionException that wraps the NumberFormatException, so we call getCause() before logging it. In Spring, @Async methods return a CompletableFuture too, as shown in Spring Boot @Async with CompletableFuture.
4.6. What Causes a Deadlock, and How Do We Prevent It?
A deadlock happens when two threads each hold a lock and wait for the lock the other one holds. Say a bank app transfers money from account A to B in one thread and from B to A in another, and each transfer locks the source account first. Each thread gets its first lock and waits forever for the second.
We prevent deadlocks in one of these ways.
- Always take locks in the same global order, for example by account ID.
- Use tryLock() with a timeout and release everything when it fails.
- Keep critical sections small and never call unknown code while holding a lock.
To find a deadlock in production, we take a thread dump with jcmd PID Thread.print or jstack; the dump ends with a “Found one Java-level deadlock” section that names both threads.
5. JVM, Memory and Garbage Collection
JVM questions check whether we can run Java in production, not only write it. Expect at least one question about an OutOfMemoryError we fixed, so it helps to have a real story ready. Lokesh: add a real case here?
5.1. What Are the Memory Areas of the JVM?
The heap holds all objects and is shared by all threads, while each thread has its own stack with one frame per method call. The garbage collector manages only the heap. Class metadata lives in Metaspace, which is native memory and replaced PermGen in Java 8.

Each area fails with its own error, so the error message tells us which area to look at.
| Area | Holds | Size flag | Error when full |
|---|---|---|---|
| Heap | Objects, arrays, string pool, static fields | -Xms, -Xmx | OutOfMemoryError: Java heap space |
| Metaspace | Class metadata | -XX:MaxMetaspaceSize | OutOfMemoryError: Metaspace |
| Thread stack | Frames, local variables, references | -Xss | StackOverflowError |
| Code cache | JIT-compiled code | -XX:ReservedCodeCacheSize | JIT stops compiling, with a warning |
5.2. How Do G1 and ZGC Differ, and Which One Should We Pick?
G1 is the default collector since Java 9 and balances throughput and pause time, with a default pause target of 200 ms. ZGC does almost all its work concurrently and stops application threads for less than a millisecond, whatever the heap size. ZGC pays for that with more CPU and memory overhead.
| G1 | ZGC | Parallel | |
|---|---|---|---|
| Flag | default (G1 docs) | -XX:+UseZGC (ZGC docs) | -XX:+UseParallelGC |
| Pause time | Target 200 ms (-XX:MaxGCPauseMillis) | Under 1 ms | Longest, stops the world |
| Heap sizes | Small to large | A few hundred MB to 16 TB | Small to large |
| Best for | Most services | Latency-sensitive APIs, big heaps | Batch jobs, maximum throughput |
Two version facts often come up. ZGC is generational only since Java 24, because JEP 490 removed the old non-generational mode. Java 27 makes G1 the default in all environments (JEP 523); before that, the JVM picked Serial GC on machines with one CPU or less than 1792 MB of memory, such as small containers.
We start with G1 and switch to ZGC only when GC logs show pauses that break our latency goal. The garbage collection algorithms post compares the older collectors.
5.3. How Can a Java Program Leak Memory if It Has a Garbage Collector?
The garbage collector frees only objects that nothing references. A leak in Java is an object that our code still references but will never use again, so the collector must keep it. The most common causes we see are these.
- A static map used as a cache that never removes entries.
- Listeners or callbacks that are registered and never unregistered.
- ThreadLocal values in a thread pool that are never removed, because pool threads live forever.
- Unclosed resources, such as streams and JDBC connections, that keep buffers alive.
private static final Map<String, byte[]> CACHE = new HashMap<>(); // never evicts
CACHE.put("report-" + i, new byte[1024 * 1024]); // 1 MB per entry, until OutOfMemoryError
The fix is a bounded cache, such as the LRU cache from section 9.2, or a cache library with a size limit and expiry. A WeakHashMap helps only when the key object itself goes out of use, and for ThreadLocal in pools we call remove() in finally, as explained in ThreadLocal.
5.4. How Do We Diagnose an OutOfMemoryError?
We start the JVM with -XX:+HeapDumpOnOutOfMemoryError, open the dump in a heap analyzer such as Eclipse Memory Analyzer, and look at the dominator tree, the objects with the biggest retained size. The class that holds them, often a static map, is the leak. The message after OutOfMemoryError tells us which area ran out, as the table in section 5.1 shows.
Running the leaking cache from the previous question with a 64 MB heap prints this.
java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=target \
-cp target/classes com.howtodoinjava.interview.jvm.LeakDemo
java.lang.OutOfMemoryError: Java heap space
Dumping heap to target/java_pid31161.hprof ...
Heap dump file created [36475834 bytes in 0.043 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.HashMap.newNode(HashMap.java:1910)
at com.howtodoinjava.interview.jvm.LeakDemo.main(LeakDemo.java:16)
The stack trace shows only the allocation that failed, not the leak, so we always read the dump. On a running JVM, jcmd PID GC.class_histogram lists the classes with the most bytes before we take a full dump with jcmd PID GC.heap_dump. A [B row (byte arrays) at the top of the histogram matches a leak like the 1 MB arrays in the static map.
6. Streams and Functional Programming
Stream questions at this level check whether we know when a stream runs and why a collector fails, more than whether we know the method names.
6.1. Are Java Streams Lazy?
Yes. Intermediate operations such as filter() and map() only build a pipeline, and nothing runs until a terminal operation such as toList() or findFirst() asks for elements. The stream pulls one element at a time through the whole pipeline, and a short-circuiting operation stops as soon as it has its answer.
List<String> log = new ArrayList<>();
Optional<String> first = Stream.of("apple", "banana", "cherry")
.peek(fruit -> log.add("saw " + fruit))
.filter(fruit -> fruit.startsWith("b"))
.findFirst(); // Optional[banana]
// log = [saw apple, saw banana], cherry is never read
We use peek() only for debugging like this, because a stream may skip it when the result does not need it, for example in count().
6.2. Why Does Collectors.toMap() Throw IllegalStateException?
Collectors.toMap() throws IllegalStateException when two elements produce the same key, because it does not know which value to keep. We pass a merge function as the third argument, or we use groupingBy() when we want all values per key.
List<String> fruits = List.of("apple", "avocado", "banana");
Map<Character, String> byLetter = fruits.stream()
.collect(Collectors.toMap(fruit -> fruit.charAt(0), fruit -> fruit));
// IllegalStateException: Duplicate key a (attempted merging values apple and avocado)
Map<Character, String> merged = fruits.stream()
.collect(Collectors.toMap(fruit -> fruit.charAt(0), fruit -> fruit,
(first, second) -> first + "," + second)); // {a=apple,avocado, b=banana}
Map<Character, Long> counts = fruits.stream()
.collect(Collectors.groupingBy(fruit -> fruit.charAt(0), Collectors.counting())); // {a=2, b=1}
7. Design Principles and Patterns
Design questions at five years are about judgement. The interviewer wants a short definition, one example from our own code, and the cost of the pattern.
7.1. Can You Explain SOLID With Examples?
SOLID is five rules that keep classes small and safe to change. We explain each one in one line and give an example from a project.
| Principle | Rule | Example |
|---|---|---|
| Single responsibility | A class has one reason to change | InvoicePdfWriter writes PDFs, InvoiceService calculates totals |
| Open/closed | Add behavior with new classes, not by editing old ones | A new Payment record instead of another if branch |
| Liskov substitution | A subtype works wherever its parent type works | A read-only list must not extend a type whose contract allows add() |
| Interface segregation | Small interfaces instead of one big one | Readable and Writable instead of one Storage with 20 methods |
| Dependency inversion | Depend on interfaces, inject the implementation | PriceService takes a TaxClient interface in its constructor |
7.2. How Do We Write a Thread-Safe Singleton?
We use an enum with one constant, or the holder idiom when we need lazy creation. Both rely on class initialization, which the JVM runs only once and under a lock (JLS 12.4.2), so we write no synchronized code.
public final class Config {
private Config() {
}
private static final class Holder {
private static final Config INSTANCE = new Config();
}
public static Config getInstance() {
return Holder.INSTANCE; // Holder is initialized on the first call only
}
}
boolean same = Config.getInstance() == Config.getInstance(); // true
Double-checked locking also works when the field is volatile, but it is longer and more error-prone. In a Spring Boot application we rarely write singletons at all, because every bean is a singleton by default.
8. Spring Boot Questions
Most Java jobs in 2026 use Spring Boot, so even a “core Java” interview has a few Spring questions. Out of the Spring Boot interview questions, these two catch experienced developers most often.
8.1. Why Is Constructor Injection Recommended?
Constructor injection is the recommended form of dependency injection because the dependencies are final and never null, and a unit test can create the class with new and a mock. Field injection with @Autowired hides the dependencies and needs a Spring context or reflection in tests. If a class has only one constructor, Spring uses it without @Autowired.
The following example is a PriceService that gets a TaxClient through its constructor. The unit test passes a Mockito mock to new PriceService(…), with no Spring context, on JUnit 6.1.3 and Mockito 5.24.0.
public PriceService(TaxClient taxClient) {
this.taxClient = taxClient;
}
TaxClient taxClient = mock(TaxClient.class);
when(taxClient.taxPercent("IN")).thenReturn(18);
PriceService service = new PriceService(taxClient);
int price = service.finalPrice(100, "IN"); // 118
verify(taxClient).taxPercent("IN"); // passes, called once with "IN"
A mock returns the values we program and records the calls. We mock what is slow or external, such as an HTTP client, and test against the real database in an integration test, for example with Testcontainers.
8.2. Why Does a Self-Call Skip @Transactional?
Spring applies @Transactional through a proxy object that it puts in front of our bean. The proxy starts the transaction before our method runs, and it commits or rolls back after the method returns or throws. When one method of the bean calls another method of the same bean, the call goes through this, not through the proxy, so no transaction starts.

The following example uses an InvoiceService bean with a @Transactional save() method and a plain saveAll() method that calls save() in a loop. A transaction manager that only logs shows when transactions begin and end, on Spring Framework 7.0.9.
--- 1. call through the proxy
BEGIN com.howtodoinjava.interview.spring.InvoiceService.save
save(apple) transaction active = true
COMMIT
--- 2. self-invocation
save(banana) transaction active = false
--- 3. checked exception, default rules
BEGIN com.howtodoinjava.interview.spring.InvoiceService.importFile
COMMIT
caught file missing
--- 4. checked exception, rollbackFor = Exception.class
BEGIN com.howtodoinjava.interview.spring.InvoiceService.importFileWithRollback
ROLLBACK
caught file missing
Case 3 is the second pitfall. By default, Spring rolls back only on RuntimeException and Error, so a method that throws IOException commits its changes. The other pitfalls interviewers ask about follow the same proxy rules.
| Pitfall | What happens | Fix |
|---|---|---|
| Self-invocation | No transaction | Move the method to another bean, or put @Transactional on the outer method |
| Checked exception | Commit | rollbackFor = Exception.class, or @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) since 6.2 |
| private method | Annotation ignored | Use a public method; protected and package-private work with class-based proxies since Spring 6.0 |
| Exception caught inside the method | Commit | Rethrow, or call setRollbackOnly() |
| New thread or @Async inside | The new thread has no transaction | Start the transaction in the code that runs on that thread |
9. System Design Warm-Up Questions
At five years, the design round in most companies is one small component, not a whole system, much like the Java coding exercises. The interviewer wants working Java code with a clear thread-safety answer, and then talks about the limits of the design.
9.1. How Would You Build a Rate Limiter?
We use a token bucket. The bucket holds up to capacity tokens and refills at a fixed rate; each request takes one token, and a request that finds the bucket empty is rejected with HTTP 429. Bursts up to the capacity pass, and the average rate stays at the refill rate.
public synchronized boolean tryAcquire() {
long now = clock.getAsLong();
tokens = Math.min(capacity, tokens + (now - lastRefill) * tokensPerNano);
lastRefill = now;
if (tokens >= 1) {
tokens -= 1;
return true;
}
return false;
}
TokenBucket limiter = new TokenBucket(2, 1, System::nanoTime); // burst of 2, then 1 per second
boolean first = limiter.tryAcquire(); // true
boolean second = limiter.tryAcquire(); // true
boolean third = limiter.tryAcquire(); // false, bucket empty: answer HTTP 429
The clock is a LongSupplier, so a test can move time forward without waiting. The follow-up is “what about three instances of the service?” A per-instance limiter allows three times the rate, so a shared limit needs a central store such as Redis, or a gateway that limits before the service.
9.2. How Do We Build an LRU Cache?
We extend LinkedHashMap with access order turned on and override removeEldestEntry(). Access order moves an entry to the end on every get(), so the first entry is always the least recently used, and removeEldestEntry() drops it when the size goes over the capacity.
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public 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;
}
}
LruCache<String, Integer> cache = new LruCache<>(2);
cache.put("apple", 5);
cache.put("banana", 3);
Integer apple = cache.get("apple"); // 5, apple is now the most recently used
cache.put("cherry", 7); // evicts banana
Set<String> keys = cache.keySet(); // [apple, cherry]
The LruCache class is not thread-safe, because even get() changes the order. For a shared cache, we wrap it with Collections.synchronizedMap() or use a cache library.
9.3. How Do We Make a Retried Request Safe?
We make the operation idempotent, so running it twice has the same effect as running it once. For a “create payment” request, the client sends an idempotency key, such as a UUID, in a header. The server stores the key with the result in the same transaction, and a retry with the same key returns the stored result instead of charging again.
GET, HEAD, PUT and DELETE are idempotent by definition in HTTP, while POST is not, which is why POST endpoints need the key. Interviewers often continue with timeouts and retries with exponential backoff, so it helps to mention that a retry without idempotency turns one network timeout into a duplicate payment.
10. Conclusion
Java interview questions for 5 years experience test the reason behind each answer, such as how HashMap picks a bucket or why volatile alone does not make a counter thread-safe.
Current Java matters as much as the classics. We know sealed records with pattern matching for switch, when virtual threads help, and that scoped values are final in Java 25 while structured concurrency is still a preview in Java 27.
For production questions, we read a heap dump to find a leak, pick G1 or ZGC from the GC logs, and explain the @Transactional proxy pitfalls. A short design task, such as a rate limiter or an LRU cache, finishes most loops.
11. References
- JEP 444 Virtual Threads
- JEP 491 Synchronize Virtual Threads without Pinning
- JEP 506 Scoped Values
- JDK 27 project page
- JEP 523 Make G1 the Default Garbage Collector in All Environments
- HashMap Javadoc (Java 25)
- ConcurrentHashMap Javadoc (Java 25)
- JLS 17.4.5 Happens-before Order
- Java 25 GC Tuning Guide, Z Garbage Collector
- Spring Framework, Using @Transactional
Happy Learning !!