☕ Java

Non-Primitive Data Types

Non-primitive data types — also called reference types — are everything beyond Java's eight primitives. Strings, arrays, classes, interfaces, enums, and records all fall into this category. They're more powerful than primitives, but they work differently in memory, comparison, and nullability. Understanding the distinction is essential for writing correct Java.

Primitives vs Non-Primitives — The Core Difference

Java's type system is split into two worlds: Primitive types (byte, short, int, long, float, double, char, boolean) store their value directly in memory. When you write int x = 5, the value 5 lives right there in the variable's memory slot. Non-primitive types (everything else) store a reference — a memory address pointing to where the actual data lives on the heap. When you write String name = "Alice", the variable name holds a reference to the String object on the heap, not the characters themselves. This distinction has four practical consequences: - Nullability — primitives always have a value; reference types can be null - Comparison — primitives compare by value (==); reference types compare by reference address (== checks if they point to the same object, not if they're equal) - Memory — primitives are stack-allocated (for locals); objects live on the heap - Methods — primitives have no methods; reference types are objects with behavior

String — The Most Used Reference Type

String is Java's built-in class for text. It's not a primitive — it's a full object — but Java gives it special syntax that makes it feel like one. Strings are immutable in Java: once created, the character sequence never changes. Any operation that appears to modify a String actually creates a new one.
Java
// String literals — special syntax, but still objects:
String name = "Alice";
String greeting = "Hello, " + name;   // creates a new String

// String is immutable — methods return new Strings, never modify in place:
String original = "  Hello, World!  ";
String trimmed   = original.trim();           // "Hello, World!"
String upper     = original.toUpperCase();    // "  HELLO, WORLD!  "
String replaced  = original.replace("World", "Java");
// original is unchanged — still "  Hello, World!  "

// Common String methods:
String s = "Hello, Java!";
s.length()                    // 12
s.charAt(0)                   // 'H'
s.indexOf("Java")             // 7
s.substring(7)                // "Java!"
s.substring(7, 11)            // "Java"
s.contains("Java")            // true
s.startsWith("Hello")         // true
s.endsWith("!")               // true
s.toLowerCase()               // "hello, java!"
s.toUpperCase()               // "HELLO, JAVA!"
s.trim()                      // removes leading/trailing whitespace
s.split(", ")                 // ["Hello", "Java!"]
s.replace("Java", "World")    // "Hello, World!"

// == vs .equals() — the most common String bug in Java:
String a = new String("Alice");
String b = new String("Alice");
System.out.println(a == b);        // false — different objects in memory
System.out.println(a.equals(b));   // true  — same character sequence

// String pool — literals are interned and reused:
String x = "Alice";
String y = "Alice";
System.out.println(x == y);        // true — both point to same pool entry
System.out.println(x.equals(y));   // true

// Always use .equals() for String comparison — never ==:
if (status.equals("ACTIVE")) { }          // correct
if (status.equalsIgnoreCase("active")) { } // case-insensitive comparison

// Null-safe comparison — put the known string first:
if ("ACTIVE".equals(status)) { }   // safe even if status is null

String Immutability and StringBuilder

Because String is immutable, concatenating strings in a loop creates a new object on every iteration — which is slow and wastes memory. StringBuilder is the mutable alternative for building strings programmatically.
Java
// DON'T — string concatenation in a loop creates N intermediate objects:
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i;   // creates a new String object on every iteration
}

// DO — StringBuilder is mutable, builds in place:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String result = sb.toString();   // one final String at the end

// StringBuilder methods:
StringBuilder sb2 = new StringBuilder("Hello");
sb2.append(", World");           // Hello, World
sb2.insert(5, " there");         // Hello there, World
sb2.delete(5, 11);               // Hello, World
sb2.reverse();                   // dlroW ,olleH
sb2.replace(0, 5, "Hi");         // Hi ,olleH  (after reverse — just for demo)
sb2.toString();                  // convert to String when done

// StringBuilder vs StringBuffer:
// StringBuilder — NOT thread-safe, faster — use this in single-threaded code
// StringBuffer  — thread-safe (synchronized), slower — use only when needed across threads

Arrays — Fixed-Size Sequences

An array is a fixed-size, ordered collection of elements of the same type. Arrays are objects in Java — they live on the heap and have a .length field. Once created, an array's size cannot change.
Java
// Declaring and initializing arrays:
int[] numbers = new int[5];             // array of 5 ints, all initialized to 0
String[] names = new String[3];         // array of 3 Strings, all null

// Array initializer — size inferred from values:
int[] scores = {95, 87, 76, 91, 88};
String[] days = {"Mon", "Tue", "Wed", "Thu", "Fri"};

// Accessing elements — zero-indexed:
System.out.println(scores[0]);    // 95 — first element
System.out.println(scores[4]);    // 88 — last element
scores[2] = 80;                   // modify element

// Array length:
System.out.println(scores.length);  // 5 — field, not method (no parentheses)

// Out of bounds throws at runtime:
scores[5] = 100;   // throws ArrayIndexOutOfBoundsException

