Two areas where the C# mental model actively hurts you: data processing and concurrency. They’re unrelated in topic but share a theme — C# built them into the language, Java bolted them on as libraries.

Part I: Data

Collections: A 30-Year Legacy

C# collections are cohesive — List<T>, Dictionary<TKey,TValue>, Queue<T>, Stack<T> all share consistent naming and behavior.

Java’s collection framework evolved in layers over decades, and it shows:

List<String> list = new ArrayList<>();
list.add("Abdullah");
list.remove("Abdullah");  // removes by value
list.remove(0);           // removes by index — same method, different behavior

Map<Integer, String> map = new HashMap<>();
map.put(1, "One");
System.out.println(map.get(1)); // Map is NOT a Collection

Historical quirks:

  • Map is not a Collection — you must ask for keySet(), values(), or entrySet()
  • Method naming inconsistencies: add vs offer, remove vs poll vs take
  • Arrays.asList() returns a fixed-size list — add() throws at runtime
  • Legacy classes (Vector, Stack, Hashtable) still live in java.util alongside modern ArrayDeque, ConcurrentHashMap

Pitfall: C# devs assume all collections behave uniformly. Java requires navigating legacy vs modern classes, multiple interfaces with overlapping behaviors, and overloads that silently do different things.

List.of() Is Not Mutable

C# lists are mutable by default:

var list = new List<string> { "a", "b", "c" };
list.Add("d"); // works

Java’s List.of() returns an immutable list:

List<String> list = List.of("a", "b", "c");
list.add("d"); // throws UnsupportedOperationException

.NET has immutable collections too, but they’re opt-in (ImmutableList.Create()). Java made immutability the default for factory methods. Many APIs return immutable collections — you must explicitly copy to modify.

Streams Are Single-Use

LINQ queries are deferred and reusable:

var numbers = new List<int> { 1, 2, 3, 4, 5 };
var evenNumbers = numbers.Where(n => n % 2 == 0);

Console.WriteLine(evenNumbers.Count()); // 2
Console.WriteLine(evenNumbers.Sum());   // 6

Java Streams are consumed after one terminal operation:

List<Integer> numbers = List.of(1, 2, 3, 4, 5);
Stream<Integer> evenNumbers = numbers.stream().filter(n -> n % 2 == 0);

System.out.println(evenNumbers.count()); // 2
System.out.println(evenNumbers.sum());   // IllegalStateException

To reuse results, collect first:

List<Integer> evenList = numbers.stream()
    .filter(n -> n % 2 == 0)
    .toList(); // now reusable

Pitfall: Passing a stream around for multiple operations hits IllegalStateException. Think of streams as one-way conveyor belts — collect if you need to re-read.

No LINQ Query Syntax

C# offers declarative query comprehension:

var adults = from p in people
             where p.Age >= 18
             select p.Name;

Java has no equivalent. It’s method chains or nothing:

List<String> adults = people.stream()
    .filter(p -> p.age() >= 18)
    .map(Person::name)
    .toList();

Part II: Async

“Everybody Adopted async/await. Java Didn’t.”

JavaScript, Python, Rust, Kotlin, Swift, C++ — all adopted some form of async/await. Java looked at it and invented something completely different.

The C# Mental Model

Async code reads like normal synchronous code:

async Task<Result> GetData()
{
    var userTask = GetUserAsync();
    var ordersTask = GetOrdersAsync();
    var profileTask = GetProfileAsync();

    await Task.WhenAll(userTask, ordersTask, profileTask);

    return new Result(
        await userTask,
        await ordersTask,
        await profileTask
    );
}

Error handling is just try/catch. The mental model: await suspends execution, errors flow normally.

The Java Approach (CompletableFuture)

Java’s async model is pipeline-based:

CompletableFuture<User> userFuture = getUserAsync();
CompletableFuture<List<Order>> ordersFuture = getOrdersAsync();
CompletableFuture<Profile> profileFuture = getProfileAsync();

CompletableFuture<Result> result =
    CompletableFuture.allOf(userFuture, ordersFuture, profileFuture)
        .thenApply(v -> new Result(
            userFuture.join(),
            ordersFuture.join(),
            profileFuture.join()
        ));

result.thenAccept(r -> System.out.println(r))
      .exceptionally(ex -> { ex.printStackTrace(); return null; });

Instead of await, you attach callbacks. Instead of try/catch, you chain .exceptionally().

Full Scenario: 3 Services, Parallel, Timeout, Error Handling

C#:

public async Task<string> GetCombinedAsync()
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

    try
    {
        var task1 = CallService1Async(cts.Token);
        var task2 = CallService2Async(cts.Token);
        var task3 = CallService3Async(cts.Token);

        var results = await Task.WhenAll(task1, task2, task3);
        return string.Join(",", results);
    }
    catch (OperationCanceledException)
    {
        return "Timeout occurred";
    }
    catch (Exception ex)
    {
        return $"Error: {ex.Message}";
    }
}

Java:

CompletableFuture<String> combined() {

    CompletableFuture<String> f1 = callService1();
    CompletableFuture<String> f2 = callService2();
    CompletableFuture<String> f3 = callService3();

    return CompletableFuture.allOf(f1, f2, f3)
        .orTimeout(2, TimeUnit.SECONDS)
        .thenApply(v ->
            f1.join() + "," +
            f2.join() + "," +
            f3.join()
        )
        .exceptionally(ex -> "Error: " + ex.getMessage());
}

C# is linear. Java is pipeline. Both work, but one reads top-to-bottom and the other reads as a chain.

Virtual Threads: The Ironic Twist

Java’s answer to async is “make threads so cheap you don’t need it.” Project Loom introduces virtual threads — lightweight threads that scale to millions:

String combined() {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

        Future<String> f1 = executor.submit(this::callService1);
        Future<String> f2 = executor.submit(this::callService2);
        Future<String> f3 = executor.submit(this::callService3);

        return f1.get(2, TimeUnit.SECONDS) + "," +
               f2.get(2, TimeUnit.SECONDS) + "," +
               f3.get(2, TimeUnit.SECONDS);

    } catch (TimeoutException e) {
        return "Timeout occurred";
    } catch (Exception e) {
        return "Error: " + e.getMessage();
    }
}

Now it’s blocking code that scales like async — the scheduler parks virtual threads during I/O.

Mapping Concepts

Concept C# Java
Async method async Task<T> CompletableFuture<T>
Await result await thenApply, join
Run in background Task.Run supplyAsync
Wait for many Task.WhenAll allOf
Lock lock synchronized
Parallel loop Parallel.ForEach parallelStream
Async streams IAsyncEnumerable Reactive Streams / Flow
Cancellation CancellationToken Future.cancel
Cheap threads N/A Virtual threads (Loom)
  C# Java
Async model Language feature Library feature
Syntax async/await CompletableFuture + callbacks
Error handling try/catch Pipeline handlers
Scaling model Async tasks Threads / futures
New approach Still async Virtual threads

The philosophical difference: C# made async a first-class language transformation. Java asked “what if we make threads so cheap you don’t need async?”


One part left. After all these differences, you might wonder: how did Java get here? Was it just age, or was there a deliberate philosophy behind it all?