C# Dev Exploring Java: No Safety Net (Part 2)
C# wraps things in sugar. Java makes you see the machinery. This part covers what Java doesn’t protect you from — null, exceptions, and how values work.
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) (Current)
- 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)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)
Null Safety: You’re on Your Own
C# has evolved null safety through nullable reference types, compiler analysis, and operators like ?. and ??:
#nullable enable
string? name = GetName();
Console.WriteLine(name ?? "Unknown");
Console.WriteLine(name?.Length); // safe
Java has none of this at the language level. Nullability is convention-based:
@Nullable String name = getName();
System.out.println(name != null ? name : "Unknown");
Annotations like @Nullable / @NotNull are hints, not guarantees. The compiler won’t stop you.
Optional<T> — The Pattern That Proves the Gap
Java introduced Optional<T> as a return type to signal “might be null”:
Optional<String> name = Optional.ofNullable(getName());
System.out.println(name.orElse("Unknown"));
C# developers recognize the intent — it’s like Nullable<T> but for reference types. The difference: string? is a language feature; Optional<T> is a library class. You can ignore it, wrap it wrong, or receive null from code that doesn’t use it.
Pitfall: NullPointerException remains common in Java. @Nullable / @NotNull are hints, not enforcement. Optional helps but is itself a class you must remember to use. C#’s nullable references are compiler-enforced; Java’s null safety is willpower-enforced.
Checked Exceptions: The Compiler Makes You Deal With It
C# exceptions are all unchecked — the compiler never forces you to handle them:
string ReadFile(string path)
{
return File.ReadAllText(path); // might throw, compiler doesn't care
}
Java has checked exceptions. If a method declares throws IOException, every caller must handle or re-declare it:
String readFile(String path) throws IOException {
return Files.readString(Path.of(path));
}
Checked exceptions are part of the method signature. The compiler enforces that callers acknowledge them:
void load() throws IOException {
String text = readFile("data.txt"); // must handle or propagate
}
Only Exception subclasses (except RuntimeException) are checked. NullPointerException, IllegalArgumentException, etc. are unchecked — so the same language has both models.
Why This Hurts
- Exceptions cascade up the call stack, forcing multiple methods to add
throws - Lambdas and streams hate checked exceptions — you can’t
map()with athrowslambda - Many Java developers wrap checked exceptions in
RuntimeExceptionanyway, defeating the purpose
files.stream()
.map(f -> Files.readString(f)) // won't compile (throws IOException)
Pitfall: What looks like “discipline” becomes friction. Checked exceptions push developers toward patterns (wrapping, swallowing) that undermine the very safety they promised.
try-with-resources vs using
C#’s using ensures deterministic disposal:
using var file = new StreamReader("data.txt");
var content = file.ReadToEnd();
// file closed here
Java’s try-with-resources works similarly but ties cleanup to exception handling:
try (var file = new FileReader("data.txt")) {
var content = file.read();
}
Any resource implementing AutoCloseable works. Multiple resources can be declared in one try:
try (
FileInputStream in = new FileInputStream("input.txt");
FileOutputStream out = new FileOutputStream("output.txt")
) {
// both auto-closed
}
Pitfall: Resources must be declared inside try(...) — forgetting means no auto-close. This pairs naturally with checked exceptions since close() typically throws IOException, which the try block already handles.
Primitives and Their Evil Twins
Java has two worlds for every numeric type:
int -> primitive, stack, fast
Integer -> object, heap, nullable
This causes subtle issues. Boxing looks automatic but isn’t free:
List<Integer> numbers = new ArrayList<>();
numbers.add(5); // boxing — hidden allocation
And == on boxed types is reference comparison, with cached values masking the problem:
Integer x = 100;
Integer y = 100;
System.out.println(x == y); // true (cached)
Integer m = 1000;
Integer n = 1000;
System.out.println(m == n); // false (not cached)
System.out.println(m.equals(n)); // true
C#’s int and int? feel unified. Java’s int vs Integer are genuinely different types with different performance characteristics and comparison semantics.
Pitfall: Using == with boxed types compares references. Java caches small values (-128 to 127), making the bug intermittent. Always use .equals() on boxed types.
No struct — Everything Goes on the Heap
C# lets you define value types with struct — stack-allocated, no GC pressure:
struct Vector3
{
public float X, Y, Z;
}
Java has no equivalent. Everything except primitives is a heap-allocated object:
class Vector3 {
float x, y, z;
}
Every new Vector3() is heap allocation. In hot paths — game loops, simulations, data processing — this creates significant GC pressure. Java’s escape analysis can stack-allocate in some cases, but it’s a JIT optimization, not a language guarantee.
Pitfall: Heavy numeric workloads create large numbers of short-lived objects, increasing garbage collection overhead. Patterns like object pooling are more common in Java for this reason.
String Isn’t Special in Java
C# gives strings first-class treatment: verbatim literals, interpolation, == overloaded for value comparison:
string path = @"C:\Users\file.txt";
string msg = $"Hello, {name}";
bool eq = (str1 == str2); // value comparison
Java strings get none of this. == is reference comparison (covered in Part 1). Verbatim strings don’t exist — escape backslashes manually. String interpolation was only added as a preview feature in Java 21 and still lags behind C#’s.
String path = "C:\\Users\\file.txt";
String msg = "Hello, " + name; // no interpolation until Java 21+
String msg2 = STR."Hello, \{name}"; // Java 21+ preview
Pitfall: String feels familiar, but lacks the syntactic sugar you expect. Every escape sequence and concatenation is manual.
All these add up: checked exceptions to handle, null checks to write, boxed types to watch, every object on the heap. What looks like “explicitness” becomes tax. Coming up next: what about expressing behavior?
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) (Current)
- 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)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)