Java Core Interview Guide: OOP to Concurrency

ยท22 min read
javaoopcollectionsconcurrencybackendinterview-preparation

Every Java interview eventually comes back to fundamentals. You might discuss Spring Boot for thirty minutes, then get asked "How does HashMap handle collisions?" or "Explain the difference between synchronized and ReentrantLock." These aren't trick questions - they reveal whether you truly understand the language you've been using.

This guide covers Java core concepts at interview depth. Not syntax basics you can look up, but the underlying mechanisms interviewers probe to gauge your expertise. Whether you're preparing for your first Java role or a senior position, these fundamentals will come up.


OOP Fundamentals

Object-oriented programming isn't just about classes and objects. Interviewers want to know if you understand why OOP principles exist and when to apply them.

The Four Pillars

Encapsulation - Hide internal state, expose behavior through methods.

// Bad: Exposed internal state
public class BankAccount {
    public double balance;  // Anyone can set this to anything
}
 
// Good: Encapsulated state with controlled access
public class BankAccount {
    private double balance;
 
    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
        this.balance += amount;
    }
 
    public void withdraw(double amount) {
        if (amount > balance) throw new InsufficientFundsException();
        this.balance -= amount;
    }
 
    public double getBalance() {
        return balance;  // Read-only access
    }
}

Encapsulation isn't just about private fields - it's about protecting invariants. The BankAccount class guarantees balance can never go negative through controlled mutations.

Inheritance - Share behavior through class hierarchies.

public abstract class Animal {
    protected String name;
 
    public Animal(String name) {
        this.name = name;
    }
 
    public abstract void makeSound();  // Subclasses must implement
 
    public void sleep() {  // Shared implementation
        System.out.println(name + " is sleeping");
    }
}
 
public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }
 
    @Override
    public void makeSound() {
        System.out.println(name + " barks");
    }
}

Interview question: "When would you use inheritance vs composition?"

Inheritance creates tight coupling - changes to the parent affect all children. Prefer composition (having an instance of another class) when you want to reuse behavior without the "is-a" relationship. Inheritance makes sense for true hierarchies (Dog is an Animal) and when you need polymorphism.

Polymorphism - Treat different types through a common interface.

public void feedAnimals(List<Animal> animals) {
    for (Animal animal : animals) {
        animal.makeSound();  // Each animal's specific implementation runs
    }
}
 
// Runtime polymorphism - actual method determined at runtime
List<Animal> animals = List.of(new Dog("Rex"), new Cat("Whiskers"));
feedAnimals(animals);  // Rex barks, Whiskers meows

Abstraction - Hide complexity behind simple interfaces.

// The user of this interface doesn't need to know HOW payments work
public interface PaymentProcessor {
    PaymentResult process(Payment payment);
}
 
// Could be Stripe, PayPal, or a mock for testing
// The abstraction hides the implementation details

Abstract Class vs Interface

This distinction comes up constantly:

FeatureAbstract ClassInterface
Instance fieldsYesNo (only constants)
ConstructorsYesNo
Method implementationAbstract and concreteAbstract, default, static
Multiple inheritanceNo (single extends)Yes (multiple implements)
Access modifiersAnyPublic (implicitly)
// Abstract class: Shared state and implementation
public abstract class DatabaseConnection {
    protected String connectionString;  // State
    protected int timeout;
 
    public DatabaseConnection(String connectionString) {
        this.connectionString = connectionString;
        this.timeout = 30;
    }
 
    public abstract void connect();  // Subclasses implement
    public abstract void disconnect();
 
    public void setTimeout(int seconds) {  // Shared implementation
        this.timeout = seconds;
    }
}
 
// Interface: Capability contract
public interface Cacheable {
    String getCacheKey();
    Duration getTtl();
 
    default boolean isExpired(Instant cachedAt) {  // Default implementation
        return Instant.now().isAfter(cachedAt.plus(getTtl()));
    }
}
 
// A class can extend one abstract class AND implement multiple interfaces
public class PostgresConnection extends DatabaseConnection implements Cacheable, AutoCloseable {
    // ...
}

