Primitives and wrappers

Eight primitives and how their literals are written, their boxed twins, autoboxing, the Integer cache, and the NullPointerException that hides in an unboxing.

15 min read Java Fundamentals

Java has two kinds of values and pretends, most of the time, that it has one. The pretence is autoboxing, and it is convenient right up to the moment it costs you a NullPointerException in a place that has no null in sight, or a hidden allocation in a loop that runs a million times. This lesson is the eight primitives and their arithmetic rules, what a wrapper is on the heap, the exact bytecode autoboxing inserts, and the numbers that make the difference between int and Integer a matter of gigabytes.

The eight primitives

TypeSizeRange / notes
byte8 bits−128 to 127
short16 bits−32,768 to 32,767
int32 bits±2.1 billion — the default for integers
long64 bits±9.2 quintillion; literal needs L
float32 bits~7 significant digits; literal needs f
double64 bits~15 significant digits — the default for decimals
char16 bitsa UTF-16 code unit, not a "character"
booleanJVM-defined — 1 byte in HotSpottrue / false

Primitives are values. They live in the frame or inline in an object; they have no identity, no methods, and cannot be null. Arithmetic on int and long wraps silently on overflow: Integer.MAX_VALUE + 1 is Integer.MIN_VALUE, with no exception. Use Math.addExact and friends when overflow must be an error, and use long for anything that counts money in minor units, bytes, or milliseconds.

Literals: how a value is written down

A literal is a value written directly in source: 42, 3.5, 'x', true, null. It has a type before it is assigned to anything, and most literal surprises come from that type.

Integer literals are int unless they end in L. That is why long big = 2147483648; does not compile, with integer number too large, even though the variable is a long: the literal is judged as an int first. Write 2147483648L. Use a capital L. A lowercase l is legal and looks like a 1, so 100l reads as one thousand and one.

The same number can be written four ways:

FormPrefixExampleValueWhere you meet it
decimalnone255255almost everywhere
hexadecimal0x0xFF255bit masks, colours, byte values, hash constants
binary0b (Java 7)0b1111_1111255flags, where each bit means something
octala leading 00377255Unix file permissions, and by accident

Octal is the trap. A leading zero looks like padding and is not: 010 is 8. Line up a table of codes as 007, 010, 011 and two of them are wrong, with no warning. 09 does not compile at all, because 9 is not an octal digit. Java 25 says illegal digit in an octal literal; Java 21 says only ';' expected, which sends you looking in the wrong place.

Hex and binary literals can spell out every bit of an int, the sign bit included. So 0xFFFFFFFF is a legal int whose value is −1, and 0x80000000 is Integer.MIN_VALUE. To go the other way, Integer.toHexString, toBinaryString and toOctalString print a value in each base, and Integer.parseInt("ff", 16) reads one back.

Underscores between digits (Java 7) are for the reader; the compiler ignores them. 1_000_000, 0xFF_FF, 0b1010_0101 and 5_000L are all fine. They are legal only between digits: _100 is an identifier, not a number, and 100_, 0x_FF, 3._14 and 1_000_L each fail with illegal underscore. Group decimal numbers by thousands and bits by fours.

Floating-point literals are double unless they end in f. float price = 1.5; does not compile (possible lossy conversion from double to float); 1.5f does. Exponents work in both: 1e3 is 1000.0 and 2.5e-3 is 0.0025.

Character literals are single-quoted, and a char is a number: 'A' is 65, so 'A' + 1 is the int 66. The escapes you will use are '\n', '\t', '\\' and '\'', plus 'A' for any UTF-16 code unit given in hex. A char also accepts an int constant that fits: char c = 65; compiles and prints A.

true, false and null are literals as well, and reserved: no variable can take those names. String literals and text blocks are covered in the strings lesson.

