Java vs Kotlin Interview Questions — Side-by-Side Prep

Reviewed by Mark Dickie · Last updated

Java and Kotlin are both statically typed languages that run on the JVM, differing mainly in Kotlin's null-safety model, concise syntax, and first-class coroutine support versus Java's more verbose, gradual evolution. For interview prep, the two overlap heavily on JVM fundamentals, collections, concurrency, and OOP, so you can share a lot of study time between them. Kotlin questions tend to probe language-specific features (smart casts, extension functions, sealed classes, coroutines), while Java questions lean on long-standing patterns (generics erasure, stream pipelines, threading models). Neither is harder by default; difficulty depends on the topic mix and how deep the interviewer pushes.

DimensionJavaKotlin
Null handlingOptional class and manual null checks baked inNullability in the type system (? suffix) enforced at compile time
VerbosityBoilerplate-heavy (getters, setters, builders)Concise defaults (data classes, single-expression functions)
ConcurrencyThreads, java.util.concurrent, CompletableFutureCoroutines with structured concurrency
Typical interview focusGenerics, streams, collections internals, JVM tuningNull safety, coroutines, scope functions, Java interop pitfalls
Where it fitsLarge legacy backends, Android (pre-2021 code), enterprise shopsNew Android projects, modern server-side, teams wanting fewer NPEs

How to decide which to prioritise before an interview:

  1. Check the job description's stack. If the team writes Kotlin, expect coroutine and null-safety questions even if they also mention Java.
  2. If you're targeting Android specifically, Kotlin is the default since 2023, but older codebases still carry Java, so brush up on both.
  3. For backend roles, confirm whether the shop is pure Java or migrating; the answer changes which concurrency model you need to explain.
  4. Study the shared core first (JVM memory model, collections, OOP, exceptions) so that language-specific topics build on the same foundation.
  5. Run questions from both sides on this page to see where your miss rate is highest, then focus remaining time there.

Java vs Kotlin, side by side

How Java and Kotlin compare on Tarmac’s interview questions.

MetricJavaKotlin
Practice questions66
Average score——
Hardest question (% who miss it)——
Average time per question——

Practice questions

Given String a = "hi"; String b = "hi"; String c = new String("hi");, what are the results of a == b and a == c?#

Options

  • a == b is true, a == c is false
  • Both are true — == compares the characters
  • Both are false — == never works for strings
  • a == b is false, a == c is true
Show answer

a == b is true but a == c is false. String literals are interned into the constant pool, so two identical literals share one object and == (a reference comparison) returns true. new String("hi") forces a separate heap object, so a == c is false. Use .equals() to compare string contents — == only checks whether two references point at the same object.

Why:

String literals are interned into the string constant pool, so a and b reference the exact same pooled object and a == b is true. new String("hi") is explicitly told to allocate a fresh object on the heap, so c is a different reference and a == c is false even though the characters match. == compares references, not contents — .equals() is what compares the actual characters and would return true for all three. The trap is that == appears to work for literals because of interning, then silently breaks for strings built at runtime (from input, concatenation, or new), which is why you should always use .equals() to compare string values.

In Kotlin, a value class (declared with value class on the JVM) must have exactly one read-only property in its primary constructor.#

Options

  • True
  • False
Show answer

True. A Kotlin value class must declare exactly one read-only (val) property in its primary constructor; the compiler inlines this single underlying value wherever possible and rejects zero- or multi-property definitions at compile time.

Why:

The Kotlin language spec requires every value class to wrap exactly one underlying value, declared as a val property in the primary constructor. A value class with zero properties or with more than one property will not compile.

You override equals() on a class but forget to override hashCode(). You then use instances as HashMap keys. What goes wrong?#

Options

  • Two objects that are equals can land in different buckets, so a lookup with an equal-but-distinct key can fail to find the entry
  • It is a compile error — overriding one forces you to override the other
  • Nothing; HashMap uses equals() alone to locate keys
  • Every key collides into one bucket, but lookups still always succeed
Show answer

Lookups break: two objects that are equals can produce different hash codes, land in different buckets, and a get() with an equal-but-distinct key returns null. HashMap uses hashCode() to choose a bucket and equals() only within it, and the contract requires equal objects to have equal hash codes. The default identity-based hashCode() violates this, so always override both together using the same fields.

Why:

The equals/hashCode contract requires that equal objects return equal hash codes. HashMap first uses hashCode() to pick a bucket, then equals() to find the entry within it. The default Object.hashCode() is identity-based, so two distinct-but-equals objects almost always produce different hash codes, route to different buckets, and a get() with an equal-but-not-same key returns null — the entry is effectively lost. It is not a compile error: the compiler never forces you to override both, which is exactly why this bug is so common. Always override hashCode() whenever you override equals() (and prefer the same fields in both).

