Rarezas de Java: 14 comportamientos que sorprenden

Las rarezas de Java no son errores del lenguaje: son reglas con su razón, casi siempre histórica, que sorprenden la primera vez. Aquí están las catorce que más problemas dan en código real, cada una con un programa que se compiló y ejecutó con Java 25, la salida copiada de esa ejecución, por qué pasa y cómo evitarlo. Si estás aprendiendo, empieza por el curso de Java; muchas se mencionan allí de pasada.

  1. Dos Integer iguales que no son ==
  2. Dos cadenas iguales que a veces son == y a veces no
  3. ‘a’ + ‘b’ no es «ab»
  4. Sumar dos positivos y obtener un negativo
  5. 0.1 + 0.2 no es 0.3
  6. remove(1) no quita el 1
  7. Una lista a la que no se le puede añadir nada
  8. Borrar dentro de un for-each lanza una excepción
  9. Dos BigDecimal iguales que no son equals
  10. Un return en finally que se come la excepción
  11. List y List son la misma clase
  12. NaN no es igual a sí mismo
  13. Un switch que ejecuta los casos siguientes
  14. Math.abs que devuelve un negativo

1. Dos Integer iguales que no son ==

void main() {
    Integer a = 127, b = 127;
    Integer c = 128, d = 128;
    IO.println("127 == 127: " + (a == b));
    IO.println("128 == 128: " + (c == d));
    IO.println("128 equals 128: " + c.equals(d));
}
salida127 == 127: true
128 == 128: false
128 equals 128: true

Por qué: Integer es un objeto, y == entre objetos compara si son el mismo objeto, no si valen lo mismo. Al convertir un int en Integer (autoboxing), Java usa Integer.valueOf(), que guarda en caché los valores de -128 a 127: para ellos siempre devuelve el mismo objeto, y fuera de ese rango crea uno nuevo. Por eso el mismo código funciona en las pruebas con números chicos y falla en producción con un id de 128. Cómo evitarlo: compara objetos con equals(), o usa int si no necesitas el objeto.

2. Dos cadenas iguales que a veces son == y a veces no

void main() {
    String a = "hola";
    String b = "ho" + "la";            // la suma la hace el compilador
    String parte = "ho";
    String c = parte + "la";           // la suma se hace al ejecutar
    IO.println("a == b: " + (a == b));
    IO.println("a == c: " + (a == c));
    IO.println("a.equals(c): " + a.equals(c));
}
salidaa == b: true
a == c: false
a.equals(c): true

Por qué: los literales de texto y las expresiones que el compilador puede calcular (constantes) se guardan una sola vez en el pool de cadenas, así que a y b son el mismo objeto. parte + "la" se calcula al ejecutar y crea un objeto nuevo con el mismo texto. Cómo evitarlo: nunca compares String con ==; usa equals(), o Objects.equals() si alguno puede ser null.

3. ‘a’ + ‘b’ no es «ab»

void main() {
    IO.println('a' + 'b');
    IO.println("" + 'a' + 'b');
    IO.println('a' + 'b' + "!");
}
salida195
ab
195!

Por qué: un char es un número (su código Unicode: 97 para la «a», 98 para la «b»), y + entre dos números suma. Solo concatena cuando uno de los dos lados ya es un String, y se evalúa de izquierda a derecha: en la tercera línea primero se suman los dos char y después se pega el «!». Cómo evitarlo: empieza por una cadena, o usa String.valueOf() o un StringBuilder.

4. Sumar dos positivos y obtener un negativo

void main() {
    int max = Integer.MAX_VALUE;
    IO.println(max + 1);
    long bien = max + 1L;
    IO.println(bien);
    try {
        Math.addExact(max, 1);
    } catch (ArithmeticException e) {
        IO.println("addExact: " + e.getMessage());
    }
}
salida-2147483648
2147483648
addExact: integer overflow

Por qué: un int tiene 32 bits. Al pasar de su máximo (2,147,483,647) da la vuelta al mínimo, sin error ni aviso. Es el error de los contadores de visitas y de los cálculos de dinero en centavos que un día salen negativos. Cómo evitarlo: usa long cuando el valor pueda crecer, y Math.addExact, multiplyExact y compañía cuando un desbordamiento deba ser un error.

5. 0.1 + 0.2 no es 0.3

import java.math.BigDecimal;

