Buenas Prácticas de Inmutabilidad en Java
Por qué la inmutabilidad es un buen valor predeterminado en Java y patrones para construir tipos inmutables de forma segura.
Un objeto inmutable es aquel cuyo estado no puede cambiar después de su construcción. Esa única propiedad elimina toda una clase de errores: no hay mutaciones sorpresa desde otro hilo, no hay sorpresas de aliasing donde dos variables comparten estado, y no es necesario hacer copias defensivas en cada lectura. En Java, la inmutabilidad no es automática — hay que construirla deliberadamente. Este capítulo cubre los patrones que hacen que un tipo sea verdaderamente inmutable y los hábitos que lo mantienen así.
Por qué la inmutabilidad es un buen valor predeterminado
El estado mutable compartido es la raíz de la mayoría de los errores de concurrencia y de un número sorprendente de errores en un solo hilo también. Cuando un objeto no puede cambiar, puedes pasarlo libremente, almacenarlo en caché y razonar sobre él sin rastrear quién más tiene una referencia.
| Propiedad | Objeto mutable | Objeto inmutable |
|---|---|---|
| Seguridad en hilos | Necesita bloqueos o cuidado | Inherentemente seguro para hilos |
| Seguro para compartir | No — los llamadores pueden mutar | Sí — entrega la misma instancia |
| Seguro como clave de mapa | Arriesgado — hashCode puede variar | Sí — la identidad es estable |
| Almacenamiento en caché | Debe invalidarse al cambiar | Cachear indefinidamente |
| Razonamiento | Rastrear cada escritura | El valor se fija en la construcción |
El costo es la asignación de memoria: cambiar un campo significa crear un nuevo objeto. Para la mayoría del código ese costo es insignificante y la seguridad lo vale. Recurre a la mutabilidad solo cuando el perfilado demuestre que la necesitas.
Las cinco reglas para una clase inmutable
Una clase es inmutable cuando se cumplen todas las condiciones siguientes. Si falta una, un llamador puede acceder y cambiar el estado.
- La clase es
final(o todos los constructores son privados) para que no pueda ser extendida con comportamiento mutable. - Todos los campos son
private final. - No hay setters — ni ningún otro método que cambie un campo.
- Los campos mutables se copian defensivamente al entrar para que la referencia del llamador no pueda usarse para mutar tu estado.
- Los getters nunca exponen directamente un objeto interno mutable — devuelven una copia o una vista no modificable.
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
public long cents() { return cents; }
public String currency() { return currency; }
// "Change" returns a new object instead of mutating this one.
public Money plus(Money other) {
return new Money(this.cents + other.cents, currency);
}
}Copias defensivas para campos mutables
Los primitivos y String ya son inmutables, por lo que almacenarlos es seguro. El peligro son los campos mutables — arrays, colecciones, fechas. Si almacenas directamente la referencia del llamador, este conserva un acceso a tus internos.
public final class Schedule {
private final List<String> slots;
public Schedule(List<String> slots) {
// Copy IN: the caller can't mutate our list later.
this.slots = List.copyOf(slots);
}
public List<String> slots() {
// copyOf already returns an unmodifiable list, so this is safe to hand out.
return slots;
}
}List.copyOf, Set.copyOf y Map.copyOf (Java 10+) realizan ambas tareas a la vez: copian los datos y devuelven una vista no modificable. Para arrays, usa array.clone() al entrar y clone() de nuevo al salir, ya que los arrays siempre son mutables y no tienen un envoltorio de solo lectura.
Records: inmutabilidad por construcción
Un record (Java 16+) es la forma más concisa de declarar un portador inmutable de datos. El compilador genera campos private final, un constructor canónico, accesores y equals/hashCode/toString basados en valor.
public record Point(int x, int y) {
// Compact constructor for validation and defensive copying.
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("coordinates must be non-negative");
}
}
}Los records cubren el caso común perfectamente, pero no son un escudo mágico: si un componente de un record es un tipo mutable (como List), aún debes copiarlo defensivamente en el constructor compacto, porque el accesor generado devuelve la referencia almacenada tal cual.
Producir copias modificadas: el patrón "wither"
Como no puedes mutar un objeto inmutable, creas una copia modificada. La convención es un método withX que devuelve una nueva instancia con un campo cambiado y el resto conservado.
public final class User {
private final String name;
private final String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public User withEmail(String newEmail) {
return new User(this.name, newEmail); // new object, original untouched
}
}Esto mantiene el original seguro para compartir mientras permite a los llamadores construir variaciones. Es el mismo modelo que el JDK usa internamente — LocalDate.plusDays, String.replace y BigDecimal.add todos devuelven nuevas instancias en lugar de mutar el receptor.
Un ejemplo ejecutable completo
El programa a continuación construye una pequeña Account inmutable, luego intenta todos los trucos que un llamador podría usar para mutarla — pasar una lista y mutar el original, mutar la lista devuelta, y renombrar. Demuestra que cada defensa se mantiene, y luego muestra por qué los valores inmutables son claves de mapa seguras.
Lo que se puede extraer de la ejecución:
Roles after mutating source: [read, write]demuestra que la copia defensiva funcionó — añadiradmina la lista original nunca llegó a laAccount.- La
UnsupportedOperationExceptionenacc.roles().add("hacker")muestra que el getter devolvió una vista no modificable, por lo que los llamadores no pueden mutar los internos a través de ella. Original name still: Adajunto aNew object name: Gracedemuestra quewithNameprodujo una copia y dejó el original intacto.Different instance: trueconfirma que el wither devolvió un objeto genuinamente nuevo en lugar de la misma referencia.Records equal by value: trueyKey still found: origin-ishmuestran que los valores inmutables se comparan y calculan el hash por contenido, lo que los convierte en clavesHashMapconfiables.