Java's Two Kinds of Types
Java has two categories of types: primitive types and reference types. Let's briefly review them.
Primitive types include int, double, boolean, etc. They store their values directly. For example, if you have an int x = 5;, the variable x directly holds the value 5. When your program runs, it allocates memory for x and stores the value 5 in that memory location. When you try to print x, the program has a lookup table that tells it where x is stored in memory (let's say at address 0x1234), and it goes to that address, reads the raw data stored there (which is in binary), converts it to an integer (because it knows x is an int), and then prints the result (5).
There is a lot of machinery that happens under the hood to make this work, but the key point is that for primitive types, the variable directly holds the value.
Reference types, on the other hand, do not hold the actual data. Instead, they hold a reference (or pointer) to the location in memory where the actual data is stored.1 Let's walk through an example to illustrate this. Suppose you write Employee e1 = new Employee(1);. This statement is actually doing a few things:
- First, it creates a new
Employeeobject in memory. Let's say this object is stored at address0x5678. - Then, it initializes the fields of that
Employeeobject (e.g., settingidto1). - Then, it creates a variable
e1. This variable also has a memory location (let's say0x1234). - Finally, it stores the reference (the address
0x5678) in the variablee1. Soe1does not hold the actualEmployeeobject; it holds a reference to where that object is stored in memory.
When you access e1.getId() (to print the employee's id as an example), the program does the following:
- It looks up the variable
e1and finds that its memory location is0x1234. - It goes to that memory location and reads the value stored there, which is some binary data.
- It interprets that binary data as a reference (because
e1is of typeEmployee). Here, "reference" simply means an address in memory where the actualEmployeeobject is stored. So it reads the reference and finds that it points to0x5678. - It then goes to the memory location
0x5678where the actualEmployeeobject is stored. - It then reads the
idfield from that object, which is1, returns it fromgetId(), and prints it.
I've glossed over some details here, but the key point is that it has to follow the reference from e1 to get to the actual object in memory. This is what it means for e1 to be a reference type. It does not hold the actual data; it holds a reference to where the data is stored.
But why do we have two types of variables? The answer is more interesting than "because Java says so." It has to do with how variables are stored in memory. Let's explore that next.
What Is a Variable, Really?
You can think of computer memory as a long row of tiny boxes, each box holding a fixed number of bits (usually 8 bits, which is called a byte). Each box has its own address, which is just a number that tells you where it is in the row. The memory is divided into regions, and the regions are used for different purposes and as such have different characteristics.
One of those regions is where your program's variables live. When you declare a variable in your program, the compiler sets aside a block of those boxes (memory units) for it. The variable's name is just a label for that block, so when you use the variable in your code, the program knows to go to that specific location to read or write its value.
Here is the part that matters. The region where variables live has a strict rule: each variable gets a block of a fixed size, decided before the program runs. When your code is compiled, the compiler has to know exactly how much room to reserve for each variable. Why must it be fixed in advance? Because the blocks are laid out next to each other in one continuous row. If variable x is followed by y in memory, the program needs to know where x ends and y begins. If x could grow or shrink while the program ran, it would crash into y. So the size is settled up front, and it never changes.
How much room? That depends on the type. This is, in fact, what the type tells the compiler. When you write int x;, the int says "reserve 4 bytes." A double says "reserve 8 bytes." The type is not just a label for you, the programmer — it is an instruction to the compiler about how big the block must be and how to read the bits inside it.
If the value is something small with a known, fixed size — a whole number, a decimal number, a single character, a true-or-false — then it fits nicely in a fixed-size block. This is exactly what a primitive type is.2
But what if the value cannot be fit into a fixed-size block? What if it is something that can vary in size?
When the Value Doesn't Fit
Let's say we define a variable to hold a person's name: String name;
How big a block should the compiler set aside for name?
An int was easy — every int is exactly 4 bytes, today and forever, so the compiler reserves 4 bytes and moves on. But a name? It could be a small 3-character string like my name, Ali, or a much longer name. The type String can store the entirety of this blog post (or any other text, however long). Moreover, you can change which string the variable refers to while the program runs: assign it a short name now, then assign it a longer name later. There is no single number of bytes the compiler can pick ahead of time that is right for every possible string.
This runs straight into the rule stated earlier: the block reserved for a variable has a fixed size, decided up front, and it never changes. A String has no fixed size. So a string simply cannot live inside the variable's reserved block the way an int does. The two facts are in direct conflict.
So what do we do? The solution is to stop trying to put the string itself in the block. Instead, we put the string somewhere else in memory — in a different region (often called the heap) where objects can be allocated at runtime with whatever size they need. (The program creates a new object of whatever size is needed, and the variable can later point to a different object.)
So what does go into the block reserved for name? We put the address of that somewhere else. The block does not hold the string. It holds a note that says "the string you want is over there, at address 0x5678."
Look at what this buys us. The string itself can be 2 bytes or 2 million; it lives elsewhere, so its size is no longer our problem. And the block corresponding to name stays small and fixed — it only ever holds an address, and an address is the same size no matter how big the thing it points to is (typically 8 bytes on a 64-bit machine). The fixed-size block and the variable-size value can finally coexist, because we stopped trying to hold the value in the block and started holding directions to the value.
That address-sitting-in-a-block is what we call a reference. The block holds a reference to the value; the value lives somewhere else in memory.
You might reasonably push back here. If there is a region of memory that can hold a string of any size, why the detour? Why not let the variable name refer to that region directly, and skip the little fixed block in the middle?
The answer is that the block is the variable, and the variable has to stay put while the thing it refers to does not.
Think about what the name name actually is. When your code is compiled, name becomes one specific block in the variable region — the place the rest of your compiled code reaches every single time it uses name, for the whole life of the variable. That block has to be settled before the program runs, which is the fixed-size rule again.
Now think about the string. It is created while the program runs, not before. At compile time there is no address to point at yet — the string does not exist, and the compiler has no way to know where it will eventually land. And it gets worse: you can point name at a different string later. Write name = "Ali" on one line and name = <something far longer> on the next, and you now have two different strings, of two different sizes, at two unrelated addresses.
So we have a fixed thing — the variable's block, pinned down at compile time — that has to refer to a moving target: a value created at runtime that can be swapped out at any moment. You cannot wire the name directly to the string's address, because when the wiring is decided there is no address yet, and even once there is one, the next line might change it. The block never moves; what changes is the address written inside it. That is what the indirection is for: the block is a stable handle you can always find, and its contents are free to point wherever the variable currently refers.
So here is one important reason Java has two kinds of types. Some values have a small, fixed, known size and can be stored directly in the variable's block — those are the primitives. Objects, on the other hand, are stored elsewhere, and the variable's block stores only a fixed-size reference to the object — those are the reference types. This keeps the variable's block small and fixed even when the object is created at runtime, replaced by a different object, shared by multiple variables, or connected to other objects.
How Big Is an Employee?
Variable size is the first reason references exist, but it is not the only one. There is a second reason, and it is about a problem that has no solution at all without references.
Go back to the Employee class, and assume it has the following definition:
public class Employee {
private int id;
private Employee manager;
// Other fields (like name, title, etc.) are omitted for simplicity
// Constructor, getters, setters, other methods are omitted for simplicity
}
Pay attention to the manager field. It is of type Employee. Interesting: one employee has a reference to another employee. This allows us to create a chain of employees, where each employee can have a manager, and that manager can have their own manager, and so on. You can imagine a junior employee who reports to a mid-level manager, who reports to a senior manager, who reports to the CEO. Each of those employees can be represented as an Employee object, and the manager field can point to the next employee in the chain. You might wonder about the CEO, who has no manager. We can represent that by setting the manager field to null for the CEO.
Let's ask a question that sounds almost too simple: how big is an Employee object? How many bytes must the compiler set aside for one?
We can try to add it up. The id field is an int, so that is 4 bytes. Then there is the manager field, which is an... Employee. So to know how big an Employee is, we first need to know how big an Employee is.
Read that again — it is not a slip. Suppose the manager field stored an entire Employee object inside it, the whole thing laid out inline. Then the size of an Employee would be:
size(Employee) = 4 bytes (id) + size(Employee)
And the size(Employee) on the right has its own manager, which is another Employee, which has its own manager, which is another Employee, with no end in sight. The block would need room for an employee, who holds an employee, who holds an employee, forever. No number of bytes satisfies this. The compiler cannot lay out even a single Employee in memory. The object is, quite literally, infinitely large.
So storing an Employee object directly inside another Employee object, by value, is not merely a bad idea — it is impossible. A type cannot contain a full inline copy of itself.
Now watch what a reference does to this.
If manager holds a reference to an Employee instead of an Employee itself, then manager is just an address. An address has a fixed, known size — the same handful of bytes whether it points to a junior hire, the CEO, or nothing at all. The infinite regress collapses:
size(Employee) = 4 bytes (id) + 8 bytes (a reference)
Around twelve bytes — the JVM adds a little bookkeeping of its own, but the point is that it is a fixed, finite number, known before the program runs. The compiler can lay it out without trouble. And that manager reference can point to another Employee who lives elsewhere in memory, who has their own manager reference pointing to yet another Employee elsewhere, and so on up to the CEO, whose manager reference is simply null.
This is the second reason references have to exist. Without them, no value could ever contain another value of its own type, and an entire family of structures would be impossible to build. This is also why Java never gives you the choice: a field whose type is a class is always a reference, never the object itself laid out inline. The language closes the trap before you can fall into it.
With references, a type can refer to its own kind — and that single capability is the foundation of a whole family of data structures, including linked lists, trees, graphs, and more.
Technically, Java references are not literally memory addresses in the way C/C++ pointers are. They are opaque values managed by the JVM. But in this write-up, we will treat references as memory addresses to keep the mental model simple and avoid adding extra cognitive load.
↩Even though the value of a variable changes, for primitive types, the size of the value does not change. An
↩intis always 4 bytes, and with that it can represent a huge range of numbers from -2 billion to +2 billion; this is also the reason that anintcannot represent a number larger than 2 billion — it simply does not have enough bits to do so. If anintcomputation produces a value outside that range, it can overflow and wrap around to a negative number. A literal that is too large to be anint, such asint x = 3000000000;, is caught as a compile-time error instead. But the key point is that the size of anintis always 4 bytes, no matter what value it holds.