void main() {
    IO.println(0.1 + 0.2);
    IO.println(0.1 + 0.2 == 0.3);
    IO.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
    IO.println(new BigDecimal(0.1));
}
salida0.30000000000000004
false
0.3
0.1000000000000000055511151231257827021181583404541015625

Por qué: double guarda los números en binario, y 0.1 no tiene representación exacta en binario, igual que 1/3 no la tiene en decimal. La última línea enseña el valor real que hay detrás de 0.1. Cómo evitarlo: para dinero, BigDecimal creado desde una cadena (new BigDecimal("0.1")); creado desde un double, hereda el error. Para comparar double, usa una tolerancia: Math.abs(a - b) < 1e-9.

6. remove(1) no quita el 1

import java.util.ArrayList;
import java.util.List;

void main() {
    List<Integer> numeros = new ArrayList<>(List.of(1, 2, 3));
    numeros.remove(1);
    IO.println(numeros);
    numeros.remove(Integer.valueOf(1));
    IO.println(numeros);
}
salida[1, 3]
[3]

Por qué: List tiene dos métodos remove: uno recibe la posición (int) y otro el elemento (Object). Con remove(1) el compilador elige el de la posición, porque 1 ya es un int y no hace falta convertirlo: quita el elemento de la posición 1, que es el 2. Cómo evitarlo: con listas de números, escribe remove(Integer.valueOf(x)) para quitar por valor, o usa removeIf(n -> n == x).

7. Una lista a la que no se le puede añadir nada

import java.util.Arrays;
import java.util.List;

void main() {
    List<String> colores = Arrays.asList("rojo", "verde");
    colores.set(0, "azul");
    IO.println(colores);
    colores.add("negro");
}
salida con error[azul, verde]
Exception in thread "main" java.lang.UnsupportedOperationException

Por qué: Arrays.asList no copia: devuelve una vista de tamaño fijo sobre el array, así que deja cambiar elementos con set pero no añadir ni quitar. List.of() va más lejos: no deja ni siquiera cambiarlos. El tipo de las dos es List, así que el compilador no puede avisar. Cómo evitarlo: si vas a modificar la lista, cópiala: new ArrayList<>(List.of(...)).

8. Borrar dentro de un for-each lanza una excepción

import java.util.ArrayList;
import java.util.List;

void main() {
    List<String> tareas = new ArrayList<>(List.of("lavar", "", "planchar", ""));
    List<String> copia = new ArrayList<>(tareas);
    copia.removeIf(String::isEmpty);
    IO.println("con removeIf: " + copia);
    for (String t : tareas) {
        if (t.isEmpty()) tareas.remove(t);
    }
}
salida con errorcon removeIf: [lavar, planchar]
Exception in thread "main" java.util.ConcurrentModificationException

Por qué: el for-each recorre la lista con un iterador que lleva la cuenta de las modificaciones. Si la lista cambia por fuera del iterador, en la siguiente vuelta lo detecta y lanza ConcurrentModificationException, aunque no haya ningún hilo de por medio. A veces no salta (si lo que borras es el penúltimo elemento, el bucle termina antes de comprobarlo), lo que lo hace todavía más traicionero. Cómo evitarlo: removeIf, o el método remove() del propio Iterator.

9. Dos BigDecimal iguales que no son equals

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;

void main() {
    BigDecimal a = new BigDecimal("2.0");
    BigDecimal b = new BigDecimal("2.00");
    IO.println("equals: " + a.equals(b));
    IO.println("compareTo: " + a.compareTo(b));
    Set<BigDecimal> precios = new HashSet<>(Set.of(a, b));
    IO.println("en un HashSet: " + precios.size() + " elementos");
}
salidaequals: false
compareTo: 0
en un HashSet: 2 elementos

Por qué: un BigDecimal guarda el valor y la escala (cuántos decimales), y equals compara las dos cosas: 2.0 y 2.00 tienen escalas distintas. compareTo compara solo el valor. Por eso un HashSet los guarda como dos precios distintos. Cómo evitarlo: compara con compareTo(...) == 0, o normaliza con stripTrailingZeros() antes de usarlos como clave.

10. Un return en finally que se come la excepción

int calcular() {
    try {
        throw new IllegalStateException("algo salió mal");
    } finally {
        return 42;
    }
}

