Variables & Data Types

Java's type system, memory model, and the pitfalls that bite everyone

← Back to Index

What Are Variables and Data Types?

A variable is a named container that holds a value your program can read and modify. Every variable has a data type that tells the JVM two things: what kind of value it can hold, and how much memory to allocate for it. Without variables there is no program — they are the fundamental mechanism for storing state.

In Java, you must declare the type of a variable before you use it. This is what static typing means: the type is known at compile time, not discovered at runtime. The compiler uses that information to catch errors before your code ever runs — trying to store a text string in an integer variable is rejected immediately, not after deployment.

// Every variable declaration has three parts:
            //   type       name     value
                int        age    = 25;
                String     name   = "Alice";
                boolean    active = true;
                double     price  = 19.99;
            
            // The compiler rejects mismatches immediately:
            int count = "hello";  // compile error: incompatible types
            int count;
            System.out.println(count);  // compile error: variable might not be initialised

The data type also determines what operations are valid. You can do arithmetic on an int, call .toUpperCase() on a String, and compare a boolean with if — but not the other way around. This is the contract the type system enforces.

Why static typing matters in practice

In a large codebase or team, static types are documentation that the compiler verifies. When a method declares it returns int, every caller knows exactly what they get — no guessing, no runtime surprises. This is one of the main reasons Java has dominated enterprise development for 30 years: the type system catches a significant class of bugs before the code ships.

Java's Type System

Java is statically typed: every variable has a declared type fixed at compile time. The compiler rejects type mismatches before the code ever runs. All types fall into two categories:

  • Primitive types — 8 built-in types (int, boolean, double…). Store actual values. Cannot be null. Live on the stack.
  • Reference types — everything else (classes, arrays, interfaces). Store a memory address pointing to an object on the heap. Can be null.
Key rules to memorise
  • Declare before use: int count = 0; — type, name, value.
  • Local variables have no default — use them uninitialised and the compiler errors.
  • Instance fields do get defaults (0, false, null) — but don't rely on that; initialise explicitly.
  • Naming: camelCase for variables, UPPER_SNAKE_CASE for constants.

How Variables Live in Memory

This is the most important mental model in Java. Get it wrong and NullPointerExceptions and unexpected mutations will haunt you.

/*
 *  JVM MEMORY — Variable Storage
 *  ================================
 *
 *  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
 *  │  STACK (per thread)                                          │
 *  │                                                              │
 *  │  Method frame: main()                                        │
 *  │  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”                                      │
 *  │  │  int age  │   25   │  ← Primitive: VALUE stored directly  │
 *  │  ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤                                      │
 *  │  │  name     │ 0x742  │  ← Reference: ADDRESS to heap object │
 *  │  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”˜                                      │
 *  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
 *                     │
 *  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
 *  │  HEAP (shared by all threads)                                │
 *  │                                                              │
 *  │  0x742: ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”                       │
 *  │         │ String "Alice"             │                       │
 *  │         │   value: ['A','l','i','c'] │                       │
 *  │         ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜                       │
 *  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
 *
 *  PRIMITIVES → value on stack (fast, no pointer lookup)
 *  REFERENCES → address on stack, object on heap (GC managed)
 */

The assignment trap: primitives vs references

// PRIMITIVES: assignment copies the VALUE
int a = 10;
int b = a;    // b gets its own copy of 10
b = 20;
System.out.println(a);  // Still 10. a is unaffected.

// REFERENCES: assignment copies the ADDRESS (same object!)
int[] arr1 = {1, 2, 3};
int[] arr2 = arr1;    // arr2 points to the SAME array
arr2[0] = 999;
System.out.println(arr1[0]);  // 999 — arr1 sees the change!
Why this matters in method calls

Java is always pass-by-value. For primitives, the method gets a copy — changes don't affect the caller. For objects, the method gets a copy of the reference — the caller's variable still points to the same object, so mutations to the object ARE visible, but reassigning the parameter is not.

Primitive Data Types

Java has exactly 8 primitives. Sizes are platform-independent — an int is always 32 bits on every JVM, unlike C/C++.