Which functions does the Kotlin compiler automatically generate for a data class based on the properties declared in its primary constructor?#

Options

  • clone(), equals(), hashCode(), and toString()
  • equals(), hashCode(), toString(), copy(), and componentN()
  • equals(), hashCode(), toString(), and deepCopy()
  • compareTo(), clone(), serialize(), and componentN()
Show answer

Kotlin's data class modifier causes the compiler to auto-generate equals(), hashCode(), toString(), copy(), and componentN() functions for the properties declared in the primary constructor. The componentN() functions enable destructuring declarations, and copy() creates a shallow copy with optional property overrides.

Why:

For each property in the primary constructor, the Kotlin compiler generates equals()/hashCode() based on those properties, toString() returning the class name and property values, copy() for creating a shallow copy with optional overrides, and componentN() functions to support destructuring declarations.

Place the following events in the order they occur when a Kotlin object declaration is first accessed at runtime.#

Put these in order

  • The object's class is loaded and its static initializer is triggered on first access
  • The declared superclass constructor (if any) executes
  • Property initializers and init blocks execute in textual (top-to-bottom) order
  • The fully initialized singleton instance is returned to the caller
Show answer

When a Kotlin object is first accessed, its class loads and the static initializer fires, then the superclass constructor runs, then property initializers and init blocks execute in top-to-bottom textual order, and finally the fully constructed singleton instance is returned to the caller. Kotlin objects are lazily initialized on first use.

Why:

Kotlin object declarations are initialized lazily — the singleton is not created until first access (the object's class is loaded and its static initializer is triggered on first access). When initialization begins, the superclass constructor runs first (the declared superclass constructor (if any) executes), mirroring normal class construction. Then property initializers and init blocks run in textual order (property initializers and init blocks execute in textual (top-to-bottom) order) — so x is initialized, then the init block runs, then y. Only after all initialization completes is the fully constructed instance handed back to the caller (the fully initialized singleton instance is returned to the caller).

A volatile int counter is incremented with counter++ from many threads. Why can the final value be wrong?#

Options

  • volatile guarantees visibility but not atomicity; counter++ is read-modify-write, so concurrent increments interleave and updates are lost
  • volatile makes the field thread-confined, so other threads never see it
  • volatile is ignored for primitives, so it has no effect at all
  • It is always correct — volatile makes counter++ atomic
Show answer

volatile guarantees visibility and ordering but not atomicity. counter++ is a read-modify-write: two threads can read the same value, each add one, and both store the same result, losing an update. For an atomic counter use AtomicInteger.incrementAndGet(), a synchronized block, or a lock. volatile is correct only for simple flags where one thread writes and others read — never for compound updates or check-then-act.

Why:

volatile provides a visibility/ordering guarantee — every read sees the latest write and writes aren't reordered around it — but it does NOT make compound operations atomic. counter++ is three steps (read, add one, write back), and two threads can both read the same value, each add one, and both write back the same result, so one increment is lost. To increment atomically use AtomicInteger.incrementAndGet(), a synchronized block, or a lock. volatile is the right tool for a simple flag (one thread writes, others read), but never for a counter or any check-then-act. Confusing visibility with atomicity is one of the most common Java concurrency mistakes.

In Kotlin, which standard-library function creates a read-only (immutable) List?#

Options

  • listOf
  • mutableListOf
  • arrayListOf
  • setOf
Show answer

listOf creates a read-only List<T> in Kotlin. The returned List interface exposes only read operations (get, size, contains, iteration) and no mutation methods, so the compiler rejects calls like .add() or .remove() at the call site. To get an in-place mutable collection you use mutableListOf, which returns a MutableList<T>.

Why:

listOf(...) returns List<T>, Kotlin's read-only list interface — it exposes get, size, contains, and iteration but no add/remove/set, so mutation calls are rejected at compile time. mutableListOf returns the mutable MutableList<T> interface; arrayListOf returns a java.util.ArrayList (mutable); and setOf creates a Set, not a List.

A Stream pipeline does no work until a terminal operation runs. Which of these are terminal operations (they trigger execution and consume the stream)? Select all that apply.#

Options

  • collect(Collectors.toList())
  • forEach(System.out::println)
  • map(String::trim)
  • filter(s -> !s.isEmpty())
  • count()
Show answer

collect, forEach, and count are terminal operations: they trigger the pipeline, produce a result or side effect, and consume the stream so it can't be reused. map and filter are intermediate and lazy — they only describe a transformation and run nothing until a terminal op pulls elements through. A pipeline with no terminal op does zero work. Other terminal ops include reduce, findFirst, anyMatch, and toArray.

Why:

Stream operations split into intermediate (lazy, return a new Stream) and terminal (eager, produce a result or side effect and consume the stream). collect, forEach, and count are terminal — they trigger the pipeline and you cannot reuse the stream afterward. map and filter are intermediate: they only describe a transformation and do nothing until a terminal op pulls elements through. The practical consequence of laziness is that a pipeline of map/filter with no terminal op runs zero times — a common 'my forEach printed nothing' confusion — and that intermediate ops are fused and applied element-by-element rather than as separate passes. Other terminal ops include reduce, findFirst, anyMatch, toArray, and min/max.

What is the exact stdout produced by the following Kotlin program? Trace the side effects carefully, paying attention to how List operations differ from Sequence operations in when and how elements flow through the pipeline.#

fun main() {
    val list = listOf(1, 2, 3, 4, 5)

    // --- List (eager) pipeline ---
    list.filter {
        print("F$it ")
        it % 2 == 0
    }.map {
        print("M$it ")
        it * 10
    }

    println()
    println("---")

    // --- Sequence (lazy) pipeline ---
    list.asSequence()
        .filter {
            print("F$it ")
            it % 2 == 0
        }
        .map {
            print("M$it ")
            it * 10
        }
        .toList()
}
Show answer
F1 F2 F3 F4 F5 M2 M4 
---
F1 F2 M2 F3 F4 M4 F5 
Why:

Kotlin List chains are eager: each intermediate operation completes over the entire collection before the next one starts. So filter walks all five elements first — printing F1 F2 F3 F4 F5 — and only then does map run on the survivors [2, 4], printing M2 M4 . A Sequence, by contrast, is lazy: elements are pulled one at a time through the entire pipeline when a terminal operation (toList()) is invoked. Element 1 enters the filter (F1 ), is rejected, and never reaches map. Element 2 passes the filter (F2 ) and immediately flows into map (M2 ). Element 3 is filtered out (F3 ), element 4 passes both stages (F4 M4 ), and element 5 is filtered out (F5 ). This interleaved, element-by-element order — F1 F2 M2 F3 F4 M4 F5 — is the hallmark of lazy sequence evaluation and the key performance reason sequences avoid intermediate collections.

Java is strictly pass-by-value: when you pass an object to a method, the method receives a copy of the reference, so reassigning the parameter inside the method does not change which object the caller's variable points to (though mutating the object's fields is visible to the caller).#

