Blog

深入 JVM:栈、堆、垃圾回收与 JIT

Java 把变量和对象放在哪里,垃圾回收器怎样决定释放什么,有垃圾回收的程序为什么照样会内存泄漏、内存耗尽,以及 JIT 为什么让代码越跑越快。

运行 Java 程序时,JVM 在背后做了很多你看不到的事。它给每个线程维护一个由栈帧组成的栈,把所有对象放在共享的堆上,释放没人能访问到的对象,还会在程序运行时把最热的方法编译成机器码。弄懂这些,就能解释栈溢出、内存泄漏、OutOfMemoryError,以及会骗人的基准测试。

本文会讲栈和堆、可达性、G1 和 ZGC 的分代垃圾回收、内存泄漏、JIT,以及类加载时发生了什么。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存为 Main.java,然后执行 java Main.java。GC 日志和耗时每次运行都不一样,所以它们以删减过的终端会话形式给出,你得到的数字也会不同。

每个线程都有一个栈帧组成的栈

每调用一次方法,JVM 就往当前线程的栈上压入一个栈帧。栈帧里放着这次调用的参数和局部变量。基本类型存的是值本身。对象存的是引用,对象本身在堆上。方法返回时,它的栈帧就被弹出。

int sumTo(int n) {
    if (n == 0) {
        IO.println("frame n=0: bottom, returns 0");
        return 0;
    }
    int rest = sumTo(n - 1);
    int total = n + rest;
    IO.println("frame n=" + n + ": rest=" + rest + ", returns " + total);
    return total;
}

List<String> makeNames() {
    var names = new ArrayList<String>();
    names.add("Ana");
    names.add("Ben");
    return names;
}

void main() {
    IO.println("sum: " + sumTo(3));
    var kept = makeNames();
    IO.println("the list outlived its frame: " + kept);
}

输出:

frame n=0: bottom, returns 0
frame n=1: rest=0, returns 1
frame n=2: rest=1, returns 3
frame n=3: rest=3, returns 6
sum: 6
the list outlived its frame: [Ana, Ben]

栈上同时有四个 sumTo 调用,每个都有自己的 nresttotal。它们按相反的顺序结束,从最底下的栈帧往上。

makeNames 展示的是另一半。它一返回,局部变量 names 就没了,但 ArrayList 并不在栈帧里。它在堆上,main 拿到的是引用的一份副本。引用怎样复制,讲值与引用的那一篇介绍过。

栈用完了:StackOverflowError

线程的栈有固定的最大大小,没有基准情况的递归会把它填满。这时 JVM 抛出 StackOverflowError。它是 Error,不是 Exception,但你照样可以捕获它:

int depth = 0;

void dive() {
    depth++;
    dive();
}

void main() {
    try {
        dive();
    } catch (StackOverflowError e) {
        IO.println("caught: " + e.getClass().getName());
        IO.println("message: " + e.getMessage());
        IO.println("deeper than 1,000 calls: " + (depth > 1_000));
    }
    IO.println("main carries on");
}

输出:

caught: java.lang.StackOverflowError
message: null
deeper than 1,000 calls: true
main carries on

这个错误没有消息。等到 catch 块运行时,栈帧已经弹出,所以有空间打印。在真实代码里,应该去修递归。

程序没有打印确切的深度,因为每次运行都不一样。我们把 catch 块改成打印 depth,跑了几次,结果是这样:

$ java Main.java
depth 17830
$ java Main.java
depth 17699
$ java -Xint Main.java
depth 9826
$ java -Xint Main.java
depth 9826

这让我们有点意外。-Xint 只用解释器运行,每次都停在同一个深度。开着 JIT 时,深度会变,而且深了将近一倍。JIT 编译完 dive 之后,它的栈帧比解释执行的栈帧占的空间小,而切换之前发生了多少次调用取决于时机。JIT 在后面会讲。

想给线程更大的栈,用 -Xssjava -Xss4m Main.java 设为 4 MB,有一次运行里,同一个程序递归到了 77,347 层。在这台 64 位 Linux 的 JDK 上,默认值是 1 MB(ThreadStackSize = 1024,单位是 KB)。

