Sitemap

Top 30 Java Interview Questions (Advanced): Real-Time Answers, Examples & Code

30 min readMay 5, 2026

--

Press enter or click to view image in full size

You’ve cleared the basics. You know what a HashMap is, you can reverse a linked list, and you’ve written enough try-catch blocks to last a career. This article is not for that level.

This is for the engineer walking into a Staff or Senior interview at a product company, or a mid-level engineer who knows they’re going to get grilled on the stuff that actually makes Java production systems behave the way they do under load.

Every question here is one I’ve seen real interviewers ask. Every answer is precise — not “interview fluff” vague. The code examples are production-grade, not illustrative pseudocode.

📘 Java Backend Interview Questions 300+ questions across all experience levels — with the depth interviewers are actually looking for.

📘 Complete Backend Interview Questions 1000+ questions covering core Java, concurrency, JVM internals, Spring, and system design.

JVM & Memory

Q1. What is the difference between Heap, Stack, and Metaspace in the JVM?

What the interviewer wants to know: Can you reason about where objects actually live, and what the implications are for performance and garbage collection?

Answer:

The heap is where object instances live. It is shared across all threads and managed by the garbage collector. The heap is divided into the Young Generation (Eden + two Survivor spaces) and the Old Generation (Tenured space). Objects are first allocated in Eden; those that survive enough GC cycles get promoted to the Old Generation.

The stack is per-thread. Each method invocation creates a stack frame that holds local variables, operand stack, and a reference back to the constant pool. Primitives and object references (not the objects themselves) live in the stack. When a method returns, its frame is popped. Stack overflow happens when frames exceed the stack size — the most common cause is unbounded recursion.

Metaspace (replaced PermGen in Java 8) stores class metadata — class definitions, method bytecodes, constant pools. Unlike PermGen, Metaspace is allocated in native memory (off-heap), so it can grow dynamically without a fixed size cap. You can still set a cap with -XX:MaxMetaspaceSize to prevent native memory exhaustion.

public class MemoryDemo {
// Stored in Metaspace: class definition, method bytecode
private static final String CONSTANT = "hello"; // reference in Metaspace, object in heap string pool
    public void demo() {
int x = 42; // x lives in the stack frame (primitive)
String name = "Alice"; // name (reference) lives in stack; "Alice" lives in string pool (heap)
Object obj = new Object(); // obj (reference) in stack; Object instance in heap
}
}

Q2. How does Garbage Collection work in Java? What is the difference between G1, ZGC, and Shenandoah?

What the interviewer wants to know: Do you understand GC pauses, concurrent vs stop-the-world phases, and which collector to choose for what workload?

Answer:

All modern Java collectors follow a generational hypothesis: most objects die young. The basic cycle is:

  1. Minor GC — clears Eden and Survivor spaces. Short, frequent, stop-the-world.
  2. Major/Full GC — clears the Old Generation. Long, infrequent.

G1 (Garbage First) — default since Java 9. Divides the heap into equal-sized regions (typically 1–32 MB each) instead of fixed Young/Old zones. Prioritizes collecting regions with the most garbage first (hence the name). Targets a user-configurable pause goal (-XX:MaxGCPauseMillis). Suitable for heaps 4 GB and above.

ZGC — available since Java 11 (production-ready in Java 15). Concurrent collector designed for sub-millisecond pauses even on multi-terabyte heaps. Achieves this by doing almost all work concurrently with the application, using colored pointers and load barriers. Pause times are measured in microseconds regardless of heap size.

Shenandoah — similar goal to ZGC (sub-millisecond pauses), introduced by Red Hat and available in OpenJDK. Uses a different concurrent compaction approach (Brooks forwarding pointers). Generally comparable to ZGC; both are suitable when GC latency is the primary concern.

Rule of thumb:

  • Batch jobs, throughput-first → use G1 or Parallel GC
  • Low-latency services (< 10 ms p99) → use ZGC or Shenandoah
  • Interactive microservices with moderate heap → G1 with tuned pause target

Q3. What is a Memory Leak in Java? How do you detect and fix one?

What the interviewer wants to know: Can you reason about GC root retention, and do you know practical tooling?

Answer:

A memory leak in Java is when objects are no longer needed by the application but are still reachable via GC roots — so the collector cannot reclaim them. The heap grows over time until OutOfMemoryError is thrown.

Common causes:

1. Static collections holding references:

public class Cache {
private static final Map<String, byte[]> cache = new HashMap<>();
    public void load(String key) {
cache.put(key, new byte[1024 * 1024]); // entries never evicted
}
}

Fix: Use a bounded LinkedHashMap with access-order eviction, or a proper cache like Caffeine.

2. Listener / callback registration without deregistration:

eventBus.register(this); // registered on startup
// ... never calls eventBus.unregister(this)

3. ThreadLocal not cleaned up in thread pool environments:

ThreadLocal<byte[]> local = new ThreadLocal<>();
local.set(new byte[1024 * 1024]);
// thread returns to pool — value lives as long as the thread does
// Fix:
try {
local.set(data);
doWork();
} finally {
local.remove(); // always clean up in thread pool contexts
}

Detection tools:

  • VisualVM / JConsole — monitor heap growth over time
  • Eclipse MAT (Memory Analyzer Tool) — analyze heap dumps, find retention paths
  • jmap -dump:format=b,file=heap.hprof <pid> — capture heap dump
  • -XX:+HeapDumpOnOutOfMemoryError — auto-capture on OOM

Q4. What is the Java Memory Model (JMM), and what does “happens-before” mean?

What the interviewer wants to know: Do you understand why visibility bugs happen in multi-threaded code, and can you reason about ordering guarantees?

Answer:

