C# Dev Exploring Java: Wait, It Does That? (Part 1)
You open IntelliJ, write your first Java class, and the compiler laughs at you. These are the day-one surprises — things that break within the first hour of writing Java.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1) (Current)
- 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)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)
== vs equals() — The First Footgun
In C#, == is often overloaded for value comparison. With strings it just works:
string a = "test";
string b = "test";
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
In Java, == compares references, not values:
String a = new String("test");
String b = new String("test");
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
Pitfall: Accidentally using == in collections or comparisons. This is usually the first bug every C# dev hits in Java.
hashCode() Is Not Optional
In C#, overriding Equals() and GetHashCode() is well understood, and tooling makes it easy:
class Person
{
public string Name { get; set; }
public override bool Equals(object obj) =>
obj is Person p && Name == p.Name;
public override int GetHashCode() =>
Name?.GetHashCode() ?? 0;
}
In Java, you must override both, or hash-based collections break silently:
class Person {
String name;
Person(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person p = (Person) o;
return name.equals(p.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}
Pitfall: Forgetting hashCode() when overriding equals() means HashMap/HashSet lookups return wrong results. C# tooling often generates both; Java leaves it entirely up to you.
Everything Is Virtual Unless You Stop It
In C#, methods are non-virtual by default — you opt in with virtual:
class Base
{
public void Method() => Console.WriteLine("Base.Method");
}
class Derived : Base
{
public new void Method() => Console.WriteLine("Derived.Method");
}
var obj = new Derived();
obj.Method(); // "Derived.Method"
In Java, methods are virtual by default unless marked final, static, or private:
class Base {
void method() {
System.out.println("Base.method");
}
}
class Derived extends Base {
@Override
void method() {
System.out.println("Derived.method");
}
}
Base obj = new Derived();
obj.method(); // "Derived.method"
Java does not require the @Override annotation — it’s a convention. Without it, a typo silently creates a new method instead of overriding.
Pitfall: Accidentally overriding methods in Java leads to subtle behavioral changes. Always use @Override to catch mistakes.
switch Falls Through
C# blocks fall-through by default:
int value = 2;
switch (value)
{
case 1:
Console.WriteLine("One");
break;
case 2:
Console.WriteLine("Two");
break;
}
// Output: Two
Java falls through unless you break:
int value = 2;
switch (value) {
case 1:
System.out.println("One");
case 2:
System.out.println("Two");
case 3:
System.out.println("Three");
}
// Output:
// Two
// Three
Java 14+ introduced arrow syntax that prevents fall-through, matching C# switch expressions:
String text = switch (number) {
case 1 -> "One";
case 2 -> "Two";
default -> "Other";
};
Pitfall: Forgetting break causes accidental fall-through. Prefer arrow syntax on modern Java.
Arrays Are Covariant (And It Can Crash at Runtime)
In C#, arrays are covariant too, but the type system mostly protects you:
string[] arr = new string[5];
object[] objArr = arr;
objArr[0] = 123; // compile-time error in most cases
In Java, array covariance produces runtime exceptions:
String[] arr = new String[5];
Object[] objArr = arr;
objArr[0] = 123; // throws ArrayStoreException at runtime
Pitfall: C# devs expect these errors at compile time. In Java, array covariance lets bad writes through to runtime.
Access Is a Different Game
C# defaults are restrictive: classes are internal, members are private. You explicitly open things up.
Java defaults are loose: classes are package-private, members are package-private. You explicitly lock things down.
class MyClass // internal by default
{
int x; // private by default
void Do() {} // private by default
}
class MyClass // package-private by default
{
int x; // package-private by default
void do() {} // package-private by default
}
Java also lacks C#’s internal (assembly-level visibility). The closest is package-private, which forces awkward package structures to simulate the same control.
Pitfall: Forgetting private in Java can expose implementation details across your entire package. C# shields you by default.
Pass-by-Value. Always.
C# and Java both pass references by value. The key difference: Java has no ref or out keywords.
void Reassign(Person p) => p = new Person(); // caller unaffected
void Mutate(Person p) => p.Name = "Ibrahim"; // caller sees change
void ReassignRef(ref Person p) => p = new Person(); // caller affected
void reassign(Person p) { p = new Person(); } // caller unaffected
void mutate(Person p) { p.name = "Ibrahim"; } // caller sees change
Pitfall: You cannot write a method that swaps two variables or replaces an object reference for the caller. Many Java developers mark parameters final to document intent and prevent accidental reassignment.
final vs readonly — Reference vs Object
Both readonly (C#) and final (Java) prevent reassignment of the reference. Neither makes the object itself immutable.
readonly List<string> items = new List<string>();
items.Add("test"); // allowed — object is mutable
final List<String> items = new ArrayList<>();
items.add("test"); // allowed — object is mutable
Pitfall: C# devs may expect final to make objects immutable. It only fixes the reference. True immutability requires records, unmodifiable wrappers, or builder patterns.
That covers the syntax-level shocks. These are things that bite your first day. Keep reading — the deeper surprises are about what Java doesn’t have.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1) (Current)
- 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)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)