没有东西能访问到的对象就是垃圾

如果从任何 GC Root 出发都没有一条引用链能到达某个对象,垃圾回收器就会释放它。GC Root 是 JVM 确定正在使用的引用:每个线程每个存活栈帧里的局部变量、已加载类的静态字段,以及一些内部引用。回收器从 GC Root 出发、顺着引用能到达的一切都保留下来。其余的都是垃圾。

WeakReference 可以亲眼看到这一点。弱引用让你能看到一个对象,却不会让它继续存活。一旦只剩下弱引用,回收器就可以清除它们。在这个程序里,两个节点互相指向对方:

import java.lang.ref.WeakReference;

class Node {
    final String name;
    Node partner;

    Node(String name) {
        this.name = name;
    }
}

String check(WeakReference<Node> ref) {
    Node node = ref.get();
    return node == null ? "collected" : node.name + " is still here";
}

void main() {
    var a = new Node("a");
    var b = new Node("b");
    a.partner = b;
    b.partner = a;
    var weakA = new WeakReference<>(a);

    System.gc();
    IO.println("while main holds a: " + check(weakA));

    a = null;
    b = null;
    System.gc();
    IO.println("after a = b = null: " + check(weakA));
}

输出:

while main holds a: a is still here
after a = b = null: collected

被回收时,两个节点仍然互相引用。这并不能让它们活下来,因为回收器不数引用。它从 GC Root 开始追踪,main 的局部变量一变成 null,就没有路径能通到任何一个节点了。

System.gc() 只是一个请求,JVM 可以忽略它。我们用 Serial、Parallel、G1、ZGC 和 Shenandoah 回收器各把这个程序跑了 25 次,弱引用每次都被清除了。所以在这里演示它是安全的。但这不是保证,一个参数就能证明:

$ java -Xlog:gc Main.java
[0.005s][info][gc] Using G1
[1.726s][info][gc] GC(0) Pause Full (System.gc()) 43M->3M(34M) 21.996ms
while main holds a: a is still here
[1.761s][info][gc] GC(1) Pause Full (System.gc()) 4M->3M(14M) 28.521ms
after a = b = null: collected
$ java -XX:+DisableExplicitGC Main.java
while main holds a: a is still here
after a = b = null: a is still here

在 G1 上,System.gc() 会执行一次完整回收。加上 -XX:+DisableExplicitGC,它什么也不做,节点就活了下来。真实代码永远不要依赖 System.gc()

用十岁孩子能懂的话说

堆是一间大家共用的大游戏室,里面满是玩具。家里每个孩子都能往里放玩具。

栈是每个孩子书桌上自己的托盘。托盘上放着他们此刻正在玩的东西,还有一些绳子。每根绳子从托盘连到游戏室里的一个玩具。玩具之间也可以用绳子相连。

垃圾回收器是帮忙收拾游戏室的人。他不会去问哪个玩具看起来重要。他从托盘开始,顺着每一根绳子走。顺着绳子能找到的玩具,都留下。没有人牵着绳子的玩具,就放回箱子里,哪怕是两个互相绑在一起的玩具。

准确的说法

线程的栈里放着栈帧。栈帧里放着方法的局部变量和中间值,其中包括引用。对象分配在堆上,所有线程共享这个堆。回收器通过追踪找出存活对象:从 GC Root 出发,把通过引用能到达的一切都标记出来。没被标记的对象占用的内存会被回收。具体怎样回收、何时回收,取决于回收器。

这个比喻的局限:故事里收拾的人从不打断任何人。真实的回收器至少在部分工作中,会让你的程序短暂停顿。它们还会搬动玩具。G1 和 ZGC 会把存活对象复制到新位置,并更新所有指向它们的引用,这些你在 Java 代码里完全看不到。而且绳子一剪断,玩具并不会马上消失。它要等到下一次回收。

大多数对象朝生夕死

