Skip to content
Preptima

Java fundamentals

The answers a Java interviewer works through before the hard questions start: language semantics, collections, concurrency, the JVM and Spring behaviour. Seventy-five items, seventeen of them worked through with code or a diagram.

75 questions

Go deeper on Java & Spring
Standard Sheet View

Language and semantics

Is Java pass-by-value or pass-by-reference?

Always pass-by-value, with no exceptions. The confusion comes from object arguments, where the value being copied is the reference rather than the object, so the method receives its own reference pointing at the same object on the heap. That is why a method can mutate an object you passed in and the caller sees the change, but cannot make your variable point at a different object — reassigning the parameter only changes the method's copy of the reference. The classic demonstration is a swap(a, b) method, which cannot work in Java no matter how it is written, because the two references it swaps are local to the method and discarded when it returns. If you need a method to replace an object, it has to return the new one.

What is the difference between `==` and `equals`?

== compares the values held in the variables, which for objects means the references, so it asks whether two names point at the same object in memory. equals is an ordinary method the class defines, so it asks whatever that class decided equality means — usually a comparison of contents. For primitives there is no equals and == compares the values directly, which is why it behaves "as expected" there and surprises people on objects. The practical rule is that == is correct for primitives, for enum constants, and for deliberate identity checks, and wrong for everything else. Comparing two strings with == occasionally works because of the string pool, which makes it worse than if it never worked at all.

Why is `String` immutable?

Because immutability makes strings safe to share, and the runtime relies heavily on that sharing. The string pool can hand the same instance to many callers, the hash code can be computed once and cached, and a string used as a map key or in a security check cannot be altered underneath you afterwards by whoever else holds a reference. If strings were mutable, a validated file path could be changed between the check and the open, which is precisely the kind of bug immutability removes by construction. The cost is that every apparent modification allocates a new object, which is why building a string inside a loop with + is quadratic and wants a StringBuilder instead.

What does `final` mean on a variable, a method and a class?

On a variable it means the binding cannot be reassigned after it is set, which is emphatically not the same as the object being immutable — a final List can still have items added to it all day, because the reference is fixed and the object is not. On a method it means no subclass may override it, which lets the compiler and the JIT reason about the call site. On a class it means no subclass at all. The third is how String guarantees its own immutability: without final, someone could extend it with a mutable subclass and every assumption about sharing would collapse. final on a local variable is also what allows it to be captured by a lambda or an anonymous class.

What is autoboxing, and where does it bite?

Autoboxing is the compiler silently converting between a primitive and its wrapper type, so Integer x = 5 and int y = someInteger both compile. It makes generics usable with numbers, since a List<int> is not possible. It bites in three specific places. An unboxed null throws a NullPointerException from a line containing no visible method call, which is one of the more confusing stack traces to read. == on two wrappers compares references rather than values. And boxing inside a hot loop allocates an object per iteration, so a Map<Integer, Integer> counter in a tight loop generates far more garbage than the arithmetic suggests.

Why does `Integer` comparison with `==` sometimes work?

Because the JVM caches small Integer instances — conventionally those from -128 to 127 — so two boxed values in that range are frequently the same object and == happens to return true. Outside the range each boxing creates a new object, so the same comparison returns false. This makes it a genuinely nasty bug: code tested with small values passes, and fails in production the first time an identifier exceeds 127. It is a caching detail rather than a language rule, and the cache size for Integer can even be tuned with a JVM flag, which means relying on it is unsafe in both directions. Compare with equals, or unbox deliberately to int.

What is the difference between an interface and an abstract class?

An abstract class can hold instance state and constructors, and a class may extend only one of them; an interface holds no instance state and a class may implement as many as it likes. Since Java 8 interfaces can carry default method implementations, so the old "interfaces have no behaviour" distinction no longer holds and the real difference is state plus single inheritance. Choose an abstract class when subclasses genuinely share fields and construction logic, and an interface when you are describing a capability that unrelated types might have. Default methods exist mainly so an interface can gain a method without breaking every existing implementation, which is how Collection acquired stream().

What does `static` actually mean?

