Java splits every value into one of two worlds: primitives, which are raw values,
and reference types, which are objects. There are exactly eight primitives and you
need all of them. Everything else — String, arrays, your own classes — is a reference
type.
Knowing which world a value lives in explains several behaviours that otherwise look arbitrary.
The eight primitives
| Type | Size | Holds | Default | Use it for |
|---|---|---|---|---|
byte | 8 bit | -128 to 127 | 0 | raw binary data |
short | 16 bit | ±32,767 | 0 | almost never |
int | 32 bit | ±2.1 billion | 0 | whole numbers — your default |
long | 64 bit | ±9.2 quintillion | 0L | timestamps, IDs, counts that grow |
float | 32 bit | ~7 digits | 0.0f | almost never |
double | 64 bit | ~15 digits | 0.0 | decimals — your default |
char | 16 bit | one character | ' ' | single characters |
boolean | 1 bit* | true/false | false | yes/no |
In practice you will use int, long, double and
boolean constantly and the other four rarely. Do not pick short over
int to save memory — the JVM pads it anyway, and you have made the code odd for nothing.
int items = 42;
long userId = 9_000_000_000L; // L suffix required — the literal is an int without it
double price = 19.99;
float rate = 1.5f; // f suffix required
char grade = 'A'; // single quotes. "A" is a String.
boolean shipped = false;
int million = 1_000_000; // underscores are legal in numeric literals and aid reading
The suffixes are not decoration. long userId = 9000000000; does not compile, because
the literal on the right is parsed as an int before it is ever assigned, and it does not
fit.
Two traps that cost real money
Integer overflow is silent. When an int exceeds its maximum it wraps
around to the negative end. No exception, no warning:
int max = Integer.MAX_VALUE; // 2147483647
System.out.println(max + 1); // -2147483648 — wrapped, silently
long safe = (long) max + 1; // 2147483648 — cast BEFORE the arithmetic
System.out.println(Math.addExact(max, 1) == 0); // throws ArithmeticException instead
Note the cast placement. (long)(max + 1) would be too late — the overflow already
happened in int arithmetic. Use long for anything that counts money in
cents, milliseconds, or database IDs.
double cannot represent 0.1. Binary floating point stores fractions
as sums of powers of two, and 0.1 is not one. The error is tiny and it compounds:
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false
// Never compare doubles with ==. Compare within a tolerance:
double a = 0.1 + 0.2;
System.out.println(Math.abs(a - 0.3) < 1e-9); // true
This is not a Java quirk — it is how binary floating point works everywhere. It matters because the obvious use for a decimal is money, and money is exactly where you must not use it.
Money: use BigDecimal
For any value where a fraction of a cent matters, use BigDecimal, which stores digits
in base ten:
BigDecimal price = new BigDecimal("19.99"); // String constructor — always
BigDecimal qty = new BigDecimal("3");
BigDecimal total = price.multiply(qty);
System.out.println(total); // 59.97 — exact
// BigDecimal is immutable: every operation returns a NEW value
BigDecimal rounded = total.setScale(2, RoundingMode.HALF_UP);
// equals() compares scale too, so 1.0 and 1.00 are "different". Use compareTo:
System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1.00"))); // false
System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1.00"))); // 0
Two rules that cover most BigDecimal bugs: construct from a String, never
from a double (new BigDecimal(0.1) faithfully preserves the error you were
trying to avoid), and compare with compareTo, not equals.
Both rules, applied in real code — from the console bank app this site uses for examples:
public static BigDecimal of(String value) {
return new BigDecimal(value).setScale(SCALE, RoundingMode.HALF_UP);
}
public static boolean isPositive(BigDecimal value) {
// compareTo, never equals: BigDecimal.equals("1.0", "1.00") is false because it compares
// scale as well as value. This trips up almost everyone once.
return value.compareTo(BigDecimal.ZERO) > 0;
}
Putting both in one place, as that app does, means the rules are followed once rather than remembered at every call site — which is the practical answer to "how do I not get this wrong".
Wrappers and autoboxing
Every primitive has a matching class — int/Integer,
double/Double, boolean/Boolean. You need them
because collections and generics only hold objects: there is no List<int>.
Java converts between the two automatically, which is convenient and occasionally dangerous:
List<Integer> numbers = new ArrayList<>();
numbers.add(5); // autoboxing: int 5 -> Integer.valueOf(5)
int first = numbers.get(0); // unboxing: Integer -> int
Integer maybe = null;
// int broken = maybe; // compiles, then throws NullPointerException at runtime
That last line is the one to remember. A wrapper can be null; a primitive cannot. So
every unboxing is a potential NullPointerException, and it happens on a line that looks
like a plain assignment.
The other trap is ==. On wrappers it compares object identity, and the JVM caches
small values, so it appears to work until it does not:
Integer a = 127, b = 127;
System.out.println(a == b); // true — cached (-128..127)
Integer c = 128, d = 128;
System.out.println(c == d); // false — outside the cache, two objects
System.out.println(c.equals(d)); // true — always compare values with equals
Primitives versus references, in one picture
A primitive variable holds the value. A reference variable holds an address pointing at an object elsewhere in memory. That single difference explains assignment and equality:
int x = 5;
int y = x; // y gets a COPY of the value
y = 10;
System.out.println(x); // 5 — unaffected
int[] p = {1, 2, 3};
int[] q = p; // q gets a copy of the ADDRESS — both point at one array
q[0] = 99;
System.out.println(p[0]); // 99 — the same array
It is also why == means "the same object" for references and "the same value" for
primitives, and why String comparison needs equals — the subject of
String.
Converting between types
int small = 100;
long widened = small; // automatic — no information can be lost
double d = 9.99;
int narrowed = (int) d; // explicit cast required; truncates to 9, does NOT round
System.out.println(Math.round(d)); // 10 — round properly
int parsed = Integer.parseInt("42"); // String -> int, throws NumberFormatException if not
String text = String.valueOf(42); // int -> String
Widening is automatic because nothing can be lost. Narrowing needs a cast because you are telling
the compiler you accept the loss — and (int) truncates rather than rounds, which is a
classic off-by-one in anything that calculates an average.
Next
You now have values and names for them. Operators is next: what you can do to those values, and the three operator behaviours that catch nearly everyone.