Java Interview Master Notes
The 10 questions every interviewer asks about Java core — Google, Amazon, Flipkart. Understand these and you'll outclass 90% of candidates in the first 15 minutes.
Think of it like a restaurant. JVM is the chef — the actual engine that executes Java code. JRE is the full kitchen — chef plus all the utensils and ingredients (class libraries, runtime support) needed to actually serve food (run Java programs). JDK is the entire restaurant building — kitchen plus the architecture room where you design new recipes (javac compiler, debugger, javadoc, jar tool, etc.).
As a user who just wants to run a Java app? You install JRE. As a developer building Java apps? You install JDK. The JDK includes a JRE inside it, so you don't need both separately.
The JVM itself is a specification — not a single product. Oracle's HotSpot, OpenJ9, GraalVM are all JVM implementations. They all run the same bytecode (.class files) but differ in performance, GC strategies, startup time etc.
- JVM is platform-specific (different JVM for Windows/Linux/Mac) but bytecode is platform-neutral
- JRE includes: JVM + Java SE API (rt.jar) + supporting files
- JDK includes: JRE + javac (compiler) + javadoc + jar + jdb (debugger) + jshell (REPL)
- From Java 9+, JRE as a standalone download was discontinued — JDK only
- HotSpot JVM uses JIT (Just-In-Time) compilation to convert hot bytecode to native machine code
Gotchas
- Saying "JVM is platform independent" is WRONG — JVM itself is platform-dependent. The bytecode it runs is platform independent.
- Don't confuse JIT compiler (inside JVM, converts bytecode to native code at runtime) with javac (converts .java to .class bytecode at compile time)
In C/C++, your code compiles directly to machine code — the binary is specific to Windows x86 or Linux ARM. You want to run on another OS? Recompile. Java took a different route.
When you write Java and compile with javac, you get .class files containing bytecode — an intermediate representation that's not native to any CPU. It's like a universal language that any JVM understands.
Each OS has its own JVM implementation that knows how to translate that bytecode to the native machine code of that specific platform. So the same HelloWorld.class runs on Windows, Linux, Mac — unchanged. The JVM absorbs the platform difference.
- Bytecode is intermediate — not machine code, not source code
- Each JVM is platform-dependent; bytecode is platform-independent
- JIT compiler inside JVM converts hot bytecode to native code at runtime for performance
- WORA: "Write Once, Run Anywhere" — Java's core marketing promise since 1995
Gotchas
- "Java is 100% platform independent" — not entirely true. File paths, native libraries (JNI), GUI rendering can still be platform-specific.
Competitive Edge
GraalVM takes this further — it can AOT (Ahead-Of-Time) compile Java to native binary (no JVM needed at runtime), giving fast startup and lower memory. Used in production by Twitter/now X, Netflix. Mention this if asked about Java performance improvements.
Every thread in Java gets its own Stack. When a method is called, a new frame is pushed with local variables, return address, operand stack. Method returns? Frame popped. Clean, automatic, no GC needed.
The Heap is where every object you create with new lives. It's shared across all threads (which is why you need synchronization). The GC watches the heap and collects objects with no remaining references.
The JVM Heap is itself divided: Young Generation (new objects — Eden + Survivor S0/S1), Old Generation / Tenured (long-lived objects), and before Java 8, PermGen (class metadata — now replaced by Metaspace in the native memory).
- Stack: thread-local, LIFO, stores primitives + references + method frames. Cleared automatically on method return.
- Heap: shared across threads, all objects go here, GC managed
- Young Gen: new objects start here. Minor GC is frequent and fast.
- Old Gen: survivors promoted here. Major/Full GC is rare and slow.
- Metaspace (Java 8+): replaced PermGen. Stores class metadata in native memory — no fixed size, grows dynamically
- StackOverflowError: stack too deep (deep recursion). OutOfMemoryError: heap exhausted.
Gotchas
- "Objects always go on heap" — mostly true, but JVM escape analysis can stack-allocate objects that don't escape the method. JIT optimization.
- PermGen was removed in Java 8, replaced by Metaspace. Saying PermGen in an interview without clarifying Java version is a yellow flag.
Java's GC is based on one key insight: most objects die young. You create a String to format a log line, use it, and it's garbage. These short-lived objects are the vast majority. So GC focuses most effort on the Young Generation where new objects land.
How it works step by step: New objects go to Eden (Young Gen). When Eden fills up, a Minor GC runs — it marks all reachable objects starting from GC roots (stack variables, static fields, JNI refs). Live objects are copied to Survivor space S0. On next Minor GC, S0 survivors go to S1, S1 goes to S0 (they alternate). After surviving a certain number of GC cycles (default 15, the "tenuring threshold"), objects are promoted to Old Gen.
When Old Gen fills up, a Major GC (or Full GC) runs — more expensive, longer pause. The goal of modern GC tuning is to keep Full GCs rare.
GC algorithms in Java: Serial GC (single-threaded, small apps), Parallel GC (multi-threaded, throughput focus — Java 8 default), G1GC (Garbage First — splits heap into regions, targets low pause time — Java 9+ default), ZGC (sub-millisecond pauses, Java 15+), Shenandoah (low pause, concurrent).
- GC Roots: thread stacks, static variables, JNI references — starting points for reachability
- Minor GC: young gen only, fast (milliseconds). Major/Full GC: entire heap, slow (can be seconds)
- Stop-The-World (STW) pause: all application threads paused while GC runs (in some phases)
- G1GC: divides heap into equal-size regions, collects garbage-first (highest garbage regions), predictable pause times. Default since Java 9.
- ZGC/Shenandoah: concurrent GC — almost all work done while app runs. Sub-millisecond pauses.
- You can request GC with System.gc() but JVM may ignore it — it's just a hint
Gotchas
- "GC prevents all memory leaks" — FALSE. If you hold references, GC won't collect. Logical memory leaks happen all the time in Java.
- finalize() is deprecated in Java 9 and removed later. Don't mention it as a GC hook — interviewers know it's unreliable.
Competitive Edge
The "generational hypothesis" is the theoretical foundation of all modern GCs — objects either die very quickly (most) or live very long (few). By segregating young and old objects and collecting them independently, GC avoids scanning the entire heap every time. This is why minor GC is 100x faster than full GC.
Think of == like asking "are these two people the same physical person?" — comparing ID cards (memory addresses). equals() is asking "do these two people have the same name?" — comparing content.
For primitives (int, char, boolean), == compares values directly (no objects involved). For objects, == compares references — same object? For Strings, == is almost always wrong because two different String objects with the same content are NOT the same reference.
The String Pool is the classic gotcha. String literals (not new String()) are interned — stored in the pool. Two literals with same content share the same reference, so == accidentally works. But new String("hello") == new String("hello") is false — two objects on heap.
// == vs equals() — the classic interview demo String a = "hello"; // goes to String Pool String b = "hello"; // same pool reference String c = new String("hello"); // new heap object System.out.println(a == b); // true (same pool ref) System.out.println(a == c); // false (different objects) System.out.println(a.equals(c)); // true (same content) // For your own classes — override equals() AND hashCode() class Person { String name; public boolean equals(Object o) { if(!(o instanceof Person)) return false; return this.name.equals(((Person) o).name); } }
- == on objects: reference equality (same memory address)
- equals() on objects: logical equality (defined by the class) — overridable
- String Pool: literals are interned, new String() bypasses the pool
- Always override hashCode() when you override equals() — contract: equal objects must have equal hash codes
- Integer cache: Java caches Integer values -128 to 127. Integer.valueOf(100) == Integer.valueOf(100) is true. Above 127? false.
Gotchas
- The Integer cache trap: Integer a = 127; Integer b = 127; a == b → true. Integer a = 128; Integer b = 128; a == b → false. Interviewers love this.
- If you override equals() but not hashCode(), your objects will break in HashMaps and HashSets — equal objects may end up in different buckets.
Competitive Edge
The equals-hashCode contract is the most important Java contract. If a.equals(b) is true, then a.hashCode() == b.hashCode() MUST be true. The reverse doesn't need to hold (hash collision is okay). Violating this silently breaks HashMap, HashSet, and any hash-based collection.
HashMap uses an array of buckets. Each bucket is a linked list (or tree for 8+ entries). put(key, value): compute hash → find bucket → add node. get(key): compute hash → find bucket → traverse list. All fast — O(1) average.
But two threads calling put() simultaneously can corrupt the structure. In Java 7, resizing could cause infinite loops in multi-threaded use (now fixed but still unsafe). Never use HashMap in multi-threaded code without external synchronization.
Hashtable was the old solution — synchronized every method. Safe, but terrible performance — one lock for everything, one thread at a time. Essentially serialized access.
ConcurrentHashMap (Java 7): splits the map into 16 segments, each with its own lock. 16 threads can write simultaneously (to different segments). Much better than Hashtable.
ConcurrentHashMap (Java 8+): Even better — no segments anymore. Uses CAS (Compare-And-Swap) operations and synchronized only on individual bucket nodes. Lock granularity is now per-bucket. In practice, reads are lock-free (volatile reads). Writes lock only the specific bucket being modified.
| Feature | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| Thread-safe? | ❌ No | ✅ Yes (method-level) | ✅ Yes (fine-grained) |
| Null keys/values | 1 null key, null values | ❌ No nulls | ❌ No nulls |
| Locking | None | Entire map | Per bucket (Java 8+) |
| Performance | Fastest | Slowest | High-concurrency best |
| Iteration | Fail-fast (ConcurrentModificationException) | Enumerator | Weakly consistent (no exception) |
- HashMap: O(1) average get/put, O(n) worst case. Java 8+ uses TreeNode (red-black tree) when bucket size > 8, making worst case O(log n)
- HashMap load factor default 0.75 — resizes when 75% full (doubles capacity)
- ConcurrentHashMap allows concurrent reads without locking (reads are weakly consistent)
- ConcurrentHashMap's iterator is weakly consistent — doesn't throw ConcurrentModificationException, but may or may not reflect concurrent modifications
- Use Collections.synchronizedMap(HashMap) for a quick thread-safe wrapper — but it's coarse-grained like Hashtable
Gotchas
- HashMap allows ONE null key and multiple null values. ConcurrentHashMap allows NEITHER — calling put(null, ...) throws NullPointerException. Hashtable also rejects nulls.
- ConcurrentHashMap's size() is only approximate in Java 8 — it sums segment counts which may not be perfectly synchronized. Use mappingCount() for large maps.
Competitive Edge
In Java 8, ConcurrentHashMap added computeIfAbsent(), merge(), and forEach() — atomic compound operations. Instead of check-then-put (race condition), use map.computeIfAbsent(key, k -> new ArrayList()).add(value) — atomically. This is the correct way to implement concurrent multi-maps.
When you run a Java program, you start a JVM process. That process has one thread at the start — the main thread. From there you spawn more threads using new Thread(runnable).start() or ExecutorService.
Java threads are kernel-level threads (1:1 with OS threads, via JNI) since the early days. This means true parallelism on multi-core CPUs. Java 19+ introduced Virtual Threads (Project Loom) — lightweight threads managed by the JVM, not the OS. You can have millions of virtual threads (vs thousands of OS threads). Virtual threads are the answer to "how do you handle 1 million concurrent connections in Java?"
Two ways to create threads: extend Thread class (bad — you waste your one inheritance slot), implement Runnable (better), or implement Callable (returns a value + can throw checked exceptions). Always prefer ExecutorService over raw threads in production.
// 3 ways to create threads — know all three // 1. Extend Thread (avoid — wastes inheritance) class MyThread extends Thread { public void run() { System.out.println("Thread"); } } // 2. Implement Runnable (preferred for simple tasks) Runnable r = () -> System.out.println("Runnable"); new Thread(r).start(); // 3. Callable — returns value, can throw checked exception Callable<Integer> c = () -> 42; ExecutorService ex = Executors.newFixedThreadPool(4); Future<Integer> f = ex.submit(c); System.out.println(f.get()); // blocks until done // Java 19+: Virtual Threads — lightweight, millions possible Thread.ofVirtual().start(() -> System.out.println("Virtual!"));
- Thread states: NEW → RUNNABLE → BLOCKED/WAITING/TIMED_WAITING → TERMINATED
- start() creates a new thread. run() just executes on current thread — common beginner mistake.
- ExecutorService (thread pool) is the production way — avoids thread creation overhead
- Daemon threads: background threads that JVM kills when all non-daemon threads finish (e.g., GC thread)
- Virtual threads (Java 21 LTS): platform threads are expensive OS threads. Virtual threads are JVM-managed, cheap. Ideal for I/O-bound workloads.
Gotchas
- Calling run() directly instead of start() — this runs the code on the current thread, no new thread created. Very common mistake.
- Thread.sleep() doesn't release any locks it holds. Object.wait() DOES release the lock. Crucial difference for synchronization.
Every Java object has an intrinsic lock (monitor). The synchronized keyword grabs that lock. Only one thread can hold a given object's lock at a time — others block.
You can synchronize a method (lock = the object) or a block (lock = any specified object). Synchronized methods are simpler; synchronized blocks are more flexible and allow finer granularity (lock only the specific object you're protecting, not the whole this).
Deadlock example in Java: Thread 1 locks Object A then tries to lock Object B. Thread 2 locks Object B then tries to lock Object A. Both block forever. Standard fix: always acquire locks in the same fixed order.
Beyond synchronized, Java's java.util.concurrent.locks package offers ReentrantLock — same mutual exclusion but with tryLock() (avoid deadlock by not waiting forever), lockInterruptibly() (respond to interrupts), and separate read/write locks (ReentrantReadWriteLock).
// synchronized method — lock is 'this' class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } } // ReentrantLock — more control, avoid deadlock with tryLock ReentrantLock lock = new ReentrantLock(); try { if (lock.tryLock(1, TimeUnit.SECONDS)) { // got the lock, do critical work lock.unlock(); } } catch (InterruptedException e) { /* handle */ } // volatile — visibility guarantee, NOT atomicity volatile boolean running = true; // other threads see this change immediately
- synchronized guarantees mutual exclusion + memory visibility (happens-before relationship)
- volatile: ensures all threads see the latest value of a variable — no caching in CPU registers. Does NOT make compound ops atomic.
- atomic package: AtomicInteger, AtomicLong — lock-free thread-safe operations using CAS
- ReentrantLock: reentrant (same thread can lock multiple times), interruptible, timed, with fairness option
- Deadlock prevention in Java: lock ordering, tryLock() with timeout, avoid nested locks
- wait()/notify()/notifyAll() must be called inside synchronized block — on the same lock object
Gotchas
- volatile doesn't make count++ atomic. It's 3 ops: read, increment, write. Use AtomicInteger instead.
- synchronized on a static method uses the Class object as lock. On an instance method, it's the instance. Different locks — they don't block each other.
- Calling wait() without checking condition in a loop leads to spurious wakeup bugs. Always: while(!condition) wait();
Competitive Edge
Java's synchronized uses biased locking, thin locks, and inflated monitors internally — it starts cheap and escalates. In Java 15+, biased locking was disabled as it slows modern workloads with high contention. This is why moving to ReentrantLock or concurrent collections is generally better for high-throughput systems.
This is the question that separates people who just use Java from people who understand Java. Let's walk through it precisely.
Step 1 — Compilation: javac HelloWorld.java → produces HelloWorld.class with bytecode. Bytecode is not native code. It's compact, platform-neutral instructions for the JVM.
Step 2 — Class Loading: When JVM starts, the Class Loader subsystem kicks in. Three built-in loaders: Bootstrap ClassLoader (loads core Java classes from rt.jar — java.lang, java.util), Extension ClassLoader (loads from ext directories), Application ClassLoader (loads your .class files and classpath). Delegation model: child always asks parent first before loading itself.
Step 3 — Bytecode Verification: The Bytecode Verifier checks that the .class file is valid, hasn't been tampered with, and doesn't violate access rules. This is Java's security gate — ensures bytecode won't crash the JVM.
Step 4 — Execution: The Execution Engine runs the bytecode. Initially via Interpreter — reads and executes one bytecode instruction at a time (slow). The JVM tracks which methods are called frequently (hot methods). Those get compiled by the JIT (Just-In-Time) Compiler to native machine code — stored in code cache — and subsequent calls run at native speed. This is why Java warms up over time and gets faster.
Step 5 — Memory Management: GC runs in the background, cleaning the heap. Main thread starts with main(). Program exits when all non-daemon threads finish.
- ClassLoader delegation: Application → Extension → Bootstrap (parent-first)
- Bytecode Verifier: security check before execution — ensures type safety, no illegal stack ops
- Interpreter runs code immediately (no warm-up). JIT kicks in for hot methods after profiling.
- JIT compiles to native code, stored in Code Cache — default 240MB, tunable with -XX:ReservedCodeCacheSize
- JVM startup has 3 phases: class loading, JIT compilation, steady-state execution
Gotchas
- JIT doesn't compile everything — only hot spots (called often). Cold code runs interpreted forever.
- Class loading is lazy by default — a class is loaded only when first referenced, not at JVM startup.
Competitive Edge
HotSpot JIT has two compilers: C1 (fast compilation, basic optimization — for methods called thousands of times) and C2 (slow compilation, aggressive optimization — for methods called millions of times, the "hot" hot methods). Java 10+ uses Graal as an optional JIT (replacing C2) — written in Java itself, proving that Java can compile Java.
String in Java is immutable. When you do str = str + "world", Java doesn't modify the existing String. It creates a NEW String object and points str to it. The old object becomes garbage. This is why concatenating Strings in a loop with + is O(n²) — you're creating n objects and copying more each time.
Immutability makes Strings safe to share across threads (no sync needed), safe to use as HashMap keys (hash never changes), and allows String pooling (interning). These are features, not accidents.
StringBuilder is a mutable buffer. append() adds to the internal char array without creating new objects. Use it for building strings in loops or when concatenating many pieces. Not thread-safe.
StringBuffer is exactly like StringBuilder but every method is synchronized. Thread-safe, but the synchronization overhead makes it slower. In the real world, you rarely need StringBuffer — if you're building a string in a multi-threaded context, you usually build it locally (StringBuilder) and then share the immutable String result.
| Feature | String | StringBuilder | StringBuffer |
|---|---|---|---|
| Mutable? | ❌ Immutable | ✅ Mutable | ✅ Mutable |
| Thread-safe? | ✅ Yes (immutable) | ❌ No | ✅ Yes (synchronized) |
| Performance | Slow for concat loops | ⚡ Fastest | Slower (sync overhead) |
| String Pool | ✅ Literals interned | N/A | N/A |
| When to use | Fixed strings, keys | Build strings in loops | Multi-threaded concat |
// BAD — O(n²), creates n new String objects in loop String result = ""; for(int i = 0; i < 1000; i++) result += i; // terrible! // GOOD — O(n), single object, internal array grows as needed StringBuilder sb = new StringBuilder(); for(int i = 0; i < 1000; i++) sb.append(i); String result = sb.toString(); // convert once at the end // Fun fact: javac optimizes simple + concatenations to StringBuilder // BUT not in loops — hence you still need to do it manually String s = "Hello" + " World"; // javac makes this StringBuilder internally
- String immutability enables: thread safety, String pooling, safe HashMap keys, caching hashCode
- String stores char[] internally (Java 8) or byte[] with encoding flag (Java 9+ — Compact Strings)
- StringBuilder initial capacity: 16 chars. Doubles when full. Amortized O(1) append.
- Java compiler optimizes "a" + "b" + "c" to use StringBuilder. But NOT in loops.
- Java 11+: String.repeat(), isBlank(), strip() added. Java 15+: Text Blocks """..."""
Gotchas
- String comparison with == in switch statements is fine in Java 7+ (compiler uses hashCode + equals internally). But in your own code, always use equals() for String comparison.
- String.intern() forces a string into the pool — useful for memory optimization when you have millions of repeated strings, but be careful — the pool lives in heap (Java 7+) and is GC'd, but intern() abuse can slow things down.
Competitive Edge
Java 9 introduced Compact Strings — if a String contains only Latin-1 characters (most strings do), it's stored as byte[] instead of char[]. This halves memory for typical strings. Java 11 added String.repeat() and isBlank(). Java 13+ added Text Blocks for multiline strings. Knowing these recent additions signals you keep up with the language evolution.
Ready to test Java Core?
Review JVM, OOP, collections, and concurrency through targeted questions.