Interview question: "When would you choose an abstract class over an interface?"

Use abstract class when you need:

  • Shared state (instance fields) across implementations
  • Constructor logic for initialization
  • Non-public methods
  • A clear hierarchical relationship

Use interface when you need:

  • Multiple inheritance of type
  • A contract that unrelated classes can fulfill
  • Maximum flexibility for implementers

SOLID Principles

Know these well - interviewers often ask for examples:

S - Single Responsibility: A class should have one reason to change.

// Bad: Multiple responsibilities
public class UserService {
    public void createUser(User user) { /* ... */ }
    public void sendWelcomeEmail(User user) { /* ... */ }  // Email is separate concern
    public String generateReport(List<User> users) { /* ... */ }  // Reporting is separate
}
 
// Good: Single responsibility
public class UserService {
    public void createUser(User user) { /* ... */ }
}
 
public class EmailService {
    public void sendWelcomeEmail(User user) { /* ... */ }
}
 
public class UserReportGenerator {
    public String generate(List<User> users) { /* ... */ }
}

O - Open/Closed: Open for extension, closed for modification.

// Bad: Must modify class to add new payment types
public class PaymentProcessor {
    public void process(Payment payment) {
        if (payment.getType().equals("CREDIT_CARD")) {
            // process credit card
        } else if (payment.getType().equals("PAYPAL")) {
            // process paypal
        }
        // Adding new type requires modifying this class
    }
}
 
// Good: Extend without modifying
public interface PaymentHandler {
    boolean supports(PaymentType type);
    void process(Payment payment);
}
 
public class PaymentProcessor {
    private List<PaymentHandler> handlers;
 
    public void process(Payment payment) {
        handlers.stream()
            .filter(h -> h.supports(payment.getType()))
            .findFirst()
            .orElseThrow()
            .process(payment);
    }
}
// New payment types = new handler classes, no modification to PaymentProcessor

L - Liskov Substitution: Subtypes must be substitutable for their base types.

// Violation: Square can't properly substitute Rectangle
public class Rectangle {
    protected int width, height;
 
    public void setWidth(int w) { width = w; }
    public void setHeight(int h) { height = h; }
    public int getArea() { return width * height; }
}
 
public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        width = w;
        height = w;  // Breaks expectations!
    }
}
 
// Code expecting Rectangle behavior breaks with Square
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50;  // Fails! Area is 100

I - Interface Segregation: Clients shouldn't depend on methods they don't use.

// Bad: Fat interface
public interface Worker {
    void work();
    void eat();
    void sleep();
}
 
public class Robot implements Worker {
    public void work() { /* ... */ }
    public void eat() { /* Robots don't eat! */ }
    public void sleep() { /* Robots don't sleep! */ }
}
 
// Good: Segregated interfaces
public interface Workable { void work(); }
public interface Eatable { void eat(); }
public interface Sleepable { void sleep(); }
 
public class Robot implements Workable {
    public void work() { /* ... */ }
}

D - Dependency Inversion: Depend on abstractions, not concretions.

// Bad: High-level module depends on low-level module
public class OrderService {
    private MySqlOrderRepository repository = new MySqlOrderRepository();
}
 
// Good: Both depend on abstraction
public class OrderService {
    private final OrderRepository repository;  // Interface
 
    public OrderService(OrderRepository repository) {
        this.repository = repository;  // Injected
    }
}

Collections Framework

The Collections Framework is a goldmine for interview questions. Understanding the internals separates senior developers from juniors.

Collection Hierarchy

                    Iterable
                       |
                   Collection
                  /    |    \
               List   Set   Queue
              /   \    |      |
     ArrayList  LinkedList  HashSet  PriorityQueue
                       |
                   TreeSet

                     Map (separate hierarchy)
                    /   \
              HashMap   TreeMap
                 |
          LinkedHashMap

List Implementations

ArrayList - Dynamic array, the default choice.

ArrayList<String> list = new ArrayList<>();
 
// Internal: Object[] elementData
// When full, grows by 50% (newCapacity = oldCapacity + oldCapacity >> 1)
 
