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.

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