The Java Memory Model (JMM) defines the rules under which one thread’s writes become visible to other threads. Modern CPUs and compilers reorder instructions for performance. Without the JMM, you cannot reason about correctness in concurrent programs.

The key concept is happens-before: if action A happens-before action B, then all effects of A (writes to variables) are guaranteed to be visible to B.

Happens-before relationships are established by:

  • Thread start: thread.start() happens-before any action in the started thread
  • Thread join: all actions in a thread happen-before thread.join() returns
  • Monitor unlock/lock: an unlock on a monitor happens-before every subsequent lock of the same monitor
  • volatile write/read: a write to a volatile variable happens-before every subsequent read of that same variable
  • Transitivity: if A hb B and B hb C, then A hb C
int value = 0;
volatile boolean ready = false;
// Thread 1
value = 42; // (1)
ready = true; // (2) - volatile write
// Thread 2
if (ready) { // (3) - volatile read
System.out.println(value); // (4) guaranteed to see 42
}

Because volatile write (2) happens-before volatile read (3), and (1) happens-before (2), Thread 2 is guaranteed to see value = 42 when ready is true. This is the JMM guarantee — not just visibility of ready, but of all writes before it.

Q5. What does volatile guarantee, and what does it NOT guarantee?

What the interviewer wants to know: Whether you know the exact semantics and won’t misuse it.

Answer:

volatile guarantees two things:

  1. Visibility: writes to a volatile variable are immediately flushed to main memory; reads always load from main memory. No thread can see a stale cached copy.
  2. Ordering: writes/reads of a volatile variable cannot be reordered relative to each other (establishes happens-before).

What volatile does NOT guarantee:

  • Atomicity of compound operations. volatile on a long or double ensures atomic reads/writes of those 64-bit values (JMM guarantee). But i++ on a volatile int is still not atomic — it is three operations: read, increment, write.
volatile int counter = 0;
// UNSAFE — even with volatile
counter++; // Thread 1
counter++; // Thread 2
// Final value may be 1, not 2
// CORRECT — use AtomicInteger for compound operations
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic CAS operation

Use volatile when: one thread writes, one or more threads read, and the written value does not depend on its previous value (e.g., a flag).

Use AtomicInteger / synchronized / Lock when: the update is compound (read-modify-write) or you need compound atomicity across multiple variables.

Concurrency

Q6. What is the difference between synchronized, ReentrantLock, and StampedLock?

What the interviewer wants to know: Can you articulate what each adds over the previous, and when each is appropriate?

Answer:

synchronized is the simplest. It is built into the JVM, automatically released on exit (even on exception), and has no API surface. The downside: you cannot attempt a lock acquisition without blocking, cannot interrupt a thread waiting for the lock, and cannot implement read/write separation.

public synchronized void increment() {
count++;
}

ReentrantLock gives you everything synchronized does, plus:

  • tryLock() — attempt without blocking; try with timeout
  • lockInterruptibly() — respond to thread interrupts while waiting
  • Condition objects — finer-grained wait()/notify() per condition
  • Fairness policy — optional fair ordering of lock requests
private final ReentrantLock lock = new ReentrantLock();
public void safeUpdate() {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
// critical section
} finally {
lock.unlock(); // always in finally
}
}
}

StampedLock (Java 8+) adds an optimistic read mode. You can read shared data without acquiring a full read lock, then validate the stamp afterward. If the stamp is invalid (a write occurred), fall back to a full read lock. Useful for read-heavy data that's only occasionally written.

StampedLock lock = new StampedLock();
public double readPoint() {
long stamp = lock.tryOptimisticRead(); // no lock acquired
double result = sharedValue;
if (!lock.validate(stamp)) { // was a write interleaved?
stamp = lock.readLock(); // fall back to real read lock
try {
result = sharedValue;
} finally {
lock.unlockRead(stamp);
}
}
return result;
}

Rule of thumb: use synchronized by default; reach for ReentrantLock when you need tryLock or Condition; reach for StampedLock when profiling shows read contention is a bottleneck.

Q7. What is ThreadLocal, and when is it appropriate? What are its pitfalls?

What the interviewer wants to know: Real understanding — not just “each thread has its own copy.”

Answer:

ThreadLocal<T> provides a per-thread variable. Each thread that accesses a ThreadLocal gets its own independently initialized copy. No synchronization is needed because no sharing occurs.

Legitimate use cases:

  • Database connection or transaction context per request thread
  • SimpleDateFormat instances (not thread-safe)
  • Per-thread random number generators
  • Spring’s RequestContextHolder and SecurityContextHolder internally use ThreadLocal
private static final ThreadLocal<DateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String format(Date date) {
return DATE_FORMAT.get().format(date); // safe: each thread has its own DateFormat
}

The critical pitfall — thread pool leaks:

Thread pools reuse threads. If you set a ThreadLocal value and don't clean it up, the next task that runs on the same thread will inherit the leftover value. This causes subtle data corruption and memory leaks.

// Always clean up in thread pool contexts
try {
threadLocal.set(value);
executeTask();
} finally {
threadLocal.remove(); // mandatory — prevents leak into next task on this thread
}

Another pitfall — InheritableThreadLocal: child threads inherit parent's values at the time of thread creation — but they don't see subsequent updates. Don't use it expecting live inheritance.

Q8. How does CompletableFuture work? What is the difference between thenApply, thenCompose, and thenCombine?

What the interviewer wants to know: Whether you understand asynchronous pipelines and can compose async operations correctly.

Answer:

CompletableFuture<T> represents a computation that will complete in the future. You chain transformations using callback methods — the key is understanding which callbacks run synchronously vs on a different thread.

