Capítulo 6: Trampas Fundamentales de Java
Este capítulo cubre las preguntas puras de Java más frecuentes en entrevistas. Son las bases que todo desarrollador Java debe dominar. Un entrevistador las plantea para verificar que usted comprende cómo funciona Java en memoria.
1. == vs .equals() — LA pregunta más frecuente
La regla de oro
| Operador | ¿Qué compara? | Usar para |
|---|---|---|
== | Las direcciones de memoria (referencias) | Tipos primitivos (int, boolean, char...) |
.equals() | El contenido (valores) | Objetos (String, Integer, List...) |
Ejemplos con String
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true ← misma referencia (String Pool)
System.out.println(a == c); // false ← referencias diferentes (new crea un nuevo objeto)
System.out.println(a.equals(c)); // true ← mismo contenido
¿Por qué a == b es true?
Java mantiene un String Pool (caché) en memoria. Cuando usted escribe "hello" directamente en el código, Java reutiliza el mismo objeto:
Memoria:
┌──────────────────────────────────┐
│ String Pool │
│ ┌─────────┐ │
│ │ "hello" │ ← a y b apuntan aquí
│ └─────────┘ │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ Heap (montón) │
│ ┌─────────┐ │
│ │ "hello" │ ← c apunta aquí (new = siempre un nuevo objeto)
│ └─────────┘ │
└──────────────────────────────────┘
Ejemplos con Integer (¡trampa favorita!)
Integer x = 127;
Integer y = 127;
System.out.println(x == y); // true ← caché Integer [-128, 127]
Integer a = 128;
Integer b = 128;
System.out.println(a == b); // false ← fuera del caché
System.out.println(a.equals(b)); // true ← mismo valor
Java almacena en caché los Integer de -128 a 127. Más allá de ese rango, == devuelve false aunque los valores sean idénticos. Por eso hay que usar siempre .equals() para comparar objetos.
La buena práctica: "CONSTANT".equals(variable)
String status = getStatus(); // puede retornar null
// PELIGROSO — NullPointerException si status es null
if (status.equals("ACTIVE")) { ... }
// SEGURO — nunca lanza NPE
if ("ACTIVE".equals(status)) { ... }
"== compara referencias (direcciones de memoria), .equals() compara contenido. Siempre uso .equals() para objetos. Para strings, pongo la constante a la izquierda para evitar NullPointerException."
2. hashCode() — ¿Qué es y por qué es importante?
Definición simple
hashCode() devuelve un número entero que representa un objeto. Es como una huella digital: rápido de comparar, pero no 100% único.
El contrato hashCode/equals
Si dos objetos son .equals(), DEBEN tener el mismo hashCode().
Si usted redefine .equals(), debe también redefinir hashCode().
// MALO — redefine equals sin 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 hay hashCode() → BUG con HashMap, HashSet
}
// BUENO — redefine ambos
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);
}
}
¿Por qué es crítico? ¡HashMap y HashSet dependen de esto!
User user1 = new User("alice@mail.com");
User user2 = new User("alice@mail.com");
// Sin hashCode correcto:
Set<User> set = new HashSet<>();
set.add(user1);
set.add(user2);
System.out.println(set.size()); // 2 ← ¡MALO! Debería ser 1
// Con hashCode correcto:
set.add(user1);
set.add(user2);
System.out.println(set.size()); // 1 ← CORRECTO
Cómo HashMap usa hashCode
hashCode("alice@mail.com") → 12345
HashMap interno:
┌──────┬──────────────────┐
│ slot │ contenido │
├──────┼──────────────────┤
│ ... │ │
│ 12345│ → User("alice") │ ← hashCode determina el slot
│ ... │ │
└──────┴──────────────────┘
1. hashCode() → encuentra el slot correcto (rápido, O(1))
2. equals() → verifica que es el mismo objeto
Con Lombok (lo más sencillo)
@Data // genera equals(), hashCode(), toString(), getters, setters
public class User {
private String email;
private String name;
}
// O para controlar qué campos usar:
@EqualsAndHashCode(of = {"email"})
public class User {
private String email;
private String name;
}
Con Java Records (lo más moderno)
public record User(String email, String name) {}
// equals(), hashCode(), toString() se generan automáticamente
"hashCode es como una huella digital para los objetos. HashMap lo usa para encontrar el bucket correcto en O(1). El contrato dice: si dos objetos son iguales, deben tener el mismo hashCode. Por eso siempre sobrescribo ambos juntos — o uso Lombok @Data o Java Records."
3. toString() — Para depuración
Sin toString
User user = new User("Alice", "alice@mail.com");
System.out.println(user);
// → com.example.User@1a2b3c4d ← ¡inútil!
Con 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'} ← ¡útil!
Con Lombok o Record
@Data // genera toString() automáticamente
public class User { ... }
// O
public record User(String name, String email) {}
// toString() → User[name=Alice, email=alice@mail.com]
4. Tipos primitivos vs Clases Wrapper
| Primitivo | Wrapper | Tamaño | Valor por defecto |
|---|---|---|---|
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' |
¿Cuándo usar cuál?
| Situación | Usar | Por qué |
|---|---|---|
| Variables locales, cálculos | int, double | Más rápido, sin boxing |
| Atributos de entidades JPA | Long, Integer | Puede ser null (importante para id aún no persistido) |
Colecciones (List, Map) | Integer, Long | Las colecciones no aceptan primitivos |
| Parámetros opcionales | Integer | Puede ser null para indicar "no proporcionado" |
Autoboxing / Unboxing
// Autoboxing : int → Integer (automático)
Integer a = 42;
// Unboxing : Integer → int (automático)
int b = a;
// TRAMPA : unboxing de null → NullPointerException
Integer c = null;
int d = c; // 💥 NullPointerException
Integer a = null;
if (a == 0) { ... } // 💥 NullPointerException (unboxing de null)
Siempre verifique null antes de comparar un Wrapper con un primitivo.
"Uso primitivos para variables locales y cálculos, y wrappers para IDs de entidades y colecciones. La trampa clave es hacer unboxing de null, lo que lanza NullPointerException."
5. final — Tres usos diferentes
| Contexto | Significado | Ejemplo |
|---|---|---|
| Variable | La referencia no puede cambiar | final List<String> list = new ArrayList<>() |
| Método | No puede ser sobrescrito (override) | public final void process() |
| Clase | No puede ser heredada | public final class String |
Trampa: final NO hace inmutable el objeto
final List<String> list = new ArrayList<>();
list.add("hello"); // ✅ OK — se modifica el CONTENIDO
list = new ArrayList<>(); // ❌ Error — se intenta cambiar la REFERENCIA
Memoria:
┌─────────────────┐
list (final) ──→ │ ArrayList │
(referencia │ ["hello"] │ ← el contenido puede cambiar
no cambia) └─────────────────┘
"final significa que la referencia no puede ser reasignada, pero el objeto en sí puede ser mutado. Para inmutabilidad real, uso List.of() o Collections.unmodifiableList()."
6. static — Pertenece a la clase, no a la instancia
public class Counter {
static int totalCount = 0; // compartido entre TODAS las instancias
int instanceCount = 0; // propio de cada instancia
public Counter() {
totalCount++;
instanceCount++;
}
}
Counter c1 = new Counter();
Counter c2 = new Counter();
Counter c3 = new Counter();
System.out.println(Counter.totalCount); // 3 (compartido)
System.out.println(c3.instanceCount); // 1 (propio de c3)
static en la práctica
| Uso | Ejemplo | ¿Buena práctica? |
|---|---|---|
| Constantes | static final int MAX = 100 | Sí |
| Métodos utilitarios | Math.max(), Collections.sort() | Sí |
| Variables compartidas | static int count | Precaución (thread safety) |
| Clases internas | static class Builder | Sí (Builder pattern) |
Evite variables static mutables en los beans de Spring. Los beans ya son singleton por defecto — use campos de instancia normales.
7. String — Inmutable por diseño
Los String son inmutables
String s = "hello";
s.toUpperCase();
System.out.println(s); // "hello" ← ¡NO modificado!
s = s.toUpperCase();
System.out.println(s); // "HELLO" ← nueva String asignada
Cada modificación crea un nuevo objeto String.
Concatenación en bucle: StringBuilder
// MALO — crea N objetos String intermedios → O(n²)
String result = "";
for (int i = 0; i < 10000; i++) {
result += i + ",";
}
// BUENO — un solo objeto, modificaciones en el lugar → O(n)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append(",");
}
String result = sb.toString();
String Pool (caché)
String a = "hello"; // va al String Pool
String b = "hello"; // reutiliza el mismo objeto del Pool
String c = new String("hello"); // crea un NUEVO objeto en el Heap
System.out.println(a == b); // true (misma referencia en el Pool)
System.out.println(a == c); // false (c está en el Heap, no en el Pool)
Comparación de Strings
| Método | Uso |
|---|---|
.equals() | Comparación sensible a mayúsculas/minúsculas |
.equalsIgnoreCase() | Comparación insensible a mayúsculas/minúsculas |
.compareTo() | Orden alfabético (devuelve int) |
.contains() | Verificar si contiene una subcadena |
.startsWith() / .endsWith() | Verificar inicio/fin |
.isBlank() | Vacío o solo espacios (Java 11+) |
.isEmpty() | Vacío (longitud 0) |
"Los Strings son inmutables en Java. Cada modificación crea un nuevo objeto. Para concatenación en bucles, uso StringBuilder. Para comparación, siempre uso .equals(), nunca ==."
8. Optional — Evitar los NullPointerException
El problema
// NullPointerException si findById retorna null
User user = userRepository.findById(1L);
String email = user.getEmail(); // 💥 NPE si user es null
La solución: Optional
Optional<User> optional = userRepository.findById(1L);
// Enfoque 1: isPresent
if (optional.isPresent()) {
User user = optional.get();
}
// Enfoque 2: orElse (valor por defecto)
User user = optional.orElse(new User("anonymous"));
// Enfoque 3: orElseThrow (excepción)
User user = optional.orElseThrow(() ->
new ResourceNotFoundException("User not found with id: 1"));
// Enfoque 4: map (transformar)
String email = optional.map(User::getEmail).orElse("N/A");
Los anti-patrones a evitar
// ❌ MALO — equivale a un null check
if (optional.isPresent()) {
return optional.get();
} else {
return null;
}
// ✅ BUENO
return optional.orElse(null);
// ❌ MALO — Optional como parámetro
public void process(Optional<String> name) { ... }
// ✅ BUENO — Optional solo como retorno
public Optional<User> findByEmail(String email) { ... }
"Optional representa un valor que puede o no estar presente. Lo uso como tipo de retorno, nunca como parámetro. Prefiero orElseThrow() para valores obligatorios y map() para transformaciones."
9. Paso por valor vs paso por referencia
Java pasa TODO por valor
// Primitivos: el VALOR se copia
void increment(int x) {
x++; // modifica la copia local
}
int a = 5;
increment(a);
System.out.println(a); // 5 ← no modificado
// Objetos: la REFERENCIA se copia (no el objeto)
void addItem(List<String> list) {
list.add("new"); // modifica el objeto apuntado por la copia de referencia
}
List<String> myList = new ArrayList<>();
addItem(myList);
System.out.println(myList); // [new] ← ¡modificado!
// Pero reasignar la referencia no cambia nada
void replace(List<String> list) {
list = new ArrayList<>(); // cambia la copia local, no el original
}
replace(myList);
System.out.println(myList); // [new] ← sigue siendo la lista original
Esquema de memoria
Llamada: addItem(myList)
main() addItem()
┌──────┐ ┌──────┐
│myList │──┐ ┌────│ list │ ← copia de la referencia
└──────┘ │ │ └──────┘
↓ ↓
┌──────────────┐
│ ArrayList │ ← ambos apuntan al MISMO objeto
│ ["new"] │
└──────────────┘
"Java siempre es pass-by-value. Para primitivos, se copia el valor. Para objetos, se copia la referencia — así que se puede modificar el objeto a través de la copia, pero no se puede hacer que la variable original apunte a otro objeto."
10. Interfaces vs Clases abstractas
| Aspecto | Interface | Clase abstracta |
|---|---|---|
| Palabra clave | implements | extends |
| Herencia múltiple | Sí (varias interfaces) | No (una sola clase) |
| Constructor | No | Sí |
| Campos de instancia | No (solo static final) | Sí |
| Métodos concretos | Sí (desde Java 8, default) | Sí |
| Cuándo usar | Contrato / capacidad ("puede hacer") | Base común ("es un") |
// Interface = contrato (lo que el objeto PUEDE hacer)
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
class Duck implements Flyable, Swimmable {
public void fly() { ... }
public void swim() { ... }
}
// Clase abstracta = base común (lo que el objeto ES)
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!"); }
}
"Uso interfaces para definir contratos (lo que un objeto puede hacer) y clases abstractas para compartir comportamiento común (lo que un objeto es). Desde Java 8, las interfaces pueden tener métodos default, lo que difumina la línea — pero sigo prefiriendo interfaces por flexibilidad y herencia múltiple."
Resumen rápido para la entrevista
| Pregunta | Respuesta en 1 frase |
|---|---|
== vs .equals() | == compara referencias, .equals() compara contenido |
hashCode() | Huella digital numérica de un objeto, usada por HashMap/HashSet |
| Contrato equals/hashCode | Si a.equals(b) entonces a.hashCode() == b.hashCode() |
final | Referencia no reasignable (no inmutabilidad del objeto) |
static | Pertenece a la clase, no a la instancia |
| String inmutable | Cada modificación crea un nuevo objeto |
Optional | Contenedor que puede estar vacío, evita null checks |
| Paso por valor | Java copia el valor (o la referencia para objetos) |
| Interface vs Abstracta | Interface = contrato, Abstracta = base común |