找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1310

积分

0

好友

168

主题
发表于 15 小时前 | 查看: 0| 回复: 0

一段简单的代码测试,却能揭示出 JVM 底层对象分配机制的奥秘。当你在高频创建对象的循环中,多写或少写一行打印语句,性能竟能有十倍的差距,这背后究竟是什么原理?

代码测试

我们先来看一下引发问题的测试代码。

import com.google.common.base.Stopwatch;
import java.util.concurrent.TimeUnit;

public class StackTest {
    public static void main(String[] args) {
        Stopwatch started = new Stopwatch();
        started.start();
        User user = null;
        for (long i = 0; i < 1000_000_000; i++) {
            user = new User();
        }
        started.stop();
        System.out.println(started.elapsed(TimeUnit.MILLISECONDS) + "ms");
        //不加打印 300ms
        //加了打印 3000ms
//        System.out.println(user);
    }
}

class User {
    private int age;
    private String userName;

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }
}

这段代码的核心是循环创建 10 亿个 User 对象。关键点在于最后一行被注释掉的 System.out.println(user)

测试结果表明:

  • 不执行最后的打印语句,耗时大约在 300ms。
  • 如果取消注释,执行打印语句,耗时则飙升至 3000ms 左右,性能下降高达 10 倍。(具体时间因机器配置而异)

如此简单的代码改动,竟能带来如此巨大的性能差异,这确实引人深思。要理解这个现象,我们必须深入 Java 对象在堆内存中的分配规则。

对象分配规则

一个Java对象在堆上诞生,并不是随意找块空地放下那么简单。JVM为了提升分配效率和程序性能,设计了一套精细的分配策略,其决策流程如下图所示:

Java对象分配决策流程图

栈上分配

栈上分配是JVM提供的一项重要优化技术。它允许将一些线程私有的对象“打散”,将其字段直接分配在虚拟机栈的局部变量表中。这样分配的对象,其生命周期与栈帧一致,方法执行结束栈帧弹出时,对象内存也随之被回收,完全不需要垃圾收集器(GC)的介入,效率极高。

当然,栈上分配的条件也较为苛刻:

  • 栈空间有限:无法容纳大对象。
  • 对象不得逃逸:即对象不能在方法外部被引用(可通过JVM参数 -XX:+DoEscapeAnalysis 开启逃逸分析)。
  • 对象可被标量替换:即对象能够被分解为其原生类型的字段来表示(通过 -XX:+EliminateAllocations 开启)。

例如在上面的Demo中,如果满足条件,JVM可能会直接用 int ageString userName 这两个变量来替代完整的 User 对象,从而避免在堆上创建。

TLAB 分配

TLAB,全称 Thread Local Allocation Buffer,即线程本地分配缓存。这是堆内 Eden 区中划出的一块线程专属内存区域。在TLAB启用的情况下(默认开启),JVM会为每个线程分配一小块TLAB。

引入TLAB的目的是为了加速对象分配。因为堆是线程共享的,直接在上面分配对象需要同步操作(如指针碰撞),存在线程竞争开销。而TLAB是线程私有的,在其范围内分配对象无需加锁,极大地提升了高频分配场景下的效率。

同样,TLAB空间较小,所以大对象无法在TLAB内分配,只能走堆上分配的路径。

分配策略:
假设一个TLAB区域大小为100KB,已使用了80KB。此时需要分配一个30KB的对象,该如何处理?

  1. 废弃当前这个TLAB,为该线程重新申请一个新的TLAB。
  2. 将这30KB的对象直接分配到堆的共享Eden区,而当前这个剩余的20KB TLAB区域保留,供后续分配小对象时使用。

JVM采用第二种策略,并在内部维护一个名为 refill_waste 的阈值。当请求分配的对象大小超过 refill_waste 时,就选择在堆中分配;否则,就废弃当前TLAB,新建一个TLAB来分配新对象。默认情况下,TLAB的大小和 refill_waste 阈值都会在运行时自适应调整,以达到最优状态。

相关JVM参数

参数 作用 备注
-XX:+UseTLAB 启用TLAB 默认启用
-XX:TLABRefillWasteFraction 设置允许空间浪费的比例 默认值64,即使用1/64的TLAB空间作为refill_waste值
-XX:-ResizeTLAB 禁止系统自动调整TLAB大小
-XX:TLABSize 指定TLAB大小 单位:B

Demo 现象剖析

结合上面的知识,现在我们就能清晰地解释测试代码中的性能差异了。

不加 System.out.println(user) 的情况
在循环中创建的 User 对象仅在循环内部被引用,没有暴露给外部方法(即 main 方法之外)。因此,这些对象被判定为“未逃逸”。在开启了逃逸分析(默认是开启的)和标量替换优化后,JVM极有可能对这些对象进行栈上分配TLAB分配。这两种分配方式的速度都非常快,且避免了大量堆内存分配和后续的GC压力,所以耗时极短(约300ms)。

加上 System.out.println(user) 的情况
打印语句导致在循环结束后,最后一个 User 对象被传递给了 System.out.println 方法。这使得该对象(从JVM优化视角看,可能影响到循环内对象的逃逸分析判断)发生了“逃逸”——它的引用被传递到了当前方法(main)之外。由于不满足栈上分配的核心条件,所有对象都必须在共享的堆Eden区进行分配。这带来了同步开销,并且创建的大量对象会迅速填满Eden区,触发频繁的Minor GC,从而导致性能急剧下降(约3000ms)。

如果你再尝试添加 -XX:-UseTLAB 参数来关闭TLAB分配,分配速度还会进一步下降(TLAB优化在多线程竞争分配时效果尤为明显,此处不再展开验证)。

这个案例生动地展示了,一行看似无害的代码,是如何改变JVM的底层行为模式,进而对程序性能产生巨大影响的。理解这些内存分配机制,对于编写高性能Java代码至关重要。如果你想深入探讨更多系统设计与性能优化的话题,欢迎在云栈社区交流分享。




上一篇:CPU与I/O设备交互方式演进详解:从程序查询到I/O处理机
下一篇:P1soda内网扫描实战:从主机探测到漏洞检测的自动化利器
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-9 19:27 , Processed in 0.306733 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表