Photo by Iva Rajović on Unsplash
Understanding Value Types and Reference Types: Stack and Heap Memory
The previous post introduced the concepts of Object and Class and how they are used to develop computer programs. This article will explain how objects are stored in the computer's memory compared to primitive data types. This is crucial for comprehending the memory management that occurs when a program runs and being able to write efficient and error-free code.
The Importance of Understanding Memory Management
To highlight the importance of understanding memory management, let’s consider the following simple program:
class Mutator {
public void increment(int n) {
n++;
}
public void upperCase(String s) {
s.toUpperCase();
}
public void changeColour(Cat c) {
c.setColour("black");
}
}
public class Program {
public static void main(String[] args) {
Mutator mutator = new Mutator();
int n = 100;
System.out.println("n before increment: " + n);
mutator.increment(n);
System.out.println("n after increment: " + n);
String river = "mississippi";
System.out.println("river before uppercase: " + river);
mutator.upperCase(river);
System.out.println("river after uppercase: " + river);
System.out.println();
// Cat is a user-defined class with a single
// attribute 'colour' and associated get/set methods
Cat foo = new Cat("pink");
System.out.println("cat colour before mutation: " + foo.getColour());
mutator.changeColour(foo);
System.out.println("cat colour after mutation: " + foo.getColour());
}
}
The program consists of a main
where three different variables are created, namely an int
(primitive), as well as objects of type String
(system-defined) and Cat
(user-defined). The allocated structures are then passed to different methods of a Mutator
class that tries to update their content. After the method invocation, the program prints the results of the performed updates:
n before increment: 100
n after increment: 100
river before uppercase: mississippi
river after uppercase: mississippi
cat colour before mutation: pink
cat colour after mutation: black
If you are wondering why the method calls affected only the content of the Cat
object in the main
—leaving the other structures unchanged—you should continue reading the rest of the article.
The differences shown in the above output stem from how various data types are allocated in memory and managed when a program is executed. This blog post will delve into value type and reference type variables, exploring how they are represented in a computer's stack and heap memory and passed between method calls.
Value Types vs. Reference Types: A Primer
Before diving into the memory allocation, let's define the meaning of the terms value type and reference type in a high-level programming language such as Java or C#.
Understanding Value Types
Value types are fundamental language built-in data types such as int
, double
, boolean
, and char
. These types are characterised by their fixed, well-defined, and predictable size, typically ranging from 8 to 64 bits.
We should see a variable like a fixed-size container. Therefore, value types can store the data values directly within a variable. For instance, when you declare an integer variable like int x = 10;
, the value 10
is stored directly in the memory location associated with x
. This direct storage makes value types efficient for both storage and manipulation.
Exploring Reference Types
Reference types include arrays and objects. Objects can be either language built-in, like those of type String
, or user-defined through classes. Examples of user-defined objects include Person, Car, and BankAccount, as discussed in the previous post.
Unlike value types, reference types do not have a predictable size and may be too big to store the actual data within a variable. Instead, they use a variable to store a reference or memory address pointing to where the data is located in memory. For example, when you create an object with BankAccount account1 = new BankAccount("AB456", 200);
, the variable account1
holds the address of the BankAccount
object in memory rather than the object itself.
The Role of Stack and Heap Memory
In another article, we explained that running an object-oriented program involves different objects interacting by sending messages, specifically by calling methods provided by other objects. When a method runs, the language runtime needs to manage the available memory to allocate the method's local variables and parameters.
A program’s memory usually consists of two main areas—the stack and the heap—where value types and reference types variables will be stored. This section will show that these two areas serve different purposes and have distinct characteristics crucial for efficient memory management.
Stack Memory: Efficiency and Speed
The stack memory is a limited, structured region that operates on a Last In, First Out (LIFO) basis. This means the most recently added data is the first to be removed. It can be seen as a pile of dishes where we have direct access only to the last one added to the top.
The Stack Pointer keeps track of the top of the stack. It is implemented via a CPU register that stores the memory address of the last item added to the stack. The address stored in the Stack Pointer is represented in green in the figure below, and it moves up when data is pushed onto the stack and down when it is popped off.
The operating system determines the stack size (allocated to a program’s thread), which can vary depending on the system and the program’s requirements. This limitation can lead to a stack overflow if too much memory is used, such as with deep or infinite recursion.
The stack is primarily used for storing method parameters, local variables, and other temporary data associated with method execution. Its structure allows for fast access because the memory allocation and deallocation follow a predictable pattern. When a method is called, its parameters and local variables are pushed onto the stack. Once the function execution is complete, this data is popped off the stack, making the stack memory available for the next method call. This simplicity and predictability make stack memory very efficient for managing short-lived data.
It should be noted that value-type data is stored directly on the stack; however, reference-type data is not kept on the stack and lives on the heap, as described in the next section.
Heap Memory: Flexibility and Persistence
Heap memory provides a larger and more flexible storage space. Unlike the stack, the heap does not follow a strict order for allocation and deallocation, which allows for dynamic memory allocation.
This flexibility is particularly useful for reference types like objects and arrays, which have a longer lifecycle than method parameters and local variables. Moreover, reference type size is unpredictable and can even change during runtime (as with some Collections); hence, the associated data are conveniently allocated on the heap.
However, this flexibility comes at a cost. Accessing data from the heap is slower than the stack because it involves more complex memory management. Over time, the heap can become fragmented, meaning free memory blocks are scattered throughout the assigned heap space. This fragmentation makes it harder to find contiguous memory blocks for new allocations, leading to inefficient memory usage and potentially slower performance.
Memory Allocation in Practice
To understand how memory allocation works in practice, consider the following code example with methodA
, methodB
, and methodC
. For simplicity, we are not focusing on the specific classes where these methods are defined and assuming they are mutually accessible.
void methodB(double b1)
{
double b2 = b1;
double b3 = 6.28;
}
void methodA(double a1, int a2)
{
int a3 = 10;
double a4 = a1;
methodB(a4);
methodC(a3);
}
void methodC(int c1)
{
int c2 = 15;
double c3 = 3.14;
ClassX c4 = new ClassX();
}
Creating Local Variable Frames
When a method is executed, the language runtime allocates space on the stack based on the number of parameters defined by the method’s signature and the local variables declared within its body. This allocated space is known as the method's local variable frame or stack frame.
As methods are called, the associated local variable frames are created and pushed on the stack, where the top one refers to the method being executed. The set of frames stored in a given moment on the stack is called the call stack.
When a method’s execution is completed, the associated frame is popped off the stack. Local variable frames are created and deleted on the stack by the language runtime according to the LIFO (Last In, First Out) approach.
The Stack Pointer keeps track of the top of the stack, namely the end address of the current method's local variable frame. To facilitate memory management, another CPU register—the Base Pointer (or Frame Pointer)—references the start address of the current method’s local variable frame.
The following figure shows the local variable frame for methodA
defined in the previous code example (where, for simplicity, the call to methodC
was removed as the last instruction of methodA
's body), highlighting the position of the Base Pointer (blue) and Stack Pointer (green). The local variable frame is created on the stack to accommodate parameters a1
and a2
, as well as local variables a3
and a4
:
When methodB
is called from methodA
, a new local variable frame for methodB
is created at the top of the stack, and the content of the Base Pointer and Stack Pointer registries is updated accordingly:
In the above figures, the content of the allocated memory space was intentionally left empty, as this aspect will be explained in the next section.
Method Invocation and Parameter Passing
During method invocations, Java (and C#) uses a default "pass by value" mechanism, meaning copies of the arguments are passed to the parameters of the called method. Since each method has its local variable frame, these copies live in separate memory locations of different local variable frames.
Notably, all the method parameters and local variables of methodA
and methodB
are of value type, and, as a result, the associated data are stored directly in their memory locations on the stack.
An example of how value type parameters are passed during method invocation is shown in the following figure for methodA
's a4
variable, which is used as the argument of methodB
's b1
parameter:
When methodB
is called by methodA
, the content of a4
in methodA
's stack frame is passed and copied into b1
space in methodB
's stack frame. As such, they are different areas of memory storing just copies of the same content.
The figure also shows that methodA
was called passing the values 3.0
and 4
for a1
and a2
, respectively. The content of the local variable a3
was updated accordingly.
Deallocation: Cleaning Up the Stack
When a method's execution terminates, its local variable frame is removed from the stack, and the control is passed back to the previous calling method. From the previous examples, when methodB
terminates, the associated local variable frame is no longer needed. Therefore, the control is returned to methodA
. The Base Pointer and Stack Pointer registries are updated accordingly, allowing methodA
to continue its execution based on its existing local variable frame:
The allocation and deallocation of the stack frames follow the LIFO behaviour of the stack. Therefore, if another method, say, methodC
was to be called from methodA
after methodB
's execution, a local variable frame for methodC
would be added to the top of the stack, overwriting the memory space previously used by methodB
:
Reference Types in the Heap: A Closer Look
The code snippet presented at the beginning of this section (and also reported in the next figure) shows that a reference type variable, c4
, of type ClassX
, is declared inside the body of methodC
. Unlike value types, when a reference type variable is declared and a new object instance is created, the object data is not stored directly on the stack.
Let's assume ClassX
defines two attributes of the int
value type, attr1
and attr2
. When an instance of ClassX
is created using the new
operator, the (Java or C#) runtime allocates memory for this object on the heap. This means the object created within c4
is not stored directly in c4
on the stack. The object (data) is allocated in the heap memory, and the variable c4
, allocated on the stack, instead contains a reference to the object's location—the object's address:
It should be noted that the value type attributes, attr1
and attr2
, are also stored within this object's memory allocation on the heap. This means that the memory for attr1
and attr2
is part of the object's memory block. Therefore, when a program accesses or modifies these attributes, it will work with the values stored inside the object on the heap.
Conclusions
Understanding how objects and primitive data types are stored in memory is crucial for writing efficient code. This article has highlighted the differences between value types and reference types and their management in stack and heap memory.
In Java and C#, method parameters and local variables of value types declared inside methods are allocated directly on the stack memory, which operates on a LIFO basis and is ideal for short-lived data. It efficiently handles method parameters and local variables, freeing up memory once a method completes.
Reference types, such as objects and arrays created within methods using the new
operator, are allocated in the heap memory. This includes their attribute values, even if those attributes are of primitive value types. Heap memory supports objects with longer lifecycles, allowing them to persist beyond a single method call. It enables dynamic memory allocation, making it suitable for objects accessed throughout a program’s execution.
By understanding these concepts, developers can better manage memory, avoid common pitfalls like stack overflow, and write more efficient programs. This knowledge is fundamental for optimising performance and ensuring the reliability of software applications.