Options

  • True
  • False
Show answer

True — Java is always pass-by-value. For objects, the value copied is the reference, so the parameter and the caller's variable point at the same object: mutating that object's fields is visible to the caller. But reassigning the parameter only rebinds the local copy of the reference, leaving the caller's variable unchanged. That is why a method can never swap two of the caller's variables — the giveaway that Java has no pass-by-reference.

Why:

Java is always pass-by-value, with no exceptions. For object types, the value that is copied is the reference itself, not the object — so the parameter and the caller's variable initially point at the same object. Mutating that shared object's state (e.g. list.add(x) or obj.setName(...)) is visible to the caller, which is why people mistakenly call it 'pass-by-reference'. But reassigning the parameter (obj = new Thing()) only rebinds the local copy of the reference; the caller's variable still points at the original object. The tell that it is pass-by-value: a method cannot swap two of the caller's variables or make the caller's variable point somewhere new.

The following Kotlin code fails to compile. What is the bug?#

sealed class Operation

data class Add(val a: Int, val b: Int) : Operation()
data class Subtract(val a: Int, val b: Int) : Operation()
data class Multiply(val a: Int, val b: Int) : Operation()

fun execute(op: Operation): Int = when (op) {
    is Add -> op.a + op.b
    is Subtract -> op.a - op.b
}

Options

  • The when expression does not cover the Multiply subclass, so it is non-exhaustive and the code will not compile.
  • sealed class cannot have data class subclasses.
  • execute should return Int? because no branch matches when the operation is Multiply.
  • Subclasses of a sealed class must be defined inside the class body.
Show answer

The when expression does not cover the Multiply subclass, so it is non-exhaustive and the code will not compile.

Why:

The when expression is used as the return value of execute, making it an expression form that Kotlin requires to be exhaustive. Operation is a sealed class with three direct subclasses: Add, Subtract, and Multiply. The when only handles Add and Subtract, omitting Multiply. Because sealed classes enable exhaustiveness checking, the compiler rejects the non-exhaustive when. Adding an is Multiply -> op.a * op.b branch (or an else branch) would fix the compile error.

Keep reading

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.