Chapter 6: Fundamental Java Traps
This chapter covers the most frequent pure Java questions in interviews. These are the basics that every Java developer must master. An interviewer asks them to verify that you understand how Java works in memory.
1. == vs .equals() — THE most asked question
The golden rule
| Operator | Compares what? | Use for |
|---|---|---|
== | Memory addresses (references) | Primitive types (int, boolean, char...) |
.equals() | Content (values) | Objects (String, Integer, List...) |
Examples with String
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true ← same reference (String Pool)
System.out.println(a == c); // false ← different references (new creates a new object)
System.out.println(a.equals(c)); // true ← same content
Why is a == b true?
Java maintains a String Pool (cache) in memory. When you write "hello" directly in the code, Java reuses the same object:
Memory:
┌──────────────────────────────────┐
│ String Pool │
│ ┌─────────┐ │
│ │ "hello" │ ← a and b point here
│ └─────────┘ │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Heap │
│ ┌─────────┐ │
│ │ "hello" │ ← c points here (new = always a new object)
│ └─────────┘ │
└──────────────────────────────────┘
Examples with Integer (favorite trap!)
Integer x = 127;
Integer y = 127;
System.out.println(x == y); // true ← Integer cache [-128, 127]
Integer a = 128;
Integer b = 128;
System.out.println(a == b); // false ← out of cache!
System.out.println(a.equals(b)); // true ← same value
Java caches Integer from -128 to 127. Beyond that, == returns false even if the values are identical. That's why you should always use .equals() to compare objects.
Best practice: "CONSTANT".equals(variable)
String status = getStatus(); // may return null
// DANGEROUS — NullPointerException if status is null
if (status.equals("ACTIVE")) { ... }
// SAFE — never throws NPE
if ("ACTIVE".equals(status)) { ... }
"== compares references (memory addresses), .equals() compares content. I always use .equals() for objects. For strings, I put the constant on the left to avoid NullPointerException."
2. hashCode() — What is it and why is it important?
Simple definition
hashCode() returns an integer that represents an object. It's like a fingerprint: quick to compare, but not 100% unique.
The hashCode/equals contract
If two objects are .equals(), they MUST have the same hashCode().
If you override .equals(), you must also override hashCode().
// BAD — overrides equals without hashCode
public class User {
private String email;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User other)) return false;
return Objects.equals(email, other.email);
}
// NO hashCode() → BUG with HashMap, HashSet!
}
// GOOD — overrides both
public class User {
private String email;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User other)) return false;
return Objects.equals(email, other.email);
}
@Override
public int hashCode() {
return Objects.hash(email);
}
}
Why is it critical? HashMap and HashSet depend on it!
User user1 = new User("alice@mail.com");
User user2 = new User("alice@mail.com");
// Without correct hashCode:
Set<User> set = new HashSet<>();
set.add(user1);
set.add(user2);
System.out.println(set.size()); // 2 ← BAD! Should be 1
// With correct hashCode:
set.add(user1);
set.add(user2);
System.out.println(set.size()); // 1 ← CORRECT
How HashMap uses hashCode
hashCode("alice@mail.com") → 12345
Internal HashMap:
┌──────┬──────────────────┐
│ slot │ content │
├──────┼──────────────────┤
│ ... │ │
│ 12345│ → User("alice") │ ← hashCode determines the slot
│ ... │ │
└──────┴──────────────────┘
1. hashCode() → finds the right slot (fast, O(1))
2. equals() → checks it's the same object
With Lombok (the simplest)
@Data // generates equals(), hashCode(), toString(), getters, setters
public class User {
private String email;
private String name;
}
// Or to control which fields to use:
@EqualsAndHashCode(of = {"email"})
public class User {
private String email;
private String name;
}
With Java Records (the most modern)
public record User(String email, String name) {}
// equals(), hashCode(), toString() are generated automatically!
"hashCode is like a fingerprint for objects. HashMap uses it to find the right bucket in O(1). The contract says: if two objects are equal, they must have the same hashCode. That's why I always override both together — or I use Lombok @Data or Java Records."
3. toString() — For debugging
Without toString
User user = new User("Alice", "alice@mail.com");
System.out.println(user);
// → com.example.User@1a2b3c4d ← useless!
With toString
@Override
public String toString() {
return "User{name='%s', email='%s'}".formatted(name, email);
}
System.out.println(user);
// → User{name='Alice', email='alice@mail.com'} ← useful!
With Lombok or Record
@Data // generates toString() automatically
public class User { ... }
// Or
public record User(String name, String email) {}
// toString() → User[name=Alice, email=alice@mail.com]
4. Primitive types vs Wrapper classes
| Primitive | Wrapper | Size | Default value |
|---|---|---|---|
int | Integer | 4 bytes | 0 |
long | Long | 8 bytes | 0L |
double | Double | 8 bytes | 0.0 |
boolean | Boolean | 1 bit | false |
char | Character | 2 bytes | '\u0000' |
When to use what?
| Situation | Use | Why |
|---|---|---|
| Local variables, calculations | int, double | Faster, no boxing |
| JPA entity attributes | Long, Integer | Can be null (important for id not yet persisted) |
Collections (List, Map) | Integer, Long | Collections do not accept primitives |
| Optional parameters | Integer | Can be null to indicate "not provided" |
Autoboxing / Unboxing
// Autoboxing: int → Integer (automatic)
Integer a = 42;
// Unboxing: Integer → int (automatic)
int b = a;
// TRAP: unboxing null → NullPointerException!
Integer c = null;
int d = c; // 💥 NullPointerException
Integer a = null;
if (a == 0) { ... } // 💥 NullPointerException (unboxing null)
Always check null before comparing a Wrapper with a primitive.
"I use primitives for local variables and calculations, and wrappers for entity IDs and collections. The key pitfall is unboxing null, which throws NullPointerException."
5. final — Three different uses
| Context | Meaning | Example |
|---|---|---|
| Variable | The reference cannot change | final List<String> list = new ArrayList<>() |
| Method | Cannot be overridden | public final void process() |
| Class | Cannot be inherited | public final class String |
Trap: final does NOT make the object immutable
final List<String> list = new ArrayList<>();
list.add("hello"); // ✅ OK — modifying the CONTENT
list = new ArrayList<>(); // ❌ Error — trying to change the REFERENCE
Memory:
┌─────────────────┐
list (final) ──→ │ ArrayList │
(reference │ ["hello"] │ ← content can change
does not move) └─────────────────┘
"final means the reference can't be reassigned, but the object itself can still be mutated. For true immutability, I use List.of() or Collections.unmodifiableList()."
6. static — Belongs to the class, not the instance
public class Counter {
static int totalCount = 0; // shared among ALL instances
int instanceCount = 0; // unique to each instance
public Counter() {
totalCount++;
instanceCount++;
}
}
Counter c1 = new Counter();
Counter c2 = new Counter();
Counter c3 = new Counter();
System.out.println(Counter.totalCount); // 3 (shared)
System.out.println(c3.instanceCount); // 1 (unique to c3)
static in practice
| Use | Example | Good practice? |
|---|---|---|
| Constants | static final int MAX = 100 | Yes |
| Utility methods | Math.max(), Collections.sort() | Yes |
| Shared variables | static int count | Caution (thread safety) |
| Inner classes | static class Builder | Yes (Builder pattern) |
Avoid mutable static variables in Spring beans. Beans are already singleton by default — use normal instance fields.
7. String — Immutable by design
Strings are immutable
String s = "hello";
s.toUpperCase();
System.out.println(s); // "hello" ← NOT modified!
s = s.toUpperCase();
System.out.println(s); // "HELLO" ← new String assigned
Each modification creates a new String object.
Loop concatenation: StringBuilder
// BAD — creates N intermediate String objects → O(n²)
String result = "";
for (int i = 0; i < 10000; i++) {
result += i + ",";
}
// GOOD — one object, in-place modifications → O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append(",");
}
String result = sb.toString();
String Pool (cache)
String a = "hello"; // goes into the String Pool
String b = "hello"; // reuses the same object from the Pool
String c = new String("hello"); // creates a NEW object in the Heap
System.out.println(a == b); // true (same reference in the Pool)
System.out.println(a == c); // false (c is in the Heap, not the Pool)
String comparison
| Method | Usage |
|---|---|
.equals() | Case-sensitive comparison |
.equalsIgnoreCase() | Case-insensitive comparison |
.compareTo() | Alphabetical order (returns int) |
.contains() | Check if contains a substring |
.startsWith() / .endsWith() | Check start/end |
.isBlank() | Empty or only spaces (Java 11+) |
.isEmpty() | Empty (length 0) |
"Strings are immutable in Java. Every modification creates a new object. For concatenation in loops, I use StringBuilder. For comparison, I always use .equals(), never ==."
8. Optional — Avoid NullPointerException
The problem
// NullPointerException if findById returns null
User user = userRepository.findById(1L);
String email = user.getEmail(); // 💥 NPE if user is null
The solution: Optional
Optional<User> optional = userRepository.findById(1L);
// Approach 1: isPresent
if (optional.isPresent()) {
User user = optional.get();
}
// Approach 2: orElse (default value)
User user = optional.orElse(new User("anonymous"));
// Approach 3: orElseThrow (exception)
User user = optional.orElseThrow(() ->
new ResourceNotFoundException("User not found with id: 1"));
// Approach 4: map (transform)
String email = optional.map(User::getEmail).orElse("N/A");
Anti-patterns to avoid
// ❌ BAD — same as null check
if (optional.isPresent()) {
return optional.get();
} else {
return null;
}
// ✅ GOOD
return optional.orElse(null);
// ❌ BAD — Optional as parameter
public void process(Optional<String> name) { ... }
// ✅ GOOD — Optional only as return
public Optional<User> findByEmail(String email) { ... }
"Optional represents a value that may or may not be present. I use it as a return type, never as a parameter. I prefer orElseThrow() for required values and map() for transformations."
9. Pass by value vs pass by reference
Java passes EVERYTHING by value
// Primitives: the VALUE is copied
void increment(int x) {
x++; // modifies the local copy
}
int a = 5;
increment(a);
System.out.println(a); // 5 ← not modified
// Objects: the REFERENCE is copied (not the object)
void addItem(List<String> list) {
list.add("new"); // modifies the object pointed by the reference copy
}
List<String> myList = new ArrayList<>();
addItem(myList);
System.out.println(myList); // [new] ← modified!
// But reassigning the reference changes nothing
void replace(List<String> list) {
list = new ArrayList<>(); // changes the local copy, not the original
}
replace(myList);
System.out.println(myList); // [new] ← still the old list
Memory diagram
Call: addItem(myList)
main() addItem()
┌──────┐ ┌──────┐
│myList │──┐ ┌────│ list │ ← copy of the reference
└──────┘ │ │ └──────┘
↓ ↓
┌──────────────┐
│ ArrayList │ ← both point to the SAME object
│ ["new"] │
└──────────────┘
"Java is always pass-by-value. For primitives, the value is copied. For objects, the reference is copied — so you can modify the object through the copy, but you can't make the original variable point to a different object."
10. Interfaces vs Abstract classes
| Aspect | Interface | Abstract class |
|---|---|---|
| Keyword | implements | extends |
| Multiple inheritance | Yes (multiple interfaces) | No (one class) |
| Constructor | No | Yes |
| Instance fields | No (only static final) | Yes |
| Concrete methods | Yes (since Java 8, default) | Yes |
| When to use | Contract / capability ("can do") | Common base ("is a") |
// Interface = contract (what the object CAN do)
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
class Duck implements Flyable, Swimmable {
public void fly() { ... }
public void swim() { ... }
}
// Abstract class = common base (what the object IS)
abstract class Animal {
protected String name;
Animal(String name) { this.name = name; }
abstract void makeSound();
void breathe() { System.out.println(name + " breathes"); }
}
class Dog extends Animal {
Dog(String name) { super(name); }
void makeSound() { System.out.println("Woof!"); }
}
"I use interfaces to define contracts (what an object can do) and abstract classes to share common behavior (what an object is). Since Java 8, interfaces can have default methods, which blurred the line — but I still prefer interfaces for flexibility and multiple inheritance."
Quick interview summary
| Question | 1-sentence answer |
|---|---|
== vs .equals() | == compares references, .equals() compares content |
hashCode() | Digital fingerprint of an object, used by HashMap/HashSet |
| equals/hashCode contract | If a.equals(b) then a.hashCode() == b.hashCode() |
final | Non-reassignable reference (not object immutability) |
static | Belongs to the class, not the instance |
| Immutable String | Each modification creates a new object |
Optional | Container that can be empty, avoids null checks |
| Pass by value | Java copies the value (or the reference for objects) |
| Interface vs Abstract | Interface = contract, Abstract = common base |