// Iterating:
for (int i = 0; i < scores.length; i++) {
    System.out.println(scores[i]);
}

// Enhanced for loop (for-each) — cleaner when index not needed:
for (int score : scores) {
    System.out.println(score);
}

// 2D arrays:
int[][] matrix = new int[3][3];
int[][] grid = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
System.out.println(grid[1][2]);  // 6 — row 1, column 2

// Arrays utility class:
import java.util.Arrays;
Arrays.sort(scores);                        // sort in place
Arrays.fill(numbers, 0);                   // fill all with 0
int[] copy = Arrays.copyOf(scores, 3);     // copy first 3 elements
int[] range = Arrays.copyOfRange(scores, 1, 4);  // copy index 1 to 3
System.out.println(Arrays.toString(scores)); // "[76, 87, 88, 91, 95]"
System.out.println(Arrays.equals(scores, copy)); // compare contents

Classes — Blueprints for Objects

A class is a user-defined type — a blueprint that defines what data an object holds (fields) and what it can do (methods). Every object in Java is an instance of a class. When you create an object with new, Java allocates memory on the heap and returns a reference to it.
Java
// Defining a class:
public class BankAccount {

    // Fields — data the object holds:
    private String accountNumber;
    private String owner;
    private double balance;

    // Constructor — called when creating an object with new:
    public BankAccount(String accountNumber, String owner, double initialBalance) {
        this.accountNumber = accountNumber;
        this.owner = owner;
        this.balance = initialBalance;
    }

    // Methods — behavior the object has:
    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
        this.balance += amount;
    }

    public void withdraw(double amount) {
        if (amount > balance) throw new IllegalStateException("Insufficient funds");
        this.balance -= amount;
    }

    public double getBalance() { return balance; }

    @Override
    public String toString() {
        return "BankAccount[" + accountNumber + ", owner=" + owner
             + ", balance=" + balance + "]";
    }
}

// Creating objects (instances) — each gets its own copy of the fields:
BankAccount acc1 = new BankAccount("ACC001", "Alice", 1000.0);
BankAccount acc2 = new BankAccount("ACC002", "Bob", 500.0);

acc1.deposit(250.0);
acc1.withdraw(100.0);
System.out.println(acc1.getBalance());   // 1150.0
System.out.println(acc1);               // BankAccount[ACC001, owner=Alice, balance=1150.0]

// Reference behavior — two variables can point to the same object:
BankAccount ref1 = acc1;
BankAccount ref2 = acc1;         // both point to the SAME object
ref1.deposit(500.0);
System.out.println(ref2.getBalance()); // 1650.0 — same object was modified

// null — a reference that points to nothing:
BankAccount empty = null;
empty.deposit(100.0);   // throws NullPointerException

Interfaces — Contracts Without Implementation

An interface defines a contract — a set of methods a class promises to implement. Interfaces are reference types: variables can be declared as an interface type, holding a reference to any object that implements it.
Java
// Defining an interface:
public interface Drawable {
    void draw();                          // abstract — must be implemented
    default String getDescription() {    // default — optional to override
        return "A drawable shape";
    }
}

public interface Resizable {
    void resize(double factor);
}

// Implementing interfaces:
public class Circle implements Drawable, Resizable {
    private double radius;

    public Circle(double radius) { this.radius = radius; }

    @Override
    public void draw() {
        System.out.println("Drawing circle with radius " + radius);
    }

    @Override
    public void resize(double factor) {
        this.radius *= factor;
    }
}

// Interface as a type — polymorphism:
Drawable shape = new Circle(5.0);  // variable type is Drawable
shape.draw();                       // calls Circle's implementation

// Works with any class that implements Drawable:
List<Drawable> shapes = new ArrayList<>();
shapes.add(new Circle(3.0));
shapes.add(new Rectangle(4.0, 6.0));
shapes.forEach(Drawable::draw);    // each draws itself its own way

Enums — Fixed Sets of Constants

An enum is a special class that represents a fixed, known set of constants. Enums are type-safe: you can't pass an invalid value where an enum is expected. They're far better than using raw int or String constants for representing fixed choices.
Java
// Basic enum:
public enum Direction {
    NORTH, SOUTH, EAST, WEST
}

// Using an enum:
Direction dir = Direction.NORTH;

switch (dir) {
    case NORTH -> System.out.println("Going north");
    case SOUTH -> System.out.println("Going south");
    case EAST  -> System.out.println("Going east");
    case WEST  -> System.out.println("Going west");
}

// Enums with fields and methods — each constant can have its own data:
public enum Planet {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS  (4.869e+24, 6.0518e6),
    EARTH  (5.976e+24, 6.37814e6),
    MARS   (6.421e+23, 3.3972e6);

    private final double mass;    // in kilograms
    private final double radius;  // in meters

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
    }

    // Surface gravity in m/s²:
    public double surfaceGravity() {
        final double G = 6.67300E-11;
        return G * mass / (radius * radius);
    }
}

System.out.println(Planet.EARTH.surfaceGravity());  // 9.802...

