Appearance
Reference Types, Wrappers, and How Java Really Manages Memory
You learned about primitives in the last lecture. Primitives are simple: an int holds a number, a boolean holds true or false, and they live right there on the stack in a fixed amount of memory. But Java has a whole second category of data types called reference types, and they work completely differently. Understanding this difference is not optional. It shows up in interviews constantly, it explains bugs that seem inexplicable at first, and it is the foundation for understanding everything from collections to threading to garbage collection.
This lecture covers reference types, the Stack and Heap memory model, Strings and the String constant pool, wrapper classes, autoboxing, the Integer cache trap, and constants. Every concept in here has appeared in Java interviews. Pay close attention.
What Makes Something a Reference Type
Let us start with what a reference type actually is, because the name is descriptive once you understand it.
When you declare a primitive variable like int a = 10, the variable a directly contains the value 10. The variable and the value are the same thing, living in the same place in memory.
Reference types are different. When you create an object, that object goes into a region of memory called the Heap. The variable you declared does not hold the object itself. It holds the memory address where the object lives. That address is the reference.
Think of it like a TV remote control and a television. The TV is the actual object sitting on your shelf. The remote control is not the TV. The remote just has information that lets you interact with the TV. If you hand someone else a copy of the remote, you both now control the same TV. Changes one person makes affect what the other person sees, because there is only one television.
In Java, the object on the Heap is the TV. The variable holding its address is the remote. You can have many variables all pointing to the same single object.
java
// Creating a class (a blueprint for objects)
class Employee {
int employeeId;
String name;
}
// Creating an object
Employee emp = new Employee();
emp.employeeId = 10;
// The memory picture:
// Stack: emp = [address: 0x7f4a2c00] <-- the variable holds an address
// Heap: at 0x7f4a2c00 --> { employeeId=10, name=null } <-- the actual objectThe variable emp is on the Stack. The Employee object is on the Heap. The variable only holds the address of the object. That is why this category is called reference types: the variable is a reference to the actual memory.
Now watch what happens when you assign one reference variable to another:
java
Employee obj2 = emp; // copies the address, NOT the objectNow both emp and obj2 hold the same address. They both point to the same Employee object on the Heap. There is still only one object.
java
obj2.employeeId = 30;
System.out.println(emp.employeeId); // prints 30 -- same object, changed through obj2This surprises beginners. But once you understand that both variables are just remote controls for the same TV, it makes complete sense. Changing the object through one reference changes it for everyone holding a reference to it.
Stack and Heap Memory: The Full Picture
Java uses two distinct regions of memory, and knowing which data lives where explains a huge amount of behavior.
The Stack is where method execution lives. Every time you call a method, Java creates a new frame on the Stack for that method. That frame holds the method's local variables and the values of primitive types. When the method finishes, its frame is popped off the Stack and all that memory is immediately reclaimed. The Stack is fast, organized like a stack of plates, and completely automatic.
The Heap is where all objects live. When you write new Employee(), Java allocates memory on the Heap for that Employee object. Objects on the Heap are not tied to any particular method. They can outlive the method that created them. The Heap is managed by the Garbage Collector, which cleans up objects that no longer have any references pointing to them.
Think of the Stack like your desk. You keep things there while you are actively working, and when you are done you clear them off. The Heap is like a warehouse. Objects get stored there and stay until nobody needs them anymore.
java
void someMethod() {
int x = 5; // x is a primitive, lives on the Stack in this frame
Employee emp = new Employee(); // emp (the address) is on the Stack
// the actual Employee object is on the Heap
emp.employeeId = 10;
}
// When someMethod() finishes:
// x is gone (Stack frame popped)
// emp variable is gone (Stack frame popped)
// The Employee object on the Heap may still exist until GC collects itThis distinction is not just theoretical. It is why primitives cannot be null (a Stack value always exists), while reference types can be null (the variable exists on the Stack but holds no address, meaning it points to nothing).
Java Is ALWAYS Pass by Value
This is one of the most commonly asked Java interview questions, and a significant number of experienced developers still get it wrong.
People often say "Java passes objects by reference." This is incorrect. Java is strictly pass by value, always, with no exceptions. The confusion comes from not understanding what value is being copied.
When you pass a primitive to a method, Java copies the actual value. The method gets its own copy and can do whatever it wants with it without affecting the original.
java
static void modifyPrimitive(int x) {
x = 20; // only modifies the local copy in this method
}
int a = 10;
modifyPrimitive(a);
System.out.println(a); // still 10 -- the original was never touchedWhen you pass an object to a method, Java copies the reference (the address). The method gets a copy of the address. But here is the key: a copy of the address still points to the same object on the Heap.
java
static void modify(Employee e) {
e.employeeId = 20; // changes the actual object through the copied address
}
Employee emp = new Employee();
emp.employeeId = 10;
modify(emp);
System.out.println(emp.employeeId); // prints 20 -- the object was mutatedSo it looks like pass by reference because mutations are visible. But watch what happens when you try to reassign the parameter:
java
static void tryToReplace(Employee e) {
e = new Employee(); // only reassigns the LOCAL copy of the address
e.employeeId = 99;
}
Employee emp = new Employee();
emp.employeeId = 10;
tryToReplace(emp);
System.out.println(emp.employeeId); // still 10 -- caller's variable was NOT reboundThe method got a copy of the address. Reassigning that copy to point somewhere else does not affect the caller's variable. If Java were truly pass by reference, this reassignment would be visible to the caller. It is not. That is the proof that Java is pass by value.
The complete answer for any interview: "Java always passes a copy of the value. For primitives, that value is the data itself. For objects, that value is the memory address (reference). Mutations through a copied address affect the same Heap object and are visible to the caller. But reassigning the parameter variable does not affect the caller's reference. Java has no true pass by reference."
Strings: A Reference Type with Special Powers
String is a reference type in Java, not a primitive. Even though it looks like one and you use it constantly, there are important differences from primitives and important differences from ordinary reference types.
The most important one is the String Constant Pool.
The String Constant Pool
Inside the Heap, there is a special area called the String Constant Pool (sometimes called the String Pool or SCP). When you write a string literal in your code, the JVM does something clever before creating anything:
- It checks whether an identical string already exists in the pool.
- If it does, your variable gets a reference to that existing pooled object. No new object is created.
- If it does not, the JVM creates the string in the pool and your variable references it.
java
String s1 = "hello"; // "hello" created in the String Constant Pool
String s2 = "hello"; // "hello" already in pool -- s2 gets the SAME reference as s1Now s1 and s2 both point to the exact same object. There is only one "hello" in memory, shared by both variables.
This optimization saves memory and improves performance. In a large application, the same strings appear thousands of times. Without the pool, you would have thousands of duplicate objects eating up Heap space.
What new String() Does Differently
When you use the new keyword with String, you bypass the pool entirely:
java
String s3 = new String("hello"); // creates a NEW object on the Heap, OUTSIDE the poolNow s1 and s3 both contain the text "hello", but they are two completely separate objects in different locations in memory. s3 is not in the String Constant Pool. It is just an ordinary Heap object.
The == vs .equals() Distinction
This is where the pool creates one of Java's most common bugs and interview questions.
The == operator, when used on reference types, compares memory addresses. It asks: "Are these two variables pointing to the exact same object?"
The .equals() method on String compares the actual character content. It asks: "Do these two strings contain the same text?"
java
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
// == compares addresses (object identity)
System.out.println(s1 == s2); // true -- same pooled object, same address
System.out.println(s1 == s3); // false -- s3 is a different object at a different address
// .equals() compares content
System.out.println(s1.equals(s2)); // true -- both contain "hello"
System.out.println(s1.equals(s3)); // true -- both contain "hello"Never use == to compare String values in real code. Always use .equals(). The == result depends on whether the string came from the pool or from new String(), which is an implementation detail you should not rely on.
Why Strings Are Immutable
Once a String object is created, it cannot be changed. This is not an accident. It is a deliberate design decision with serious reasons behind it.
When you write this:
java
String s1 = "hello";
String s2 = "hello";
s1 = "hello world";You have not modified the string "hello". You have created a new string "hello world" (or found it in the pool if it already exists) and pointed s1 at it. The original "hello" string is unchanged. s2 still safely references "hello".
Now think about why this matters for the pool. The pool works because multiple variables share the same object. If strings were mutable, one variable could change "hello" to "goodbye" and every other variable pointing to the same pooled object would suddenly see "goodbye". That would be catastrophic. Immutability is what makes sharing safe.
There are four reasons strings are immutable, and you should know all of them for interviews:
First, pool safety. Shared pooled strings cannot be corrupted by one variable modifying them.
Second, thread safety. Immutable objects can be safely shared across threads without synchronization. Since the content can never change, no thread can see an inconsistent state.
Third, hashing. String is the most commonly used key in HashMap and HashSet. The hash code is based on the string's content. If the content could change after the string was inserted into a map, the hash code would change and the string would end up in the wrong bucket, making it impossible to find. Immutability guarantees stable hash codes.
Fourth, security. Class names, file paths, database connection strings, and network addresses are all represented as strings. If they could be mutated after security checks pass, an attacker could change them. Immutability prevents this attack vector.
Interface References
An interface is also a reference type. You cannot create an instance of an interface directly, but you can declare a variable of an interface type and store any object whose class implements that interface.
java
interface Person {
String profession();
}
class Engineer implements Person {
public String profession() {
return "Software Engineer";
}
}
class Teacher implements Person {
public String profession() {
return "Teacher";
}
}
// Interface reference holding a child class object -- valid
Person p1 = new Engineer();
Person p2 = new Teacher();
// This would be a compile error -- cannot instantiate an interface
// Person p3 = new Person();
System.out.println(p1.profession()); // Software Engineer
System.out.println(p2.profession()); // TeacherThe variable p1 is typed as Person, but the actual object is an Engineer. When you call p1.profession(), Java calls Engineer's implementation at runtime. This is polymorphism. You will explore this much more deeply when you study interfaces and inheritance.
You can also store an object in a variable of its own type:
java
Engineer e = new Engineer(); // storing in same class type
Person p = e; // also valid, since Engineer implements PersonBoth variables point to the same Engineer object. The type of the variable determines which methods you can call. Through a Person reference, you can only call methods declared in Person. Through an Engineer reference, you can call all methods on Engineer.
Arrays Are Reference Types Too
Arrays in Java are objects. When you create an array, the array object is allocated on the Heap and your array variable holds a reference to it, just like any other reference type.
java
// 1D array
int[] arr = new int[5]; // creates an int array of 5 elements, all defaulting to 0
arr[3] = 40;
// Array literal -- size is inferred from the initializer
int[] numbers = {30, 20, 10, 40, 50};
// 2D array
int[][] matrix = new int[5][4]; // 5 rows, 4 columns, all zeros
matrix[2][2] = 20; // set row 2, column 2 to 20
// 2D array literal
int[][] grid = { {1, 5, 7}, {4, 2, 3} };
// grid[1][2] == 3 (row index 1, column index 2)The square brackets can go before or after the variable name. Both int[] arr and int arr[] are legal Java. The first form is preferred by convention.
Because arrays are objects on the Heap, assigning one array variable to another copies the reference, not the array:
java
int[] a = {1, 2, 3};
int[] b = a; // b points to the same array object
b[0] = 99;
System.out.println(a[0]); // 99 -- same array, changed through bThis is the same remote control behavior you saw with Employee objects.
Wrapper Classes: Giving Primitives Object Superpowers
Primitives are fast and efficient, but they have limitations. The most painful limitation appears when you try to use them with Java's collection framework. You cannot do this:
java
List<int> numbers = new ArrayList<>(); // COMPILE ERROR -- int is not an object typeJava's generic types (List<T>, Map<K,V>, Set<T>) require objects. Primitives are not objects. They cannot be used as generic type parameters.
To solve this, Java provides a wrapper class for each primitive type. A wrapper class is an ordinary Java class that contains a primitive value inside it, making it an object that can participate in collections and generics.
| Primitive | Wrapper Class |
|---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
Notice the naming pattern. Most wrapper class names are just the capitalized primitive name. The exceptions are int which becomes Integer and char which becomes Character.
There are two main reasons wrapper classes exist.
First, collections. Every generic collection type requires objects. To store int values in a List, you must use List<Integer>.
Second, nullability. A primitive int cannot be null. An Integer can be null. When you need to represent the absence of a value, as opposed to the value zero, you need a wrapper type.
java
// Cannot store primitives in collections
List<Integer> scores = new ArrayList<>();
scores.add(95); // works -- Integer can go in the list
scores.add(87);
// Wrapper types can be null
Integer maybeAge = null; // valid
int plainAge = null; // COMPILE ERROR -- primitive cannot be nullAutoboxing and Unboxing
Writing Integer.valueOf(10) every time you wanted to add a number to a list would be exhausting. Java solves this with autoboxing and unboxing.
Autoboxing is the automatic conversion from a primitive to its wrapper object. Unboxing is the automatic conversion from a wrapper object back to its primitive. The compiler inserts these conversions for you invisibly.
java
Integer a = 10; // AUTOBOXING: compiler converts int 10 to Integer.valueOf(10)
int x = a; // UNBOXING: compiler extracts the int value from the Integer object
List<Integer> list = new ArrayList<>();
list.add(5); // AUTOBOXING: 5 (int primitive) becomes Integer(5) object
int first = list.get(0); // UNBOXING: Integer(5) becomes int 5Autoboxing and unboxing happen transparently. You write natural code and the compiler handles the conversions. But you need to know this is happening, because it has implications for performance (unnecessary object creation in tight loops) and for the behavior of ==.
The Integer Cache: A Classic Interview Trap
When Java autoboxes an int value into an Integer, it does not always create a new object. For values between -128 and 127 inclusive, Java maintains a cache of pre created Integer objects. Instead of creating a new object, it returns the cached one.
This range was chosen because small integers appear extremely frequently in typical programs. Caching them reduces garbage and speeds up code.
The trap: when you compare two Integer variables with ==, you are comparing object references, not values. For cached values, both variables point to the same cached object, so == returns true. For values outside the cache range, new objects are created each time, so == returns false even if the values are equal.
java
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true -- both are the SAME cached object
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false -- different objects (outside cache range)
// Always use .equals() to compare wrapper values
System.out.println(c.equals(d)); // true -- compares the actual int valuesThis is one of the most frequently asked Java interview questions. The answer depends on the value range. Values from -128 to 127 will show == as true because they share cached objects. Values outside that range will show == as false because they are distinct objects. Always use .equals() to compare Integer values.
Useful Methods on Wrapper Classes
Wrapper classes are not just containers. They come with useful utility methods:
java
// Parsing strings to primitives
int parsed = Integer.parseInt("42"); // converts the String "42" to int 42
double d = Double.parseDouble("3.14"); // converts "3.14" to double
// Converting primitives to strings
String s = Integer.toString(42); // converts int 42 to String "42"
// Boundary values
int max = Integer.MAX_VALUE; // 2147483647 (2^31 - 1)
int min = Integer.MIN_VALUE; // -2147483648 (-2^31)
// Bit manipulation utilities
int bits = Integer.bitCount(255); // counts how many bits are 1: returns 8
String binary = Integer.toBinaryString(10); // "1010"
String hex = Integer.toHexString(255); // "ff"
String octal = Integer.toOctalString(8); // "10"The parseInt method is extremely commonly used. Any time you receive numeric data as text (from user input, file reading, HTTP parameters), you parse it to a primitive using these methods.
Why Primitives Are Not Stored on the Heap
You might wonder: if objects live on the Heap, why not just make everything an object and put everything there? Why have primitives at all?
Heap access is slower than Stack access. Every time you access a Heap object, there is indirection: follow the reference to find the object, then access the data. Stack access is direct. Primitives, being directly on the Stack, avoid this indirection.
Memory overhead is also significant. Every object on the Heap carries metadata (type information, hash code, synchronization state). An int is 4 bytes. An Integer object is typically 16 bytes on a 64-bit JVM, four times larger, just to store the same number. In an array of a million numbers, this difference is enormous.
For performance critical code like numerical computation, using primitives (int, double) instead of wrappers (Integer, Double) makes a measurable difference. This is why Java has both: primitives for performance, wrappers for when you need objects.
Constants: When a Value Should Never Change
A constant in Java is a variable whose value is set once and never changes. You declare constants using two keywords together: static and final, plus the convention of ALL_CAPS naming.
java
class MathConstants {
static final double PI = 3.14159265358979;
static final int MAX_CONNECTIONS = 100;
static final String DEFAULT_HOST = "localhost";
}static means there is one copy of this variable for the entire class, not one per object. All instances of the class share it. You do not need to create an object to access it.
final means the value cannot be reassigned after it is initialized. Any attempt to reassign a final variable is a compile error.
java
static final int EMPLOYEE_ID = 10;
// Later in code:
EMPLOYEE_ID = 20; // COMPILE ERROR -- cannot assign a value to a final variableTogether, static and final create a true constant: one copy for the whole program, and that copy never changes.
Without static, a final variable would still be unchangeable, but each object would have its own copy. That is useful for instance constants but wasteful for program wide constants. Adding static ensures you are not creating redundant copies.
Without final, a static variable would be shared but mutable. Any object could change it and corrupt the shared value for everyone. Adding final prevents that.
The ALL_CAPS naming with underscores separating words (like MAX_VALUE, DEFAULT_HOST) is a Java convention that signals to readers that this is a constant, not a regular variable.
Putting It All Together: The Reference Types
Java has four categories of reference types:
Classes are the most common. Any class you write or that exists in the Java standard library produces reference types. Employee, String, ArrayList, HashMap are all class types. Objects of these classes live on the Heap.
Interfaces cannot be instantiated, but interface variables hold references to objects of implementing classes. Person p = new Engineer() is valid because Engineer implements Person.
Arrays are objects on the Heap. Any array type, whether int[], String[], or Employee[][], is a reference type. The variable holds an address to the array object, not the array itself.
Enums and annotation types are also reference types, though you will study them in dedicated lectures.
The key insight that ties everything together: whenever you see the word new in Java, you are creating a Heap object and getting back a reference to it. The variable you assign it to is just a label for that address. Everything flows from there: why sharing references allows mutation, why reassigning a parameter does not affect the caller, why two variables can point to the same object, why the Garbage Collector can reclaim objects when no references remain.
Interview Questions Summary
These questions come up repeatedly in Java interviews, and you now have the knowledge to answer all of them completely.
What is the difference between primitive and reference types? Primitives store the actual value directly (on the Stack). Reference types store a memory address (on the Stack) pointing to an object on the Heap.
Where are objects stored in Java memory? On the Heap. Reference variables (the addresses) are on the Stack.
Is Java pass by value or pass by reference? Always pass by value. For primitives, the value copied is the data. For objects, the value copied is the reference (address). Mutations through a copied reference affect the original object. Reassigning the parameter does not affect the caller's variable.
What is the String Constant Pool? A special area in the Heap where string literals are stored and deduplicated. Two string literals with the same content share a single object.
When should you use == vs .equals() for Strings? Always use .equals() for comparing string content. Use == only when you specifically need to check if two variables point to the exact same object (which is rare with strings).
What does new String("hello") do differently from "hello"? The literal "hello" checks the pool and may reuse an existing object. new String("hello") always creates a fresh Heap object outside the pool.
Why are Strings immutable? Pool safety (shared strings cannot be corrupted), thread safety (immutable objects need no synchronization), hashing stability (hash code stays constant, making String a reliable HashMap key), and security (prevents mutation of class names and paths after security checks).
What is autoboxing? The automatic compiler conversion from a primitive to its wrapper object. Integer a = 10 autoboxes the int 10.
What is unboxing? The automatic compiler conversion from a wrapper object to its primitive. int x = someInteger unboxes the Integer.
What is the Integer cache? Java caches Integer objects for values from -128 to 127. Autoboxing an int in this range reuses a cached object. Values outside the range always create new objects.
Why does Integer a = 127; Integer b = 127; a == b return true? Because both are autoboxed to the same cached Integer object for value 127. This is within the cache range.
Why does Integer a = 128; Integer b = 128; a == b return false? Because 128 is outside the cache range. Two separate Integer objects are created, and == compares their addresses, which differ.
What are wrapper classes and why do they exist? Wrapper classes wrap primitives in objects. They exist because Java's generic collections require object types (primitives cannot be generic type parameters), and because wrapper types can be null while primitives cannot.
What is a constant in Java? A static final field. static ensures one shared copy. final prevents reassignment. ALL_CAPS naming is convention.