C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5)
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.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1)
- C# Dev Exploring Java: No Safety Net (Part 2)
- C# Dev Exploring Java: How Do I Say That? (Part 3)
- C# Dev Exploring Java: Generics, But Not Really (Part 4)
- C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5) (Current)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)
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:
Mapis not aCollection— you must ask forkeySet(),values(), orentrySet()- Method naming inconsistencies:
addvsoffer,removevspollvstake Arrays.asList()returns a fixed-size list —add()throws at runtime- Legacy classes (
Vector,Stack,Hashtable) still live injava.utilalongside modernArrayDeque,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?
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1)
- C# Dev Exploring Java: No Safety Net (Part 2)
- C# Dev Exploring Java: How Do I Say That? (Part 3)
- C# Dev Exploring Java: Generics, But Not Really (Part 4)
- C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5) (Current)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)