C# vs Java Interview Questions

Reviewed by Mark Dickie · Last updated

C# and Java are both statically typed, object-oriented languages running on managed virtual machines, differing mainly in their platform ecosystems, language features, and corporate stewardship. For interview preparation, the two overlap heavily on core OOP concepts, collections, generics, and concurrency, but they diverge on specifics like LINQ versus streams, delegates versus functional interfaces, and the .NET runtime versus the JVM. A candidate preparing for both will find that shared fundamentals carry over, but each language has distinct APIs, idioms, and tooling that interviewers probe in different ways. Neither is universally harder; the difficulty depends on the role, the team's stack, and how deeply the interview goes into language internals.

AspectC#Java
Primary runtime.NET CLRJVM
Corporate stewardMicrosoftOracle (OpenJDK community-driven)
Signature featureLINQ, async/await, propertiesStreams, modules, records
Common interview focus.NET ecosystem, ASP.NET, async patternsSpring, JVM internals, concurrency
Where it dominatesWindows-heavy shops, game dev (Unity)Enterprise backends, Android, big data
  1. Identify the stack the employer actually uses. If the job description mentions Spring, Kafka, or Hadoop, lean Java; if it mentions .NET, ASP.NET, or Unity, lean C#.
  2. Spend the first study pass on shared ground: OOP, collections, generics, exception handling, and basic threading. These topics appear in both tracks and let you reuse effort.
  3. Drill the language-specific idioms next: LINQ and delegates for C#, streams and functional interfaces for Java. Interviewers use these to separate casual familiarity from working knowledge.
  4. Practice on real questions from both tracks so you can switch context quickly. Some interviews ask you to solve the same problem in either language and compare trade-offs.
  5. If time is short, pick the language matching the target role and go deep rather than splitting effort across both at a shallow level.

C# vs Java, side by side

How C# and Java compare on Tarmac’s interview questions.

MetricC#Java
Practice questions66
Average score——
Hardest question (% who miss it)——
Average time per question——

Practice questions

In C#, CancellationToken.None is a static readonly token whose IsCancellationRequested property always returns false and whose CanBeCanceled property always returns false. It is safe to pass to any async method that accepts a CancellationToken.#

Options

  • True
  • False
Show answer

True

Why:

A CancellationToken is a lightweight value type that exposes read-only members like IsCancellationRequested and Register. Cancellation is triggered exclusively through its CancellationTokenSource (via Cancel, CancelAfter, or linking), never through the token itself.

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.

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

In C#, a lock object used with the lock statement should be declared as a private field marked with the keyword _____ so that the reference cannot be reassigned after construction, which helps prevent deadlocks caused by external code changing the synchronization target.#

Show answer

In C#, a lock object used with the lock statement should be declared as a private field marked with the keyword **readonly** so that the reference cannot be reassigned after construction, which helps prevent deadlocks caused by external code changing the synchronization target.

Why:

Marking the lock object readonly guarantees the field reference is fixed for the object's lifetime. If the reference could be reassigned, different threads might lock on different objects and bypass mutual exclusion. The private modifier prevents external callers from locking on the same instance and causing contention or deadlock.

What does the following C# code print to the console?#

int[] arr = { 1, 2, 3 };
List<int> list = new List<int>(arr);
list.Add(4);
arr[0] = 99;
Console.WriteLine(string.Join(",", arr));
Console.WriteLine(string.Join(",", list));
Show answer
99,2,3
1,2,3,4
Why:

The List<int>(IEnumerable<int>) constructor copies each element from the source array into a new internal backing array, so the list is independent of arr. When arr[0] is set to 99 the list is unaffected. The first WriteLine prints the modified array (99,2,3), and the second prints the list with the appended 4 (1,2,3,4).

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.

A Task is created with new Task(Action), then started with .Start() on the default thread-pool TaskScheduler, and runs to successful completion. Place its TaskStatus values in the order they occur.#

Put these in order

  • Created
  • WaitingToRun
  • Running
  • RanToCompletion
Show answer

A Task created with new Task(Action) and started via Start() on the default thread-pool scheduler transitions through four statuses in order: Created → WaitingToRun → Running → RanToCompletion. The task starts in Created, is queued to the thread pool (WaitingToRun) when Start is called, enters Running when the delegate begins executing, and becomes RanToCompletion upon successful completion.

Why:

A Task constructed with new Task(...) begins in the Created status. Calling Start() schedules it with the default scheduler, transitioning it to WaitingToRun. When the thread pool picks it up and the delegate begins executing, the status becomes Running. Upon successful completion the status becomes RanToCompletion. These transitions are documented and sequential — the task passes through each state exactly once in this order.

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.

In C#, which statement correctly describes how a Dictionary<TKey, TValue> stores its entries?#

Options

  • Each key must be unique; adding a duplicate key throws an ArgumentException.
  • Duplicate keys are allowed; the latest value overwrites the previous one silently.
  • Keys are sorted automatically in insertion order.
  • A key can appear multiple times, and all values are stored in a list per key.
Show answer

In a C# Dictionary<TKey, TValue>, each key must be unique. Calling Add with a key that already exists throws an ArgumentException; to overwrite you use the indexer assignment dict[key] = value instead. Duplicate keys are never allowed.

Why:

A Dictionary<TKey, TValue> enforces key uniqueness by hash code comparison. Calling Add with a key that already exists throws an ArgumentException. To silently overwrite, you use the indexer (dict[key] = value) instead of Add. The dictionary does not maintain sort or insertion order (prior to .NET-specific ordered variants), and it does not store multiple values per key — that would be ILookup<TKey, TValue> or Dictionary<TKey, List<TValue>>.

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.

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.