Fundamentos de Java: tipos, strings y == vs equals

Java para principiantes en OpenJDK 26: overflow de int, double vs BigDecimal, == vs equals y la caché de Integer (127 vs 128), con errores reales de javac.

Serie: curso de Java 1/6 (principiante). Para quien ya sabe escribir un bucle en algún lenguaje y ahora quiere escribir Java que compile a la primera y no le mienta. Al terminar vas a saber qué comparaciones, tipos numéricos y operaciones con strings son seguras, y vas a reconocer los errores del compilador que frenan a la mayoría de los principiantes.

Si estás en tu primer trabajo en una empresa que mantiene un backend en Spring, o llevas la materia de programación orientada a objetos en la universidad, la mayoría de tus primeros bugs en Java no van a ser de lógica. Van a ser un número que dio la vuelta en silencio, un == que funcionó en un test y falló en producción, o un error del compilador cuyo texto no te dice qué cambiar. Este curso cae a propósito en cada uno de ellos, en OpenJDK 26 (Temurin 26+35) sobre una Mac con Apple Silicon (M1 Pro), y muestra la salida real.

Cada fragmento es un solo archivo que puedes ejecutar con java FileName.java, sin herramienta de build y sin IDE.

Ejecutar Java sin herramienta de build: java Hello.java y JShell

Desde Java 11 puedes ejecutar directamente un único archivo fuente. El lanzador lo compila en memoria y lo ejecuta; no se escribe ningún archivo .class en disco.

public class HelloClassic {
    public static void main(String[] args) {
        System.out.println("Hello, " + (args.length > 0 ? args[0] : "world"));
    }
}
$ java HelloClassic.java Duke
Hello, Duke

En Java 25 y posteriores, un archivo puede incluso omitir la declaración de clase (un "compact source file"), y existe IO.println pensado para principiantes:

