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.
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
| Type | Size | Range / notes |
|---|---|---|
byte | 8 bits | −128 to 127 |
short | 16 bits | −32,768 to 32,767 |
int | 32 bits | ±2.1 billion — the default for integers |
long | 64 bits | ±9.2 quintillion; literal needs L |
float | 32 bits | ~7 significant digits; literal needs f |
double | 64 bits | ~15 significant digits — the default for decimals |
char | 16 bits | a UTF-16 code unit, not a "character" |
boolean | JVM-defined — 1 byte in HotSpot | true / 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:
| Form | Prefix | Example | Value | Where you meet it |
|---|---|---|---|---|
| decimal | none | 255 | 255 | almost everywhere |
| hexadecimal | 0x | 0xFF | 255 | bit masks, colours, byte values, hash constants |
| binary | 0b (Java 7) | 0b1111_1111 | 255 | flags, where each bit means something |
| octal | a leading 0 | 0377 | 255 | Unix 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
\uescapes 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
intis computed asint.byte b = 10; b = b + 1;does not compile:b + 1is anint, and assigning it back needs a cast.b += 1does compile, because compound assignment carries an implicit cast, which is how abytecounter reaches 127 and becomes −128 without a warning.chararithmetic is the same:'a' + 1is theint98. - Integer division truncates toward zero, and
%takes the sign of the dividend.-7 / 2is-3,-7 % 2is-1.Math.floorModis 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.
inttolongandinttodoubleare silent;longtointneeds(int)and keeps the low 32 bits;inttofloatis silent and loses precision above 2²⁴ (16,777,216), because afloathas 24 bits of mantissa.(int) 3.99is 3;(int) Double.NaNis 0;(int) 1e20isInteger.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:
0.1000000000000000055511151231257827021181583404541015625Fifty-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:
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
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 arithmeticThe 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:
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:
rateis adoublelocal, assigned at line 64 fromcustomer.getDiscountRate().getDiscountRate()returnsDouble, the JPA-mapped type for a nullablediscount_ratecolumn.javacinserteddoubleValue()at line 64 to fit aDoubleinto adouble... 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:-OmitStackTraceInFastThrowthe 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 barejava.lang.NullPointerExceptionwith noatlines.- The row: a customer created before the discount column existed,
discount_rateisNULL.
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
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:
$ 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) -> trueThat 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:
Long sum = 0L;
for (long i = 0; i < 10_000_000; i++) sum += i; // ~10 million Long objectssum += 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:
Long sum 240,000,000 bytes allocated 22 ms
long sum 0 bytes allocated 4 msSix 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.
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.
add. The actual work: one machine instruction, on two primitives, costing nothing. Everything else on this track exists to get here and back.
box. Long.valueOf(result) -- and above 127 the cache does not apply, so this is a fresh 24-byte object on the heap. Every iteration.
store. The new reference is written to sum. The old Long becomes unreachable -- which is why the JIT cannot keep this in a register and skip the allocation.
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,booleanfor fields, locals and parameters. - Use a wrapper when
nullis 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/doublefor money.
Try it yourself
Predict the output
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
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?
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
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
- "
Integerandintare interchangeable." Interchangeable in syntax, becausejavacinsertsvalueOfandintValue. Not in nullability, identity, memory or speed. - "
==on smallIntegers 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
Longaccumulator survives, and it is the ones that survive that fill the heap. - "
doubleis fine for money if I round at the end."0.1 + 0.2is already wrong before the end; the rounding hides errors that accumulate in the middle.BigDecimalorlongminor units. - "Overflow throws." It wraps, silently, in
intandlong. OnlyMath.*Exactthrows, andBigIntegernever overflows. - "A leading zero is just padding." It makes the literal octal.
010is 8, and09does 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 += 1compiles). - 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:-OmitStackTraceInFastThrowfor the traces that vanish. - JEP 401 and Project Valhalla for where value types are going.