thenApply(Function<T, R>) — transforms the result of the current stage. Like Stream.map(). The function runs in the completing thread (or the calling thread if already complete). Use thenApplyAsync to run on a separate executor.

CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> fetchUserId()) // runs on ForkJoinPool
.thenApply(id -> "User-" + id); // transforms the result

thenCompose(Function<T, CompletableFuture<R>>) — for chaining two async operations where the second depends on the first. If you used thenApply here, you'd get CompletableFuture<CompletableFuture<R>> — the wrong shape.

CompletableFuture<UserProfile> profile = CompletableFuture
.supplyAsync(() -> fetchUserId())
.thenCompose(id -> fetchProfileAsync(id)); // second async call depends on first result

thenCombine(CompletableFuture<U>, BiFunction<T, U, R>) — runs two independent async operations and combines their results when both complete.

CompletableFuture<String> nameF = CompletableFuture.supplyAsync(() -> fetchName());
CompletableFuture<Integer> ageF = CompletableFuture.supplyAsync(() -> fetchAge());
CompletableFuture<String> result = nameF.thenCombine(ageF,
(name, age) -> name + " is " + age + " years old");

Error handling:

future.exceptionally(ex -> {
log.error("Failed", ex);
return "default-value";
});

Java Core

Q9. Explain the equals() and hashCode() contract. What breaks if you violate it?

What the interviewer wants to know: Whether you understand the structural consequences, not just the rule itself.

Answer:

The contract is:

  1. If a.equals(b) is true, then a.hashCode() must equal b.hashCode()
  2. If a.hashCode() equals b.hashCode(), a.equals(b) may be true or false (collisions are allowed)
  3. hashCode() must be consistent across multiple calls within the same JVM run for the same object, as long as no fields used in the comparison change

Note: hashCode() has no relationship to the object's memory address. The JVM spec only requires that the value be consistent within one JVM run. Different JVM implementations may or may not use memory-related values in their default hashCode() implementation — but you should never depend on this.

What breaks when you override equals() without hashCode():

public class Key {
private final String value;
Key(String value) { this.value = value; }
    @Override
public boolean equals(Object o) {
if (!(o instanceof Key)) return false;
return value.equals(((Key) o).value);
}
// No hashCode override!
}
Map<Key, String> map = new HashMap<>();
map.put(new Key("abc"), "data");
map.get(new Key("abc")); // returns null — different hashCode, wrong bucket

The HashMap first uses hashCode() to find the bucket. Two new Key("abc") objects have the same logical equality but different hashCode() values (from Object.hashCode()), so the second lookup goes to the wrong bucket entirely.

Correct implementation:

@Override
public int hashCode() {
return Objects.hash(value); // consistent with equals
}

Q10. What is type erasure in Java Generics? What are its practical implications?

What the interviewer wants to know: Can you explain why List<String> and List<Integer> are the same type at runtime, and what limitations this creates?

Answer:

Type erasure is the process by which the Java compiler removes all generic type information and replaces type parameters with their bounds (or Object if unbounded) at compile time. The bytecode contains no generic type information.

// Source code
List<String> names = new ArrayList<>();
names.add("Alice");
String name = names.get(0);
// What the compiler effectively generates
List names = new ArrayList();
names.add("Alice");
String name = (String) names.get(0); // compiler inserts cast

Practical implications:

1. You cannot use instanceof with generic types:

if (list instanceof List<String>) { } // COMPILE ERROR
if (list instanceof List<?>) { } // OK — unbounded wildcard

2. You cannot create arrays of generic types:

List<String>[] arr = new List<String>[10]; // COMPILE ERROR
List<?>[] arr = new List<?>[10]; // OK

3. You cannot create instances of type parameters:

public <T> T create() {
return new T(); // COMPILE ERROR — T is erased to Object at runtime
}
// Workaround:
public <T> T create(Class<T> clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}

4. Overloading with generic types fails:

// COMPILE ERROR — same erasure after type erasure
public void process(List<String> list) { }
public void process(List<Integer> list) { }

Reifiable types (types whose information is fully available at runtime) include raw types, non-generic types, unbounded wildcards, and arrays of reifiable types. Generic types are not reifiable.

Q11. What is the difference between Comparable and Comparator? When do you use each?

Answer:

Comparable<T> defines the natural ordering of a class. It is implemented by the class itself via compareTo(T other). There is only one natural order per class.

public class Employee implements Comparable<Employee> {
private String name;
private int salary;
    @Override
public int compareTo(Employee other) {
return this.name.compareTo(other.name); // natural order: alphabetical by name
}
}
List<Employee> employees = new ArrayList<>(Arrays.asList(e1, e2, e3));
Collections.sort(employees); // uses natural order

Comparator<T> defines an external, custom ordering. It lives outside the class and can be defined multiple times for different sort criteria. Lambdas make this concise.

// Sort by salary descending
Comparator<Employee> bySalaryDesc = Comparator
.comparingInt(Employee::getSalary)
.reversed();
// Sort by department, then by name
Comparator<Employee> byDeptThenName = Comparator
.comparing(Employee::getDepartment)
.thenComparing(Employee::getName);
employees.sort(byDeptThenName);

Rule: Implement Comparable when there is one obvious, universally applicable ordering (e.g., String alphabetical, Integer numeric). Use Comparator for alternate orderings, or when you don't own the class.

The contract for compareTo: sgn(x.compareTo(y)) == -sgn(y.compareTo(x)). Violating this breaks TreeSet, TreeMap, and any sorted collection.

Q12. How does HashMap work internally? What happens during a collision, and what changed in Java 8?

Answer:

HashMap uses an array of buckets (internally Node<K,V>[] table). When you put a key-value pair:

  1. key.hashCode() is called, then a supplemental hash function is applied to spread bits
  2. (n - 1) & hash gives the bucket index (n = array length, always a power of 2)
  3. If the bucket is empty, the node goes directly in
  4. If occupied (collision), the new node is appended to a chain