list.add("a");     // O(1) amortized - may trigger resize
list.get(0);       // O(1) - direct array access
list.remove(0);    // O(n) - shifts all subsequent elements
list.contains("a"); // O(n) - linear search

LinkedList - Doubly-linked nodes.

LinkedList<String> list = new LinkedList<>();
 
// Internal: Node with prev, next, and element references
 
list.addFirst("a");  // O(1)
list.addLast("b");   // O(1)
list.get(50);        // O(n) - must traverse nodes
list.remove(node);   // O(1) if you have the node reference

Interview question: "When would you use LinkedList over ArrayList?"

Almost never in practice. ArrayList's cache locality (contiguous memory) outweighs LinkedList's theoretical O(1) insertions. LinkedList wins only when you:

  • Frequently insert/remove at known positions during iteration
  • Need a Deque (though ArrayDeque is often better)
  • Memory is extremely constrained and elements are large

Set Implementations

HashSet - Hash table, no order guaranteed.

HashSet<String> set = new HashSet<>();
 
// Internal: HashMap<E, PRESENT> where PRESENT is a dummy object
 
set.add("a");       // O(1) average
set.contains("a");  // O(1) average
set.remove("a");    // O(1) average
 
// Order is NOT guaranteed
set.add("c"); set.add("a"); set.add("b");
// Iteration order might be: b, c, a (based on hash codes)

LinkedHashSet - Maintains insertion order.

LinkedHashSet<String> set = new LinkedHashSet<>();
set.add("c"); set.add("a"); set.add("b");
// Iteration order: c, a, b (insertion order)

TreeSet - Sorted order, backed by TreeMap.

TreeSet<String> set = new TreeSet<>();
set.add("c"); set.add("a"); set.add("b");
// Iteration order: a, b, c (natural ordering)
 
set.add("a");       // O(log n) - tree insertion
set.contains("a");  // O(log n) - tree search
set.first();        // O(log n) - smallest element
set.headSet("b");   // O(log n) - elements less than "b"

Map Implementations

HashMap - The workhorse. Understanding its internals is essential.

HashMap<String, Integer> map = new HashMap<>();
 
// Internal structure:
// - Array of Node<K,V>[] buckets (default 16)
// - Each bucket: linked list or tree (if > 8 entries)
// - Load factor: 0.75 (resize when 75% full)

How put() works:

  1. Calculate hashCode() of key
  2. Compute bucket index: hash & (n - 1) where n = array length
  3. If bucket empty, insert new Node
  4. If bucket occupied, traverse list/tree
    • If key exists (equals() returns true), update value
    • Otherwise, append new Node
  5. If size exceeds threshold, resize (double capacity, rehash all entries)
// Hash collision example
class BadKey {
    @Override
    public int hashCode() { return 1; }  // All keys go to same bucket!
}
// With proper hashCode(): O(1) average
// With constant hashCode(): O(n) - degrades to linked list traversal

Interview question: "Why must you override both hashCode() and equals() when using custom objects as HashMap keys?"

HashMap uses hashCode() to find the bucket and equals() to find the exact key within the bucket. If two objects are equals(), they must have the same hashCode() - otherwise, you could insert duplicate keys in different buckets.

class Person {
    String name;
    int age;
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(name, age);  // Must use same fields as equals()
    }
}

TreeMap - Sorted by keys, Red-Black tree implementation.

TreeMap<String, Integer> map = new TreeMap<>();
map.put("c", 3);
map.put("a", 1);
map.put("b", 2);
 
map.keySet();        // [a, b, c] - sorted
map.firstKey();      // "a"
map.lastKey();       // "c"
map.floorKey("bb");  // "b" - greatest key <= "bb"
map.ceilingKey("bb"); // "c" - smallest key >= "bb"
 
// All operations: O(log n)

ConcurrentHashMap - Thread-safe without locking entire map.

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
 
// Thread-safe operations
map.put("key", 1);
map.computeIfAbsent("key", k -> expensiveComputation(k));
map.merge("key", 1, Integer::sum);  // Atomic increment
 
// Iteration is weakly consistent - may not reflect concurrent updates

Generics

