Java Variables and Types: Primitives, References, Casting and Autoboxing Pitfalls
Key takeaways
Java has eight primitive types that hold values directly and reference types that point to objects. Most beginner bugs come from the boundary between them: silent int overflow, narrowing casts, == on Strings and Integers, and NullPointerException when a null wrapper is unboxed.
Introduction
Java is statically typed: every variable has a type fixed at compile time, and the compiler refuses assignments that could lose information unless you write an explicit cast. That strictness catches many mistakes early, but a few conversions happen silently, and those are where beginners get hurt. This post walks through the eight primitive types, reference types, casting, and wrapper classes, with the actual compiler errors and outputs from a current JDK.
The key mental model is the split between primitives and references. A primitive variable contains its value: an int is 32 bits of integer, stored wherever the variable lives. A reference variable contains a pointer-like reference to an object on the heap, or null. Everything else in this post (why == behaves differently, why null can crash an int assignment, why collections cannot hold int) follows from that split.
Primitive types
| Type | Size | Range / notes |
|---|---|---|
byte | 8 bits | -128 to 127 |
short | 16 bits | -32,768 to 32,767 |
int | 32 bits | about ±2.1 billion |
long | 64 bits | about ±9.2 × 10^18 |
float | 32 bits | IEEE 754, about 7 significant decimal digits |
double | 64 bits | IEEE 754, about 15–16 significant digits |
char | 16 bits | a UTF-16 code unit, 0 to 65,535 |
boolean | — | true or false only |
Unlike C and C++, these sizes are fixed by the language specification on every platform, and all integer types are signed (there is no unsigned int; char is the only unsigned type).
Integers and literal suffixes
byte b = 127;
short s = 32767;
int i = 2147483647;
long l = 9223372036854775807L; // L suffix required for literals beyond int range
int million = 1_000_000; // underscores are allowed for readability
An integer literal without a suffix is an int. Writing a value that does not fit is a compile error:
error: integer number too large
Use an uppercase L; a lowercase l looks like the digit 1.
Overflow is silent
The compiler checks literals, but arithmetic is never checked:
System.out.println(Integer.MAX_VALUE + 1); // -2147483648
int a = 1_000_000;
System.out.println(a * a); // -727379968
long wrong = 1_000_000 * 1_000_000; // -727379968
long right = 1_000_000L * 1_000_000; // 1000000000000
The wrong line is the classic trap: the multiplication happens in int (both operands are int), overflows, and only then is the already-wrong result widened to long. Making one operand long moves the whole calculation to 64 bits. When overflow must be an error rather than a wrap-around, use Math.addExact, Math.multiplyExact and friends, which throw ArithmeticException: integer overflow.
For everyday code, int is the default choice. Use long for values that can exceed about two billion: millisecond timestamps, byte counts of large files, database IDs that grow for years. byte and short rarely save anything for local variables; they matter in large arrays and binary formats.
Floating point
float f = 3.14f; // f suffix required: 3.14 alone is a double
double d = 3.14159;
double sci = 1.23e-4;
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
float and double are binary fractions, so most decimal fractions (0.1, 0.2) cannot be represented exactly. That is fine for measurements and graphics and wrong for money. Use BigDecimal for exact decimal arithmetic, and construct it from a string: new BigDecimal(0.1) faithfully preserves the binary error shown above. Writing float f = 3.14; fails with possible lossy conversion from double to float.
Two more floating-point surprises: Double.NaN == Double.NaN is false, and 0.0 == -0.0 is true. Use Double.isNaN(x) to test for NaN.
char
char c = 'A';
char hangul = '가'; // Unicode escape
c += 1; // compound assignment: c is now 'B'
System.out.println('A' + 1); // 66, because char + int is an int
A char is a 16-bit UTF-16 code unit, not a full character. Emoji and other characters outside the Basic Multilingual Plane take two chars (a surrogate pair), so "😀".length() is 2. Use codePoints() when you need to count characters as users see them.
boolean
boolean isActive = false;
boolean isAdult = age >= 18;
Java does not convert integers to booleans: if (count) does not compile. Primitive boolean can never be null; the wrapper Boolean can, which matters in the unboxing section below.
Reference types
Classes, interfaces, arrays, enums and records are reference types. A reference variable either points to an object or is null. Assigning one reference to another copies the reference, not the object:
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 99;
System.out.println(a[0]); // 99: a and b refer to the same array
String and equality
String name1 = "Jane";
String name2 = "Jane";
String name3 = new String("Jane");
System.out.println(name1 == name2); // true (same interned literal)
System.out.println(name1 == name3); // false (different objects)
System.out.println(name1.equals(name3)); // true (same contents)
System.out.println(name1 == name3.intern()); // true
== on references asks “is this the same object?”, and equals asks “do these have the same contents?”. String literals with the same text are interned into one shared object, which is why name1 == name2 is true and why code using == often passes quick tests. Strings built at runtime (read from a file, a request, substring, StringBuilder) are new objects, so the same comparison fails in production. Always use equals, or "expected".equals(input) when input may be null.
Strings are immutable: toUpperCase() returns a new string and leaves the original unchanged. Forgetting to assign the result (name.trim(); on its own line) is a common no-op bug.
Arrays
int[] numbers = {1, 2, 3, 4, 5};
int[] zeros = new int[5]; // elements default to 0 (false, null for other types)
System.out.println(numbers.length);
int[][] jagged = new int[2][];
jagged[0] = new int[3];
jagged[1] = new int[1]; // rows can have different lengths
Array length is fixed at creation. An out-of-range index throws ArrayIndexOutOfBoundsException at runtime rather than reading memory past the end, as C would. Use ArrayList when the size changes.
Type casting
Widening (implicit)
byte b = 10;
int i = b;
long l = i;
float f = l;
double d = f;
Widening conversions happen automatically because the target type covers the source’s range. One caveat: long to float (and int to float, long to double) can lose precision even though it is “widening”:
long big = 16_777_217L;
float f = big;
System.out.println((long) f); // 16777216
float has 24 bits of precision, so integers above 2^24 are not all representable, and the compiler does not warn.
Narrowing (explicit)
Assigning a wider type to a narrower one without a cast is a compile error:
error: incompatible types: possible lossy conversion from long to int
With a cast, you take responsibility for the loss:
System.out.println((int) 3.99); // 3 (truncates toward zero)
System.out.println((int) -3.99); // -3
System.out.println((int) 1e20); // 2147483647 (floating to int saturates)
System.out.println((byte) 130); // -126 (integer narrowing keeps the low 8 bits)
Note the different behaviours: floating-point to integer truncates and clamps to the target’s range, while integer to smaller integer simply drops high bits. Use Math.round if you want rounding, and Math.toIntExact(long) if a narrowing conversion that loses data should throw.
Arithmetic promotion
byte b = 10;
// b = b + 1; // error: possible lossy conversion from int to byte
b += 5; // compiles: compound assignment includes an implicit cast
System.out.println(5 / 2); // 2 (integer division)
System.out.println(5 / 2.0); // 2.5
In arithmetic, byte, short and char operands are promoted to int, so b + 1 is an int and cannot be assigned back to a byte without a cast. Compound operators like += hide that cast, which means they can also hide an overflow. Integer division truncates; (double) sum / count casts before dividing, whereas (double) (sum / count) casts the already-truncated result.
Wrapper classes and autoboxing
| Primitive | Wrapper |
|---|---|
| byte | Byte |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| char | Character |
| boolean | Boolean |
Generics work only with reference types, so List<int> is not allowed; you write List<Integer>, and the compiler converts between int and Integer automatically (autoboxing and unboxing):
List<Integer> numbers = new ArrayList<>();
numbers.add(10); // boxing: Integer.valueOf(10)
int first = numbers.get(0); // unboxing: .intValue()
Pitfall 1: unboxing null
Integer count = null;
int x = count;
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "java.lang.Integer.intValue()" because "count" is null
The line has no visible method call, which makes this confusing the first time. The message (from helpful NullPointerExceptions, on by default in modern JDKs) names the hidden intValue() call. It shows up most often with Map<String, Integer>.get(key) for a missing key, and with nullable database columns mapped to Integer fields. Handle it explicitly, for example map.getOrDefault(key, 0).
Pitfall 2: == on wrappers
Integer x = 100, y = 100, p = 1000, q = 1000;
System.out.println(x == y); // true
System.out.println(p == q); // false
System.out.println(p.equals(q)); // true
Integer.valueOf caches values from -128 to 127, so small boxed values are shared objects and == appears to compare values. Outside that range, each boxing usually creates a new object.
I think this is the nastiest bug in the post, because it passes every test that uses small IDs and quantities and then fails once real data crosses 127. It usually hides in code like if (order.getUserId() == currentUser.getId()) where both getters return Long. The comparison works in development with a handful of users and silently returns false in production. The fix is Objects.equals(a, b) or comparing unboxed primitives, and static analysis tools (IntelliJ inspections, SpotBugs) flag == between boxed types.
Pitfall 3: boxing in loops
Long total = 0L;
for (long i = 0; i < 1_000_000; i++) {
total += i; // unbox, add, box a new Long every iteration
}
Declaring total as a wrapper by accident turns each addition into an unbox, an add, and a new object allocation. The result is correct but slower and generates garbage. Keep accumulators as primitives and box once at the end if you must.
Parsing and conversion
int num = Integer.parseInt("123");
double d = Double.parseDouble("3.14");
String str = String.valueOf(123);
Integer.parseInt("abc"); // throws NumberFormatException: For input string: "abc"
parseInt returns a primitive and valueOf returns a wrapper. Neither trims whitespace, so Integer.parseInt(" 42") also throws; call strip() on user input first.
var (Java 10+)
var name = "Jane"; // String
var age = 25; // int
var list = new ArrayList<String>(); // ArrayList<String>
// var x; // error: cannot infer type for local variable x
var is local type inference, not dynamic typing: the variable’s type is fixed from the initializer at compile time. It is allowed only for local variables with an initializer (and in for loops and lambdas’ parameters), not for fields or method parameters.
It helps when the type is obvious from the right-hand side and hurts when it is not. var result = service.process(input); forces the reader to look up process to learn the type. Also watch for inferred types that differ from what you meant: var list = new ArrayList<>(); infers ArrayList<Object>, and var total = 0; is an int, so a large sum overflows as in section 1.
Practical examples
Parsing input safely
public class TypeConversion {
static int parseOrDefault(String input, int fallback) {
try {
return Integer.parseInt(input.strip());
} catch (NumberFormatException e) {
return fallback;
}
}
public static void main(String[] args) {
System.out.println(parseOrDefault(" 123 ", 0)); // 123
System.out.println(parseOrDefault("abc", 0)); // 0
}
}
Average and maximum of an array
public class ArrayExample {
public static void main(String[] args) {
int[] scores = {85, 92, 78, 95, 88};
int sum = 0;
int max = scores[0];
for (int score : scores) {
sum += score;
if (score > max) max = score;
}
double average = (double) sum / scores.length; // cast before dividing
System.out.println("Average: " + average); // Average: 87.6
System.out.println("Max: " + max); // Max: 95
}
}