Toda variable de Java guarda un valor primitivo o una referencia a un objeto. Saber cuál explica el desbordamiento silencioso, el paso por valor, == frente a equals y por qué dos Integer con 128 no son iguales.
Una variable de Java guarda una de dos clases de cosa. Una variable de tipo primitivo, como int o double, guarda el valor mismo. Una variable de cualquier otro tipo guarda una referencia a un objeto que vive en otro lugar. La mayoría de las sorpresas que encuentran quienes empiezan con Java vienen de confundir las dos.
Este post cubre los ocho tipos primitivos, el desbordamiento y la aritmética, las conversiones, var, las referencias, el paso por valor, == frente a equals, el boxing y la trampa del Integer ==, y lo que final fija de verdad. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.
Los ocho tipos primitivos de Java
Java tiene exactamente ocho tipos primitivos: seis para números, uno para caracteres y uno para verdadero o falso. El lenguaje fija sus tamaños, así que un int tiene 32 bits en cualquier máquina. No hace falta memorizar los rangos, porque la clase envoltorio (wrapper) de cada tipo los trae como constantes:
void main() {
IO.println("byte " + Byte.MIN_VALUE + " to " + Byte.MAX_VALUE);
IO.println("short " + Short.MIN_VALUE + " to " + Short.MAX_VALUE);
IO.println("int " + Integer.MIN_VALUE + " to " + Integer.MAX_VALUE);
IO.println("long " + Long.MIN_VALUE + " to " + Long.MAX_VALUE);
IO.println("float largest " + Float.MAX_VALUE);
IO.println("double largest " + Double.MAX_VALUE);
IO.println("char " + (int) Character.MIN_VALUE + " to " + (int) Character.MAX_VALUE);
IO.println("bits " + Byte.SIZE + " " + Short.SIZE + " " + Integer.SIZE + " " + Long.SIZE
+ " " + Float.SIZE + " " + Double.SIZE + " " + Character.SIZE);
boolean done = true;
IO.println("boolean " + done + " or " + !done);
}
Imprime:
byte -128 to 127
short -32768 to 32767
int -2147483648 to 2147483647
long -9223372036854775808 to 9223372036854775807
float largest 3.4028235E38
double largest 1.7976931348623157E308
char 0 to 65535
bits 8 16 32 64 32 64 16
boolean true or false
La mayor parte del código usa int para números enteros, long cuando dos mil millones no alcanzan, double para medidas y boolean para condiciones. Un char es un número de 16 bits, y por eso su rango se imprime como 0 a 65535.
Cómo escribir literales numéricos
Los literales numéricos pueden llevar guiones bajos, un sufijo para su tipo y un prefijo para hexadecimal o binario. Los guiones bajos son solo para tus ojos, y el compilador los ignora:
void main() {
int million = 1_000_000;
long worldPopulation = 8_100_000_000L;
float ratio = 1.5f;
double price = 19.99;
int mask = 0xFF;
int flags = 0b1010;
char grade = 'A';
IO.println(million + " " + worldPopulation + " " + ratio + " " + price);
IO.println(mask + " " + flags + " " + grade);
}
Imprime:
1000000 8100000000 1.5 19.99
255 10 A
0xFF es 255 en hexadecimal, y 0b1010 es 10 en binario. Los sufijos son lo que hay que aprender. Un literal entero es un int a menos que termine en L, y un literal decimal es un double a menos que termine en f. Si omites la L en un número demasiado grande para un int, el programa no compila:
void main() {
long worldPopulation = 8100000000;
IO.println(worldPopulation);
}
La compilación falla con:
Main.java:2: error: integer number too large
long worldPopulation = 8100000000;
^
La variable es un long, pero eso no ayuda. El compilador lee primero el literal, como int, y no cabe. Usa una L mayúscula, porque una l minúscula se parece al dígito 1.
Los campos reciben valores por defecto, las variables locales no
Un campo de una clase empieza con un valor por defecto: cero para los números, false para boolean y null para las referencias. Una variable local dentro de un método no recibe ningún valor por defecto.
class Account {
int balance;
double rate;
boolean frozen;
String owner;
}
void main() {
var account = new Account();
IO.println(account.balance + " " + account.rate + " " + account.frozen + " " + account.owner);
}
Imprime:
0 0.0 false null
Prueba lo mismo con una variable local, y el compilador no te deja leerla antes de asignarla:
void main() {
int count;
IO.println(count);
}
La compilación falla con:
Main.java:3: error: variable count might not have been initialized
IO.println(count);
^
Java revisa cada camino del método antes de ejecutarlo. Si algún camino puede llegar a la lectura sin una asignación, la compilación falla.
El desbordamiento de enteros da la vuelta en silencio
Cuando un cálculo con int pasa del int más grande, Java no se detiene ni te avisa. El valor da la vuelta al int más negativo y sigue desde ahí.
void main() {
int max = Integer.MAX_VALUE;
IO.println(max + 1);
IO.println(max * 2);
long wrong = Integer.MAX_VALUE + 1;
long right = Integer.MAX_VALUE + 1L;
IO.println(wrong);
IO.println(right);
}
Imprime:
-2147483648
-2
-2147483648
2147483648
El segundo par es la trampa que vale la pena recordar. wrong es un long, que tiene espacio de sobra, y aun así guarda un número negativo. El lado derecho se calcula primero, con aritmética de int, y ya dio la vuelta antes de que el resultado se amplíe a long. Si haces que un operando sea long, con 1L, toda la suma pasa a ser aritmética de long.
Cuando un desbordamiento sería un bug, como con dinero o conteos, pide una excepción. Math.addExact, Math.multiplyExact y sus parientes lanzan una excepción cuando el resultado no cabe:
void main() {
int stock = 2_000_000_000;
IO.println("before: " + stock);
stock = Math.addExact(stock, 500_000_000);
IO.println("after: " + stock);
}
Imprime y se detiene:
before: 2000000000
Exception in thread "main" java.lang.ArithmeticException: integer overflow
Un fallo con un mensaje claro es mejor que un inventario de menos 1,800 millones.
División, resto y punto flotante
Dividir dos enteros da un entero, y todo lo que va después del punto decimal se descarta. No redondea. Trunca hacia cero, y eso importa en cuanto un número es negativo.
void main() {
IO.println(7 / 2);
IO.println(-7 / 2);
IO.println(7 / 2.0);
IO.println(-7 % 3);
IO.println(7 % -3);
IO.println(Math.floorMod(-7, 3));
IO.println(0.1 + 0.2);
IO.println(1.0 / 0);
IO.println(0.0 / 0);
}
Imprime:
3
-3
3.5
-1
1
2
0.30000000000000004
Infinity
NaN
Línea por línea:
7 / 2es 3, y-7 / 2es -3, no -4. La división trunca hacia cero.7 / 2.0es 3.5, porque un operando esdouble, así que la división también lo es.%toma el signo del operando izquierdo.-7 % 3es -1. Si usas%para dar la vuelta a un índice o elegir un día de la semana, una entrada negativa da una respuesta negativa.Math.floorModsiempre da un resultado con el signo del divisor, que suele ser lo que querías.0.1 + 0.2no es 0.3. Undoubleguarda fracciones binarias, y 0.1 no tiene una forma binaria exacta, igual que 1/3 no tiene una forma decimal exacta. Para dinero, usa centavos enlongoBigDecimal.- La división por cero en punto flotante no lanza excepción. Da
InfinityoNaN. La división entera por cero sí lanza unaArithmeticException.
Conversiones entre tipos
Java convierte un tipo más chico en uno más grande por ti, y eso se llama ampliación (widening). Ir en el otro sentido, lo que se llama estrechamiento (narrowing), necesita un cast, y el cast puede perder datos sin ningún error.
void main() {
int small = 42;
long wide = small;
double wider = wide;
IO.println(wide + " " + wider);
long tooBig = 3_000_000_000L;
IO.println((int) tooBig);
IO.println((byte) 200);
IO.println((int) 3.99);
IO.println((int) -3.99);
IO.println((int) 1e20);
int exact = 16_777_217;
float approx = exact;
IO.println(approx);
IO.println((int) approx);
char letter = 'A';
IO.println(letter + 1);
IO.println((char) (letter + 1));
}
Imprime:
42 42.0
-1294967296
-56
3
-3
2147483647
1.6777216E7
16777216
66
B
Los casts desde long e int conservan solo los bits bajos. Por eso 3 mil millones se vuelve un número negativo y 200 se vuelve -56 como byte. Un cast de double a int se comporta distinto: descarta la fracción, y un valor demasiado grande se queda en Integer.MAX_VALUE en lugar de dar la vuelta.
Las líneas de float nos sorprendieron. Asignar un int a un float no necesita cast, porque Java lo cuenta como ampliación. Aun así, 16,777,217 volvió como 16,777,216. Un float tiene solo 24 bits para los dígitos de un número, así que por encima de unos 16 millones no puede guardar todos los enteros. Lo mismo pasa de long a double por encima de unos 9 mil billones. La ampliación nunca pierde el tamaño de un número, pero puede perder sus últimos dígitos.
Las dos últimas líneas muestran que un char es un número. 'A' + 1 es el int 66, el código de A más uno, y convertirlo de vuelta a char da B.
Una cosa más que notamos al probar. short s = 1; s += 70000; compila, porque una asignación compuesta como += incluye un cast oculto. Con javac -Xlint:all, eso sí, Java 25 avisa implicit cast from int to short in compound assignment is possibly lossy. Esa revisión está apagada a menos que la pidas, y vale la pena activarla.
var: el compilador escribe el tipo por ti
var declara una variable local y deja que el compilador deduzca su tipo a partir del valor que asignas. Llegó en Java 10.
void main() {
var count = 10;
var price = 19.99;
var name = "Ana";
var scores = new ArrayList<Integer>();
scores.add(90);
IO.println(count + " " + price + " " + name + " " + scores);
}
Imprime:
10 19.99 Ana [90]
count es un int, price es un double, name es un String y scores es un ArrayList<Integer>. Esos tipos quedan fijos en tiempo de compilación, exactamente como si los hubieras escrito. var no es tipado dinámico. Intenta guardar después un string en count:
void main() {
var count = 10;
count = "ten";
IO.println(count);
}
La compilación falla con:
Main.java:3: error: incompatible types: String cannot be converted to int
count = "ten";
^
var también necesita un valor que mirar. var total; falla con cannot use 'var' on variable without initializer, y var nothing = null; falla porque null no dice qué tipo querías.
Usa var cuando el tipo ya está en la misma línea, como en var scores = new ArrayList<Integer>(). Evítalo cuando el tipo es lo útil. var total = calculate(order); no le dice al lector si total es un int, un long o un BigDecimal, y con dinero esa diferencia importa.
Una variable de tipo clase guarda una referencia
Una variable cuyo tipo es una clase no guarda el objeto. Guarda una referencia, que es la información que Java necesita para encontrar ese objeto. Asignar una variable a otra copia la referencia, así que las dos variables llevan al mismo objeto.
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
void main() {
int first = 1;
int second = first;
second = 5;
IO.println("ints: " + first + " " + second);
var a = new Point(1, 2);
var b = a;
b.x = 5;
IO.println("points: " + a + " " + b);
b = new Point(9, 9);
IO.println("points: " + a + " " + b);
}
Imprime:
ints: 1 5
points: Point(5, 2) Point(5, 2)
points: Point(5, 2) Point(9, 9)
Las dos mitades se parecen y se comportan distinto. Con los int, second = first copió el número 1, así que cambiar second no tocó first. Con los puntos, var b = a copió la referencia. Seguía habiendo un solo Point, así que b.x = 5 también cambió el objeto al que lleva a.
La última asignación es distinta otra vez. b = new Point(9, 9) no cambia ningún objeto. Hace que b se refiera a uno nuevo, y a sigue refiriéndose al primero.
Explicado como si tuvieras diez años
Imagina una fila de casilleros de la escuela. Una variable que guarda un primitivo es una caja con el número adentro. Si copias la caja, tienes una segunda caja con el mismo número, y rayar una no toca la otra.
Una variable que guarda una referencia es un papelito con un número de casillero escrito. El juguete está en el casillero, no en el papel. Cuando copias el papelito, tienes dos papelitos que dicen “casillero 14”. Si tu amigo usa su papelito para abrir el casillero 14 y pinta el juguete de rojo, vas a encontrar un juguete rojo cuando abras el casillero 14 con el tuyo.
Ahora tu amigo borra su papelito y escribe “casillero 30”. Eso no mueve el juguete ni cambia tu papelito. El tuyo sigue diciendo 14.
La versión precisa
Los valores de Java son de dos clases. Un valor primitivo es el número, el carácter o el boolean mismo. Un valor de referencia apunta a un objeto en el heap, o es null. Una variable de tipo primitivo guarda un valor primitivo. Una variable de cualquier tipo clase, interfaz, record, enum o array guarda un valor de referencia.
La asignación siempre copia el valor que hay en la variable. Para un primitivo, es el número. Para una referencia, es la referencia, así que después dos variables se refieren al mismo objeto. Usar . sobre una referencia, como en b.x = 5, la sigue hasta el objeto y trabaja sobre el objeto. Asignar a la variable misma, como en b = new Point(9, 9), reemplaza la referencia en b y no toca ningún objeto.
Dónde falla la analogía: un número de casillero es algo que puedes leer y con lo que puedes hacer cuentas. Una referencia de Java no. No puedes imprimirla, sumarle nada ni elegir a qué objeto se refiere, y el recolector de basura (garbage collector) puede mover el objeto por el heap sin que tus referencias cambien. Y cuando ya ningún papelito menciona un casillero, el recolector de basura lo vacía.
Cómo dos variables comparten un objeto
La animación recorre el mismo código de Point y muestra las dos variables como flechas hacia el heap:
Dos variables, un objeto. var b = a copia la referencia, así que b.x = 5 también cambia el objeto al que apunta a. b = new Point(9, 9) mueve solo la flecha de b, y a sigue apuntando al primer objeto.
Aquí están esos pasos en palabras, por si la animación no se reproduce:
var a = new Point(1, 2)crea un objetoPointen el heap, yaguarda una referencia a él.a.xes 1.var b = acopia la referencia deaenb. No se crea ningún objeto nuevo, así que las dos flechas llevan al mismoPoint.b.x = 5sigue la referencia debhasta ese objeto y pone suxen 5.a.xahora también es 5. Aano le pasó nada: lleva al mismo objeto, y el objeto cambió.b = new Point(9, 9)crea un segundo objeto y pone una referencia a él enb. Solo se mueve la flecha deb.asigue refiriéndose al primer objeto, que sigue teniendoxigual a 5.
Java siempre pasa los argumentos por valor
Cuando llamas a un método, Java copia el valor de cada argumento en el parámetro del método. Para un objeto, el valor que copia es la referencia. Esa única regla explica por qué algunos cambios dentro de un método se ven en quien llama y otros no.
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
void reassign(Point p) {
p = new Point(0, 0);
IO.println("inside reassign: " + p);
}
void mutate(Point p) {
p.x = 100;
IO.println("inside mutate: " + p);
}
void main() {
var point = new Point(1, 2);
reassign(point);
IO.println("after reassign: " + point);
mutate(point);
IO.println("after mutate: " + point);
}
Imprime:
inside reassign: Point(0, 0)
after reassign: Point(1, 2)
inside mutate: Point(100, 2)
after mutate: Point(100, 2)
En las dos llamadas, p empieza como una copia de la referencia que hay en point. En reassign, p = new Point(0, 0) reemplaza esa copia. El point de quien llama nunca se entera. En mutate, p.x = 100 sigue la copia hasta el único objeto compartido y lo cambia, así que quien llama ve el cambio.
A veces se dice que “Java pasa los objetos por referencia”. No es así. Si lo fuera, reassign habría cambiado la variable de quien llama. Un método puede cambiar un objeto que le pasas, pero nunca puede hacer que tu variable se refiera a otro objeto.
== compara referencias, equals compara contenidos
Con dos primitivos, == compara valores. Con dos referencias, == pregunta si se refieren exactamente al mismo objeto, y equals pregunta si dos objetos cuentan como iguales.
class Money {
final long cents;
Money(long cents) {
this.cents = cents;
}
}
class Price {
final long cents;
Price(long cents) {
this.cents = cents;
}
@Override
public boolean equals(Object other) {
return other instanceof Price p && p.cents == cents;
}
@Override
public int hashCode() {
return Long.hashCode(cents);
}
}
void main() {
String typed = "hello";
String built = new StringBuilder("hel").append("lo").toString();
IO.println(typed == built);
IO.println(typed.equals(built));
IO.println(new Money(500) == new Money(500));
IO.println(new Money(500).equals(new Money(500)));
IO.println(new Price(500) == new Price(500));
IO.println(new Price(500).equals(new Price(500)));
}
Imprime:
false
true
false
false
false
true
Los dos strings tienen las mismas letras, pero son dos objetos separados, así que == da false. equals compara los caracteres y dice true. Compara siempre los strings con equals. Dos literales con el mismo texto pueden compartir un objeto, así que a veces == parece funcionar, y eso es peor que fallar siempre.
Money muestra qué pasa cuando una clase no define equals. Hereda el de Object, que no hace nada más que ==. Price sobrescribe equals para comparar cents, así que dos precios de 500 son iguales. También sobrescribe hashCode, porque los dos deben coincidir. La parte sobre equals, hashCode y las colecciones explica por qué. Los records escriben los dos métodos por ti.
Boxing y la trampa del Integer ==
Cada tipo primitivo tiene una clase envoltorio que guarda un valor como objeto: Integer para int, Long para long, Double para double, y así sucesivamente. Java convierte entre ellos automáticamente. Convertir un int en un Integer se llama boxing, y lo contrario es unboxing. Las colecciones necesitan los envoltorios, porque un List<int> no está permitido.
Como un Integer es un objeto, == entre dos de ellos compara referencias. Eso produce la sorpresa más famosa de Java:
void main() {
Integer a = 127, b = 127;
IO.println("127 == 127: " + (a == b));
Integer c = 128, d = 128;
IO.println("128 == 128: " + (c == d));
IO.println("128 equals: " + c.equals(d));
int plain = 128;
IO.println("Integer == int: " + (c == plain));
Long big1 = 127L, big2 = 127L;
Long big3 = 128L, big4 = 128L;
IO.println("Long 127: " + (big1 == big2) + ", Long 128: " + (big3 == big4));
}
Imprime:
127 == 127: true
128 == 128: false
128 equals: true
Integer == int: true
Long 127: true, Long 128: false
El mismo código, números que difieren en uno, y una respuesta distinta. El boxing llama a Integer.valueOf, y valueOf mantiene una caché de objetos Integer para valores chicos. Si haces boxing de 127 dos veces, obtienes el mismo objeto de la caché, así que == da true. Si haces boxing de 128 dos veces, obtienes dos objetos nuevos, así que == da false.
La Java Language Specification exige que la caché cubra de -128 a 127 para los boxes de int, short y byte, y hasta 127 para char. Long usa el mismo rango en la práctica, como muestra la última línea. Una implementación puede guardar más en caché. En HotSpot, puedes subir el límite de Integer con un flag de la JVM, y entonces 128 compara true:
java -XX:AutoBoxCacheMax=1000 Main.java
Por eso el bug es difícil de encontrar. Las pruebas con números chicos pasan, y después los IDs reales pasan de 127.
La línea Integer == int da true porque un lado es un primitivo. Java hace unboxing del Integer y compara números. La regla para los envoltorios es simple: compáralos con equals, o conviértelos primero en primitivos con unboxing.
Hacer unboxing de un null lanza una excepción
Una variable Integer puede guardar null, y un int no. Cuando Java hace unboxing de un null para obtener un int, no hay ningún número que obtener, así que lanza NullPointerException. El código que la lanza ni siquiera parece tocar un objeto:
Map<String, Integer> stock = new TreeMap<>();
Integer lookup(String item) {
return stock.get(item);
}
void main() {
stock.put("apples", 3);
int apples = lookup("apples");
IO.println("apples: " + apples);
int pears = lookup("pears");
IO.println("pears: " + pears);
}
Imprime y se detiene:
apples: 3
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "Main.lookup(String)" is null
Map.get devuelve null para una clave que no existe. La línea int pears = lookup("pears") tiene una llamada oculta a intValue(), y esa es la llamada que nombra el mensaje. Las JVM modernas imprimen por defecto un mensaje útil como este, y aquí te dice exactamente qué pasó. Si lo que querías era “cero cuando falta”, dilo con stock.getOrDefault(item, 0).
final fija la referencia, no el objeto
Una variable final solo se puede asignar una vez. Cuando la variable guarda una referencia, eso significa que siempre se refiere al mismo objeto. No impide que alguien cambie ese objeto.
void main() {
final List<String> names = new ArrayList<>();
names.add("Ana");
names.add("Ben");
names.remove("Ana");
IO.println(names);
}
Imprime:
[Ben]
La lista cambió tres veces a través de una variable final, y el compilador no tuvo ninguna objeción. Lo que final prohíbe es apuntar la variable a otro lado:
void main() {
final List<String> names = new ArrayList<>();
names.add("Ana");
names = new ArrayList<>();
IO.println(names);
}
La compilación falla con:
Main.java:4: error: cannot assign a value to final variable names
names = new ArrayList<>();
^
Si quieres una lista que nadie pueda cambiar, necesitas un objeto inmodificable, no una variable final. List.of("Ana", "Ben") crea uno, y llamar a add sobre él lanza UnsupportedOperationException.
Qué recordar
- Java tiene ocho tipos primitivos de tamaño fijo. Lee sus límites en constantes como
Integer.MAX_VALUEen lugar de sacarlos de memoria. - La aritmética de
intda la vuelta en silencio cuando se desborda, incluso si guardas el resultado en unlong. UsaMath.addExacty sus parientes cuando un desbordamiento sería un bug. - La división entera trunca hacia cero,
%toma el signo del operando izquierdo, ydoubleno puede guardar 0.1 exactamente. vardeduce un tipo fijo en tiempo de compilación. No es tipado dinámico.- Una variable de tipo clase guarda una referencia. Asignarla o pasarla copia la referencia, así que los cambios al objeto se ven a través de cada copia, pero reasignar una copia no cambia nada más.
- Compara los objetos, incluidos
StringeInteger, conequals.Integerguarda en caché de -128 a 127, así que==funciona en las pruebas y falla en producción. - Hacer unboxing de un
nulllanzaNullPointerException, yfinalimpide la reasignación, no la mutación.
Una variable primitiva guarda un valor, y cualquier otra variable guarda una referencia a un objeto.