Sitemap

The Java Interview Questions That 90% of Seniors Get Wrong

12 min readMay 9, 2026

--

I’ve been on both sides of the table — spending years as a candidate walking into interviews at product companies, and more recently as an interviewer running technical rounds for senior and staff engineers. After hundreds of these conversations, one pattern stands out: the questions that expose the most experienced candidates aren’t the algorithm puzzles. They’re the deceptively simple questions about Java fundamentals.

Press enter or click to view image in full size

These are questions that a developer with 5+ years of Java experience should answer in their sleep. But they don’t — because the wrong answer is so intuitive, so plausible, that even strong engineers state it confidently and move on.

This is a collection of those questions. Not gotchas for the sake of it. Each one maps to a real misunderstanding that shows up in production code.

📘 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.

📘 Springboot Interview Questions 200+ questions across all experience levels — with the depth interviewers are actually looking for.

Question 1: “Can you override a static method in Java?”

What 90% of seniors say: “No, you can’t override static methods.”

Why that’s incomplete — and what the follow-up reveals:

The answer “no” is correct but most candidates stop there and miss the deeper point. The real question is: what actually happens if you define a static method with the same name and signature in a subclass?

class Animal {
public static void sound() {
System.out.println("Animal makes a sound");
}
}
class Dog extends Animal {
public static void sound() {
System.out.println("Dog barks");
}
}
public class Test {
public static void main(String[] args) {
Animal a = new Dog(); // Reference type: Animal. Object type: Dog.
a.sound(); // What prints?
}
}

Output: Animal makes a sound

Almost everyone expects Dog barks. After all, a holds a Dog object.

The reason it prints the parent’s version: static methods are not overridden — they are hidden. Method resolution for static methods happens at compile time, based on the declared reference type, not the runtime object type. There is no dynamic dispatch. When you call a.sound(), the compiler sees Animal a and binds the call to Animal.sound() at compile time.

The correct full answer: Static methods cannot be overridden because override requires dynamic dispatch at runtime. Defining a static method with the same signature in a subclass is called method hiding. The subclass method hides the parent’s, but they are two completely independent methods with no polymorphic relationship.

Why this matters in production: If you call a static utility method through a reference that you expect to be polymorphic, you will silently call the wrong version. No compiler warning. No runtime exception.

Question 2: “volatile makes your code thread-safe — true or false?"

What 90% of seniors say: “True — volatile guarantees thread safety."

The correct answer: volatile guarantees visibility and happens-before ordering for individual reads and writes. It does NOT make compound operations atomic.

This distinction destroys production systems. Consider:

public class Counter {
private volatile int count = 0;
    public void increment() {
count++; // Is this thread-safe?
}
}

count++ is NOT thread-safe, even with volatile.

Here’s why: count++ is not one operation. It is three:

  1. Read count from main memory (visibility guaranteed by volatile)
  2. Add 1
  3. Write the new value back

Two threads can both read count = 5, both compute 6, and both write back 6. The result is 6 when it should be 7. You lost an increment.

The fix:

import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Truly atomic
}
}

When is volatile actually correct? Use it for a single flag that is written by one thread and read by others — where you need visibility but the write is not conditional on a prior read:

private volatile boolean shutdown = false;
// Writer thread
shutdown = true;
// Reader thread — will see the updated value promptly
while (!shutdown) { /* do work */ }

The precise guarantee: volatile establishes a happens-before relationship — a write to a volatile variable happens-before every subsequent read of that variable. But "subsequent" refers to program order and synchronization order, not wall clock time between threads.

Question 3: “What is hashCode() based on — does it return the memory address?"

What 90% of seniors say: “Yes, hashCode() returns the memory address of the object."

The correct answer: The JVM specification does NOT mandate this. The default Object.hashCode() implementation may be derived from an internal address, but:

  1. It is not guaranteed to be the memory address.
  2. It is not stable across JVM runs — the same object can get a different hashCode() value if you restart the JVM.
  3. It is not stable during the object’s lifetime if the JVM’s garbage collector moves the object in memory (which G1GC and ZGC do constantly). The JVM caches the first computed value to keep it stable within a run, but it is not the live address.