在典型的程序里,大多数对象只用一小会儿就被丢掉:为一行日志拼出来的字符串、一个迭代器、一个临时的 record。这叫分代假说,HotSpot 的回收器都是围绕它设计的。堆分成新生代老年代,新对象进新生代,存活得久的对象进老年代。

新生代有一个 Eden 区,对象在这里分配,还有 Survivor 区。一次新生代回收会把存活对象从 Eden 复制到 Survivor 区,然后把整个 Eden 当作空闲。它从不访问死对象,所以当大多数对象都已死亡时,代价很低。对象每挺过一次回收,年龄就加一。年龄够大后,它就晋升到老年代,老年代回收得没那么频繁。

eden 新生代 survivor 新生代 old 老年代 A C D E B F 年龄 1 年龄 1 S 年龄 3 G 程序在运行 新生代回收:程序已暂停 程序恢复运行 简化示意:新对象进 eden,S 挺过了之前的 GC 大多数对象朝生夕死:A、C、D、E 已不可达 新生代回收把存活的 B 和 F 复制到 survivor S 挺过的回收次数够多:晋升到老年代 死对象不复制:整个 eden 一次释放 eden 又空了,G 这样的新对象开始填满它

简化的新生代回收。Eden 里的大多数新对象都会死亡。回收把存活的对象复制到 Survivor 区,把挺过足够多次回收的对象晋升到老年代,并一次性释放整个 Eden。

如果动画没有播放,下面是用文字描述的步骤:

  1. 新对象 A 到 F 分配在 Eden 中。Survivor 区里已经有 S,它挺过了之前的几次回收。
  2. 程序继续运行,A、C、D 和 E 变得不可达。大多数对象朝生夕死。
  3. 一次新生代回收暂停程序,把存活对象 B 和 F 复制到 Survivor 区。它们的年龄现在是 1。
  4. S 挺过的回收次数已经够多,于是晋升:被复制到老年代。
  5. 死对象从不被复制,也不被访问。整个 Eden 一次性释放。
  6. 暂停结束。Eden 又空了,G 这样的新对象开始填进来。

这张图是简化过的。G1 并不使用三个固定的格子。它把堆分成大小相等的 Region,在这台机器上每个 2 MB,再把每个 Region 标记为 Eden、Survivor 或老年代。晋升前的最大年龄是 MaxTenuringThreshold,默认为 15。

G1:默认的回收器,以及它的日志

G1 是 HotSpot 默认的垃圾回收器,但 JVM 会根据机器来选。在这台 12 个 CPU、16 GB 内存的机器上,它选了 G1:

$ java -XX:+PrintFlagsFinal -version | grep -E ' (UseG1GC|UseSerialGC|MaxHeapSize) '
   size_t MaxHeapSize                              = 4095737856                                {product} {ergonomic}
     bool UseG1GC                                  = true                                      {product} {ergonomic}
     bool UseSerialGC                              = false                                     {product} {default}
$ java -XX:ActiveProcessorCount=1 -Xlog:gc -version
[0.003s][info][gc] Using Serial
$ systemd-run --user --scope -q -p MemoryMax=1G java -Xlog:gc -version
[0.003s][info][gc] Using Serial

{ergonomic} 表示这个值是 JVM 自己选的。只有一个 CPU,或者在 1 GB 的内存限制下,它改选了 Serial 回收器。默认的最大堆是内存的四分之一:这里约 3.8 GB,在限制下是 256 MB。在容器里,要看看 JVM 在那里选了什么。

这个程序创建 2000 万个订单,每一千个留下一个:

record Order(int id, byte[] payload) {}

void main() {
    var recent = new ArrayList<Order>();
    long checksum = 0;
    for (int i = 0; i < 20_000_000; i++) {
        var order = new Order(i, new byte[256]);
        checksum += order.id() % 7;
        if (i % 1_000 == 0) {
            recent.add(order);
        }
    }
    IO.println("orders created: 20000000");
    IO.println("orders kept:    " + recent.size());
    IO.println("checksum:       " + checksum);
}

输出:

orders created: 20000000
orders kept:    20000
checksum:       59999997

