Common Java Pitfalls
A grab bag of everyday Java mistakes — == vs equals, broken hashCode contracts, mutable map keys, Optional.get(), swallowed exceptions, and more — that quietly produce bugs even experienced developers hit repeatedly.
Beyond the language-edge-case and concurrency pitfalls covered separately, a set of everyday mistakes recurs constantly in real Java codebases regardless of experience level. This topic works through them one by one: == versus equals(), broken equals/hashCode contracts, mutable objects used as map keys, string concatenation habits, Optional misuse, overly broad exception handling, resource leaks, forgetting to shut down executors, accidental boxing, the BigDecimal(double) constructor trap, modifying a collection while iterating it, and relying on unspecified implementation behavior.
== Versus equals()
== compares primitive values directly, or reference identity for reference types (do the two variables point to the same object). .equals() is a method whose actual comparison logic depends entirely on the class's implementation — Object's default equals() is reference identity too, but many classes (String, Integer, records, and any class that overrides it) implement content-based comparison instead. Using == where content equality is intended is one of the single most common Java bugs.
Not every class's equals() is automatically deep/value equality — it depends on what that class chose to implement; the default from Object is still identity comparison unless overridden.
String input = new String("stop"); // deliberately not pooled, for illustration
if (input == "stop") { // WRONG: compares reference identity
System.out.println("matched");
} else {
System.out.println("no match"); // this branch runs, even though content matches!
}
if (input.equals("stop")) { // RIGHT: compares content
System.out.println("matched via equals");
}Broken equals()/hashCode() Contracts
Overriding equals() without also overriding hashCode() (or vice versa, inconsistently) breaks the documented contract: equal objects must produce equal hash codes. This silently corrupts hash-based collections like HashMap and HashSet — an object can be 'equal' by equals() to a key already in the map yet never be found, because it hashes to a different bucket.
Without a matching hashCode(), the two equal Points land in different hash buckets, so contains() never finds the match — HashSet relies on hashCode() to even locate the bucket to check equals() against.
class Point {
final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
// missing hashCode() override — BUG: uses Object's identity-based hashCode
}
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
System.out.println(points.contains(new Point(1, 2))); // false! should be true
// Fix: override hashCode() consistently with equals()
@Override
public int hashCode() { return Objects.hash(x, y); }Mutable Objects as Map Keys
Using a mutable object as a HashMap/HashSet key is dangerous: if the key's fields (that hashCode()/equals() depend on) change after insertion, the object's hash code changes, but the map does not re-bucket it — the entry becomes unreachable via the normal lookup path (get() with an equal key fails) even though it's technically still stored somewhere in the map's internal structure.
Prefer immutable keys (String, wrapper types, records, or a class with all-final fields) — an object used as a map key should never be mutated in a way that changes its equals()/hashCode() result while it's in the map.
class MutableKey {
int id;
MutableKey(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof MutableKey k && k.id == id;
}
@Override public int hashCode() { return id; }
}
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey(1);
map.put(key, "value");
key.id = 2; // mutating the key after insertion
System.out.println(map.get(key)); // null! the entry is now in the wrong bucketString Concatenation Habits
Repeated '+' concatenation of a String across loop iterations is O(n) per operation, O(n²) overall (see the Performance Pitfalls topic) — use StringBuilder for building strings incrementally in a loop. A related habit worth avoiding: constructing SQL or shell commands via string concatenation of untrusted input, which is a correctness and security problem (injection), not just a style issue — use parameterized queries/APIs instead.
Optional.get() Misuse
Optional exists to make the possible absence of a value explicit in a method's return type, but calling .get() unconditionally defeats the entire point — it throws NoSuchElementException on an empty Optional, exactly the kind of unchecked failure Optional was meant to help avoid. Optional should be consumed via orElse(), orElseGet(), orElseThrow() (with a meaningful exception), ifPresent(), or map()/flatMap() chains — reserving a bare .get() only for cases already proven present, such as immediately after isPresent().
orElseThrow with a specific, meaningful exception communicates intent and gives callers a clear, typed failure instead of a generic NoSuchElementException with no context about which lookup failed.
Optional<User> maybeUser = repository.findById(id);
// Bad: defeats the purpose of Optional
User user = maybeUser.get(); // throws NoSuchElementException if empty
// Good: handle absence explicitly
User user2 = maybeUser.orElseThrow(() -> new UserNotFoundException(id));Catching Exception Blindly and Swallowing Exceptions
catch (Exception e) {} (or worse, catch (Throwable t) {}) catches far more than intended, including programming errors (like NullPointerException) that usually indicate a bug that should propagate and be fixed, not be silently absorbed. An empty catch block — 'swallowing' the exception — is especially dangerous because it hides failures entirely: the program continues as if nothing went wrong, with no log entry to explain a later, seemingly unrelated symptom.
Catching a specific, expected exception type and either handling it meaningfully or logging and rethrowing preserves diagnostic information, instead of catching broadly and discarding the failure entirely.
// Bad: catches everything, including bugs, and discards all information
try {
processOrder(order);
} catch (Exception e) {
// swallowed — no log, no rethrow, failure is invisible
}
// Better: catch the specific exception(s) the code can actually handle,
// and always log or rethrow anything else
try {
processOrder(order);
} catch (PaymentDeclinedException e) {
notifyCustomer(order, e.getMessage());
} catch (IOException e) {
logger.error("Failed to process order {}", order.getId(), e);
throw new OrderProcessingException(order.getId(), e);
}Resource Leaks and Forgetting Shutdown
Garbage collection reclaims memory, not other resources — file handles, sockets, database connections, and thread pools all need explicit lifecycle management. Forgetting to close a Closeable resource (or forgetting to call shutdown() on an ExecutorService) leaks the underlying resource even though the Java object referencing it may eventually be garbage collected; an unclosed thread pool in particular can also prevent the JVM from exiting, since non-daemon threads keep it alive.
try-with-resources (any AutoCloseable) guarantees close() runs even when an exception is thrown from the try block, which manual try/finally code is easy to get subtly wrong (e.g. forgetting the finally, or closing in the wrong order for multiple resources).
// Bad: connection leaks if readData() throws
Connection conn = dataSource.getConnection();
String data = readData(conn);
conn.close(); // never reached on exception
// Good: try-with-resources closes automatically, even on exception
try (Connection conn2 = dataSource.getConnection()) {
String data2 = readData(conn2);
} // conn2.close() called automatically here
// Executors also need explicit shutdown
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
// submit tasks...
} finally {
pool.shutdown(); // without this, pool threads may keep the JVM alive
}Accidental Boxing and BigDecimal(double)
Accidental boxing (covered in depth in the Performance Pitfalls topic) is also a correctness trap when combined with == comparisons on wrapper types outside the small Integer cache range. Separately, constructing a BigDecimal from a double — new BigDecimal(0.1) — captures the exact (and non-obviously ugly) binary floating-point representation of 0.1, not the decimal value '0.1' a developer intends, because double itself cannot represent 0.1 exactly.
new BigDecimal(String) or BigDecimal.valueOf(double) should be preferred over new BigDecimal(double) whenever the intended value is a decimal literal — the double constructor exposes the binary floating-point representation exactly, which is almost never what's wanted for money or other exact-decimal use cases.
BigDecimal fromDouble = new BigDecimal(0.1);
System.out.println(fromDouble); // 0.1000000000000000055511151231257827021181583404541015625 (!)
BigDecimal fromString = new BigDecimal("0.1"); // correct: exact decimal value
System.out.println(fromString); // 0.1
BigDecimal fromValueOf = BigDecimal.valueOf(0.1); // also correct: uses Double.toString() internally
System.out.println(fromValueOf); // 0.1Modifying a Collection During Iteration
Adding or removing elements from a collection directly while iterating it with a for-each loop or an Iterator (other than through the Iterator's own remove() method) throws ConcurrentModificationException from most standard collection implementations, because they track a modification count and detect the mismatch. The fix is to use Iterator.remove(), collect items to remove into a separate list and remove them after iterating, or use Collection.removeIf().
removeIf() is the simplest fix for the common case of removing by a predicate; Iterator.remove() is the general-purpose safe way to remove during manual iteration.
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4, 5));
// Bad: throws ConcurrentModificationException
// for (Integer n : nums) {
// if (n % 2 == 0) nums.remove(n);
// }
// Good: removeIf handles this safely
nums.removeIf(n -> n % 2 == 0);
System.out.println(nums); // [1, 3, 5]
// Also good: explicit Iterator.remove()
Iterator<Integer> it = nums.iterator();
while (it.hasNext()) {
if (it.next() == 3) it.remove(); // safe: goes through the iterator
}Relying on Implementation-Specific Behavior
Some observed behaviors are specified guarantees (HashMap has no guaranteed iteration order; LinkedHashMap does guarantee insertion order); others are merely typical of a particular JDK build and not something the language promises. Relying on unspecified behavior — such as assuming a HashMap always iterates in a particular order because that's what one JVM version happened to do — is fragile and can break silently on a JDK upgrade or even between runs.
When an order or a specific behavior actually matters, choose a type that documents the guarantee explicitly (LinkedHashMap for insertion order, TreeMap for sorted order) rather than depending on an implementation detail that happens to hold today.
Key Takeaways
Use .equals() for content comparison; == compares primitive values or reference identity, never guaranteed content equality for reference types.
Overriding equals() without a consistent hashCode() silently breaks HashMap/HashSet lookups for otherwise-equal objects.
Never mutate an object's key-relevant fields while it's in use as a HashMap/HashSet key — prefer immutable keys.
Handle Optional explicitly (orElse/orElseThrow/map) rather than calling .get() unconditionally, which defeats its purpose.
Avoid catching Exception/Throwable broadly, and never swallow an exception silently — at minimum log it; ideally handle or rethrow with context.
Use try-with-resources for anything Closeable, and always shut down ExecutorServices — garbage collection does not manage non-memory resources.
Prefer new BigDecimal(String) or BigDecimal.valueOf(double) over new BigDecimal(double), which captures binary floating-point imprecision exactly.
Never structurally modify a collection during for-each iteration except through Iterator.remove() or Collection.removeIf().
Why does new String("stop") == "stop" evaluate to false, and what should be used instead?
== compares reference identity; new String(...) explicitly creates a distinct object from the pooled literal, so identity comparison fails even though the content is equal. .equals() should be used to compare String content.
What breaks if a class overrides equals() but not hashCode(), and why?
The equals()/hashCode() contract requires equal objects to produce equal hash codes. Without a matching hashCode() override, two 'equal' objects can land in different HashMap/HashSet buckets, so lookups like contains() or get() silently fail to find an entry that is, by equals(), actually present.
Why is mutating a field that a HashMap key's hashCode() depends on, after the key is already inserted, dangerous?
The map does not re-bucket the entry when the key changes; the key's hash code changes but its stored bucket location does not, so subsequent lookups with an equal key fail to find the entry through the normal API even though it's still stored internally.
What is wrong with calling Optional.get() unconditionally, and what should be used instead?
It throws NoSuchElementException if the Optional is empty, which defeats the purpose of Optional (making absence explicit and forcing the caller to handle it). orElse(), orElseGet(), orElseThrow() with a meaningful exception, or map()/ifPresent() chains handle absence explicitly instead.
Why is new BigDecimal(0.1) a common mistake, and what should be used instead?
double cannot represent 0.1 exactly in binary floating point, so the double constructor captures that exact imprecise binary value, producing a long, ugly BigDecimal rather than a clean 0.1. new BigDecimal("0.1") or BigDecimal.valueOf(0.1) should be used instead to get the intended decimal value.
What happens when you call list.remove() directly inside a for-each loop over that same list, and how should removal during iteration be done instead?
It typically throws ConcurrentModificationException, because most standard collections track structural modifications and detect the mismatch during iteration. Use Collection.removeIf(predicate), or Iterator.remove() during manual iteration, both of which are safe.
Why does garbage collection not solve resource leaks from unclosed database connections or thread pools?
GC only reclaims memory occupied by unreachable Java objects; it does not know how to release non-memory resources like file handles, sockets, or database connections, and has no special awareness of an ExecutorService's threads. These need explicit lifecycle management via try-with-resources or explicit shutdown() calls.
What does this print, and why, given the equals() override shown?
It prints false. Employee overrides equals() but not hashCode(), so it still uses Object's default identity-based hashCode(). The two Employee instances have different hash codes, so HashSet looks in the wrong bucket and never even calls equals() on the right entry — contains() fails despite the objects being 'equal' by the overridden equals().
Write a corrected version of the transferFunds method below, which currently leaks a database Connection if withdraw() throws, and swallows any exception from deposit().
void transferFunds(DataSource ds, String from, String to, double amount) throws SQLException { try (Connection conn = ds.getConnection()) { withdraw(conn, from, amount); deposit(conn, to, amount); } catch (SQLException e) { logger.error("Transfer failed from {} to {}", from, to, e); throw e; // propagate, do not swallow } }
The original leaks the Connection if withdraw() throws (close() is never reached) and silently swallows any exception from deposit() or the outer SQLException, hiding real failures. The fix uses try-with-resources so the connection is always closed, removes the inner empty catch so a deposit failure propagates, and logs and rethrows the outer exception with context instead of discarding it.
Given List<String> names = new ArrayList<>(List.of("Al", "Bo", "Cy", "Di"));, write code that removes every name shorter than 3 characters without throwing ConcurrentModificationException.
List<String> names = new ArrayList<>(List.of("Al", "Bo", "Cy", "Di")); names.removeIf(name -> name.length() < 3); System.out.println(names); // [] removeIf() safely removes matching elements in a single pass without the ConcurrentModificationException that would result from calling list.remove() directly inside a for-each loop over the same list — all four names are length 2, so the result is an empty list.
class Employee {
String name;
Employee(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
return o instanceof Employee e && e.name.equals(name);
}
}
Set<Employee> team = new HashSet<>();
team.add(new Employee("Ana"));
System.out.println(team.contains(new Employee("Ana")));void transferFunds(DataSource ds, String from, String to, double amount) {
try {
Connection conn = ds.getConnection();
withdraw(conn, from, amount);
try {
deposit(conn, to, amount);
} catch (Exception e) {
}
conn.close();
} catch (SQLException e) {
}
}