Generics enable type-safe collections and methods. Interview questions probe beyond basic usage into bounded types and wildcards.

Type Parameters

// Generic class
public class Box<T> {
    private T content;
 
    public void set(T content) { this.content = content; }
    public T get() { return content; }
}
 
Box<String> stringBox = new Box<>();
stringBox.set("hello");
String s = stringBox.get();  // No cast needed
 
// Generic method
public <T> T firstOrNull(List<T> list) {
    return list.isEmpty() ? null : list.get(0);
}

Bounded Type Parameters

// Upper bound: T must be Number or subclass
public <T extends Number> double sum(List<T> numbers) {
    return numbers.stream()
        .mapToDouble(Number::doubleValue)
        .sum();
}
 
sum(List.of(1, 2, 3));        // Works: Integer extends Number
sum(List.of(1.5, 2.5));       // Works: Double extends Number
sum(List.of("a", "b"));       // Compile error: String doesn't extend Number
 
// Multiple bounds: T must be both Comparable AND Serializable
public <T extends Comparable<T> & Serializable> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}

Wildcards

Wildcards represent unknown types. The mnemonic "PECS" helps: Producer Extends, Consumer Super.

// Upper bounded wildcard: ? extends T (producer - read from)
public double sumAll(List<? extends Number> numbers) {
    // Can read as Number
    double sum = 0;
    for (Number n : numbers) {
        sum += n.doubleValue();
    }
    // Can NOT add (except null) - don't know exact type
    // numbers.add(1);  // Compile error
    return sum;
}
 
// Lower bounded wildcard: ? super T (consumer - write to)
public void addNumbers(List<? super Integer> list) {
    // Can add Integer (and subclasses)
    list.add(1);
    list.add(2);
    // Can only read as Object
    Object obj = list.get(0);
    // Integer i = list.get(0);  // Compile error
}
 
// Unbounded wildcard: ? (read-only, type doesn't matter)
public void printAll(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

Interview question: "What is type erasure?"

Java generics are compile-time only. At runtime, generic type information is erased:

List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
 
// At runtime, both are just ArrayList
strings.getClass() == integers.getClass();  // true
 
// This is why you can't do:
// if (obj instanceof List<String>)  // Compile error
// new T[]  // Compile error

Streams & Functional Programming

Streams provide a declarative way to process collections. They're not always faster, but they're often more readable.

Stream Operations

List<Person> people = getPeople();
 
// Stream pipeline: source -> intermediate ops -> terminal op
List<String> adultNames = people.stream()         // Source
    .filter(p -> p.getAge() >= 18)                // Intermediate
    .map(Person::getName)                          // Intermediate
    .sorted()                                      // Intermediate
    .distinct()                                    // Intermediate
    .collect(Collectors.toList());                // Terminal
 
// Intermediate operations are lazy - nothing happens until terminal op

Common intermediate operations:

stream.filter(predicate)      // Keep elements matching predicate
stream.map(function)          // Transform elements
stream.flatMap(function)      // Transform to stream, then flatten
stream.sorted()               // Natural order
stream.sorted(comparator)     // Custom order
stream.distinct()             // Remove duplicates (uses equals())
stream.limit(n)               // Take first n elements
stream.skip(n)                // Skip first n elements
stream.peek(consumer)         // Debug - perform action without modifying

Common terminal operations:

stream.collect(collector)     // Accumulate into collection
stream.forEach(consumer)      // Perform action on each
stream.reduce(identity, op)   // Combine all elements
stream.count()                // Count elements
stream.anyMatch(predicate)    // True if any match
stream.allMatch(predicate)    // True if all match
stream.noneMatch(predicate)   // True if none match
stream.findFirst()            // First element (Optional)
stream.findAny()              // Any element (Optional) - better for parallel

Collectors

// To List
List<String> list = stream.collect(Collectors.toList());
 
// To Set
Set<String> set = stream.collect(Collectors.toSet());
 
// To Map
Map<Long, Person> byId = people.stream()
    .collect(Collectors.toMap(Person::getId, p -> p));
 
// Grouping
Map<Department, List<Person>> byDept = people.stream()
    .collect(Collectors.groupingBy(Person::getDepartment));
 
// Grouping with downstream collector
Map<Department, Long> countByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.counting()
    ));
 