From the Java specification (JDK docs):

“As much as is reasonably practical, the hashCode method defined by class Object does return distinct integers for distinct objects. (The hashCode may or may not be implemented as some function of an object’s memory address at some point in time.)”

The only contracts hashCode() must satisfy:

  • Consistent within a single JVM execution (multiple calls on the same unchanged object return the same value)
  • If a.equals(b), then a.hashCode() == b.hashCode() (the inverse is NOT required)
  • Different objects should produce different hash codes, but this is not required
Object a = new Object();
System.out.println(a.hashCode()); // e.g., 1829164700
System.out.println(a.hashCode()); // Same value — consistent within this JVM run
// Restart the JVM — different value. No memory address guarantee.

Why this matters in interviews: Saying “it’s the memory address” is technically wrong and would fail under cross-examination by any interviewer who knows the JVM spec.

Question 4: “Is HashMap.get() always O(1)?"

What 90% of seniors say: “Yes, HashMap is O(1) for get and put.”

The correct answer: O(1) is the average case under a good hash distribution. The actual complexity depends on collisions.

Pre-Java 8: Worst case is O(n). If all keys hash to the same bucket, the entire bucket is a linked list that must be traversed linearly.

Java 8+: When a bucket’s linked list reaches 8 entries AND the table has at least 64 total buckets, the list converts to a red-black tree. Worst case becomes O(log n), not O(n). This conversion is called treeification.

Bucket state evolution:
Entries 1-7: linked list → O(n) lookup within bucket
Entry 8+: red-black tree → O(log n) lookup within bucket
If entries drop to 6: tree reverts to linked list (untreeification)

The caveat most candidates miss: Treeification only works if the keys implement Comparable. If they don't, the bucket remains a linked list even with 8+ entries.

// These two hash to different buckets — normal O(1)
map.put("abc", 1);
map.put("xyz", 2);
// Adversarial case: craft keys with the same hashCode()
// All land in the same bucket → O(log n) with treeification in Java 8+

The complete answer: HashMap averages O(1) for get/put with a good hash function and load factor ≤ 0.75. With collisions, it degrades to O(log n) in Java 8+ (treeification threshold: 8 entries, minimum table capacity: 64) or O(n) in Java 7 and earlier.

Question 5: “Does the finally block always execute?"

What 90% of seniors say: “Yes, finally always runs — that's its whole purpose."

The correct answer: finally almost always runs. There are cases where it does not:

try {
System.out.println("try");
System.exit(0); // JVM terminates — finally does NOT run
} finally {
System.out.println("finally"); // Never printed
}

Cases where finally is skipped:

  1. System.exit() is called — the JVM shuts down immediately
  2. Runtime.getRuntime().halt(int) is called — abrupt JVM termination
  3. The JVM crashes (OutOfMemoryError, SIGSEGV, etc.)
  4. The thread running the try-finally is a daemon thread and all non-daemon threads exit — daemon threads are killed without cleanup

There is one more subtlety most candidates miss — what happens when both try and finally have a return statement:

public static int test() {
try {
System.out.println("try");
return 1;
} finally {
System.out.println("finally");
return 2; // This return overrides the try's return
}
}
// test() returns 2, not 1
// Output: try, finally

The finally block's return silently discards the exception or return value from the try block. This is a well-known footgun — never put return or throw in a finally block.

Question 6: “Integer a = 128; Integer b = 128; a == b — what does this print?"

What 90% of seniors say: “true — they hold the same value."

Get Vighneshwar Bhat’s stories in your inbox

Join Medium for free to get updates from this writer.

The correct answer: false.

Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false

