Java data types and variables
Java is statically typed, and its type system draws a hard line between primitives
(raw values) and objects (references on the heap). That distinction drives how ==
behaves, why Integer caching produces baffling comparison results, why Strings are
immutable, and what "Java is pass-by-value" really means. This guide walks through it
all.
The eight primitives
| Type | Size | Notes |
|---|---|---|
byte | 8-bit | −128…127 |
short | 16-bit | −32,768…32,767 |
int | 32-bit | default for integer literals |
long | 64-bit | suffix L |
float | 32-bit | IEEE-754, suffix f |
double | 64-bit | default for decimal literals |
char | 16-bit | a single UTF-16 code unit |
boolean | JVM-defined | true / false |
Primitives hold their value directly and can never be null. Everything else —
String, arrays, your classes — is a reference type accessed through a pointer.
Fields default to a zero value (0, false, null); local variables get no default
and must be assigned before use.
Primitives vs wrappers, and autoboxing
Every primitive has an object wrapper (int->Integer, double->Double, …). The
primitive is a raw value; the wrapper is a heap object that can be null and is required
wherever Java needs an object — generics (List<Integer>, never List<int>),
collections, nullable fields.
int a = 5; // raw value
Integer b = 5; // object reference, can be null
Integer c = null;
int x = c; // NullPointerException — unboxing null
Autoboxing converts a primitive to its wrapper automatically; unboxing does the
reverse. It's convenient but has costs: unboxing null throws, and boxing in a loop
creates garbage (Integer sum = 0; sum += x; boxes every iteration — use a primitive
accumulator).
The == vs .equals() story (and Integer caching)
== compares references for objects and raw values for primitives; .equals()
compares logical equality defined by the class. This produces one of Java's most
famous gotchas:
Integer a = 127, b = 127;
System.out.println(a == b); // true — Java caches Integers from −128 to 127
Integer c = 128, d = 128;
System.out.println(c == d); // false — outside the cache, distinct objects
System.out.println(c.equals(d)); // true — value comparison
The IntegerCache reuses small, common integers. The lesson: always compare wrapper
values with .equals() (or unbox), never ==.
Strings: immutability and the pool
A String's character data is final and never changes; any "modification" returns a
new String. Immutability enables the string pool, thread safety, cached hash codes,
and security.
String s = "hello";
s.concat(" world"); // new String, discarded
s = s.concat(" world"); // reassign to the new String
The string pool stores one shared copy of each literal, so identical literals are the
same object — but new String(...) creates a fresh heap object that isn't pooled.
String a = "hi", b = "hi";
String c = new String("hi");
a == b // true (same pooled object)
a == c // false (new heap object)
a == c.intern() // true (intern() returns the pooled copy)
This is why comparing strings with == seems to work for literals but breaks for
computed strings — always use .equals(). For heavy string building, String
concatenation in a loop is O(n²) garbage; use a StringBuilder (mutable,
unsynchronized) — or StringBuffer when you need thread safety.
Pass-by-value — always
Java is always pass-by-value, no exceptions. The subtlety: for objects, the value copied is the reference, not the object. So a method can mutate the object the reference points to, but reassigning the parameter doesn't affect the caller.
void mutate(int[] arr) { arr[0] = 99; } // changes the caller's array
void reassign(int[] arr) { arr = new int[]{0}; } // no effect on caller
int[] a = {1};
mutate(a); // a is now {99}
reassign(a); // a is still {99}
Say it crisply: "Java passes references by value." The reference is copied; both copies point at the same object until one is reassigned.
Conversions, overflow, and floating point
Widening (smaller -> larger type) is implicit and safe; narrowing requires an explicit cast and truncates (toward zero for floating->integer):
long l = 100; // widening — automatic
int n = (int) 3.99; // narrowing — explicit; truncates to 3
byte b = (byte) 300; // overflows to 44
Integer arithmetic wraps silently on overflow (Integer.MAX_VALUE + 1 is
Integer.MIN_VALUE) — use long, Math.addExact, or BigInteger when that matters.
And float/double are binary IEEE-754, so 0.1 + 0.2 != 0.3; use BigDecimal
(constructed from a String) for exact decimals, and never compare floats with ==.
In expressions, operands smaller than int promote to int, and integer division
truncates — 5 / 2 is 2, while 5 / 2.0 is 2.5.
var, final, and static
var (Java 10+) is local type inference — still statically typed, just less verbose,
and only for local variables with an initializer. final means assign-once: for a
reference it fixes which object the variable points to, not the object's contents.
static binds a member to the class (one shared copy) rather than each instance.
var list = new ArrayList<String>(); // inferred ArrayList<String>
final List<String> xs = new ArrayList<>();
xs.add("ok"); // mutating allowed
xs = new ArrayList<>(); // can't reassign a final binding
Constants are the idiomatic static final in UPPER_SNAKE_CASE.
Recap
Java splits the world into primitives (raw values, never null) and objects
(references). That split explains == vs .equals(), the surprising Integer cache,
and why Strings (immutable, pooled) must be compared with .equals(). Java is
always pass-by-value — it copies references, so methods can mutate but not reassign.
Mind widening vs narrowing, silent integer overflow, and floating-point imprecision, and
use var/final/static deliberately. These fundamentals underpin everything else in
Java.