The Java runtime manages memory for you, but not magically. Objects live on the heap, the garbage collector reclaims heap memory that is no longer reachable, and the stack holds the frames of running methods. Understanding how those areas are divided explains most of what people call "Java memory problems" - which are usually either a leak in your own code or a heap that is simply too small for what you asked it to hold.
Where does memory live?
There are three areas you will meet early. The heap holds every object and array - it is where the garbage collector works. The stack holds the local variables and the bookkeeping for each method call, and it grows and shrinks as methods are entered and left. The metaspace holds the class metadata - the description of your types themselves - and it lives outside the heap.
public class MemoryAreas {
static int staticField = 7;
int instanceField = 9;
public static void main(String[] args) {
int local = 5;
System.out.println("static field : " + staticField);
System.out.println("instance field : " + new MemoryAreas().instanceField);
System.out.println("local : " + local);
Runtime runtime = Runtime.getRuntime();
long total = runtime.totalMemory();
long free = runtime.freeMemory();
System.out.println("heap in use (MB) : " + (total - free) / (1024 * 1024));
System.out.println("max heap (MB) : " + runtime.maxMemory() / (1024 * 1024));
System.out.println("available processors : " + runtime.availableProcessors());
}
}static field : 7
instance field : 9
local : 5
heap in use (MB) : 1
max heap (MB) : 2548
available processors : 4Two distinctions explain most Java's behaviour. A local variable declared inside a method lives on the stack and disappears when the method returns. A field of an object lives in the heap and disappears when the object becomes unreachable. And a static field lives in the heap for as long as the class is loaded, which is why static collections are the classic source of leaks.
What does "unreachable" mean?
The garbage collector does not look for objects that are "no longer used". It looks for objects that cannot be reached from a root. The roots are the references held by live thread stacks, by static fields, and by the runtime itself. If nothing can reach an object, it is garbage - even if some other object still holds a reference to it, as long as that holder is itself unreachable.
import java.util.ArrayList;
import java.util.List;
public class LeakDemo {
static final List<String> KEPT = new ArrayList<>();
static class Node {
final String name;
Node(String name) {
this.name = name;
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
Node node = new Node("node-" + i);
KEPT.add(node.name);
}
System.out.println("static list size : " + KEPT.size());
Runtime runtime = Runtime.getRuntime();
long used = runtime.totalMemory() - runtime.freeMemory();
System.out.println("heap in use (MB) : " + used / (1024 * 1024));
KEPT.clear();
System.gc();
Thread.sleep(100);
long after = runtime.totalMemory() - runtime.freeMemory();
System.out.println("after clearing (MB) : " + after / (1024 * 1024));
}
}static list size : 1000
heap in use (MB) : 1
after clearing (MB) : 1That is the whole model, and it explains how leaks happen. A static collection that keeps growing is a root that keeps everything in it alive forever, even though your program has no interest in the old entries. The program in the output holds a thousand names in a static list; clear it and the memory becomes collectable again. The same shape appears in real applications: a cache with no eviction policy, a listener registry that never removes listeners, a static map used as a scratch pad.
What are weak, soft and phantom references?
When you genuinely want an object to be collectable even though something still points at it, you can hold it through a reference wrapper. A WeakReference does not prevent collection. A SoftReference is kept as long as memory is comfortable and dropped when it is not. A PhantomReference is for cleanup callbacks and is rarely needed.
import java.lang.ref.WeakReference;
public class ObjectLifetime {
static WeakReference<String> tracked;
static void create() {
String local = "a string that can be collected";
tracked = new WeakReference<>(local);
System.out.println("inside create : " + tracked.get());
}
public static void main(String[] args) throws Exception {
create();
System.gc();
Thread.sleep(100);
System.out.println("after gc, still referenced : " + (tracked.get() != null));
}
}inside create : a string that can be collected
after gc, still referenced : trueThe output shows the reference still resolving immediately after the method returned - the garbage collector is allowed to run at any time, and asking it to with System.gc() is a hint, not a command. The important part is the contract: a weak reference never keeps its referent alive, so a cache built on weak references cannot leak.
What happens when memory runs out?
When the heap cannot satisfy an allocation, the collector runs harder. If it still cannot free enough, the runtime throws an OutOfMemoryError. That is an Error, not an Exception, so a catch (Exception e) will not catch it.
import java.util.ArrayList;
import java.util.List;
public class FinalizationAndErrors {
public static void main(String[] args) {
List<byte[]> blocks = new ArrayList<>();
try {
for (int i = 0; i < 200; i++) {
blocks.add(new byte[1024 * 1024]);
}
System.out.println("allocated 200 MB");
} catch (OutOfMemoryError e) {
System.out.println("caught : " + e.getClass().getSimpleName());
} finally {
blocks.clear();
}
System.out.println("program survived and cleaned up");
}
}allocated 200 MB
program survived and cleaned upThat program tries to allocate two hundred megabytes at once. On a machine with a small default heap it fails, and the catch block catches the error so the program can still exit cleanly. The lesson is not that you should catch it - you should not run out of memory in the first place - but that a long-running service should at least fail loudly rather than corrupt state and carry on.
How does the collector decide what to free?
Modern collectors work on the observation that most objects die young, so they separate objects by age. New objects are allocated in a small area and survive a collection only if they are still reachable. Older objects that have survived several collections are promoted to a long-lived area, which is collected far less often. The tuning knobs - heap size, region sizes, pause targets - exist to balance memory use against the time the collector pauses the application.
The practical advice is unchanged whatever the collector is: do not create millions of short-lived objects if you can avoid it, and do not hold on to objects you no longer need.
How do you diagnose a memory problem?
- Read
Runtime.getRuntime()for total, free and maximum heap figures. - Run with a small, explicit heap to make a leak show up faster:
-Xmx64m. - Take a heap dump when the problem appears and analyse which objects dominate it.
- Look for static collections, unbounded caches, and listeners that are never removed.
- Remember that a
WeakHashMapis the usual fix for a cache keyed by objects you do not own.
The habits to keep
- Keep static collections small, or do not use them at all.
- Give caches an eviction policy or use weak references.
- Let the collector do its job - do not call
System.gc()in production code. - Size the heap explicitly for services whose working set you know.
- Remember that
OutOfMemoryErroris anError, so ordinary exception handling will not catch it. - Profile before tuning - the default settings are reasonable for most applications.
Try it yourself: write a program that keeps a static list of objects in a loop, printing the heap usage every thousand iterations. Watch the used heap climb. Then replace the static list with a local list and watch the heap stay flat. Finally, add a WeakHashMap-based cache and show that entries disappear once nothing else refers to their keys.