Before Java 8: collisions were handled with a linked list. Worst case: all keys hash to the same bucket → O(n) lookup.

Java 8 change (treeification): when a single bucket’s chain grows beyond 8 nodes AND the total table size is ≥ 64, the chain is converted to a red-black tree. Lookup degrades to O(log n) instead of O(n) in the worst case. When nodes fall back below 6, it reverts to a linked list.

// Load factor default = 0.75
// When size > capacity * loadFactor, the table is resized (doubled)
// Resizing recomputes all bucket positions — expensive, so pre-size if known
Map<String, Integer> map = new HashMap<>(1024); // pre-sized to avoid rehashing

Thread safety: HashMap is not thread-safe. Concurrent modification can cause infinite loops during resize in older JVMs (Java 7 and below), and undefined behavior in general. Use ConcurrentHashMap for concurrent access. ConcurrentHashMap does not permit null keys or null values — a deliberate design choice to avoid ambiguity in concurrent access patterns.

Q13. What is String interning? How does the String pool work?

Answer:

String literals in Java are automatically interned — the JVM maintains a pool (part of the heap since Java 7, previously PermGen) of unique string instances. When you write "hello", the JVM checks the pool first; if the string exists, it returns the existing reference.

String a = "hello";
String b = "hello";
System.out.println(a == b); // true — same pool reference
String c = new String("hello");
System.out.println(a == c); // false — c is a new heap object
String d = c.intern();
System.out.println(a == d); // true — intern() returns the pool reference

String.intern() manually places a string into the pool (if absent) and returns the pool reference. This is useful when you have many duplicate strings created dynamically (e.g., parsing CSV data with repeated category names) — interning reduces memory.

When to use: Rarely, and deliberately. If you need it, consider a HashMap<String, String> as a manual deduplication cache instead — it's more transparent and GC-friendly.

Important: == on strings compares references. Always use .equals() for value comparison. This is not just a style rule — it is a correctness requirement.

Q14. Explain Java’s try-with-resources and how it differs from finally.

Answer:

try-with-resources (introduced in Java 7) automatically closes any AutoCloseable resources declared in the try statement when the block exits — whether normally, via return, or via exception.

// Old pattern — fragile
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("file.txt"));
return br.readLine();
} finally {
if (br != null) br.close(); // what if close() also throws?
}
// Modern pattern — clean and correct
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
return br.readLine();
} // br.close() called automatically

The critical difference — suppressed exceptions:

If both the try block and close() throw exceptions, finally discards the original exception (the close exception propagates instead — original exception is lost). try-with-resources preserves the original exception and attaches the close() exception as a suppressed exception:

try {
Throwable[] suppressed = caughtException.getSuppressed();
// close() exception is here, original exception is still the primary
}

Multiple resources: declared in order, closed in reverse order (last declared, first closed):

try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
// stmt closed first, then conn
}

Q15. What is the difference between checked and unchecked exceptions? Why does the debate around checked exceptions exist?

Answer:

Checked exceptions extend Exception (but not RuntimeException). The compiler enforces that you either catch them or declare them in the method signature with throws. Examples: IOException, SQLException, ClassNotFoundException.

Unchecked exceptions extend RuntimeException (or Error). The compiler does not enforce handling. Examples: NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException.

// Checked — caller must handle or propagate
public String readFile(String path) throws IOException {
return Files.readString(Paths.get(path));
}
// Unchecked — no declaration needed
public void validateAge(int age) {
if (age < 0) throw new IllegalArgumentException("Age cannot be negative: " + age);
}

The debate:

The argument for checked exceptions: they force API consumers to think about failure modes at compile time. FileNotFoundException is a real scenario callers must handle — making it checked ensures they don't forget.

The argument against: in layered architectures, checked exceptions propagate through every layer. A SQLException thrown in the DAO layer forces throws SQLException declarations all the way up to the controller, even though the controller has no idea what to do with it. Most frameworks (Spring, Hibernate) wrap checked exceptions in unchecked ones (DataAccessException) for exactly this reason.

Modern practice: libraries tend to use unchecked exceptions for recoverable conditions callers will realistically want to handle (Spring does this throughout), while the JDK still uses checked exceptions in I/O and reflection APIs. Neither is universally right — it depends on the API contract.

Java 8+ Features

Q16. How does the Stream API work? What is the difference between intermediate and terminal operations?

Answer:

A Stream is a pipeline of operations over a sequence of elements. It is lazy: intermediate operations are not executed until a terminal operation is invoked. Streams do not store elements — they process them on demand.

Get Vighneshwar Bhat’s stories in your inbox

Join Medium for free to get updates from this writer.

Intermediate operations return a Stream and are lazy. They fall into two categories:

  • Stateless: operate on each element independently — filter(), map(), flatMap(), peek()
  • Stateful: must see all (or many) elements before producing output — sorted(), distinct(), limit(), skip()

Important: sorted() is a stateful intermediate operation — it must consume the entire stream to produce a sorted result. It does not "break laziness" in the sense of terminating the pipeline, but it does buffer all elements internally before passing them downstream.

Terminal operations trigger pipeline execution and produce a result: collect(), forEach(), count(), reduce(), findFirst(), anyMatch(), toArray().

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Anna");
List<String> result = names.stream()
.filter(n -> n.startsWith("A")) // stateless intermediate
.map(String::toUpperCase) // stateless intermediate
.sorted() // stateful intermediate — buffers all filtered elements
.collect(Collectors.toList()); // terminal — triggers execution