Type Size Range Default When to use
byte 8 bits -128 to 127 0 Binary data, byte streams
short 16 bits -32,768 to 32,767 0 Rarely — legacy/interop only
int 32 bits -2,147,483,648 to 2,147,483,647 0 Default choice for integers
long 64 bits ±9.2 Ɨ 1018 0L Timestamps, IDs, large counters
float 32 bits ~6-7 sig. digits 0.0f Graphics, memory-critical only
double 64 bits ~15-16 sig. digits 0.0d Default choice for decimals
char 16 bits 0 to 65,535 (Unicode) '\u0000' Single Unicode character
boolean JVM-dependent true / false false Flags, conditions
// Integer literals — Java 7+ allows underscores for readability
int million = 1_000_000;
long timestamp = 1_716_000_000_000L;  // L suffix required for long literals > int range
int hex = 0xFF;                         // 255
int binary = 0b1010_0011;              // 163

// Floating point
double pi = 3.141592653589793;         // double is the default
float price = 19.99f;                  // f suffix required for float literals

// char is numeric — arithmetic works
char c = 'A';
System.out.println((int) c);       // 65
System.out.println((char)(c + 1)); // B

// boolean: integers are NOT valid in Java (unlike C/C++)
// if (1) { }  // ERROR — only true/false allowed
Never use float or double for money

IEEE 754 cannot represent many decimal fractions exactly. 0.1 + 0.2 is 0.30000000000000004. For financial calculations, always use BigDecimal with String constructors (not double constructors):

BigDecimal price = new BigDecimal("19.99");   // āœ… exact
BigDecimal wrong = new BigDecimal(19.99);    // āŒ inherits float imprecision

Reference Types: String, Arrays, Wrappers

String

String is immutable — every "modification" creates a new object. String literals are interned in the String Pool; new String(...) always creates a heap object.

// Literals go to the String Pool
String name = "John Doe";

// Useful methods
name.length();                      // 8
name.toUpperCase();                 // "JOHN DOE"
name.contains("Doe");              // true
name.replace("John", "Jane");      // "Jane Doe"
name.split(" ");                    // ["John", "Doe"]
"  hello  ".strip();               // "hello" (Java 11+, Unicode-aware)

// Text blocks — Java 15+
String json = """
    {
        "name": "John",
        "age": 30
    }
    """;

// Formatting — Java 15+
String msg = "Hello %s, you are %d".formatted(name, 30);

// āŒ WRONG: == compares references, not content
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2);       // false
System.out.println(s1.equals(s2));  // true
System.out.println(Objects.equals(s1, s2));  // true + null-safe

// StringBuilder for building strings in loops
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) sb.append("a");
String result = sb.toString();
// String concatenation in a loop creates 10,000 intermediate objects — use StringBuilder

Arrays

// Declaration and initialisation
int[] numbers = {1, 2, 3, 4, 5};
int[] scores = new int[10];          // All zeros by default
String[] names = {"Alice", "Bob"};

// Arrays are fixed-size — use List for dynamic collections
numbers[0];                           // 1 (0-indexed)
numbers[numbers.length - 1];         // last element
numbers.length;                       // field, not method!

// 2D array
int[][] matrix = {{1,2},{3,4}};
int val = matrix[1][0];               // 3

Wrapper Classes

Each primitive has an object wrapper needed for generics and collections. Java handles conversion automatically via autoboxing/unboxing.

List<Integer> list = new ArrayList<>();
list.add(42);        // Autoboxing: int → Integer
int x = list.get(0); // Unboxing: Integer → int

// Useful utility methods
int parsed  = Integer.parseInt("123");
String bin  = Integer.toBinaryString(42);   // "101010"
int max     = Integer.MAX_VALUE;              // 2,147,483,647
boolean dig = Character.isDigit('5');        // true

// āŒ DANGER: unboxing null throws NullPointerException
Integer maybeNull = null;
int value = maybeNull;  // NullPointerException at runtime!

// āœ… SAFE
int value = (maybeNull != null) ? maybeNull : 0;
int value = Optional.ofNullable(maybeNull).orElse(0);

var, final and Scope

Local variable type inference: var (Java 10+)

// var: compiler infers type from the right-hand side
var name   = "Alice";                             // String
var age    = 25;                                  // int
var prices = new ArrayList<Double>();            // ArrayList<Double>
var map    = Map.of("a", 1);                     // Map<String, Integer>