-Xlog:gc,gc+heap 让 G1 报告每次回收。我们把堆限制在 64 MB,让它频繁回收。下面是某次运行中间的两次回收:

$ java -Xmx64m -Xlog:gc,gc+heap Main.java
[0.008s][info][gc] Using G1
...
[2.398s][info][gc,heap] GC(63) Eden regions: 37->0(37)
[2.398s][info][gc,heap] GC(63) Survivor regions: 1->1(5)
[2.398s][info][gc,heap] GC(63) Old regions: 12->13
[2.398s][info][gc     ] GC(63) Pause Young (Normal) (G1 Evacuation Pause) 48M->11M(64M) 0.944ms
...
[3.506s][info][gc,heap] GC(154) Eden regions: 37->0(37)
[3.506s][info][gc,heap] GC(154) Survivor regions: 1->1(5)
[3.506s][info][gc,heap] GC(154) Old regions: 17->17
[3.506s][info][gc     ] GC(154) Pause Young (Normal) (G1 Evacuation Pause) 52M->15M(64M) 1.813ms

每一块是一次新生代回收。Eden 从 37 个 Region 变成 0,因为所有存活的东西都被复制出去了。一个 Survivor Region 就够用,因为大多数订单早已死亡。大多数回收没有改变老年代 Region 的数量,大约每二十次有一次加了一个,那是留下的订单晋升了。每次暂停大约一毫秒。

G1 力求把暂停控制在一个目标以内,也就是 MaxGCPauseMillis,默认 200 ms。这是目标,不是保证。

ZGC:暂停时间最要紧的时候

ZGC 几乎所有工作都在你的程序继续运行时完成,所以即使堆很大,暂停也非常短。用 -XX:+UseZGC 开启它:

$ java -XX:+UseZGC -Xlog:gc -version
[0.079s][info][gc] Using The Z Garbage Collector
$ java -XX:+UseZGC -XX:+ZGenerational -version 2>&1 | grep warning
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0

第二条命令让我们有点意外。旧的教程会让你加上 -XX:+ZGenerational。从 JDK 24 起,ZGC 始终是分代的,这个参数会被忽略并给出警告。下面是订单程序跑在 ZGC 上,暂停相关的行是从 -Xlog:gc* 里挑出来的:

$ java -XX:+UseZGC -Xlog:gc* Main.java
[1.678s][info][gc          ] GC(0) Major Collection (Warmup)
[1.679s][info][gc,phases   ] GC(0) Y: Pause Mark Start (Major) 0.032ms
[1.702s][info][gc,phases   ] GC(0) Y: Pause Mark End 0.039ms
[1.704s][info][gc,phases   ] GC(0) Y: Pause Relocate Start 0.047ms
[1.712s][info][gc,phases   ] GC(0) O: Pause Mark End 0.033ms
[1.726s][info][gc,phases   ] GC(0) O: Pause Relocate Start 0.032ms
[1.726s][info][gc          ] GC(0) Major Collection (Warmup) 356M(9%)->42M(1%) 0.048s

Y: 是新生代,O: 是老年代。在这次运行里,每次暂停都远低于一毫秒,而整个回收与程序并行进行,共用了 48 ms。

当暂停是问题所在时,选 ZGC,比如堆很大、对响应时间要求严格的服务。它在后台线程上工作,所以需要富余的 CPU。没有暂停问题,就留在 G1。对只看吞吐量的批处理任务,也测一测 -XX:+UseParallelGC

有垃圾回收的语言里的内存泄漏

垃圾回收器只释放不可达的东西,所以 Java 的内存泄漏,就是你已经不用、却仍然可达的对象。回收器没法知道你用完了。常见的元凶有:只增不减的静态集合、添加后从不移除的监听器,以及没有上限的缓存。

import java.lang.ref.WeakReference;
import java.util.function.Consumer;

class EventBus {
    static final List<Consumer<String>> listeners = new ArrayList<>();

    static void subscribe(Consumer<String> listener) {
        listeners.add(listener);
    }

    static void unsubscribe(Consumer<String> listener) {
        listeners.remove(listener);
    }
}