findFirst() and encounter order: findFirst() returns the first element in the stream's encounter order (the order defined by the source, if the source is ordered). For an unordered stream (e.g., from a HashSet, or after .unordered()), findFirst() may return any element. In parallel streams, findFirst() respects encounter order but may be slower than findAny(), which does not.

Optional<String> first = names.stream()
.filter(n -> n.length() > 3)
.findFirst(); // returns "Alice" for this ordered list; behavior depends on encounter order

Q17. What is Optional and how should it be used? What are its anti-patterns?

Answer:

Optional<T> is a container that may or may not hold a non-null value. Its primary purpose is to make the possibility of absence explicit in a method's return type, replacing the ambiguous contract of returning null.

// Old pattern — ambiguous
public User findById(Long id) {
return db.query(id); // returns null if not found? contract unclear
}
// With Optional — explicit
public Optional<User> findById(Long id) {
return Optional.ofNullable(db.query(id)); // explicitly may be absent
}
// Usage — chain without null checks
findById(42L)
.map(User::getEmail)
.map(String::toLowerCase)
.ifPresent(email -> sendNotification(email));

Anti-patterns:

1. Using Optional.get() without checking:

Optional<User> user = findById(id);
user.get().getEmail(); // throws NoSuchElementException if empty — defeats the purpose
// Use: user.orElseThrow(() -> new UserNotFoundException(id))

2. Using Optional as a field type or method parameter:

// BAD — Optional is not Serializable and adds overhead
private Optional<String> middleName;
// BAD — awkward and unnecessary
public void process(Optional<String> param) { }

Optional is designed for return types only.

3. Using Optional just to call isPresent() + get():

if (user.isPresent()) { doSomething(user.get()); } // same as null check, gains nothing
user.ifPresent(this::doSomething); // correct idiom

4. Optional.of() with a potentially null value:

Optional.of(null);           // throws NullPointerException immediately
Optional.ofNullable(value); // correct — handles null gracefully

Q18. What are functional interfaces? Explain Function, Predicate, Supplier, and Consumer.

Answer:

A functional interface is an interface with exactly one abstract method (SAM — Single Abstract Method). It can be used as the target type for a lambda expression or method reference. The @FunctionalInterface annotation is optional but recommended — it causes a compile error if the contract is accidentally broken.

@FunctionalInterface
public interface Transformer<T, R> {
R transform(T input);
// default and static methods are fine — they don't count as abstract
}

The four core functional interfaces in java.util.function:

Interface Signature Use case Function<T, R> R apply(T t) Transform T to R Predicate<T> boolean test(T t) Boolean condition on T Supplier<T> T get() Produce a T with no input Consumer<T> void accept(T t) Consume T, no return

Function<String, Integer> length = String::length;
Predicate<String> isEmpty = String::isEmpty;
Supplier<List<String>> listFactory = ArrayList::new;
Consumer<String> printer = System.out::println;
// Composition
Function<String, String> trimThenUpper =
((Function<String, String>) String::trim).andThen(String::toUpperCase);
Predicate<String> notEmpty = isEmpty.negate();
Predicate<String> longAndNotEmpty = notEmpty.and(s -> s.length() > 5);

BiFunction<T, U, R> takes two inputs. UnaryOperator<T> is a Function<T, T>. BinaryOperator<T> is a BiFunction<T, T, T>.

Q19. What are default methods in interfaces? What problem do they solve, and what are their limitations?

Answer:

Default methods (Java 8+) allow interfaces to provide a concrete method implementation. They were introduced primarily to enable backward-compatible evolution of existing interfaces — particularly to add Stream API methods to Collection, List, Iterable, etc. without breaking every existing implementation.

public interface Greeter {
String greet(String name); // abstract
    default String greetLoudly(String name) { // default
return greet(name).toUpperCase();
}
static Greeter formal() { // static factory
return name -> "Good day, " + name;
}
}

Limitations and the diamond problem:

If a class implements two interfaces that both declare the same default method, the compiler forces you to override it:

interface A { default void log() { System.out.println("A"); } }
interface B { default void log() { System.out.println("B"); } }
class C implements A, B {
@Override
public void log() {
A.super.log(); // explicitly choose which default to delegate to
}
}

If a class inherits a method from a superclass and a default method from an interface with the same signature, the class method wins. This rule prevents default methods from changing the behavior of existing class hierarchies.

Default methods cannot override Object's methods (equals, hashCode, toString) — this was a deliberate restriction to prevent unexpected behavior when interfaces accidentally define semantics for these fundamental methods.

Q20. What are WeakReference, SoftReference, and PhantomReference? When are they used?

Answer:

Java has four reference strengths. The GC’s treatment of an object depends on the strongest reference pointing to it.

Strong reference — the default. Object obj = new Object(). GC will never collect an object with a live strong reference.

SoftReference<T> — GC collects the referent only when memory is low (before throwing OutOfMemoryError). Ideal for in-memory caches where you want to use available memory but gracefully yield under pressure.

SoftReference<byte[]> imageCache = new SoftReference<>(loadImage());
// imageCache.get() returns null if GC collected it due to memory pressure
byte[] img = imageCache.get();
if (img == null) img = loadImage(); // reload if GC collected

WeakReference<T> — GC may collect the referent at any time when no strong references exist. Used by WeakHashMap for a map that doesn't prevent keys from being collected.

WeakReference<Widget> ref = new WeakReference<>(widget);
// If no other strong reference to widget exists, GC can collect it at will

PhantomReference<T> — the referent is already finalized and enqueued. You cannot retrieve the object via get() — it always returns null. Used with a ReferenceQueue to run cleanup logic after finalization without the problems of finalize().