It means the member belongs to the class rather than to any instance, so it exists exactly once in the process regardless of how many objects you create, and it can be used without one. A static method therefore cannot reference instance fields, because there is no instance to take them from — which is why main cannot touch anything non-static directly. Static final constants are uncontroversial. Static mutable state is where the trouble lives: it is shared across every thread and every test in the same JVM, it makes a class impossible to construct twice with different configuration, and it is the usual cause of tests that pass individually and fail when run together.

What is type erasure?

Generic type arguments exist for the compiler and are removed from the bytecode, so List<String> and List<Integer> are the same class at run time. The compiler checks your usage and inserts casts where needed, which is why generics give you compile-time safety and nothing at all afterwards. The consequences are concrete: you cannot write new T[10], you cannot test instanceof List<String>, and you cannot overload two methods that differ only by type argument because they erase to the same signature. Erasure was chosen for backward compatibility, so that generic and pre-generic code could interoperate on the same JVM, and the cost is a type system that is more honest at compile time than at run time.

Show me how a mutable static field breaks a test suite.

The failure is that state written by one test is still there for the next, so the suite passes in the order it was written and fails when the runner parallelises or reorders it.

public final class RateLimiter {
    // One instance for the whole JVM, so one instance for the whole test run.
    private static final Map<String, Integer> COUNTS = new HashMap<>();

    public static boolean allow(String user, int max) {
        int used = COUNTS.merge(user, 1, Integer::sum);
        return used <= max;
    }
}

class RateLimiterTest {
    @Test void allowsUpToTheLimit() {
        assertTrue(RateLimiter.allow("alice", 2));
        assertTrue(RateLimiter.allow("alice", 2));   // passes
    }

    @Test void rejectsPastTheLimit() {
        // Intends to start from zero. Actually starts from whatever the other
        // test left behind, so this passes for the wrong reason - or fails,
        // depending on which test ran first.
        assertTrue(RateLimiter.allow("alice", 2));
        assertTrue(RateLimiter.allow("alice", 2));
        assertFalse(RateLimiter.allow("alice", 2));
    }
}

The usual response is a @BeforeEach that clears the map, which works and treats the symptom: the class is still impossible to construct twice, still shared across threads, and still unusable if two parts of the application want different limits. The structural fix is to make it an ordinary object with the map as an instance field, constructed by whoever needs it and injected where it is used. Then each test builds its own, no reset hook is required, and the class can be configured per caller — which is the same argument as the one against getInstance(), arriving through a test failure rather than through design.

When do you use `extends` and when `super` in a generic bound?

Use ? extends T when you only read from the structure, because every element is guaranteed to be at least a T even if the actual list is of a subtype. Use ? super T when you only write into it, because whatever the actual type is, it can certainly accept a T. The mnemonic is producer-extends, consumer-super. The reason you cannot have both is that a read-write wildcard would let you insert the wrong subtype: a List<? extends Number> might really be a List<Integer>, so adding a Double has to be forbidden. Collections.copy, which reads from one list and writes to another, is the canonical example that needs one of each.

Show me what type erasure actually removes.

The compiler checks your generic usage and then discards the type arguments, so the bytecode has no idea List<String> was ever different from List<Integer>.

List<String> names = new ArrayList<>();
List<Integer> ids  = new ArrayList<>();

names.getClass() == ids.getClass();      // true - both are just ArrayList

// So none of these compile, and each failure follows from erasure:
// if (names instanceof List<String>) {}   no type argument survives to test
// T[] array = new T[10];                  no runtime type to allocate
// void f(List<String> a) {}
// void f(List<Integer> a) {}              same erased signature

You can also demonstrate the hole it leaves, which is the part that makes the concept concrete:

List<String> strings = new ArrayList<>();
List raw = strings;                       // unchecked, compiles with a warning
raw.add(42);                              // no check happens here at all

// The failure surfaces somewhere else entirely, at the implicit cast:
String first = strings.get(0);            // ClassCastException

The exception is thrown at the get, in code that is completely correct, by a cast the compiler inserted. That is why an unchecked warning is worth treating as an error rather than suppressing it — the damage is done at the insertion point and reported at the retrieval point, which can be a different class entirely.

Erasure was chosen for backward compatibility, so that generic and pre-generic code could run on the same JVM and against the same libraries. The price is a type system that is stronger at compile time than at run time, and the standard workaround where a type genuinely must survive is to pass a Class<T> token explicitly.