class Screen {
    final byte[] image = new byte[10_000];
    final Consumer<String> listener = this::onEvent;

    void open() {
        EventBus.subscribe(listener);
    }

    void close(boolean unsubscribe) {
        if (unsubscribe) {
            EventBus.unsubscribe(listener);
        }
    }

    void onEvent(String event) {
    }
}

int stillAlive(List<WeakReference<Screen>> refs) {
    System.gc();
    int alive = 0;
    for (var ref : refs) {
        if (ref.get() != null) {
            alive++;
        }
    }
    return alive;
}

void run(boolean unsubscribe) {
    var refs = new ArrayList<WeakReference<Screen>>();
    for (int i = 0; i < 1_000; i++) {
        var screen = new Screen();
        screen.open();
        screen.close(unsubscribe);
        refs.add(new WeakReference<>(screen));
    }
    IO.println("unsubscribe on close: " + unsubscribe);
    IO.println("  listeners held: " + EventBus.listeners.size());
    IO.println("  screens the GC couldn't free: " + stillAlive(refs));
}

void main() {
    run(false);
    EventBus.listeners.clear();
    run(true);
}

输出:

unsubscribe on close: false
  listeners held: 1000
  screens the GC couldn't free: 1000
unsubscribe on close: true
  listeners held: 0
  screens the GC couldn't free: 0

每个屏幕都关闭并丢弃了,可在第一轮里,没有一个能被释放。方法引用 this::onEvent 持有它所属 Screen 的引用。静态列表持有监听器,而静态字段是 GC Root。所以每个屏幕连同它的图像都一直可达。关闭时移除监听器,问题就解决了。

缓存也会以同样的方式泄漏。一个记住每个键的每个结果的 map,程序跑多久它就长多久。LinkedHashMap 可以在满了之后淘汰最老的条目:

Map<String, String> unbounded = new HashMap<>();

Map<String, String> bounded = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 100;
    }
};

String render(String userId) {
    return "<profile of " + userId + ">";
}

void main() {
    for (int i = 0; i < 50_000; i++) {
        String userId = "user-" + i;
        unbounded.computeIfAbsent(userId, this::render);
        bounded.computeIfAbsent(userId, this::render);
    }
    IO.println("unbounded cache entries: " + unbounded.size());
    IO.println("bounded cache entries:   " + bounded.size());
    IO.println("newest still cached: " + bounded.containsKey("user-49999"));
    IO.println("oldest still cached: " + bounded.containsKey("user-0"));
}

输出:

unbounded cache entries: 50000
bounded cache entries:   100
newest still cached: true
oldest still cached: false

构造器里的 true 让条目按最近访问排序。removeEldestEntry 在每次插入后运行,返回 true 就会丢掉最久未使用的条目。无上限的 map 留下了全部 50,000 个用户,有上限的只留下 100 个。

OutOfMemoryError:堆里塞满了存活对象

如果回收之后堆里仍然放不下新对象,JVM 就抛出 OutOfMemoryError。内存泄漏会慢慢把你带到这一步。这个程序留下自己分配的每一块内存,很快就到了:

List<byte[]> kept = new ArrayList<>();

void main() {
    IO.println("keeping 1 MB blocks until the heap runs out");
    while (true) {
        kept.add(new byte[1_000_000]);
    }
}

它必然失败,所以没有和其他程序一起运行。用 16 MB 的堆运行,它还会打印栈跟踪,这里删减掉了:

$ java -Xmx16m Main.java
keeping 1 MB blocks until the heap runs out
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

Java heap space 表示普通对象填满了堆。-Xmx 设置最大堆大小。-XX:+HeapDumpOnOutOfMemoryError 会在出现这种情况时写出堆转储,VisualVM 或 Eclipse MAT 这类工具可以打开它,看是什么占着内存。

我们还试了 8 MB 的堆:

$ java -Xmx8m Main.java
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.util.EnumMap.values(EnumMap.java:423)
	at jdk.compiler/com.sun.tools.javac.util.Log.flush(Log.java:486)