// Partitioning (special case of grouping with boolean)
Map<Boolean, List<Person>> adultsAndMinors = people.stream()
    .collect(Collectors.partitioningBy(p -> p.getAge() >= 18));
 
// Joining strings
String names = people.stream()
    .map(Person::getName)
    .collect(Collectors.joining(", "));  // "Alice, Bob, Charlie"
 
// Statistics
IntSummaryStatistics stats = people.stream()
    .collect(Collectors.summarizingInt(Person::getAge));
stats.getAverage();  // Average age
stats.getMax();      // Max age
stats.getCount();    // Count

Optional

Optional represents a value that may or may not be present. It forces explicit handling of absence.

Optional<Person> maybePerson = repository.findById(id);
 
// Bad: Defeats the purpose
if (maybePerson.isPresent()) {
    Person p = maybePerson.get();
}
 
// Good: Functional style
String name = maybePerson
    .map(Person::getName)
    .orElse("Unknown");
 
// Chain operations safely
String city = maybePerson
    .map(Person::getAddress)
    .map(Address::getCity)
    .orElseThrow(() -> new NotFoundException("Person not found"));
 
// Conditional action
maybePerson.ifPresent(p -> sendEmail(p));
 
// With alternative
Person person = maybePerson
    .orElseGet(() -> createDefaultPerson());  // Lazy evaluation

Interview question: "When should you NOT use Optional?"

  • Don't use as class fields (use null and document it)
  • Don't use for collection return types (return empty collection instead)
  • Don't use as method parameters (overloading is clearer)
  • Don't use in performance-critical code (creates object overhead)

Concurrency

Java concurrency is complex. Interviewers test whether you understand the pitfalls, not just the APIs.

Thread Fundamentals

// Creating threads (basic - rarely used directly)
Thread t1 = new Thread(() -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
});
t1.start();  // Don't call run() - that runs in current thread!
 
// Thread states
// NEW -> RUNNABLE -> (BLOCKED/WAITING/TIMED_WAITING) -> TERMINATED

Race Conditions

public class Counter {
    private int count = 0;
 
    public void increment() {
        count++;  // NOT atomic! Read-modify-write
    }
}
 
// With multiple threads:
Counter counter = new Counter();
// Thread 1: reads count (0), increments, writes (1)
// Thread 2: reads count (0), increments, writes (1)
// Expected: 2, Actual: 1 - lost update!

Synchronization

// synchronized method
public class Counter {
    private int count = 0;
 
    public synchronized void increment() {
        count++;  // Only one thread at a time
    }
 
    public synchronized int getCount() {
        return count;
    }
}
 
// synchronized block (more granular)
public class BankAccount {
    private final Object lock = new Object();
    private double balance;
 
    public void transfer(BankAccount target, double amount) {
        synchronized (lock) {
            if (balance >= amount) {
                balance -= amount;
                target.deposit(amount);
            }
        }
    }
}

Interview question: "What is a deadlock and how do you prevent it?"

Deadlock occurs when threads wait for each other's locks:

// Deadlock scenario
// Thread 1: locks A, waits for B
// Thread 2: locks B, waits for A
 
public void transfer(Account from, Account to, double amount) {
    synchronized (from) {
        synchronized (to) {
            // If another thread calls transfer(to, from, x) simultaneously...
            // DEADLOCK!
        }
    }
}
 
// Prevention: Always acquire locks in consistent order
public void transfer(Account from, Account to, double amount) {
    Account first = from.getId() < to.getId() ? from : to;
    Account second = from.getId() < to.getId() ? to : from;
 
    synchronized (first) {
        synchronized (second) {
            // Safe - consistent ordering
        }
    }
}

Atomic Classes

For simple operations, atomic classes avoid synchronization overhead:

private AtomicInteger count = new AtomicInteger(0);
 
count.incrementAndGet();    // Atomic increment, returns new value
count.getAndIncrement();    // Atomic increment, returns old value
count.compareAndSet(5, 10); // Set to 10 only if currently 5
 