// var restrictions — all compile errors:
// var x;                 no initialiser
// var nothing = null;    cannot infer from null
// var nums = {1, 2, 3};  cannot infer array type
// Only for local variables — not fields, params, return types
When to use var and when not to

Use var when the type is obvious from the initialiser: var response = httpClient.send(request, BodyHandlers.ofString());

Avoid var when the type isn't self-evident: var result = calculate(); — what type is this? Makes code review harder in teams. IntelliJ shows the inferred type on hover, but reviewers on GitHub don't have that.

final variables and constants

// final local variable: cannot be reassigned after first assignment
final int MAX_RETRIES = 3;
// MAX_RETRIES = 5;  // ERROR

// Class-level constant: public static final + UPPER_SNAKE_CASE
public static final int    DEFAULT_TIMEOUT_MS = 30_000;
public static final String DB_URL = "jdbc:mysql://localhost:3306/mydb";

// IMPORTANT: final on a reference freezes the reference, NOT the object
final List<String> names = new ArrayList<>();
names.add("Alice");            // āœ… modifying the object is allowed
// names = new ArrayList<>(); // āŒ reassigning the reference is not

Variable scope

public class ScopeExample {
    private static int classCounter = 0;       // class scope — shared by all instances
    private String instanceField = "hello";   // instance scope — each object gets its own

    public void doWork(String param) {          // param: method scope
        int local = 10;                          // method scope

        for (int i = 0; i < 5; i++) {           // i: loop scope only
            int loopVar = i * 2;               // loopVar: loop scope only
        }
        // i and loopVar not accessible here
    }
}

Type Conversion

Widening (implicit) — no data loss

// byte → short → int → long → float → double (automatic)
byte b = 10;
int i = b;        // automatic
double d = i;     // automatic

// āš ļø Arithmetic promotes operands to at least int
byte b1 = 10, b2 = 20;
// byte result = b1 + b2;  // ERROR: b1+b2 is int
int result = b1 + b2;     // OK

Narrowing (explicit cast) — may lose data

double d = 100.99;
int i = (int) d;          // 100 — decimal part silently lost

int big = 130;
byte small = (byte) big;  // -126 — overflow! 130 doesn't fit in byte

// Safe pattern: validate range before casting
long value = getSomeValue();
if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) {
    int safe = (int) value;
} else {
    throw new ArithmeticException("Value out of int range");
}

String conversions

// Primitive → String
String s = String.valueOf(42);        // preferred
String s = Integer.toString(42);      // also fine

// String → Primitive
int     n = Integer.parseInt("123");
double  d = Double.parseDouble("3.14");
boolean b = Boolean.parseBoolean("true");
long    l = Long.parseLong("999999999999");

// Always guard parsing — throws NumberFormatException on bad input
try {
    int val = Integer.parseInt(userInput);
} catch (NumberFormatException e) {
    // handle gracefully
}

Naming Conventions

Element Convention Examples
Variables & methods camelCase userName, totalAmount, calculateTotal()
Classes & interfaces PascalCase UserAccount, HttpClient
Constants UPPER_SNAKE_CASE MAX_SIZE, DEFAULT_TIMEOUT_MS
Packages lowercase, reversed domain com.example.myapp.service
Boolean variables is/has/can/should prefix isActive, hasPermission, canEdit
// āŒ BAD: vague, single letters, type prefixes
int x = 42;
String strName = "John";
boolean flag = true;

// āœ… GOOD: intention-revealing, no type in name
int userAge = 42;
String customerName = "John";
boolean isEmailVerified = true;

Common Pitfalls

Integer division truncates silently
double result = 5 / 2;    // 2.0, NOT 2.5 — both operands are int
double result = 5 / 2.0;  // āœ… 2.5 — one operand is double
double result = (double) 5 / 2;  // āœ… 2.5
Integer overflow wraps silently
int overflow = Integer.MAX_VALUE + 1;  // -2,147,483,648 (wraps!)

// āœ… Use Math.addExact() — throws ArithmeticException on overflow
int safe = Math.addExact(Integer.MAX_VALUE, 1);

// āœ… Or use long
long result = (long) Integer.MAX_VALUE + 1;
String comparison with ==
String a = "hello";
String b = new String("hello");
a == b;             // false — different objects
a.equals(b);        // true — same content
Objects.equals(a, b); // āœ… true + null-safe
Floating-point equality
double sum = 0.1 + 0.2;
sum == 0.3;   // false! (sum is 0.30000000000000004)

