Un objeto es inmutable cuando su estado no puede cambiar después de crearlo. String, Integer y LocalDate lo son. Parece una limitación, pero es lo que permite compartirlos entre hilos sin miedo o usarlos como claves de un mapa. Vemos qué significa en la práctica y cómo hacer los tuyos.
También te puede interesar
¿Se puede cambiar un objeto inmutable?
No, y por eso los métodos que parecen modificarlo en realidad devuelven otro objeto:
String s = "hola"; String t = s.toUpperCase(); IO.println(s + " / " + t);
salidahola / HOLA
s sigue siendo hola. toUpperCase no tocó la cadena: creó una nueva. Lo mismo hacen replace, trim o plusDays de LocalDate. Si llamas al método y no guardas lo que devuelve, no ha pasado nada.
final no es lo mismo que inmutable
final List<String> lista = new ArrayList<>();
lista.add("se puede");
IO.println("final pero mutable: " + lista);
List<String> fija = List.of("a", "b");
try {
fija.add("c");
} catch (UnsupportedOperationException e) {
IO.println("List.of: " + e.getClass().getSimpleName());
}
var original = new ArrayList<>(List.of("x"));
var vista = Collections.unmodifiableList(original);
var copia = List.copyOf(original);
original.add("y");
IO.println("vista: " + vista + " copia: " + copia);java Inmutable.javahola / HOLA final pero mutable: [se puede] List.of: UnsupportedOperationException vista: [x, y] copia: [x]
Tres cosas distintas que se confunden a menudo:
| ¿Qué impide? | ¿Inmutable? | |
|---|---|---|
final List | Que la variable apunte a otra lista. La lista en sí se puede cambiar. | No |
Collections.unmodifiableList(x) | Que la cambies a través de la vista. Si alguien cambia x, la vista lo refleja (sale [x, y]). | No |
List.of(…) / List.copyOf(x) | Cualquier cambio. copyOf hace una copia propia y no le afecta lo que pase después con x. | Sí |
Cómo crear una clase inmutable
Desde Java 16, con un record: todos sus campos son final, no tiene métodos que los cambien, y además trae equals, hashCode y toString. Para «modificarlo» se devuelve uno nuevo:
record Punto(int x, int y) {
Punto mover(int dx, int dy) { return new Punto(x + dx, y + dy); }
}
void main() {
var p = new Punto(1, 2);
var q = p.mover(3, 0);
IO.println(p + " -> " + q);
var claves = new java.util.HashMap<Punto, String>();
claves.put(new Punto(0, 0), "origen");
IO.println(claves.get(new Punto(0, 0)));
}java Punto.javaPunto[x=1, y=2] -> Punto[x=4, y=2] origen
Si el record tiene campos que a su vez son mutables, como una lista, hay que copiarlos en el constructor con List.copyOf. Si no, quien te la pasó puede seguir cambiándola. Lo vemos con un ejemplo en las buenas prácticas de Java, en el punto 3.
Por qué importa: las claves de un mapa
De las ventajas que se suelen enumerar, esta es la que más se nota. Un HashMap coloca cada clave según su hashCode en el momento de guardarla. Si la clave cambia después, queda en un sitio que ya no le corresponde:
var mapa = new HashMap<Punto, String>(); var p = new Punto(0, 0); mapa.put(p, "origen"); p.x = 5;
java MapaMutable.java — con un Punto mutableget(new Punto(0,0)): null get(new Punto(5,0)): null tamaño: 1, containsKey(p): false
La entrada sigue en el mapa (el tamaño es 1), pero no se puede encontrar ni con la clave vieja ni con la nueva, ni siquiera con el mismo objeto p. Con el record de arriba esto no puede pasar, y por eso new Punto(0, 0) encuentra «origen» sin problema.
Las otras ventajas
| Ventaja | Por qué |
|---|---|
| Se comparten entre hilos sin sincronizar | Nadie puede cambiarlos, así que ningún hilo lee un estado a medias |
| No hace falta copiarlos | Se pueden devolver y pasar sin copias defensivas |
| Siempre están en un estado válido | Se comprueban una vez, en el constructor, y ya no cambian |
| Son fáciles de probar y de razonar | El mismo objeto da siempre los mismos resultados |
El coste es crear un objeto nuevo en cada «cambio». Para objetos pequeños es despreciable; si vas a hacer miles de cambios seguidos sobre algo grande, como un texto que se va construyendo, para eso existen las versiones mutables, como StringBuilder.
Entrada publicada en 2012 y revisada en octubre de 2026. Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio; las salidas están copiadas de esa ejecución.