// AtomicReference for objects
AtomicReference<User> currentUser = new AtomicReference<>();
currentUser.compareAndSet(oldUser, newUser);

ExecutorService

Modern Java uses thread pools, not raw threads:

// Fixed thread pool
ExecutorService executor = Executors.newFixedThreadPool(4);
 
// Submit tasks
Future<String> future = executor.submit(() -> {
    return computeResult();
});
 
// Get result (blocks until complete)
String result = future.get();  // Can throw ExecutionException
 
// Shutdown properly
executor.shutdown();  // Stops accepting new tasks
executor.awaitTermination(60, TimeUnit.SECONDS);  // Wait for completion

CompletableFuture

For composable async operations:

CompletableFuture<User> userFuture = CompletableFuture
    .supplyAsync(() -> userService.findById(id))
    .thenApply(user -> enrichWithProfile(user))
    .thenApply(user -> enrichWithPreferences(user));
 
// Combine multiple futures
CompletableFuture<String> nameFuture = getNameAsync();
CompletableFuture<Integer> ageFuture = getAgeAsync();
 
CompletableFuture<String> combined = nameFuture
    .thenCombine(ageFuture, (name, age) -> name + " is " + age);
 
// Handle errors
CompletableFuture<User> withFallback = userFuture
    .exceptionally(ex -> {
        log.error("Failed to fetch user", ex);
        return defaultUser;
    });
 
// Wait for all
CompletableFuture.allOf(future1, future2, future3).join();
 
// Wait for any (first to complete)
CompletableFuture.anyOf(future1, future2, future3);

Concurrent Collections

// ConcurrentHashMap - thread-safe map
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.computeIfAbsent("key", k -> expensiveComputation(k));  // Atomic
 
// CopyOnWriteArrayList - for read-heavy, write-light scenarios
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// Writes create a new copy - expensive
// Reads never block - great for iteration during concurrent modification
 
// BlockingQueue - producer-consumer pattern
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
queue.put(task);     // Blocks if full
queue.take();        // Blocks if empty

Memory Management

Understanding JVM memory helps you write efficient code and debug memory issues.

JVM Memory Structure

+------------------+
|   Method Area    |  Class metadata, static variables, constant pool
+------------------+
|       Heap       |  Objects (Young + Old generation)
+------------------+
|      Stack       |  Per-thread: local variables, method calls
+------------------+
|   Native Memory  |  Direct buffers, JNI, metaspace
+------------------+

Heap generations:

Heap
โ”œโ”€โ”€ Young Generation (new objects)
โ”‚   โ”œโ”€โ”€ Eden (new allocations)
โ”‚   โ”œโ”€โ”€ Survivor 0 (survived 1+ GC)
โ”‚   โ””โ”€โ”€ Survivor 1 (survived 1+ GC)
โ””โ”€โ”€ Old Generation (long-lived objects)

Garbage Collection

GC automatically reclaims unreachable objects. Objects are "reachable" if referenced from GC roots (stack variables, static fields, JNI references).

public void process() {
    User user = new User();  // Reachable via stack
    user = null;             // Now unreachable - eligible for GC
}  // user goes out of scope - also makes object unreachable

GC algorithms (simplified):

  • Young GC (Minor): Frequent, fast. Copies live objects from Eden to Survivor. Most objects die here.
  • Old GC (Major): Less frequent, slower. Mark-sweep-compact on old generation.
  • Full GC: Stop-the-world, both generations. Try to avoid.

Modern collectors:

  • G1 (default since Java 9): Divides heap into regions, collects garbage-first regions
  • ZGC (Java 15+): Sub-millisecond pauses, scales to terabytes
  • Shenandoah: Concurrent compaction, low latency

Memory Leaks

Java can still have memory leaks - objects that are reachable but no longer needed:

// Classic leak: static collection that grows forever
public class Cache {
    private static Map<String, Object> cache = new HashMap<>();
 
    public static void put(String key, Object value) {
        cache.put(key, value);  // Never removed!
    }
}
 