ReferenceQueue<Resource> queue = new ReferenceQueue<>();
PhantomReference<Resource> phantom = new PhantomReference<>(resource, queue);
// When GC reclaims the object, the phantom reference is enqueued
// Poll the queue on a background thread to perform cleanup

Practical rule: use SoftReference for caches, WeakReference to avoid retaining objects you don't own (listener maps, metadata), PhantomReference only when you need post-collection cleanup in library code.

Classes, Types & Patterns

Q21. What is the difference between an abstract class and an interface in modern Java?

Answer:

Since Java 8 and Java 9 added default methods and private methods to interfaces, the gap has narrowed significantly. But key differences remain:

Abstract Class Interface State Can have instance fields No instance fields (constants only) Constructor Can have constructors No constructors Multiple inheritance A class can extend only one A class can implement many Access modifiers Methods can be any modifier Abstract methods are implicitly public Default methods Can have concrete methods Can have default and private methods (Java 9+)

Use abstract class when:

  • You need shared mutable state across subclasses
  • You want to define a template method pattern with protected helpers
  • You’re modeling an “is-a” relationship with shared behavior AND data

Use interface when:

  • Defining a contract that unrelated classes can implement
  • You need multiple inheritance of type
  • Defining a capability (e.g., Comparable, Serializable, Runnable)
// Abstract class — shares state and partial implementation
abstract class Shape {
protected String color; // shared state
    abstract double area();    public String describe() {
return color + " shape with area " + area(); // template method
}
}
// Interface — defines capability contract
interface Resizable {
void resize(double factor);
default void doubleSize() { resize(2.0); }
}
class Circle extends Shape implements Resizable {
private double radius;
Circle(String color, double radius) {
this.color = color; this.radius = radius;
}
@Override public double area() { return Math.PI * radius * radius; }
@Override public void resize(double factor) { radius *= factor; }
}

Q22. How do you create an immutable class in Java? Why does immutability matter in concurrent programming?

Answer:

An immutable object’s state cannot change after construction. This makes it inherently thread-safe — no synchronization is needed because there are no writes to protect.

Rules for a properly immutable class:

  1. Declare the class final (prevent subclassing that could add mutable state)
  2. All fields private final
  3. No setters
  4. Initialize all fields in the constructor
  5. Defensive copy mutable inputs and mutable returns
public final class ImmutablePoint {
private final int x;
private final int y;
private final List<String> tags;
    public ImmutablePoint(int x, int y, List<String> tags) {
this.x = x;
this.y = y;
// Defensive copy — prevent caller from modifying internals via the original list
this.tags = Collections.unmodifiableList(new ArrayList<>(tags));
}
public int getX() { return x; }
public int getY() { return y; }
public List<String> getTags() {
return tags; // already unmodifiable — no copy needed on return
}
}

Why it matters for concurrency: immutable objects can be freely shared between threads without locks. String, Integer, BigDecimal are all immutable — this is why they are safe to use in any concurrent context without synchronization. Designing value objects (Money, UserId, Coordinate) as immutable eliminates an entire category of concurrency bugs.

Java 16+ record: Java records are syntactic sugar for immutable data carriers. Fields are implicitly private final, the constructor is generated, equals/hashCode/toString are derived from the component fields.

public record Point(int x, int y) {}
Point p = new Point(3, 4);
p.x(); // accessor — not a getter, but a record component accessor

Q23. What is the Proxy pattern in Java? How does Spring use it for AOP?

Answer:

The Proxy pattern wraps an object to intercept access — adding behavior before/after the real method call without modifying the target class. Java offers two built-in proxying mechanisms:

JDK Dynamic Proxy — works only with interfaces. Creates a proxy at runtime that implements the target’s interface(s) and delegates to an InvocationHandler.

UserService proxy = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(),
new Class[]{UserService.class},
(proxyObj, method, args) -> {
System.out.println("Before: " + method.getName());
Object result = method.invoke(realService, args);
System.out.println("After: " + method.getName());
return result;
}
);

CGLIB Proxy — subclasses the target class at runtime (no interface required). Spring uses CGLIB when no interface is present.

Spring AOP uses this mechanism to implement @Transactional, @Cacheable, @Async, and custom aspects. The Spring container wraps your bean in a proxy.

The critical limitation — self-invocation:

When a method in a Spring bean calls another method in the same bean directly, it bypasses the proxy:

@Service
public class OrderService {
    @Transactional
public void processOrder(Order order) {
// This calls the real method, not the proxy
// The @Transactional on saveOrder is IGNORED
saveOrder(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOrder(Order order) {
repository.save(order);
}
}

Fix: inject the bean into itself (@Autowired private OrderService self) and call self.saveOrder(order), or refactor into a separate bean.

Additionally, @Transactional does not apply to private methods — proxies cannot override them. The annotation is silently ignored on private methods.

Q24. What is Java ClassLoader? Explain the parent delegation model.

Answer:

A ClassLoader is responsible for loading .class files and creating Class objects in the JVM. Java uses three built-in class loaders:

  1. Bootstrap ClassLoader — loads core Java classes (java.lang.*, java.util.*) from the JDK. Written in native code; has no parent.
  2. Platform ClassLoader (Extension ClassLoader in Java 8) — loads classes from lib/ext or platform modules.
  3. Application ClassLoader — loads classes from the application’s classpath. This is the default loader for your code.

Parent delegation model: when a class loader is asked to load a class, it first delegates to its parent. Only if the parent cannot find the class does the child attempt to load it. This prevents rogue classes from overriding java.lang.String.

Request: load "com.example.MyService"
→ Application ClassLoader: "can my parent load this?"
→ Platform ClassLoader: "can my parent load this?"
→ Bootstrap ClassLoader: "not in core JDK" (fails)
→ Platform ClassLoader: "not in extension" (fails)
→ Application ClassLoader: loads from classpath ✓

Custom ClassLoaders are used in application servers (Tomcat, JBoss) to provide isolation between deployed applications — each app has its own class loader, preventing class version conflicts.

Java 9 modules (JPMS) changed the class loader hierarchy to support the module system, but the delegation model remains the foundation.

Q25. What are Records, Sealed Classes, and Pattern Matching instanceof? (Java 16–17+)

Answer:

Records (Java 16): Concise immutable data classes. The compiler generates constructor, accessors, equals(), hashCode(), and toString() based on the component list.

public record Person(String name, int age) {
// Compact constructor — add validation
public Person {
if (age < 0) throw new IllegalArgumentException("Age cannot be negative");
}
}
Person p = new Person("Alice", 30);
p.name(); // "Alice" — accessor, not getName()
p.age(); // 30

Records cannot extend other classes (they implicitly extend Record), cannot be abstract, and their components are immutable. They can implement interfaces.

Sealed Classes (Java 17): Restrict which classes can extend or implement a type. Enables exhaustive pattern matching — the compiler knows all possible subtypes.

public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

Pattern Matching instanceof (Java 16): Eliminates the cast-after-check boilerplate.

// Old pattern
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// Pattern matching
if (obj instanceof String s) {
System.out.println(s.length()); // s is in scope and typed
}
// With sealed classes — exhaustive switch (Java 21 switch expressions)
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed — compiler knows Shape is sealed with these three permits
};

Collections Deep Dive

Q26. How does ConcurrentHashMap differ from HashMap and Collections.synchronizedMap()?

Answer:

HashMap — not thread-safe. Concurrent modification is undefined behavior.

Collections.synchronizedMap(map) — wraps a map and synchronizes every method on a single lock (the wrapper itself). Correct under concurrent access, but every operation — including reads — acquires the same lock. High contention, low throughput.

Map<String, String> synced = Collections.synchronizedMap(new HashMap<>());
// Still requires external synchronization for iteration:
synchronized (synced) {
for (Map.Entry<String, String> e : synced.entrySet()) { ... }
}

ConcurrentHashMap — designed for high-concurrency read/write. Key design points:

  • Java 7: used segment-level locking (16 segments by default — 16 concurrent writes possible)
  • Java 8+: moved to node-level locking with CAS operations. Each bucket’s head node is the lock. Reads are almost always lock-free (volatile reads). Writes lock only the specific bucket.
  • Iteration reflects the state at some point during traversal — it does not throw ConcurrentModificationException
  • Does not permit null keys or null values. This is deliberate: in concurrent contexts, map.get(key) == null would be ambiguous — does the key not exist, or is its value null? Banning null eliminates the ambiguity.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("count", 0);
// Atomic compound operation — no external synchronization needed
map.compute("count", (k, v) -> v == null ? 1 : v + 1);
map.merge("count", 1, Integer::sum);

Use ConcurrentHashMap for read-heavy or mixed concurrent access. Use synchronized map only if you need legacy compatibility. For read-dominant workloads with infrequent writes, also consider CopyOnWriteArrayList/CopyOnWriteArraySet for lists/sets.

Q27. What is BlockingQueue? Describe its contract and key implementations.

Answer:

BlockingQueue<E> is a thread-safe queue that supports blocking operations:

  • Put: if the queue is full, the producer thread blocks until space is available
  • Take: if the queue is empty, the consumer thread blocks until an element arrives

This makes it the natural fit for producer-consumer patterns without manual wait()/notify() coordination.

Key implementations:

ArrayBlockingQueue — bounded, backed by a fixed-size array. Fair ordering of waiting threads is optional (comes with a performance cost).

LinkedBlockingQueue — optionally bounded (Integer.MAX_VALUE if no bound specified — effectively unbounded). Separate locks for put and take; higher throughput than ArrayBlockingQueue in high-contention scenarios.

PriorityBlockingQueue — unbounded, elements ordered by natural ordering or a Comparator. No blocking on put (unbounded); take blocks if empty.

SynchronousQueue — zero capacity. Each put must wait for a corresponding take, and vice versa. A direct handoff between threads. Used internally by Executors.newCachedThreadPool().

DelayQueue — holds Delayed elements; a take only succeeds when the element's delay has expired. Used for scheduled tasks.

BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100); // bounded to 100
// Producer thread
queue.put(task); // blocks if full
// Consumer thread
Task task = queue.take(); // blocks if empty
// Non-blocking variants with timeout
boolean offered = queue.offer(task, 500, TimeUnit.MILLISECONDS);
Task polled = queue.poll(500, TimeUnit.MILLISECONDS); // returns null on timeout

Advanced Design & Patterns

Q28. What is the Builder pattern? How does Lombok @Builder implement it?

Answer:

The Builder pattern constructs complex objects step by step. It separates the construction of an object from its representation, particularly useful when a class has many optional parameters — eliminating the telescoping constructor anti-pattern.

Manual builder:

public class HttpRequest {
private final String url;
private final String method;
private final Map<String, String> headers;
private final String body;
private final int timeoutMs;
    private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = Collections.unmodifiableMap(new HashMap<>(builder.headers));
this.body = builder.body;
this.timeoutMs = builder.timeoutMs;
}
public static class Builder {
private final String url; // required
private String method = "GET"; // optional with default
private Map<String, String> headers = new HashMap<>();
private String body;
private int timeoutMs = 5000;
public Builder(String url) { this.url = url; }
public Builder method(String m) { this.method = m; return this; }
public Builder header(String k, String v) { headers.put(k, v); return this; }
public Builder body(String b) { this.body = b; return this; }
public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
public HttpRequest build() { return new HttpRequest(this); }
}
}
HttpRequest req = new HttpRequest.Builder("https://api.example.com/users")
.method("POST")
.header("Content-Type", "application/json")
.body("{\"name\":\"Alice\"}")
.timeout(3000)
.build();