我们的第一行根本没打印出来。java Main.java 会在同一个 JVM 里先编译你的源代码再运行,而编译器先耗尽了堆。栈帧里写的是 javac,不是 Main

JIT:代码越跑越快

JVM 一开始解释执行字节码,一次一条指令。它会统计每个方法运行的次数。变热的方法会被 C1 编译成机器码,C1 是一个编译很快的编译器,同时会记录性能分析数据。如果方法一直很热,C2 会利用这些分析数据再编译一次,优化得更狠。这就是分层编译,默认开启。

int digitSum(int n) {
    int sum = 0;
    while (n > 0) {
        sum += n % 10;
        n /= 10;
    }
    return sum;
}

void main() {
    long total = 0;
    for (int i = 0; i < 5_000_000; i++) {
        total += digitSum(i);
    }
    IO.println("sum of all digits below 5,000,000: " + total);
}

输出:

sum of all digits below 5,000,000: 145000000

-XX:+PrintCompilation 为每次编译打印一行。下面是某次运行里关于 digitSum 的几行。耗时和 ID 每次运行都会变:

$ java -XX:+PrintCompilation Main.java | grep digitSum
1115 1544       3       Main::digitSum (23 bytes)
1116 1545       4       Main::digitSum (23 bytes)
1118 1544       3       Main::digitSum (23 bytes)   made not entrant: not used
1124 1548 %     4       Main::digitSum @ 2 (23 bytes)

各列依次是启动后的毫秒数、编译 ID、标志、层级和方法。第 3 层是带性能分析的 C1,第 4 层是 C2。“made not entrant”表示 C2 的版本就绪后,C1 的版本就退役了。% 表示栈上替换(OSR):为 while 循环编译出的版本,已经在循环里的调用可以直接跳进去。

整次运行打印了 1,700 多行,digitSum 之前大约有 1,500 次编译。其中大部分是 javac 本身在编译你的文件。

预热,以及微基准测试为什么会骗人

代码一开始慢、后来变快,所以只计时一次说明不了什么。这个版本对八轮同样的工作计时。它会打印耗时,所以不能和其他程序一起运行:

int digitSum(int n) {
    int sum = 0;
    while (n > 0) {
        sum += n % 10;
        n /= 10;
    }
    return sum;
}

void main() {
    for (int round = 1; round <= 8; round++) {
        long start = System.nanoTime();
        long total = 0;
        for (int i = 0; i < 50_000; i++) {
            total += digitSum(i);
        }
        long micros = (System.nanoTime() - start) / 1_000;
        IO.println("round " + round + ": " + micros + " microseconds, total " + total);
    }
}
$ java Main.java
round 1: 4776 microseconds, total 1000000
round 2: 3878 microseconds, total 1000000
round 3: 3254 microseconds, total 1000000
round 4: 3717 microseconds, total 1000000
round 5: 3669 microseconds, total 1000000
round 6: 3696 microseconds, total 1000000
round 7: 1539 microseconds, total 1000000
round 8: 659 microseconds, total 1000000
$ java -Xint Main.java
round 1: 11436 microseconds, total 1000000
...
round 8: 11645 microseconds, total 1000000

同样的工作,到第 8 轮快了大约七倍。用 -Xint 时,它始终没有变快。在其他几次运行里,提速早一轮或晚一轮出现。只测前几轮,你测到的是解释器和编译器,不是你的代码。GC 暂停和 CPU 频率变化还会带来更多噪声。

要做真正的测量,用 JMH,也就是 OpenJDK 项目的 Java 微基准测试工具(Java Microbenchmark Harness)。它会处理预热、派生全新的 JVM,并报告误差范围。它是一个独立的库,所以本系列没有用它。

逃逸分析

C2 编译器会做逃逸分析。它检查在编译后代码里创建的对象,在外面能不能被看到。如果看不到,C2 可能会省掉这次分配,把对象的字段放在局部变量或 CPU 寄存器里。这就是标量替换。你在 Java 代码里观察不到它:输出一样,也没有任何 API 会告诉你某次分配被省掉了。你只能从外部看到它,比如在 GC 日志里。一个循环创建了 2 亿次小小的 record Point(long x, long y),从不让它离开循环,在这台机器上只引发了一次 GC 暂停。加上 -XX:-DoEscapeAnalysis,引发了 17 次。这是 JIT 可能采用的优化,不是你可以依赖的规则。