// āœ… Compare with epsilon
Math.abs(sum - 0.3) < 1e-10;   // true

// āœ… Or use BigDecimal for exact arithmetic
new BigDecimal("0.1").add(new BigDecimal("0.2"))
    .equals(new BigDecimal("0.3"));  // true

Senior Topics: Performance and Hidden Traps

Autoboxing performance cost

// āŒ SLOW: Integer wrapper creates objects on every iteration
Integer sum = 0;
for (int i = 0; i < 1_000_000; i++) {
    sum += i;  // unbox, add, rebox — allocates ~1M Integer objects
}

// āœ… FAST: primitive, no allocation
int sum = 0;
for (int i = 0; i < 1_000_000; i++) {
    sum += i;
}
// ~10-15x faster. Use primitives in hot paths.

The Integer cache trap (classic senior interview question)

// JVM caches Integer instances from -128 to 127
Integer a = 127;
Integer b = 127;
System.out.println(a == b);   // true — same cached object

Integer c = 128;
Integer d = 128;
System.out.println(c == d);   // false — different objects, outside cache

// Lesson: NEVER use == to compare Integer objects.
// Always use .equals() or unbox to int first.
Memory footprint: primitives vs wrappers

int = 4 bytes on the stack. Integer = 16 bytes on the heap (object header + value) plus the 4-byte reference on the stack. An int[1000] costs ~4 KB. An Integer[1000] costs ~20 KB. For large arrays in memory-sensitive code (caches, processing pipelines), use primitive arrays or libraries like Eclipse Collections that support primitive collections natively.

Modern alternative: records (Java 16+)

When you need a typed data carrier, prefer record over a class with fields. Records are immutable by default, generate equals(), hashCode() and toString() automatically, and make intent explicit.

// āŒ Old way: boilerplate class with fields
public class Point {
    private final int x;
    private final int y;
    // constructor, getters, equals, hashCode, toString... 40 lines
}

// āœ… Modern: record — one line, same behaviour
record Point(int x, int y) {}

Point p = new Point(10, 20);
p.x();         // accessor (no "get" prefix in records)
p.equals(new Point(10, 20));  // true — structural equality generated

Interview Questions

šŸŽ“ Junior level

Q: What's the difference between primitive and reference types?
Primitives store values directly on the stack; cannot be null; no methods. Reference types store a heap address on the stack; can be null; have methods. Assignment of a primitive copies the value; assignment of a reference copies the pointer — both variables then point to the same object.

Q: Why does 0.1 + 0.2 != 0.3?
IEEE 754 binary floating-point cannot represent most decimal fractions exactly. The closest binary approximation of 0.1 plus the closest approximation of 0.2 does not equal the closest approximation of 0.3. Use BigDecimal with String constructors for exact arithmetic.

Q: What is autoboxing?
Automatic conversion between primitive and wrapper type. list.add(42) autoboxes int to Integer. Unboxing a null wrapper throws NullPointerException — a common production bug.

šŸ”„ Senior level

Q: What is the Integer cache and why does it matter?
The JVM caches Integer instances for values -128 to 127. Integer a = 127; Integer b = 127; a == b is true (same cached instance). For 128, it's false. This is why you must never use == to compare wrapper objects — the results are unpredictable across the cache boundary.

Q: When would you choose long over int for IDs?
Always use long for database primary keys and external IDs. At scale, auto-increment int IDs overflow (~2.1 billion). Twitter's Snowflake IDs, UUID representations, and most ORMs use Long. The JPA @Id with Long is the standard for a reason.

Q: What's wrong with using String concatenation in a loop?
String is immutable. Each += in a loop allocates a new String object — O(n²) allocations total. Use StringBuilder for explicit loops. Note: the compiler optimises concatenation in a single expression ("a" + "b" + "c") to a StringBuilder chain automatically, so this only applies to loops.

Q: What does final guarantee on a reference type?
Only that the reference cannot be reassigned — the object itself is fully mutable. final List<String> list = new ArrayList<>(); list.add("x"); is valid. For true immutability, the object itself must be designed as immutable (like String, record, or Collections.unmodifiableList()).