void main() {
    IO.println("resultado: " + calcular());
}
salidaresultado: 42

Por qué: el bloque finally siempre se ejecuta, y si termina con return, ese return sustituye a lo que estuviera en marcha, también a una excepción. El error desaparece sin dejar rastro. Cómo evitarlo: nunca pongas return, break ni throw en un finally; úsalo solo para liberar recursos, y mejor todavía, usa try con recursos. javac -Xlint:finally lo avisa.

11. List y List son la misma clase

import java.util.ArrayList;
import java.util.List;

void main() {
    List<String> textos = new ArrayList<>();
    List<Integer> numeros = new ArrayList<>();
    IO.println(textos.getClass() == numeros.getClass());
    IO.println(textos.getClass().getName());
}
salidatrue
java.util.ArrayList
import java.util.List;

class Suma {
    int total(List<Integer> xs) { return 0; }
    double total(List<Double> xs) { return 0; }
}
javacBorradoSobrecarga.java:6: error: name clash: total(List<Double>) and total(List<Integer>) have the same erasure
    double total(List<Double> xs) { return 0; }

Por qué: los genéricos de Java existen solo para el compilador. Al compilar se «borran» (type erasure): en el código que se ejecuta, las dos listas son ArrayList a secas. Por eso no se pueden sobrecargar métodos que solo se distinguen por el tipo genérico, ni preguntar instanceof List<String>, ni crear un new T(). Se decidió así en 2004 para que el código nuevo funcionara con las bibliotecas anteriores a los genéricos. Cómo evitarlo: pon nombres distintos a los métodos (totalEnteros, totalDecimales) y, si necesitas el tipo al ejecutar, pásalo como Class<T>.

12. NaN no es igual a sí mismo

void main() {
    double x = 0.0 / 0.0;
    IO.println(x);
    IO.println("x == x: " + (x == x));
    IO.println("Double.isNaN(x): " + Double.isNaN(x));
    IO.println("equals: " + Double.valueOf(x).equals(x));
}
salidaNaN
x == x: false
Double.isNaN(x): true
equals: true

Por qué: la norma de coma flotante IEEE 754 dice que NaN («no es un número») no es igual a nada, ni a sí mismo. Pero Double.equals hace una excepción para que un NaN se pueda buscar en una colección. Y fíjate en que dividir 0.0 / 0.0 no lanza ninguna excepción; solo la división entera entre cero lo hace. Cómo evitarlo: comprueba con Double.isNaN().

13. Un switch que ejecuta los casos siguientes

void main() {
    int dia = 6;
    switch (dia) {
        case 6:
            IO.println("sábado");
        case 7:
            IO.println("domingo");
        default:
            IO.println("entre semana");
    }
    String tipo = switch (dia) {
        case 6, 7 -> "fin de semana";
        default -> "entre semana";
    };
    IO.println("con flechas: " + tipo);
}
salidasábado
domingo
entre semana
con flechas: fin de semana

Por qué: en el switch clásico, heredado de C, cada case es solo una etiqueta: la ejecución entra por el caso que coincide y sigue de largo por los de abajo hasta encontrar un break. Cómo evitarlo: usa la forma con flechas (case 6, 7 ->), de Java 14, que nunca cae al siguiente caso y además puede devolver un valor.

14. Math.abs que devuelve un negativo

void main() {
    IO.println(Math.abs(-5));
    IO.println(Math.abs(Integer.MIN_VALUE));
    IO.println(Math.abs((long) Integer.MIN_VALUE));
}
salida5
-2147483648
2147483648

Por qué: los int van de -2,147,483,648 a 2,147,483,647: hay un negativo más que positivos. El valor absoluto del mínimo sería 2,147,483,648, que no cabe, y se desborda de vuelta a sí mismo. Aparece en código real al repartir datos con Math.abs(hashCode()) % n, que de vez en cuando da un índice negativo. Cómo evitarlo: Math.floorMod(x, n) para repartir, o Math.absExact, que lanza una excepción en vez de mentir.

Cada programa de esta página se compiló y se ejecutó con Java 25 LTS (Eclipse Temurin) en un contenedor limpio; las salidas y los mensajes de error están copiados de esa ejecución. Los programas usan la forma compacta de Java 25 (void main() e IO.println): guárdalos como Nombre.java y ejecútalos con java Nombre.java.