类加载时发生了什么

类的代码运行之前,JVM 要先加载它(读取字节码),再链接它(校验字节码,并把静态字段设为默认值),然后初始化它(从上到下运行静态字段初始化器和 static 块)。加载可以提前发生,但初始化要等到第一次真正使用,比如读取一个静态字段。这就是为什么讲类的那一篇里,静态块是在 main 开始之后才运行的。

class Config {
    static final int MAX_USERS = 100;
    static final String VERSION = "2.1";
    static final List<String> REGIONS = List.of("eu", "us");

    static {
        IO.println("  Config is being initialised");
    }
}

void main() {
    IO.println("1. declare an array of Config");
    Config[] slots = new Config[3];
    IO.println("2. use the class literal: " + Config.class.getSimpleName());
    IO.println("3. read a constant int: " + Config.MAX_USERS);
    IO.println("4. read a constant String: " + Config.VERSION);
    IO.println("5. read a List field: " + Config.REGIONS);
    IO.println("6. read it again: " + Config.REGIONS.size() + " regions, "
            + slots.length + " slots");
}

输出:

1. declare an array of Config
2. use the class literal: Config
3. read a constant int: 100
4. read a constant String: 2.1
  Config is being initialised
5. read a List field: [eu, us]
6. read it again: 2 regions, 3 slots

创建 Config 数组、使用 Config.class 都没有让类初始化。读取 MAX_USERSVERSION 也没有。它们是编译期常量,所以 javac100"2.1" 直接复制进了 mainREGIONS 是运行时真正读取的字段,所以第 5 步触发了初始化,只触发一次。第 6 步没有再触发。

-Xlog:class+load,class+init 能显示这些步骤。下面是关于我们两个类的几行,路径缩短过:

$ java -Xlog:class+load,class+init Main.java
[1.341s][info][class,load] Main source: file:/home/you/Main.java
1. declare an array of Config
[1.344s][info][class,load] Main$Config source: file:/home/you/Main.java
2. use the class literal: Config
3. read a constant int: 100
4. read a constant String: 2.1
[1.345s][info][class,init] Start class verification for: Main$Config
[1.345s][info][class,init] End class verification for: Main$Config
  Config is being initialised
5. read a List field: [eu, us]

main 一需要数组类型,Main$Config 就被加载了,但直到第 5 步才校验和初始化。之所以有 Main$,是因为紧凑源文件会把所有东西包在一个叫 Main 的类里。

完整日志列出了大约 2,600 个已加载的类,其中大约 1,100 个来自 jdk.compiler。先用 javac 编译、再用 java Main 运行时,程序只加载了大约 600 个类,Main 在 0.06 秒后加载,而不是 1.3 秒。

要点

  • 每个线程都有一个栈帧组成的栈,里面放着局部变量和引用。对象放在共享的堆上。递归太深会抛出 StackOverflowError-Xss 设置栈大小。
  • 如果从 GC Root 出发没有引用链能到达某个对象,它就是垃圾。只互相引用的对象照样是垃圾。
  • 大多数对象朝生夕死。新生代回收把少数存活对象复制出 Eden,其余的一次性释放。存活得久的对象晋升到老年代。
  • G1 通常是默认回收器,但 JVM 会根据 CPU 和内存来选。暂停时间最要紧时选 ZGC,并用 -Xlog:gc 确认。
  • Java 的内存泄漏,是你已经用完却仍然可达的对象:不断增长的静态列表、从不移除的监听器、没有上限的缓存。
  • JIT 分层编译热点代码,所以代码越跑越快。别相信手动计时的循环,用 JMH。
  • 类的加载、链接和初始化都是惰性的,而 java Main.java 会先在同一个 JVM 里运行编译器。

垃圾回收器释放的是没有东西能访问到的对象,所以内存泄漏永远是你还攥着不放的东西。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。