// Built-in enum methods:
Direction.values()               // Direction[] — all constants
Direction.valueOf("NORTH")       // Direction.NORTH — from string
Direction.NORTH.name()           // "NORTH" — constant name as String
Direction.NORTH.ordinal()        // 0 — position in declaration order

Records — Immutable Data Carriers (Java 16+)

A record is a concise way to declare an immutable data class. Java automatically generates the constructor, getters, equals(), hashCode(), and toString() from the record's components. Use records for simple data-holding classes where immutability is desired.
Java
// Traditional immutable class — lots of boilerplate:
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) { this.x = x; this.y = y; }
    public int x() { return x; }
    public int y() { return y; }

    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
    @Override public String toString() { ... }
}

// The same thing as a record — one line:
public record Point(int x, int y) { }

// Using a record:
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);

System.out.println(p1.x());          // 3 — accessor method (not getX)
System.out.println(p1.y());          // 4
System.out.println(p1);              // Point[x=3, y=4] — auto toString
System.out.println(p1.equals(p2));   // true — auto equals compares values

// Records are immutable — no setters, fields are final:
// p1.x = 10;   // COMPILE ERROR — no setter, field is final

// Records can have custom methods:
public record Circle(double centerX, double centerY, double radius) {
    // Compact constructor — add validation:
    Circle {
        if (radius <= 0) throw new IllegalArgumentException("Radius must be positive");
    }

    // Custom method:
    public double area() {
        return Math.PI * radius * radius;
    }
}

Circle c = new Circle(0, 0, 5.0);
System.out.println(c.area());   // 78.539...

Wrapper Classes — Primitives as Objects

Each primitive type has a corresponding wrapper class that boxes the primitive value into an object. Wrapper classes are needed when you must use an object where a primitive won't work — generics, collections, Optional, and APIs that require Object.
Java
// The eight wrapper classes:
// byteByte
// shortShort
// intInteger
// longLong
// floatFloat
// doubleDouble
// charCharacter
// booleanBoolean

// Autoboxing — Java automatically converts primitive to wrapper:
Integer boxed = 42;           // same as: Integer.valueOf(42)
Double price = 19.99;         // same as: Double.valueOf(19.99)

// Unboxing — wrapper automatically converts back to primitive:
int value = boxed;            // same as: boxed.intValue()
double d = price;             // same as: price.doubleValue()

// Required for generics — List<int> is illegal, List<Integer> is correct:
List<Integer> numbers = new ArrayList<>();
numbers.add(1);               // autoboxed: Integer.valueOf(1)
numbers.add(2);
int first = numbers.get(0);   // unboxed: .intValue()

// Wrapper utility methods:
Integer.parseInt("42")         // Stringint
Integer.toString(42)           // intString
Integer.MAX_VALUE              // 2147483647
Integer.MIN_VALUE              // -2147483648
Integer.toBinaryString(255)    // "11111111"
Integer.toHexString(255)       // "ff"
Double.parseDouble("3.14")     // Stringdouble
Boolean.parseBoolean("true")   // Stringboolean

// Autoboxing pitfall — == compares references, not values:
Integer a = 1000;
Integer b = 1000;
System.out.println(a == b);      // false — different objects
System.out.println(a.equals(b)); // true  — same value

// Integer cache — values -128 to 127 are cached, == works "by accident":
Integer x = 127;
Integer y = 127;
System.out.println(x == y);   // true — cached, same object
Integer p = 128;
Integer q = 128;
System.out.println(p == q);   // false — outside cache range, different objects
// Moral: always use .equals() for wrapper comparison, never ==

null — The Absent Reference

null is the default value for all reference types — it means the variable holds no reference, pointing to nothing. It's the source of Java's most common runtime exception: NullPointerException (NPE). Modern Java provides several tools to handle null safely.
Java
// null is the default for uninitialized reference fields:
String name;          // null (field)
String[] arr;         // null (field)
BankAccount account;  // null (field)

// Dereferencing null throws NullPointerException:
String s = null;
s.length();          // throws NullPointerException

// Null checks — the traditional approach:
if (name != null) {
    System.out.println(name.length());
}

// Optional — the modern approach (Java 8+):
// Wrap values that might be absent in Optional instead of returning null:
Optional<String> maybeName = Optional.ofNullable(getName());

// Use safely:
maybeName.ifPresent(n -> System.out.println(n.length()));
String result = maybeName.orElse("Unknown");
String upper = maybeName.map(String::toUpperCase).orElse("UNKNOWN");

// Helpful NullPointerExceptions (Java 14+):
// Before Java 14: NullPointerException (no details)
// Java 14+: Cannot invoke "String.length()" because "name" is null
// Much easier to diagnose which variable was null

// Objects utility for null-safe operations:
import java.util.Objects;
Objects.requireNonNull(name, "name must not be null");  // throws with message if null
Objects.isNull(name)      // true if null
Objects.nonNull(name)     // true if not null
String safe = Objects.toString(name, "default");  // returns "default" if null