Lombok @Builder generates this boilerplate at compile time:

@Builder
@Getter
public class HttpRequest {
private final String url;
@Builder.Default private final String method = "GET";
private final String body;
private final int timeoutMs;
}
// Usage — identical to manual builder
HttpRequest.builder()
.url("https://api.example.com")
.method("POST")
.build();

Lombok generates the static builder() factory, the inner Builder class with fluent setters, and the build() method. @Builder.Default sets a default value for optional fields. Note: Lombok's generated builder does not perform defensive copying of mutable fields — add a custom build() method override if you need it.

Q29. What is the Singleton pattern? What are the thread-safe ways to implement it in Java?

Answer:

Singleton ensures only one instance of a class exists in a JVM. There are several implementations with different thread-safety and performance characteristics.

Eager initialization — instance created at class load time. Simple, always thread-safe (class loading is thread-safe):

public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}

Downside: created even if never used.

Double-checked locking — lazy, thread-safe with volatile:

public class Singleton {
private static volatile Singleton instance; // volatile is essential
    private Singleton() {}    public static Singleton getInstance() {
if (instance == null) { // first check — no lock
synchronized (Singleton.class) {
if (instance == null) { // second check — inside lock
instance = new Singleton();
}
}
}
return instance;
}
}

volatile is mandatory here. Without it, the JVM may reorder instance = new Singleton() (allocate memory, assign reference, initialize object), allowing another thread to see a non-null but partially initialized instance.

Initialization-on-demand holder — the cleanest lazy, thread-safe approach using the class loader guarantee:

public class Singleton {
private Singleton() {}
    private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // Holder class loaded on first call — thread-safe by JVM spec
}
}

Enum singleton — recommended by Effective Java. Serialization-safe, reflection-safe:

public enum Singleton {
INSTANCE;
public void doWork() { ... }
}
Singleton.INSTANCE.doWork();

Q30. What are Java Modules (JPMS)? What problem do they solve and how do they work?

Answer:

The Java Platform Module System (JPMS), introduced in Java 9, addresses two problems that the classpath-based system could not solve:

  1. Encapsulation at the package level was impossible. A public class was accessible to everyone on the classpath — no way to say "public within this JAR, but not to external code."
  2. The classpath was a flat, unstructured list. Dependency conflicts, split packages, and “JAR hell” were real problems in large applications.

How modules work:

Each module has a module-info.java at its root:

// module-info.java for module "com.example.orders"
module com.example.orders {
requires java.sql; // this module depends on java.sql
requires transitive com.example.model; // transitive: consumers of orders also get model
exports com.example.orders.api; // only this package is visible to others
// com.example.orders.internal is NOT exported — truly encapsulated
opens com.example.orders.api to com.example.jackson; // allow reflection (for serialization)
}

Key directives:

  • requires — declare a dependency on another module
  • exports — make a package accessible to other modules
  • opens — grant deep reflective access (needed for frameworks like Jackson, Spring, Hibernate that use reflection at runtime)
  • provides ... with — service loader: declare that this module provides an implementation of an interface
  • uses — declare that this module consumes a service

Practical reality: Most applications still use the classpath (--class-path) rather than the module path, because not all libraries have been fully modularized. Spring Boot 3.x applications run fine on the classpath; full JPMS adoption in application code remains uncommon outside of library development. The JDK itself is fully modularized — java.base, java.sql, java.desktop, etc.

Quick Reference — What Each Question Tests

# Question Core Concept 1 Heap / Stack / Metaspace JVM internals 2 GC algorithms GC strategy selection 3 Memory leaks GC root analysis 4 Java Memory Model Happens-before reasoning 5 volatile Visibility vs atomicity 6 synchronized vs Locks Concurrency primitives 7 ThreadLocal Per-thread state + pitfalls 8 CompletableFuture Async composition 9 equals() + hashCode() Object contract 10 Type erasure Generics reality 11 Comparable vs Comparator Sort semantics 12 HashMap internals Data structure depth 13 String pool Interning and reference semantics 14 try-with-resources Exception hygiene 15 Checked vs unchecked API design tradeoffs 16 Stream API Lazy evaluation + stateful ops 17 Optional Null avoidance idioms 18 Functional interfaces Lambda/method ref types 19 Default methods Interface evolution 20 Reference types GC-cooperative caching 21 Abstract class vs interface Type system design 22 Immutability Thread safety by design 23 Proxy pattern + Spring AOP Framework internals 24 ClassLoader JVM class loading 25 Records / Sealed / Pattern matching Modern Java 26 ConcurrentHashMap Concurrent collections 27 BlockingQueue Producer-consumer primitives 28 Builder pattern Object construction 29 Singleton Thread-safe lazy init 30 JPMS Module system

How to Use This Article in Your Prep

Don’t memorize. Understand the mechanism behind each answer and practice saying it out loud in under two minutes. Interviewers at senior level don’t want a recited definition — they want to hear you reason from first principles.

For each question, ask yourself: “What breaks if this mechanism didn’t exist?” That framing — understanding what problem each feature solves — is what separates a strong hire from a hire.

📘 Java Backend Interview Questions 300+ questions across all experience levels — with the depth interviewers are actually looking for.

📘 Complete Backend Interview Questions 1000+ questions covering core Java, concurrency, JVM internals, Spring, and system design.

This article is part of a series on Java interview preparation. For hands-on coding problems with Java solutions, see the companion article on 30 Java Coding Interview Questions Asked in Top Product Companies.

--

--