Why? The auto-boxing statement Integer a = 128 compiles to Integer a = Integer.valueOf(128). The Integer.valueOf() method maintains an internal cache for integers in the range -128 to 127. Within that range, the same cached object is returned every time — so == comparison works by coincidence. Outside that range, a new Integer object is created each time, so == compares object identity, not value.

The cache can be extended (but this makes things worse, not better):

// JVM flag: -XX:AutoBoxCacheMax=<size>
// This extends the upper bound of the Integer cache, but it's non-standard
// and makes the behavior even less predictable across environments.

The rule: Never use == to compare wrapper types (Integer, Long, Double, etc.). Always use .equals() or, when one operand may be null, Objects.equals(a, b).

This is one of the most common causes of subtle production bugs — the code works in testing (small values within the cache range) and fails in production (larger IDs, counts, etc.).

Question 7: “Is String immutable because of the final keyword?"

What 90% of seniors say: “Yes — String is final, which makes it immutable."

The correct answer: final on the class prevents subclassing, but it is not what makes String immutable. The final keyword on a reference variable only prevents reassigning the reference — it says nothing about the state of the object itself.

String's immutability is achieved by a combination of design decisions:

// Simplified internal structure of String (Java 8 and earlier)
public final class String {
private final char[] value; // 1. Private field — no external access
private int hash; // 2. Lazily computed, never reset
    // 3. No setter methods or mutation methods exposed in the public API
// 4. Defensive copying in constructors that accept char[]
// 5. final class — cannot be subclassed to add mutation
}

The four pillars:

  1. The char[] backing array is private — no external code can get a reference to it
  2. No mutation methods are exposed — substring(), toUpperCase(), etc. all return new String objects
  3. Defensive copying — when constructed from a char[], String makes its own copy
  4. final class — prevents a subclass from breaking the immutability contract

A common follow-up trap: Can you break String immutability via reflection?

String s = "hello";
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(s);
value[0] = 'H';
System.out.println(s); // "Hello" — the String was mutated
// But this also mutates every other String that happens to share this char[]
// including the String pool entry for "hello" — catastrophic in real code