void main() {
    IO.println("Hello, Java " + Runtime.version().feature());
}
$ java Hello.java
Hello, Java 26
$ javac --release 21 Hello.java
Hello.java:1: error: implicitly declared classes are not supported in -source 21
void main() {
^
  (use -source 25 or higher to enable implicitly declared classes)

También pusimos App.java y Greeter.java en una misma carpeta; java App.java encontró Greeter automáticamente (lanzamiento de varios archivos fuente, Java 22+). El resto de esta serie usa archivos clásicos con public class para que el código también funcione en Java 21.

Para experimentos de una línea, usa JShell, el REPL que viene con el JDK. Es la forma más rápida de comprobar qué hace de verdad una expresión:

jshell> int x = Integer.MAX_VALUE + 1
x ==> -2147483648
jshell> "a" + 1 + 2
$5 ==> "a12"
jshell> 1 + 2 + "a"
$6 ==> "3a"

Las dos últimas líneas son una pregunta clásica de examen: + se evalúa de izquierda a derecha, y en cuanto uno de los lados es un String, se convierte en concatenación.

Primitivos vs referencias, y números que mienten en silencio

Java tiene ocho tipos primitivos (byte short int long float double char boolean). Una variable primitiva guarda el valor en sí. Cualquier otro tipo (String, Integer, arrays, tus clases) es un tipo de referencia: la variable guarda una referencia a un objeto en el heap. Esa sola distinción explica la mayor parte de este curso.

La aritmética con primitivos no comprueba el overflow. Este programa muestra cuatro trampas silenciosas:

import java.math.BigDecimal;

public class Basics {
    public static void main(String[] args) {
        int max = Integer.MAX_VALUE;
        System.out.println("max     = " + max);
        System.out.println("max + 1 = " + (max + 1));
        long bad  = 1_000_000 * 3_000;          // int * int, then widened
        long good = 1_000_000L * 3_000;
        System.out.println("bad  = " + bad + ", good = " + good);
        try { Math.addExact(max, 1); }
        catch (ArithmeticException e) { System.out.println("addExact: " + e.getMessage()); }

        System.out.println("7 / 2   = " + (7 / 2));
        System.out.println("-7 / 2  = " + (-7 / 2) + ", -7 % 2 = " + (-7 % 2));
        System.out.println("7 / 2.0 = " + (7 / 2.0));
        System.out.println("0.1 + 0.2 = " + (0.1 + 0.2));
        double sum = 0;
        for (int i = 0; i < 10; i++) sum += 0.1;
        System.out.println("0.1 x10 = " + sum + "  == 1.0? " + (sum == 1.0));
        System.out.println("new BigDecimal(0.1)   = " + new BigDecimal(0.1));
        System.out.println("BigDecimal(\"0.1\")+\"0.2\" = "
            + new BigDecimal("0.1").add(new BigDecimal("0.2")));
        System.out.println("1.10 equals 1.1? " + new BigDecimal("1.10").equals(new BigDecimal("1.1"))
            + ", compareTo = " + new BigDecimal("1.10").compareTo(new BigDecimal("1.1")));
        System.out.println("1.0 / 0 = " + (1.0 / 0) + ", 0.0/0 = " + (0.0 / 0));
        try { System.out.println(1 / 0); }
        catch (ArithmeticException e) { System.out.println("1 / 0 -> " + e); }
    }
}
max     = 2147483647
max + 1 = -2147483648
bad  = -1294967296, good = 3000000000
addExact: integer overflow
7 / 2   = 3
-7 / 2  = -3, -7 % 2 = -1
7 / 2.0 = 3.5
0.1 + 0.2 = 0.30000000000000004
0.1 x10 = 0.9999999999999999  == 1.0? false
new BigDecimal(0.1)   = 0.1000000000000000055511151231257827021181583404541015625
BigDecimal("0.1")+"0.2" = 0.3
1.10 equals 1.1? false, compareTo = 0
1.0 / 0 = Infinity, 0.0/0 = NaN
1 / 0 -> java.lang.ArithmeticException: / by zero

Lo que te tienes que llevar de aquí:

  • long bad = 1_000_000 * 3_000 es la línea más traicionera. El lado derecho se calcula en int y desborda antes de ampliarse a long. Asignarlo a un long no ayuda; haz que uno de los operandos sea long (1_000_000L). javac -Xlint:all no dio ninguna advertencia.
  • Math.addExact, multiplyExact y compañía lanzan una excepción en lugar de dar la vuelta. Úsalos donde un overflow sería un bug (contadores, tamaños, dinero en unidades mínimas).
  • La división entera trunca hacia cero, así que -7 / 2 da -3, y % conserva el signo del lado izquierdo.
  • El 1 / 0 entero lanza una excepción, pero el 1.0 / 0 de punto flotante da Infinity. Aquí javac sí advirtió: warning: [divzero] division by zero.
  • double no puede representar 0.1 de forma exacta. Para dinero, construye BigDecimal desde un String, nunca desde un double, y compara con compareTo, porque equals también compara la escala (1.10 tiene escala 2, 1.1 tiene escala 1).

Los casts también truncan. En la misma ejecución, (int) 2.7 dio 2, (int) -2.7 dio -2, Math.round(2.5) dio 3 pero Math.round(-2.5) dio -2, y (int) 5_000_000_000L dio 705032704.

== vs equals: strings y la caché de Integer

Con los tipos de referencia, == pregunta "¿es el mismo objeto?" y equals pregunta "¿representan el mismo valor?". Con strings y números encapsulados, la respuesta a la primera pregunta depende de detalles que no controlas, y por eso el código que usa == puede pasar un test y aun así estar mal.

public class Equality {
    public static void main(String[] args) {
        String a = "hello";
        String b = "hello";
        String c = new String("hello");
        String d = "hel" + "lo";                 // compile-time constant
        String part = "hel";
        String e = part + "lo";                  // built at run time
        System.out.println("a == b      : " + (a == b));
        System.out.println("a == c      : " + (a == c));
        System.out.println("a == d      : " + (a == d));
        System.out.println("a == e      : " + (a == e));
        System.out.println("a == e.intern(): " + (a == e.intern()));
        System.out.println("a.equals(e) : " + a.equals(e));

        Integer i1 = 127, i2 = 127;
        Integer j1 = 128, j2 = 128;
        System.out.println("127 == 127 : " + (i1 == i2));
        System.out.println("128 == 128 : " + (j1 == j2));
        System.out.println("128 equals : " + j1.equals(j2));
        Long big = 128L;
        System.out.println("Long(128).equals(128) : " + big.equals(128));
    }
}
a == b      : true
a == c      : false
a == d      : true
a == e      : false
a == e.intern(): true
a.equals(e) : true
127 == 127 : true
128 == 128 : false
128 equals : true
Long(128).equals(128) : false

Los literales de string van a un pool, y el compilador pliega "hel" + "lo" en ese mismo literal, así que a == b y a == d dan true. Un string construido en tiempo de ejecución (desde la entrada del usuario, un archivo, un JSON) es un objeto nuevo. Ahí es exactamente donde == se rompe en programas reales: los tests usan literales, producción usa entradas.

Integer i1 = 127 se compila a Integer.valueOf(127), que devuelve un objeto en caché para los valores de -128 a 127. Por eso 127 parece funcionar y 128 no. Y empeora: el límite superior es configurable. Ejecutar la misma clase con java -XX:AutoBoxCacheMax=1000 imprimió 128 == 128 : true. El código que depende de == puede comportarse distinto según los flags de la JVM.

La última línea es otra trampa: big.equals(128) encapsula 128 como Integer, y Long.equals devuelve false para cualquier cosa que no sea un Long. Ni error de compilación ni advertencia.

Diagrama de decisión. Inicio: comparar dos valores. Pregunta 1: ¿ambos son primitivos, como int o double? Si sí, usa a == b. Si no, pregunta 2: ¿es un BigDecimal? Si sí, usa a.compareTo(b) == 0, porque equals también compara la escala. Si no, usa Objects.equals(a, b), que llama a equals y es seguro cuando alguno de los lados es null.
Qué comparación usar en Java. == compara valores solo con primitivos; con objetos compara referencias.
Comparas Usa Por qué
int, long, double, char, boolean == los primitivos guardan el valor
String, Integer, Long, tus objetos a.equals(b) o Objects.equals(a, b) == es identidad; Objects.equals además maneja null
BigDecimal a.compareTo(b) == 0 equals también compara la escala
resultados double de una operación Math.abs(a - b) < 1e-9 error de redondeo, como vimos arriba
enums == cada constante es un único objeto

Los strings son inmutables: constrúyelos con StringBuilder

Un objeto String nunca cambia. Métodos como toUpperCase, trim y replace devuelven un string nuevo. Ignorar el valor de retorno es un error típico de principiante:

String s = "java";
s.toUpperCase();
System.out.println(s);        // java
s = s.toUpperCase();
System.out.println(s);        // JAVA

La inmutabilidad es lo que hace que los strings se puedan compartir sin riesgo y usar como claves de un HashMap. Su costo aparece cuando construyes un string pedazo a pedazo: s += x dentro de un bucle copia el string completo cada vez, así que el trabajo crece con el cuadrado de la longitud. Lo medimos:

public class Concat {
    static String plus(int n) {
        String s = "";
        for (int i = 0; i < n; i++) s += i % 10;
        return s;
    }
    static String builder(int n) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) sb.append(i % 10);
        return sb.toString();
    }
    static double timeMs(java.util.function.IntFunction<String> f, int n) {
        long t0 = System.nanoTime();
        String r = f.apply(n);
        long t1 = System.nanoTime();
        if (r.length() != n) throw new AssertionError();
        return (t1 - t0) / 1e6;
    }
    public static void main(String[] args) {
        for (int w = 0; w < 5; w++) { plus(5_000); builder(5_000); }   // JIT warm-up
        for (int n : new int[]{10_000, 50_000, 100_000, 200_000}) {
            double best1 = Double.MAX_VALUE, best2 = Double.MAX_VALUE;
            for (int rep = 0; rep < 5; rep++) {
                best1 = Math.min(best1, timeMs(Concat::plus, n));
                best2 = Math.min(best2, timeMs(Concat::builder, n));
            }
            System.out.printf("n=%,7d  +=: %8.1f ms   StringBuilder: %6.2f ms%n", n, best1, best2);
        }
    }
}
n= 10,000  +=:      2.4 ms   StringBuilder:   0.14 ms
n= 50,000  +=:     64.6 ms   StringBuilder:   0.15 ms
n=100,000  +=:    248.7 ms   StringBuilder:   0.32 ms
n=200,000  +=:    924.2 ms   StringBuilder:   0.56 ms

Compilado con javac, ejecutado con java -cp, mejor de 5 tras un breve calentamiento del JIT, OpenJDK 26 en una M1 Pro. Tres ejecuciones separadas quedaron a menos de un 5% entre sí (la fila de 200,000 fue de 924 a 976 ms). Duplicar n multiplica por cuatro, más o menos, el tiempo de +=; StringBuilder se mantiene lineal.

Gráfico de barras en milisegundos. += con n = 10,000: 2.4. n = 50,000: 64.6. n = 100,000: 248.7. n = 200,000: 924.2. StringBuilder con n = 200,000: 0.56. Duplicar n multiplicó por cuatro, más o menos, el tiempo de +=.
Construir un string de n dígitos con += en un bucle vs StringBuilder. OpenJDK 26, Mac con Apple Silicon (M1 Pro), mejor de 5 ejecuciones tras calentar el JIT. += crece más o menos con n²; StringBuilder se quedó por debajo de 1 ms.

No necesitas StringBuilder en todas partes. "Hello, " + name + "!" en una sola línea está bien; el compilador optimiza una expresión única. La regla es: concatenación dentro de un bucle que se repite muchas veces → StringBuilder (o String.join / Collectors.joining cuando ya tienes una colección).

var, text blocks y switch expressions

Tres funciones más recientes cambian el aspecto del código de principiante. Compilamos cada una con --release para comprobar en qué versión pasó a ser estándar.

var list = new ArrayList<String>();      // inferred: ArrayList<String>
var ratio = 10 / 4;                      // inferred: int, value 2

String json = """
    {
      "name": "t.laboratory",
      "path": "C:\\temp"
    }
    """;

static String kind(int day) {
    return switch (day) {
        case 6, 7 -> "weekend";
        case 1, 2, 3, 4, 5 -> {
            String s = "week";
            yield s + "day";
        }
        default -> throw new IllegalArgumentException("day " + day);
    };
}
  • var sigue siendo tipado estático: el tipo queda fijo al compilar. var ratio = 10 / 4 es un int con valor 2, no 2.5. var a;, var b = null; y var c = {1, 2}; son todos errores (cannot infer type for local variable a, con el motivo entre paréntesis).
  • En un text block, la indentación del """ de cierre decide cuánto espacio inicial se elimina, y los escapes siguen funcionando (\\ se imprimió como una sola barra invertida).
  • Un switch con flecha nunca "cae" al siguiente caso. La forma antigua con dos puntos sí: con day = 1, nuestro switch al estilo viejo imprimió Monday, Tuesday y other. javac -Xlint:fallthrough lo advierte: warning: [fallthrough] possible fall-through into case.
  • Una switch expression debe cubrir todos los valores. Sin default sobre un int, javac dijo: error: the switch expression does not cover all possible input values.

Errores comunes: los mensajes de javac que de verdad ves al empezar

Estos están copiados de javac 26. Ten en cuenta que javac reporta los errores por fases: primero los de tipos, luego los de flujo, como "missing return". Si corriges el primer grupo y aparece un grupo nuevo, es por eso, no porque hayas roto algo.

int count = "42";                 // 1
long big = 5_000_000_000L;
int small = big;                  // 2
double avg = 2.5;
int rounded = avg;                // 3
String s = null;
if (s = null) { }                 // 4
Mistakes.java:11: error: incompatible types: String cannot be converted to int
Mistakes.java:13: error: incompatible types: possible lossy conversion from long to int
Mistakes.java:15: error: incompatible types: possible lossy conversion from double to int
Mistakes.java:17: error: incompatible types: String cannot be converted to boolean
Error Qué pasó en realidad Solución
String cannot be converted to int Java nunca convierte texto a números de forma implícita Integer.parseInt("42") (lanza NumberFormatException si la entrada es inválida)
possible lossy conversion from long to int estrechar el tipo podría perder bits déjalo como long, o usa Math.toIntExact(big) para que lance una excepción si desborda; un cast (int) simple dio 705032704 sin avisar
possible lossy conversion from double to int se perdería la parte decimal decide: (int) trunca, Math.round redondea
String cannot be converted to boolean = asigna, == compara if (s == null)

Los errores de flujo:

static int sign(int x) {
    if (x > 0) return 1;
    else if (x < 0) return -1;
}                                       // x == 0 returns nothing
static int after() {
    return 0;
    System.out.println("never");
}
public static void main(String[] args) {
    int total;
    if (args.length > 0) total = 1;
    System.out.println(total);
}
Flow.java:5: error: missing return statement
Flow.java:8: error: unreachable statement
Flow.java:17: error: variable total might not have been initialized

"Missing return" señala la llave de cierre porque es por ahí donde la ejecución puede salir sin un valor. La solución es un return 0; final o una rama else, no return null. Una sentencia después de un while (true) { } sin break también es "unreachable".

NullPointerException: lee el mensaje útil

Desde Java 14 (activado por defecto desde la 15), una NPE te dice qué expresión era null. Esta viene del auto-unboxing de un valor que falta en un Map, un bug que ni siquiera tiene un . en la línea:

Map<String, Integer> stock = new HashMap<>();
stock.put("apple", 3);
int n = stock.get("banana");
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
    at Npe.main(Npe.java:7)

Con una cadena de records, u.address().city().length() imprimió Cannot invoke "Modern$Address.city()" because the return value of "Modern$User.address()" is null, así que sabes qué eslabón se rompió.

Una sorpresa: para una variable local, tanto java Npe2.java como un build con javac a secas imprimieron because "<local1>" is null. Solo después de compilar con javac -g imprimió because "name" is null. Las herramientas de build suelen pasar -g, así que verás nombres en builds con Maven o Gradle, pero no al compilar a mano.

Límites honestos y notas de versión

  • Todo lo anterior se compiló y ejecutó en OpenJDK 26 (Temurin 26+35), Mac con Apple Silicon (M1 Pro). Los tiempos son de una sola computadora, el mejor de N tras calentamiento, no un benchmark con JMH; léelos como proporciones.
  • Afirmaciones de versión que comprobamos con javac --release N: var falla en 9 y funciona en 10; las switch expressions fallan en 13 y funcionan en 14; los text blocks fallan en 14 y funcionan en 15; los compact source files (void main()) fallan en 24 y funcionan desde 25. --release 8 todavía funciona en JDK 26, pero imprime source value 8 is obsolete and will be removed in a future release.
  • No ejecutamos el lanzamiento de un solo archivo de Java 11 ni los mensajes de NPE de Java 14 en esos JDK antiguos; solo los ejecutamos en 26. El lanzamiento de varios archivos fuente (Java 22) se ejecutó solo en 26.
  • -XX:AutoBoxCacheMax es una opción de HotSpot; otras JVM pueden no tenerla.

Siguiente: Curso de Java 2/6: clases, records e interfaces (en inglés). El mismo nivel en otros lenguajes: curso de Python 1 y curso de VB.NET 1. ¿Vienes de C#? Compara con variables y tipos de datos en C# (en inglés).

Preguntas frecuentes

¿Por qué en Java Integer 127 == 127 da true pero 128 == 128 da false?

El autoboxing pasa por Integer.valueOf, que devuelve objetos en caché para los valores de -128 a 127. Dos 127 encapsulados son el mismo objeto, dos 128 encapsulados son dos objetos distintos, y == entre objetos compara identidad. El límite superior de la caché incluso se puede subir con -XX:AutoBoxCacheMax, y en nuestra prueba eso hizo que 128 == 128 imprimiera true. Compara los números encapsulados con equals (o desencapsúlalos).

¿Uso double o BigDecimal para manejar dinero en Java?

Ni double ni new BigDecimal(0.1). Sumar 0.1 diez veces en double dio 0.9999999999999999, y new BigDecimal(0.1) imprimió 0.1000000000000000055511151231257827021181583404541015625. Usa BigDecimal construido desde un String (new BigDecimal("0.1")) o guarda unidades mínimas enteras (centavos) en un long. Compara valores BigDecimal con compareTo, porque 1.10 y 1.1 no son equals().

¿Por qué mi NullPointerException dice <local1> en lugar del nombre de la variable?

Los mensajes útiles de NPE solo nombran variables locales si la clase se compiló con información de depuración (javac -g). Sin eso, OpenJDK 26 imprimió: Cannot invoke "String.length()" because "<local1>" is null. Con -g imprimió because "name" is null. Los campos, los valores de retorno de métodos y los elementos de arrays se nombran en ambos casos.

  • #Java
  • #principiante
  • #OpenJDK 26
  • #String
  • #equals
  • #BigDecimal
  • #javac

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