System.out.println("ran");` is a comment, then a line

break, then a statement, and the statement runs. Keep \u escapes inside string and character literals, where they mean what they appear to.

Under the hood: the arithmetic rules

Three rules of the JVM's integer arithmetic explain most numeric surprises:

  • Everything narrower than int is computed as int. byte b = 10; b = b + 1; does not compile: b + 1 is an int, and assigning it back needs a cast. b += 1 does compile, because compound assignment carries an implicit cast, which is how a byte counter reaches 127 and becomes −128 without a warning. char arithmetic is the same: 'a' + 1 is the int 98.
  • Integer division truncates toward zero, and % takes the sign of the dividend. -7 / 2 is -3, -7 % 2 is -1. Math.floorMod is the version that stays non-negative, which is what a hash bucket or a day-of-week calculation wants.
  • Widening is implicit, narrowing is explicit, and both can lose. int to long and int to double are silent; long to int needs (int) and keeps the low 32 bits; int to float is silent and loses precision above 2²⁴ (16,777,216), because a float has 24 bits of mantissa. (int) 3.99 is 3; (int) Double.NaN is 0; (int) 1e20 is Integer.MAX_VALUE.

float and double are IEEE 754 binary floating point. 0.1 has no exact binary representation. new BigDecimal(0.1) prints the value that is actually stored:

plaintext
0.1000000000000000055511151231257827021181583404541015625

Fifty-five digits, and not one of them is the 0.1 you typed — so 0.1 + 0.2 == 0.3 is false and a loop that adds 0.1 ten times does not reach 1.0. For money, use BigDecimal with an explicit scale and a rounding mode, or a long count of the smallest unit. Never double. For comparisons of computed doubles, compare against a tolerance, and remember that NaN != NaN and -0.0 == 0.0. Those two are worth pairing with their opposites: Double.compare(-0.0, 0.0) returns −1 and Double.valueOf(Double.NaN).equals(Double.NaN) is true — so == and compareTo disagree about zero, and a TreeMap<Double, …> keeps -0.0 and 0.0 apart while an if (a == b) does not.

The wrappers

Each primitive has a class: Integer, Long, Double, Boolean, Character, and so on. A wrapper is an object: it lives on the heap, has identity, can be null, and can go where a primitive cannot — into a List<Integer>, a Map<String, Long>, a generic type parameter, or a nullable field.

An Integer is a 12-byte object header plus a 4-byte int value field: 16 bytes, holding four bytes of information, reached through a 4-byte reference. Its fields are final, so a wrapper is immutable; count++ on an Integer does not change the object, it makes a new one.

Autoboxing converts a primitive to its wrapper when the context needs an object; unboxing does the reverse. Both are inserted by javac, silently:

java
List<Integer> xs = new ArrayList<>();
xs.add(5);              // javac emits: xs.add(Integer.valueOf(5))
int first = xs.get(0);  // javac emits: xs.get(0).intValue()

javap shows exactly that: an invokestatic Integer.valueOf before the add, an invokevirtual Integer.intValue after the get. There is no magic in the JVM; boxing is a method call the compiler wrote for you, and every trap below is a property of those two methods.

The NullPointerException in an unboxing

java
class Order {
    Integer discountPercent;   // nullable — most orders have no discount
}
 
int pct = order.discountPercent;   // NPE if null: Integer.intValue() on null
long total = price * (100 - order.discountPercent) / 100;   // same, hidden in arithmetic

The second line is the dangerous one. There is no . in it, nothing that looks like a dereference, and the trace points at an arithmetic expression, because the intValue() call is in the bytecode and not in the source. Since Java 14 the message at least says Cannot invoke "java.lang.Integer.intValue()" because "order.discountPercent" is null. The fix is a decision, not a cast: either the field is not nullable (make it int, default 0), or it is, and the code says what null means:

java
int pct = order.discountPercent == null ? 0 : order.discountPercent;

Walkthrough: the NPE three layers away

A report shows NullPointerException at PricingService.java:71, a line that reads return base * (1 - rate);. There is no null on that line. Trace it back:

  1. rate is a double local, assigned at line 64 from customer.getDiscountRate().
  2. getDiscountRate() returns Double, the JPA-mapped type for a nullable discount_rate column.
  3. javac inserted doubleValue() at line 64 to fit a Double into a double... except the trace says line 71. Why? Because the JIT inlined the getter and the unboxing into the expression, and the debug info attributed the fault to the expression that consumed it. With -XX:-OmitStackTraceInFastThrow the JVM stops recycling a preallocated NPE and the line becomes accurate again; that flag exists because hot NPEs lose their stack traces entirely and print as a bare java.lang.NullPointerException with no at lines.
  4. The row: a customer created before the discount column existed, discount_rate is NULL.

The fix is at the schema and the entity, not at line 71: NOT NULL DEFAULT 0 and double, or keep it nullable and write Optional<Double> discountRate() so every caller sees the choice.

== on wrappers

java
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(c.equals(d));

== on references compares identity. Integer.valueOf caches instances for −128 to 127, and the upper bound is a JVM flag. Same class file, same JVM, one option apart:

plaintext
$ java Box
Integer.valueOf(128)==Integer.valueOf(128)   -> false
Integer.valueOf(1000)==Integer.valueOf(1000) -> false
 
$ java -XX:AutoBoxCacheMax=1000 Box
Integer.valueOf(128)==Integer.valueOf(128)   -> true
Integer.valueOf(1000)==Integer.valueOf(1000) -> true

That is the whole argument against relying on the cache in either direction: the same code gives opposite answers on two JVMs that are both correct. (-128 is fixed, and Long, Short, Byte and Character are not tunable at all.)

Long, Short and Byte cache the same range, Character caches 0 to 127, Boolean has two instances, and Double and Float cache nothing. Larger values are freshly allocated and are never the same object. This is not a rule to rely on in either direction — it is the reason to never use == on wrappers. Use equals, or unbox to a primitive first. A related trap: Long.valueOf(1).equals(Integer.valueOf(1)) is false, because equals checks the class first, and a Map<Long, X> looked up with an int key never finds anything.

The cost of boxing

Every autobox above the cache range is an allocation. In a loop it adds up:

java
Long sum = 0L;
for (long i = 0; i < 10_000_000; i++) sum += i;   // ~10 million Long objects

sum += i unboxes, adds, and boxes a new Long each iteration. A Long is not 16 bytes like an Integer: it is a 12-byte header plus an 8-byte long, padded to 24. So that loop is 10 million × 24 = 240 MB of garbage to compute one number, and the JIT cannot remove it because the Long is stored to a field it must keep boxed.

Measured on a real JVM with ThreadMXBean.getThreadAllocatedBytes, after warm-up:

the same loop, boxed and notplaintext
Long sum    240,000,000 bytes allocated    22 ms
long sum              0 bytes allocated     4 ms

Six to seven times faster is the part people quote. Zero bytes against 240 MB is the part that matters, and it is not a ratio — it is a different kind of program. The same trap hides in Map<String, Integer> counters (map.put(k, map.get(k) + 1): unbox, add, box, and a null on the first key) and in streams over List<Integer>, which is why IntStream, LongStream and mapToInt exist.

unboxaddboxstore
on the stacka longallocated0 bytesgarbage so farthe previous Long

unbox. sum.longValue() -- the Long on the heap is read back out into a primitive. javac inserted this call; there is nothing in the source that looks like it.

1 / 4

A List<Integer> of a million elements is a million Integer objects (16 MB) plus a million references (4 MB) plus the array: about 20 MB and a million things for the collector to trace, against 4 MB for an int[]. For large numeric data use int[], long[], or a primitive-collection library (Eclipse Collections, fastutil). Project Valhalla's value classes are the long-term answer, and in 2026 they are still arriving.

Choosing

  • Default to primitives. int, long, double, boolean for fields, locals and parameters.
  • Use a wrapper when null is meaningful — and then handle it on every read.
  • Use a wrapper when the type system requires an object: generics, collections, reflection.
  • Never use == on wrappers; never rely on the cache.
  • Never use float/double for money.

Try it yourself

Predict the output

java
byte b = 127; b += 1;
int i = 7 / 2 * 2;
double d = 0.1 * 3;
long big = 1 << 31;
System.out.println(b + " " + i + " " + (d == 0.3) + " " + big);
Answer

-128 6 false -2147483648. b += 1 narrows silently and wraps to −128. 7 / 2 * 2 is left to right: 7 / 2 truncates to 3, then 3 * 2 is 6. 0.1 * 3 is 0.30000000000000004, so the comparison is false. 1 << 31 is computed as an int before the widening to long, and the sign bit is set: -2147483648. Write 1L << 31 for 2147483648.

Read the literals

java
System.out.println(010 + 0x10 + 0b10);
System.out.println(0xFFFFFFFF);
System.out.println(0_10);
Answer

26, -1 and 8. 010 is octal 8, 0x10 is hex 16 and 0b10 is binary 2. 0xFFFFFFFF sets all 32 bits of an int, which is −1 in two's complement. 0_10 is still octal: an underscore may follow the leading zero, and it changes nothing about the base.

Where is the null?

java
Map<String, Integer> counts = new HashMap<>();
for (String w : words) counts.put(w, counts.get(w) + 1);

What happens on the first word, and what are two correct versions?

Answer

counts.get(w) returns null for a key that is not there, and null + 1 unboxes the null: NullPointerException on the first word. Correct: counts.merge(w, 1, Integer::sum), which is one lookup and boxes once; or counts.computeIfAbsent(w, k -> new int[1])[0]++ when the loop is hot enough that a million Integers matter. getOrDefault(w, 0) + 1 also works and does two lookups.

Count the garbage

java
Integer total = 0;
for (int i = 0; i < 1_000_000; i++) total += i;

Roughly how many objects does this allocate, and what changes if the loop runs to 100?

Answer

About one million Integer objects, 16 MB, one per iteration once total passes 127; below that the cache serves them. To 100, zero allocations: every value is in the cache. That is why a boxing bug is invisible in a unit test with small numbers and a garbage-collection problem in production with large ones.

Misconceptions

  • "Integer and int are interchangeable." Interchangeable in syntax, because javac inserts valueOf and intValue. Not in nullability, identity, memory or speed.
  • "== on small Integers is safe." It is true for −128 to 127 because of a cache whose upper bound is a JVM flag. Code that depends on it works on one machine and not another.
  • "Boxing is free after the JIT." Escape analysis removes a box that never leaves the method. A box stored in a collection, a field or a Long accumulator survives, and it is the ones that survive that fill the heap.
  • "double is fine for money if I round at the end." 0.1 + 0.2 is already wrong before the end; the rounding hides errors that accumulate in the middle. BigDecimal or long minor units.
  • "Overflow throws." It wraps, silently, in int and long. Only Math.*Exact throws, and BigInteger never overflows.
  • "A leading zero is just padding." It makes the literal octal. 010 is 8, and 09 does not compile.

Going deeper

  • JLS §4.2 (primitive types and values), §5.1.2–5.1.3 (widening and narrowing), §15.26.2 (why b += 1 compiles).
  • JLS §5.1.7, boxing conversion, where the cache guarantee for −128..127 is written down.
  • "What Every Computer Scientist Should Know About Floating-Point Arithmetic" (Goldberg), the first ten pages.
  • JEP 358 for the NPE messages; -XX:-OmitStackTraceInFastThrow for the traces that vanish.
  • JEP 401 and Project Valhalla for where value types are going.
Progress is saved on this device and to your account when signed in.