Yes, you can break it with reflection, but you will corrupt the String pool in unpredictable ways. This is why SecurityManager existed (and why the JVM's module system locks down java.lang in Java 9+).

Question 8: “Does new String("hello") == "hello" return true?"

What 90% of seniors say: After a pause — “True? Maybe? They have the same value…”

The correct answer: false.

String a = "hello";           // Points to String pool entry
String b = new String("hello"); // Creates a new object on the heap
System.out.println(a == b);       // false — different object references
System.out.println(a.equals(b)); // true — same content
String c = b.intern(); // Returns the pool reference
System.out.println(a == c); // true

== on objects compares references (memory identity), not content. "hello" is a string literal that is automatically placed in the String pool (part of the heap since Java 7; PermGen in Java 6 and earlier). new String("hello") explicitly creates a new object on the heap, separate from the pool.

The common production mistake this reveals: Developers who learned that String equality is special sometimes assume == works for strings. It works by coincidence for two literals that reference the same pool entry. It fails for any String created at runtime (parsed from JSON, read from a database, received over a network — all are non-interned heap objects).

Rule: Always use .equals() for String comparison. The only valid use of == on strings is checking for null first.

Question 9: “Is ConcurrentHashMap fully consistent under concurrent access?"

What 90% of seniors say: “Yes — it’s thread-safe, so reads are always consistent.”

The correct answer: ConcurrentHashMap is thread-safe (no data corruption), but its iterators are weakly consistent — not strongly consistent.

What “weakly consistent” means:

  • Iterators will never throw ConcurrentModificationException (unlike HashMap)
  • But they may or may not reflect writes that happened after the iterator was created
  • No guarantee that a traversal sees all modifications made after iteration begins
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a", 1);
map.put("b", 2);
// Thread 1 starts iterating
for (Map.Entry<String, Integer> entry : map.entrySet()) {
// Thread 2 might add map.put("c", 3) here
// The iterator may or may not return entry "c" — weakly consistent
System.out.println(entry.getKey());
}

size() is not exact under concurrent modification:

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Multiple threads adding/removing entries concurrently
int size = map.size(); // This is a snapshot — may not reflect concurrent modifications

The Java documentation explicitly states: “The result of size() and isEmpty() reflect transient state, and may not be consistent with concurrent modifications."

Why does this matter? Code that checks map.size() > 0 before doing something may behave unexpectedly under load. Code that assumes a for-each over a ConcurrentHashMap is a consistent snapshot is wrong. If you need a truly consistent snapshot, you need external synchronization or a Collections.synchronizedMap() with explicit locking.

Question 10: “Does @Transactional work on a private method?"

What 90% of seniors say: “Yes — I just annotate the method and Spring handles the transaction.”

The correct answer: No. @Transactional on a private method is silently ignored by Spring.

Spring’s @Transactional works through AOP proxies. When you inject a Spring bean, you're actually getting a proxy object that wraps your bean. The proxy intercepts method calls, opens a transaction, invokes your method, and commits or rolls back.

The proxy can only intercept calls that go through it — which means only calls on the external interface of the bean. Two things break this silently:

Problem 1: Private methods:

@Service
public class OrderService {
    @Transactional
private void saveOrder(Order order) { // WRONG — @Transactional is ignored
orderRepository.save(order);
}
}

Spring’s proxy cannot intercept private method calls. The annotation is present, but Spring ignores it without any warning or error. The method runs without a transaction.

Problem 2: Self-invocation:

@Service
public class OrderService {
    public void processOrder(Order order) {
saveOrder(order); // Calling own method — bypasses the proxy entirely
}
@Transactional
public void saveOrder(Order order) { // @Transactional is ignored for self-calls
orderRepository.save(order);
}
}

When processOrder calls saveOrder, the call goes directly from this to this — it never goes through the proxy. No transaction is started. The fix is to inject the bean into itself (using ApplicationContext or @Lazy self-injection) or restructure the code to avoid self-invocation.

@Service
public class OrderService {
    @Autowired
private ApplicationContext context;
public void processOrder(Order order) {
// Get the proxy reference, not "this"
OrderService self = context.getBean(OrderService.class);
self.saveOrder(order); // Now goes through the proxy — transaction starts
}
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}

This is one of the most common Spring production bugs. The code looks correct, the tests pass (if they don’t check transaction behavior), and the bug only surfaces in production when you need rollback.

The Pattern Behind All of These

Every question above follows the same structure: the wrong answer is a simplification that works 95% of the time but breaks in specific, real scenarios. Senior engineers who haven’t been burned by the edge case carry the wrong mental model for years.

  • volatile = thread safety — works until you use compound operations
  • hashCode = memory address — works until you reason about cross-JVM identity
  • HashMap = O(1) — works until you hit adversarial input or measure production latency spikes
  • finally = always runs — works until someone calls System.exit() in a shutdown hook
  • Integer == comparison — works until your IDs exceed 127
  • @Transactional — works until someone extracts a method or calls it internally

The best way to prepare is not to memorize these answers, but to understand the mechanism behind them. Why does volatile not provide atomicity? Why does Spring's AOP not intercept private methods? The mechanism question is always the real interview question.

📘 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.

📘 Springboot Interview Questions 200+ questions across all experience levels — with the depth interviewers are actually looking for.

Further Reading

If you want to go deeper on all of these — and the full range of Java interview topics from core JVM internals to microservices patterns — I’ve compiled 200+ Java interview questions with detailed answers and runnable code in my Java interview preparation book. It covers everything from the questions above through distributed system design, JPA pitfalls, and Kafka integration patterns with the same level of technical depth.

These questions come from real interviews — patterns I’ve seen across multiple companies over years of both giving and taking technical interviews. If you’re preparing for a senior Java role, the bar is not just knowing the answer. It’s knowing why the obvious answer is wrong.

--

--