// Listener leak: registered but never unregistered
button.addActionListener(listener);
// If button lives longer than expected, listener is retained
 
// Inner class leak: holds reference to outer class
public class Outer {
    private byte[] largeData = new byte[10_000_000];
 
    public Runnable getTask() {
        return new Runnable() {  // Anonymous inner class holds Outer.this
            public void run() {
                // Even if this doesn't use largeData,
                // it keeps Outer (and largeData) alive
            }
        };
    }
}
 
// Fix: use static nested class or lambda (if it doesn't capture 'this')
public Runnable getTask() {
    return () -> System.out.println("No reference to Outer");
}

JVM Tuning Basics

# Heap size
-Xms512m        # Initial heap
-Xmx2g          # Maximum heap
 
# GC selection
-XX:+UseG1GC           # G1 (default in modern Java)
-XX:+UseZGC            # ZGC (low latency)
 
# GC logging
-Xlog:gc*:file=gc.log  # GC logs for analysis
 
# Memory analysis
-XX:+HeapDumpOnOutOfMemoryError  # Dump heap on OOM
-XX:HeapDumpPath=/tmp/heapdump.hprof

Java Language Features (17-24)

Modern Java has evolved significantly. Know the recent features interviewers expect.

Records (Java 16+)

Immutable data carriers without boilerplate:

// Old way
public class Point {
    private final int x;
    private final int y;
 
    public Point(int x, int y) { this.x = x; this.y = y; }
    public int x() { return x; }
    public int y() { return y; }
    public boolean equals(Object o) { /* ... */ }
    public int hashCode() { /* ... */ }
    public String toString() { /* ... */ }
}
 
// Record
public record Point(int x, int y) { }
// Automatically generates: constructor, accessors, equals, hashCode, toString
// Records are final and immutable

Sealed Classes (Java 17+)

Control which classes can extend:

public sealed class Shape permits Circle, Rectangle, Triangle {
    // Only Circle, Rectangle, Triangle can extend Shape
}
 
public final class Circle extends Shape { }  // Must be final, sealed, or non-sealed
public final class Rectangle extends Shape { }
public non-sealed class Triangle extends Shape { }  // Opens hierarchy

Pattern Matching

instanceof (Java 16+):

// Old
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}
 
// New
if (obj instanceof String s) {
    System.out.println(s.length());  // s already cast and in scope
}

switch (Java 21+):

// Pattern matching in switch
String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case String s -> "String of length " + s.length();
        case null -> "null";
        default -> "Unknown";
    };
}
 
// With sealed classes - exhaustive, no default needed
double area(Shape shape) {
    return 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();
    };
}

Virtual Threads (Java 21+)

Lightweight threads for high-concurrency:

// Platform thread (OS thread) - expensive
Thread platformThread = new Thread(() -> blockingOperation());
 
// Virtual thread - cheap, millions possible
Thread virtualThread = Thread.ofVirtual().start(() -> blockingOperation());
 
// With ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i -> {
        executor.submit(() -> {
            // Each task gets its own virtual thread
            // Blocking operations don't waste OS threads
            return fetchFromDatabase(i);
        });
    });
}

Common Interview Questions

Q: What is the difference between == and equals()?

== compares references (are they the same object?). equals() compares values (are they logically equal?). For objects, always use equals() unless you specifically want reference comparison.

Q: What is immutability and why is it useful?

Immutable objects can't be modified after creation. Benefits: thread-safety (no synchronization needed), safe as map keys, easier reasoning. Create with final class, final fields, no setters, defensive copies of mutable fields.

Q: Explain final, finally, and finalize().

  • final: Keyword for constants (variables), preventing inheritance (classes), preventing override (methods)
  • finally: Block that always executes after try/catch (for cleanup)
  • finalize(): Deprecated method called before GC. Don't use it - use try-with-resources instead.

Q: What is the difference between checked and unchecked exceptions?

Checked exceptions (extend Exception) must be caught or declared. Unchecked exceptions (extend RuntimeException) don't require handling. Use checked for recoverable conditions (file not found), unchecked for programming errors (null pointer).


Related Resources

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides