Blog

Values and References in Java: Primitives, Objects and the Integer == Trap

Every Java variable holds either a primitive value or a reference to an object. Knowing which one explains silent overflow, pass-by-value, == versus equals, and why two Integers holding 128 aren’t equal.

A Java variable holds one of two kinds of thing. A variable of a primitive type, such as int or double, holds the value itself. A variable of any other type holds a reference to an object that lives somewhere else. Most of the surprises new Java programmers hit come from mixing the two up.

This post covers the eight primitive types, overflow and arithmetic, conversions, var, references, pass-by-value, == versus equals, boxing and the Integer == trap, and what final really fixes. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.

Java’s eight primitive types

Java has exactly eight primitive types: six for numbers, one for characters and one for true or false. Their sizes are fixed by the language, so an int is 32 bits on every machine. You don’t have to memorise the ranges, because each type’s wrapper class carries them as constants:

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);
}

It prints:

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

Most code uses int for whole numbers, long when two billion isn’t enough, double for measurements and boolean for conditions. A char is a 16-bit number, which is why its range prints as 0 to 65535.

Writing number literals

Number literals can hold underscores, a suffix for their type, and a prefix for hex or binary. The underscores are only for your eyes, and the compiler ignores them:

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);
}

It prints:

1000000 8100000000 1.5 19.99
255 10 A

0xFF is hex for 255, and 0b1010 is binary for 10. The suffixes are the part to learn. A whole-number literal is an int unless it ends in L, and a decimal literal is a double unless it ends in f. Leave off the L on a number too big for an int and the program doesn’t compile:

void main() {
    long worldPopulation = 8100000000;
    IO.println(worldPopulation);
}

The build fails with:

Main.java:2: error: integer number too large
    long worldPopulation = 8100000000;
                           ^

The variable is a long, but that doesn’t help. The compiler reads the literal first, as an int, and it doesn’t fit. Use a capital L, because a lower-case l looks like the digit 1.

Fields get default values, local variables don’t

A field in a class starts with a default value: zero for numbers, false for boolean, and null for references. A local variable inside a method gets no default at all.

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);
}

It prints:

0 0.0 false null

Try the same with a local variable, and the compiler refuses to let you read it before you’ve assigned it:

void main() {
    int count;
    IO.println(count);
}

The build fails with:

Main.java:3: error: variable count might not have been initialized
    IO.println(count);
               ^

Java checks every path through the method before it runs. If any path can reach the read without an assignment, the build fails.

Integer overflow wraps around silently

When an int calculation goes past the largest int, Java doesn’t stop or warn you. The value wraps around to the most negative int and carries on from there.

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);
}

It prints:

-2147483648
-2
-2147483648
2147483648

The second pair is the trap worth remembering. wrong is a long, which has plenty of room, yet it still holds a negative number. The right-hand side is worked out first, as int arithmetic, and it has already wrapped before the result is widened to long. Making one operand a long, with 1L, makes the whole sum long arithmetic.

When overflow would be a bug, such as for money or counts, ask for an exception instead. Math.addExact, Math.multiplyExact and friends throw when the result doesn’t fit:

void main() {
    int stock = 2_000_000_000;
    IO.println("before: " + stock);
    stock = Math.addExact(stock, 500_000_000);
    IO.println("after: " + stock);
}

It prints, then stops:

before: 2000000000
Exception in thread "main" java.lang.ArithmeticException: integer overflow

A crash with a clear message beats a stock count of minus 1.8 billion.

Division, remainder and floating point

Dividing two integers gives an integer, and anything after the decimal point is thrown away. It doesn’t round. It truncates towards zero, which matters as soon as a number is negative.

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);
}

It prints:

3
-3
3.5
-1
1
2
0.30000000000000004
Infinity
NaN

Line by line:

  • 7 / 2 is 3, and -7 / 2 is -3, not -4. Division truncates towards zero.
  • 7 / 2.0 is 3.5, because one operand is a double, so the division is too.
  • % takes the sign of the left operand. -7 % 3 is -1. If you’re using % to wrap an index or pick a day of the week, a negative input gives a negative answer. Math.floorMod always gives a result with the sign of the divisor, which is usually what you wanted.
  • 0.1 + 0.2 isn’t 0.3. A double stores binary fractions, and 0.1 has no exact binary form, just as 1/3 has no exact decimal form. For money, use long cents or BigDecimal.
  • Floating-point division by zero doesn’t throw. It gives Infinity or NaN. Integer division by zero does throw an ArithmeticException.

Converting between types

Java converts a smaller type to a bigger one for you, which is called widening. Going the other way, called narrowing, needs a cast, and the cast can lose data without any 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));
}

It prints:

42 42.0
-1294967296
-56
3
-3
2147483647
1.6777216E7
16777216
66
B

The casts from long and int keep only the low bits. That’s why 3 billion becomes a negative number and 200 becomes -56 as a byte. A cast from double to int behaves differently: it drops the fraction, and a value that’s too large sticks at Integer.MAX_VALUE instead of wrapping.

The float lines surprised us. Assigning an int to a float needs no cast, because Java counts it as widening. Yet 16,777,217 came back as 16,777,216. A float has only 24 bits for the digits of a number, so above about 16 million it can’t hold every whole number. The same is true of long to double above about 9 quadrillion. Widening never loses the size of a number, but it can lose its last digits.

The last two lines show that a char is a number. 'A' + 1 is the int 66, the code for A plus one, and casting it back to char gives B.

Here’s one more thing we noticed while testing. short s = 1; s += 70000; compiles, because a compound assignment like += includes a hidden cast. With javac -Xlint:all, though, Java 25 warns implicit cast from int to short in compound assignment is possibly lossy. That check is off unless you ask for it, and it’s worth turning on.

var: the compiler writes the type for you

var declares a local variable and lets the compiler work out its type from the value you assign. It arrived in 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);
}

It prints:

10 19.99 Ana [90]

count is an int, price is a double, name is a String and scores is an ArrayList<Integer>. Those types are fixed at compile time, exactly as if you had written them out. var isn’t dynamic typing. Try to store a string in count later:

void main() {
    var count = 10;
    count = "ten";
    IO.println(count);
}

The build fails with:

Main.java:3: error: incompatible types: String cannot be converted to int
    count = "ten";
            ^

var also needs a value to look at. var total; fails with cannot use 'var' on variable without initializer, and var nothing = null; fails because null doesn’t say which type you meant.

Use var when the type is already on the same line, as in var scores = new ArrayList<Integer>(). Skip it when the type is the useful part. var total = calculate(order); tells the reader nothing about whether total is an int, a long or a BigDecimal, and for money that difference matters.

A variable of a class type holds a reference

A variable whose type is a class doesn’t hold the object. It holds a reference, which is the information Java needs to find that object. Assigning one variable to another copies the reference, so both variables lead to the same object.

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);
}

It prints:

ints:   1 5
points: Point(5, 2) Point(5, 2)
points: Point(5, 2) Point(9, 9)

The two halves look alike and behave differently. With the ints, second = first copied the number 1, so changing second left first alone. With the points, var b = a copied the reference. There was still only one Point, so b.x = 5 changed the object that a leads to as well.

The last assignment is different again. b = new Point(9, 9) doesn’t change any object. It makes b refer to a new one, and a still refers to the first.

Explain it like I’m ten

Picture a row of school lockers. A variable holding a primitive is a box with the number inside it. If you copy the box, you get a second box with the same number, and scribbling on one doesn’t touch the other.

A variable holding a reference is a slip of paper with a locker number written on it. The toy is in the locker, not on the paper. When you copy the slip, you now have two slips saying “locker 14”. If your friend uses their slip to open locker 14 and paints the toy red, you’ll find a red toy when you open locker 14 with yours.

Now your friend rubs out their slip and writes “locker 30”. That doesn’t move the toy or change your slip. Yours still says 14.

The precise version

Java’s values come in two kinds. A primitive value is the number, character or boolean itself. A reference value points to an object on the heap, or is null. A variable of a primitive type holds a primitive value. A variable of any class, interface, record, enum or array type holds a reference value.

Assignment always copies the value in the variable. For a primitive, that’s the number. For a reference, that’s the reference, so afterwards two variables refer to the same object. Using . on a reference, as in b.x = 5, follows it to the object and works on the object. Assigning to the variable itself, as in b = new Point(9, 9), replaces the reference in b and touches no object.

Where the analogy breaks: a locker number is something you can read and do sums with. A Java reference isn’t. You can’t print it, add to it or choose which object it refers to, and the garbage collector is free to move the object around the heap without your references changing. And when no slip mentions a locker any more, the garbage collector empties it.

Watching two variables share one object

The animation steps through the same Point code, showing both variables as arrows to the heap:

variables heap a ref Point x = 1 x = 5 y = 2 b ref Point x = 9 y = 9 a.x is 1 a.x is 1, b.x is 1 a.x is 5, b.x is 5 a.x is 5, b.x is 9 var a = new Point(1, 2): a holds a reference to it var b = a: the reference is copied, not the object b.x = 5: follow b's arrow and change the object a.x is 5 too: a points at the same object b = new Point(9, 9): only b's arrow moves a still points at the first object, with x = 5

Two variables, one object. var b = a copies the reference, so b.x = 5 changes the object a points at too. b = new Point(9, 9) moves only b’s arrow, and a keeps pointing at the first object.

Here are those steps in words, in case the animation doesn’t play for you:

  1. var a = new Point(1, 2) creates one Point object on the heap, and a holds a reference to it. a.x is 1.
  2. var b = a copies the reference in a into b. No new object is made, so both arrows lead to the same Point.
  3. b.x = 5 follows b‘s reference to that object and sets its x to 5.
  4. a.x is now 5 as well. Nothing happened to a itself: it leads to the same object, and the object changed.
  5. b = new Point(9, 9) creates a second object and puts a reference to it in b. Only b‘s arrow moves.
  6. a still refers to the first object, which still has x equal to 5.

Java always passes arguments by value

When you call a method, Java copies each argument’s value into the method’s parameter. For an object, the value it copies is the reference. That one rule explains why some changes inside a method show up in the caller and others don’t.

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);
}

It prints:

inside reassign: Point(0, 0)
after reassign:  Point(1, 2)
inside mutate:   Point(100, 2)
after mutate:    Point(100, 2)

In both calls, p starts as a copy of the reference in point. In reassign, p = new Point(0, 0) replaces that copy. The caller’s point never hears about it. In mutate, p.x = 100 follows the copy to the one shared object and changes it, so the caller sees the change.

People sometimes say “Java passes objects by reference”. It doesn’t. If it did, reassign would have changed the caller’s variable. A method can change an object you hand it, but it can never make your variable refer to a different object.

== compares references, equals compares contents

On two primitives, == compares values. On two references, == asks whether they refer to the very same object, and equals asks whether two objects count as equal.

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)));
}

It prints:

false
true
false
false
false
true

The two strings hold the same letters, but they’re two separate objects, so == is false. equals compares the characters and says true. Always compare strings with equals. Two literals with the same text can share one object, so == sometimes seems to work, which is worse than always failing.

Money shows what happens when a class doesn’t define equals. It inherits the one from Object, which does nothing more than ==. Price overrides equals to compare cents, so two prices of 500 are equal. It overrides hashCode too, because the two must agree. The part on equals, hashCode and collections explains why. Records write both methods for you.

Boxing and the Integer == trap

Every primitive type has a wrapper class that holds one value as an object: Integer for int, Long for long, Double for double, and so on. Java converts between them automatically. Turning an int into an Integer is called boxing, and the reverse is unboxing. Collections need the wrappers, because a List<int> isn’t allowed.

Because an Integer is an object, == on two of them compares references. That produces the most famous surprise in 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));
}

It prints:

127 == 127: true
128 == 128: false
128 equals: true
Integer == int: true
Long 127: true, Long 128: false

Same code, one number apart, and a different answer. Boxing calls Integer.valueOf, and valueOf keeps a cache of Integer objects for small values. Box 127 twice and you get the same cached object, so == is true. Box 128 twice and you get two new objects, so == is false.

The Java Language Specification requires the cache to cover -128 to 127 for int, short and byte boxes, and for char up to 127. Long uses the same range in practice, as the last line shows. An implementation is allowed to cache more. On HotSpot, you can raise the Integer limit with a JVM flag, and then 128 compares true:

java -XX:AutoBoxCacheMax=1000 Main.java

That’s why the bug is hard to find. Tests with small numbers pass, then real IDs go past 127.

The Integer == int line is true because one side is a primitive. Java unboxes the Integer and compares numbers. The rule for wrappers is simple: compare them with equals, or unbox them to primitives first.

Unboxing a null throws

An Integer variable can hold null, and an int can’t. When Java unboxes a null to get an int, there’s no number to get, so it throws NullPointerException. The code that throws doesn’t even look like it touches an object:

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);
}

It prints, then stops:

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 returns null for a missing key. The line int pears = lookup("pears") has a hidden call to intValue(), and that’s the call the message names. Modern JVMs print a helpful message like this one by default, and here it tells you exactly what happened. If you meant “zero when missing”, say so with stock.getOrDefault(item, 0).

final fixes the reference, not the object

A final variable can be assigned only once. When the variable holds a reference, that means it always refers to the same object. It doesn’t stop anyone changing that object.

void main() {
    final List<String> names = new ArrayList<>();
    names.add("Ana");
    names.add("Ben");
    names.remove("Ana");
    IO.println(names);
}

It prints:

[Ben]

The list changed three times through a final variable, and the compiler had no objection. What final forbids is pointing the variable somewhere else:

void main() {
    final List<String> names = new ArrayList<>();
    names.add("Ana");
    names = new ArrayList<>();
    IO.println(names);
}

The build fails with:

Main.java:4: error: cannot assign a value to final variable names
    names = new ArrayList<>();
    ^

If you want a list nobody can change, you need an unmodifiable object, not a final variable. List.of("Ana", "Ben") builds one, and calling add on it throws UnsupportedOperationException.

What to remember

  • Java has eight primitive types with fixed sizes. Read their limits from constants such as Integer.MAX_VALUE rather than from memory.
  • int arithmetic wraps around silently on overflow, even when you store the result in a long. Use Math.addExact and friends when overflow would be a bug.
  • Integer division truncates towards zero, % takes the sign of the left operand, and double can’t hold 0.1 exactly.
  • var infers a fixed type at compile time. It’s not dynamic typing.
  • A variable of a class type holds a reference. Assigning or passing it copies the reference, so changes to the object are visible through every copy, but reassigning a copy changes nothing else.
  • Compare objects, including String and Integer, with equals. Integer caches -128 to 127, so == works in tests and fails in production.
  • Unboxing a null throws NullPointerException, and final stops reassignment, not mutation.

A primitive variable holds a value, and every other variable holds a reference to an object.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.