Java · lesson 40 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11JVM Memory Model
See JVM memory at work: stack frames and StackOverflowError, heap limits with -Xmx, OutOfMemoryError, garbage collectors, weak references and leaks.
By SkillByExample · Updated
The JVM splits memory into a stack per thread (method frames and local variables), one shared
heap (every object), and Metaspace (class metadata, in native memory). Every example is a
single file run with the JDK 21 source launcher (java File.java); JVM flags go before the file
name.
Stack frames and StackOverflowError
Each method call pushes a frame onto the calling thread’s stack. Recursion without a base case keeps pushing until the stack is full.
public class StackDepth {
static int depth = 0;
static void recurse() {
depth++;
recurse(); // no base case
}
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("caught " + e.getClass().getSimpleName());
System.out.println("more than 1,000 frames deep? " + (depth > 1_000));
}
}
}
$ java StackDepth.java
caught StackOverflowError
more than 1,000 frames deep? true
The exact depth depends on frame size and the -Xss stack size (512 KB to 1 MB by default, per
thread), so the program only checks it was large. StackOverflowError is an Error, not an
Exception — catching it is fine for a demo, but in real code it means a bug.
References on the stack, objects on the heap
A local variable of object type holds a reference; the object itself lives on the heap. Java passes that reference by value.
import java.util.ArrayList;
import java.util.List;
public class RefDemo {
static void addItem(List<String> list) {
list.add("added"); // mutates the shared heap object
}
static void reassign(List<String> list) {
list = new ArrayList<>(); // changes only this frame's copy
list.add("lost");
}
public static void main(String[] args) {
List<String> items = new ArrayList<>();
addItem(items);
reassign(items);
System.out.println(items);
}
}
$ java RefDemo.java
[added]
Both methods received a copy of the reference. addItem followed it to the heap object main also
sees; reassign pointed its own copy at a new list, which became garbage as soon as its frame was
popped.
Sizing the heap with -Xmx
Runtime reports the heap as the running JVM sees it. -Xmx sets the maximum.
public class HeapSize {
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
long mb = 1024 * 1024;
System.out.println("max heap: " + rt.maxMemory() / mb + " MB");
System.out.println("processors: " + (rt.availableProcessors() > 0));
}
}
$ java -Xmx256m HeapSize.java
max heap: 256 MB
processors: true
Without -Xmx the JVM picks a default of a quarter of physical (or container) memory. Setting
-Xms equal to -Xmx stops the heap from resizing at runtime, a common choice for servers.
OutOfMemoryError: Java heap space
A collection that only grows is the classic leak. With a deliberately small heap it fails fast.
import java.util.ArrayList;
import java.util.List;
public class Leak {
static final List<byte[]> CACHE = new ArrayList<>(); // never evicted
public static void main(String[] args) {
try {
while (true) {
CACHE.add(new byte[1024 * 1024]); // 1 MB per entry
}
} catch (OutOfMemoryError e) {
int held = CACHE.size();
CACHE.clear(); // release before printing
System.out.println(e.getMessage());
System.out.println("held fewer than 32 MB? " + (held < 32));
}
}
}
$ java -Xmx32m Leak.java
Java heap space
held fewer than 32 MB? true
Every array stays reachable through the static CACHE, so the collector can free nothing and the
JVM gives up with Java heap space. In production add -XX:+HeapDumpOnOutOfMemoryError and open the
.hprof file in Eclipse MAT or VisualVM to see what is holding the memory.
Which garbage collector is running
The GarbageCollectorMXBeans name the collector the JVM chose; a flag picks a different one.
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
public class WhichGc {
public static void main(String[] args) {
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.println(gc.getName());
}
}
}
$ java -XX:+UseG1GC WhichGc.java && java -XX:+UseParallelGC WhichGc.java && java -XX:+UseZGC -XX:+ZGenerational WhichGc.java
G1 Young Generation
G1 Concurrent GC
G1 Old Generation
PS MarkSweep
PS Scavenge
ZGC Minor Cycles
ZGC Minor Pauses
ZGC Major Cycles
ZGC Major Pauses
G1 (the default on machines with 2+ CPUs and 2+ GB) collects the young generation often and the old generation in concurrent cycles. Parallel GC uses stop-the-world collections tuned for throughput. Generational ZGC does nearly all its work concurrently, keeping pauses under a millisecond.
Weak references let the collector win
A WeakHashMap holds its keys weakly: once nothing else references a key, its entry may vanish at
the next collection.
import java.util.Map;
import java.util.WeakHashMap;
public class WeakCache {
public static void main(String[] args) throws InterruptedException {
Map<Object, String> cache = new WeakHashMap<>();
Object kept = new Object();
Object dropped = new Object();
cache.put(kept, "kept");
cache.put(dropped, "dropped");
System.out.println("before gc: " + cache.size());
dropped = null; // last strong reference gone
System.gc(); // a request, honoured by default on HotSpot
Thread.sleep(100);
System.out.println("after gc: " + cache.size() + " " + cache.values());
System.out.println(kept != null);
}
}
$ java WeakCache.java
before gc: 2
after gc: 1 [kept]
true
kept is still strongly referenced by a local variable, so its entry survives. SoftReference is
the gentler cousin — cleared only when memory runs low — and PhantomReference (or the
java.lang.ref.Cleaner API) supports post-mortem cleanup.
A bounded cache instead of a leak
The fix for an ever-growing map is an eviction policy. LinkedHashMap.removeEldestEntry gives one in
a few lines.
import java.util.LinkedHashMap;
import java.util.Map;
public class BoundedCache {
public static void main(String[] args) {
Map<String, Integer> cache = new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > 3;
}
};
for (String k : new String[] {"a", "b", "c", "d", "e"}) {
cache.put(k, k.charAt(0) - 'a');
System.out.println(cache);
}
}
}
$ java BoundedCache.java
{a=0}
{a=0, b=1}
{a=0, b=1, c=2}
{b=1, c=2, d=3}
{c=2, d=3, e=4}
After each put, the map asks whether to drop its oldest entry, so it never holds more than three.
For production caches, Caffeine adds size, time and weight limits with better hit rates.
Practice
- Turn
BoundedCacheinto an LRU cache: reading a key should make it the most recently used, so it is evicted last. Inserta,b,c, reada, then insertdand print the keys.
Solution
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCache {
public static void main(String[] args) {
Map<String, Integer> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > 3;
}
};
cache.put("a", 1);
cache.put("b", 2);
cache.put("c", 3);
cache.get("a"); // a becomes most recently used
cache.put("d", 4); // evicts b, the least recently used
System.out.println(cache.keySet());
}
}
$ java LruCache.java
[c, a, d]
The third constructor argument, accessOrder = true, reorders entries on every get, so the
“eldest” entry is the least recently used rather than the first inserted.
- An
EventSourcekeeps listeners in a list. Register three listeners, unregister one, and fire an event to show the removed listener is no longer held (and no longer called).
Solution
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class Listeners {
static final List<Consumer<String>> listeners = new ArrayList<>();
public static void main(String[] args) {
Consumer<String> a = e -> System.out.println("a got " + e);
Consumer<String> b = e -> System.out.println("b got " + e);
Consumer<String> c = e -> System.out.println("c got " + e);
listeners.add(a);
listeners.add(b);
listeners.add(c);
listeners.remove(b); // forgetting this is the leak
System.out.println("held: " + listeners.size());
listeners.forEach(l -> l.accept("saved"));
}
}
$ java Listeners.java
held: 2
a got saved
c got saved
A listener that is never removed keeps itself, and everything it captures, reachable for as long as the source lives.
- Print the heap’s maximum size using
ManagementFactory.getMemoryMXBean()instead ofRuntime, and run it with-Xmx64m.
Solution
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryUsage;
public class HeapBean {
public static void main(String[] args) {
MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
System.out.println("max heap: " + heap.getMax() / (1024 * 1024) + " MB");
System.out.println("used <= committed? " + (heap.getUsed() <= heap.getCommitted()));
}
}
$ java -Xmx64m HeapBean.java
max heap: 64 MB
used <= committed? true
The same bean exposes getNonHeapMemoryUsage(), which covers Metaspace and the JIT code cache.
- Catch a
StackOverflowErrorfrom a recursive method, then show that the same thread can still call a normal method afterwards — the stack unwinds when the error propagates.
Solution
public class Recover {
static int down(int n) { return down(n + 1); }
static int sum(int a, int b) { return a + b; }
public static void main(String[] args) {
try {
down(0);
} catch (StackOverflowError e) {
System.out.println("overflowed");
}
System.out.println("after: " + sum(2, 3));
}
}
$ java Recover.java
overflowed
after: 5
Every frame between main and the overflow point is popped as the error propagates, so the stack is
almost empty again by the time catch runs.