Idioms & Common Gotchas - Write It Like a Java Dev, Dodge the Traps
You can write Java that compiles and runs. This phase closes the gap between that and code that looks like a seasoned Java developer wrote it - plus the short list of traps that have caught every Java programmer alive. (Genuinely - the == versus .equals() one alone has cost the industry more debugging hours than anyone wants to count. You're about to skip past it.)
Two halves. First the idioms - conventions that make Java code feel coherent instead of arbitrary. The mental model: Java rewards small contracts and unchanging data. Depend on interfaces, not concrete classes; make things final and immutable so they can't change behind your back; say "no value" out loud with Optional instead of the silent landmine that is null.
Then a scannable gotcha cheat-card - surprises named before they bite, so you recognize one instead of staring at a stack trace. The mental model: Java does exactly what the rules say, not what you assumed.
Idioms - the way it's written today
Program to interfaces, not implementations
What it actually is. When declaring a variable, parameter, or return type, name the interface (List, Map, Collection) rather than the concrete class (ArrayList, HashMap). You still create the concrete thing with new - just don't let the rest of your code depend on which one.
// Idiomatic: the type is the contract, not the implementation.
List names ;
// Not idiomatic: now everything downstream is welded to ArrayList.
ArrayList names2 ;
What just happened: both lines create an ArrayList, but the first types the variable as List. A method taking List<String> accepts an ArrayList, a LinkedList, or List.of(...) without changes, and you swap implementations later by editing one line. That's why you'll see void process(List<Order> orders) everywhere and almost never void process(ArrayList<Order> orders).
💡 Key point. Declare with the most general interface that does the job (List, Map, Set, Collection), construct with the concrete class. Flexible outside, specific inside.
Prefer immutability
What it actually is. An immutable object's state can't change after it's built. Mark fields final, don't expose setters, assign everything in the constructor - once it exists, it's frozen.
What just happened: Point has no setters and every field is final, so once constructed it never changes. Need a different x? withX hands you a brand-new Point, leaving the original untouched. Sounds wasteful, but immutable objects are automatically thread-safe, safe to share and cache, and never in a half-updated state. When in doubt, make value objects immutable. (In Phase 13, record automates this pattern down to one line.)
⚠️ Gotcha - final on a reference freezes the reference, not the object. final List<String> items = new ArrayList<>(); stops you reassigning items, but items.add(...) still works all day. final means "this variable always points at the same object," not "that object can't change." True immutability needs the object itself unchangeable (List.copyOf(items)).
Use Optional instead of returning null
What it actually is. Optional<T> is a small box holding a value or explicitly empty. When a method might legitimately have "no answer" (no user found, no config set), returning Optional<User> instead of User (which might secretly be null) makes the absence visible in the type.
;
Optional
// Caller is forced to deal with the empty case:
String shown ;
System.out.;
(none)
What just happened: findNickname returns an Optional<String>, so its signature announces "there might be nothing here." The caller used .orElse("(none)") for a fallback. Compare that to returning null: nothing warns you, and the day you forget a null check, you get a NullPointerException in production instead of a compiler nudge.
💡 Key point. Use Optional for return types that may be absent. Skip it for fields or parameters (it adds ceremony without payoff there), and never call .get() without checking first - that just reinvents the NPE.
Loop with the enhanced for and streams
What it actually is. The enhanced for-loop (for (var x : items)) iterates without a manual index. Streams go further: describe what to do to a collection - filter, map, collect - instead of spelling out how with a counter.
;
List names ;
// Enhanced for - no index to fat-finger.
for
// Stream - describe the transformation as a pipeline.
List longOnes ;
System.out.;
ADA
BOB
CLEO
[cleo]
What just happened: the enhanced for-loop walked the list with no int i to manage and no off-by-one risk. The stream expressed "keep names longer than three characters" as a readable pipeline - filter describes intent, not mechanics. Streams shine when chaining transformations; for a simple walk, the enhanced for is perfectly idiomatic. (Streams and lambdas get their own treatment in Phase 12.)
Write meaningful equals, hashCode, and toString
What it actually is. By default, equals checks identity, and toString prints something useless like Point@1b6d3586. If two objects with the same field values should count as "equal" - especially bound for a HashMap or HashSet - override equals and hashCode together.
;
public boolean
public int
public String
What just happened: equals now compares actual field values, so two Point(1, 2) objects are equal despite being separate objects in memory. hashCode uses the same fields - non-negotiable, since hash-based collections use hashCode to find the bucket and equals to confirm the match. Disagree, and your objects vanish into a HashMap unrecoverably. toString makes debugging humane. (Records generate all three; until then, honor this contract.)
💡 The umbrella idiom: make intent explicit, illegal states impossible. Interfaces narrow the contract, immutability removes a whole class of "who changed this?" bugs,
Optionalmakes absence visible, properequals/hashCodemakes equality mean what you mean. Favor composition over deep inheritance - clarity over cleverness.
The gotcha cheat-card
Hit something baffling? Find the symptom here. These trap everyone - recognizing them on sight is most of the battle.
| The trap | What bites you | The fix |
|---|---|---|
== vs .equals() |
== compares references, so equal-looking objects/strings can be "not equal" |
Compare objects with .equals(); reserve == for primitives and identity |
null / NPE |
Calling a method on null throws NullPointerException at runtime |
Optional, explicit null checks, Objects.requireNonNull on inputs |
| Autoboxing surprises | Integer caches −128..127, so == "works" then mysteriously breaks; unboxing a null Integer throws NPE |
Use .equals() for Integer; keep arithmetic in primitives |
| Mutating a list while looping it | Removing during a for-each throws ConcurrentModificationException |
Use Iterator.remove(), removeIf(...), or collect-then-remove |
| Integer division | 5 / 2 is 2, not 2.5 - the fraction is silently discarded |
Cast one operand to double: 5 / 2.0 |
| Shared mutable state / arrays | Passing a list or array hands over a reference; the callee can mutate your data | Defensive copies (List.copyOf) or immutable objects |
Catching Exception too broadly |
A blanket catch (Exception e) {} swallows bugs and hides real failures |
Catch specific types; never leave a catch block empty |
Now the why behind the sharpest three.
== vs .equals()
The big one. == asks "are these the same object in memory?" .equals() asks "do these represent the same value?" For primitives == is correct and the only option. For objects - including String - == almost never means what you want.
String a ;
String b ;
System.out.; // false - two different objects
System.out.; // true - same characters
false
true
What just happened: new String("hello") built two separate String objects holding the same characters. == compared references - different objects, false. .equals() compared contents - same text, true. Dangerous because string literals ("hello" == "hello") often return true, since Java pools identical literals, lulling beginners into thinking == works on strings. Then a string comes from user input, the pool doesn't apply, and == quietly returns false. Always compare strings and objects with .equals().
Autoboxing and the Integer cache
Java auto-converts between primitives (int) and object wrappers (Integer) - autoboxing. Convenient, and the source of two classic traps. First, == on two Integer objects compares references, not values - but the JVM caches small Integers from −128 to 127, so == accidentally "works" in that range and breaks above it.
Integer a , b ;
System.out.; // true - both from the cache (-128..127)
Integer c , d ;
System.out.; // false - outside cache, two real objects
System.out.; // true - value comparison, correct
true
false
true
What just happened: 100 falls inside the cached range, so a and b point at the same cached Integer and == is true. 200 is outside the cache, so c and d are separate objects and == is false - even though both hold 200. Vicious, since it passes every test with small numbers and fails in production on large ones. Use .equals() for wrappers. Second trap: unboxing a null Integer (int x = someInteger; when null) throws a NullPointerException from a line that never mentions null. Keep arithmetic in primitives and sidestep both.
Integer division
Dividing two ints gives an int - Java discards the remainder instead of producing a fraction. Bites every beginner computing an average or a percentage.
int total , count ;
System.out.; // 2 - fraction discarded!
System.out.; // 2.5 - cast first
2
2.5
What just happened: 5 / 2 is integer arithmetic, so the result is 2 with the .5 silently dropped - no error, no warning, just a wrong number. Casting one operand to double forces floating-point division and gets 2.5. The rule: for a fractional result, make at least one operand a double before the division happens. (double)(total / count) is too late - integer division already ran.
📝 The other three, in one line each. ConcurrentModificationException - removed from a list inside a for-each loop; use list.removeIf(...) or Iterator.remove(). Shared mutable state - handing out your internal list or array lets the receiver mutate it; return List.copyOf(...) instead. Over-broad catch - catch (Exception e) {} swallows the bug you need to see; catch the specific exception, never leave the block empty.
Recap
- Program to interfaces - declare
List/Map, constructArrayList/HashMap: narrow contracts, swappable implementations. - Prefer immutability -
finalfields, no setters, "change" returns a new object: thread-safe and bug-resistant by construction. (finalon a reference freezes only the reference.) - Return
Optional, notnull- make "no value" visible in the type so callers can't forget it. - Loop with enhanced for and streams, and write real
equals/hashCode/toString(matched pair for the first two) - records automate this later. - ⚠️ The cheat-card -
==compares references (use.equals()for objects/strings); NPEs come fromnull;Integercaching makes==lie outside −128..127;5/2is2; shared references let others mutate your data; don't swallow exceptions.
That's idiomatic Java. You can now read other people's Java and write code that looks like it belongs - and you've met the traps before they meet you. Next: deep on generics - how List<T> really works, wildcards, and why the compiler sometimes argues about types.
Quick check
Test yourself on the three traps that catch everyone:
[
{
"q": "You have two String objects built with `new String(\"hi\")`. What do `a == b` and `a.equals(b)` return?",
"choices": [
"`a == b` is false (different objects), `a.equals(b)` is true (same characters)",
"Both are true - strings always compare by value",
"Both are false - the strings are stored separately",
"`a == b` is true, `a.equals(b)` is false"
],
"answer": 0,
"explain": "`==` compares references, and `new String(...)` makes two distinct objects, so `a == b` is false. `.equals()` compares contents, so it's true. Always use `.equals()` for strings and objects."
},
{
"q": "Why does `Integer a = 100, b = 100; a == b` print `true`, but `Integer c = 200, d = 200; c == d` print `false`?",
"choices": [
"Java caches Integer objects from −128 to 127, so 100 reuses one cached object while 200 creates two separate ones",
"200 is too large to fit in an int, so it overflows",
"`==` rounds large numbers differently",
"It's undefined behavior and the result is random"
],
"answer": 0,
"explain": "The JVM caches small Integer objects (−128..127), so `a` and `b` are the same cached object and `==` is true. 200 is outside the cache, so `c` and `d` are separate objects and `==` is false. Use `.equals()` for wrapper objects."
},
{
"q": "What does `5 / 2` evaluate to in Java, and how do you get `2.5`?",
"choices": [
"It's `2` (integer division discards the fraction); cast an operand to double, e.g. `5 / 2.0` or `(double) 5 / 2`",
"It's `2.5` already - Java promotes to double automatically",
"It's `3` - Java rounds to the nearest integer",
"It throws an ArithmeticException for non-divisible numbers"
],
"answer": 0,
"explain": "Dividing two ints gives an int, dropping the remainder, so `5 / 2` is `2`. Force floating-point division by making at least one operand a double before the division runs."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. You have two String objects built with `new String("hi")`. What do `a == b` and `a.equals(b)` return?
2. Why does `Integer a = 100, b = 100; a == b` print `true`, but `Integer c = 200, d = 200; c == d` print `false`?
3. What does `5 / 2` evaluate to in Java, and how do you get `2.5`?