两个new出来的对象,为什么一个快一个慢?
先看一段代码:
java
public class MemoryDemo {
private int age = 25; // 这个age存在哪?
public void doSomething() {
int count = 0; // 这个count存在哪?
String name = "张三"; // "张三"这个对象呢?
Object obj = new Object(); // new出来的对象,存在哪?
}
}
你可能能答出来:age 在堆里,count 在栈里,"张三" 在字符串常量池里,new Object() 也在堆里。
但如果我问你:为什么有些数据放在栈里,有些放在堆里?堆和栈到底有什么区别?什么时候栈会溢出?为什么局部变量比全局变量快?
这些问题,是对"你的代码由谁执行、怎样执行"的进一步追问。上一篇文章我们讲清了CPU和IO的分工------CPU执行指令时极快,但等待IO时主动让出时间片。现在CPU要去内存里取数据了:内存到底长什么样?CPU怎么找到你的变量?整个过程为什么有快有慢?
一、内存的分层------不是所有内存都一样快
你可能以为计算机的内存就是一块大数组,访问任何一个位置速度都一样。但实际的内存体系远比这复杂得多:
CPU核心(最快,0.3ns)
↓
寄存器(<1ns,容量:几十个字节)------CPU运算的"临时草稿纸"
↓
L1缓存(~1ns,容量:32KB)------每个核心独享
↓
L2缓存(~3ns,容量:256KB)------每个核心独享
↓
L3缓存(~12ns,容量:几MB)------所有核心共享
↓
主内存(~60ns,容量:几GB到几百GB)------我们常说的"内存条"
↓
本地磁盘 / 网络存储(毫秒级,容量:TB级)------不属于内存,但CPU最终还是要从这里读数据
越往上越快,但容量越小、成本越高。越往下容量越大、但速度呈指数级下降。 CPU从寄存器拿数据不到1纳秒,从主内存拿数据约60纳秒------差了上百倍。上一次我们从CPU切到了IO的视角,看到了CPU和网络的差距。这次从CPU切到了内存的视角,差距虽然没有网络那么大(10ms vs 60ns),但仍然是一个数量级的倍数。优化CPU缓存命中率,让数据尽量留在L1/L2缓存里,是现代程序性能优化的核心手段。
二、当进程启动时------操作系统给了你一整块"虚拟地盘"
在你的Java程序启动之前,操作系统先做了一件事:为这个进程分配虚拟地址空间。
所谓虚拟地址空间,就是操作系统给每个进程画的一块"大饼"------一个完全属于你自己的、从0到最大地址的连续空间。你在这个空间里可以任意读写,但实际上这些地址不是真实的物理内存地址。操作系统维护着一张映射表,把你虚拟地址翻译到真正的物理内存页上。
进程的虚拟地址空间(简化版):
┌──────────────────────────────┐ 高地址
│ 内核空间 │ 操作系统保留,用户程序不能访问
├──────────────────────────────┤
│ 栈区 │ 局部变量、方法调用栈帧
│ ↓ 向下增长 │ 由编译器自动管理
├──────────────────────────────┤
│ (空白区域) │ 栈和堆之间的"缓冲区"------可以动态扩展
│ │
├──────────────────────────────┤
│ 堆区 │ new出来的对象、数组
│ ↑ 向上增长 │ 由程序员(或GC)管理
├──────────────────────────────┤
│ 数据段(静态区) │ 静态变量、常量
├──────────────────────────────┤
│ 代码段(文本段) │ 编译后的机器指令(只读)
└──────────────────────────────┘ 低地址
栈从高地址向低地址增长,堆从低地址向高地址增长。中间留了空白区域给它们各自扩展。如果栈增长太多(递归太深、局部变量太大),它就会撞上堆------这就是栈溢出。如果堆增长太多(创建了太多对象),它就会撞上栈------就是堆内存溢出。
三、栈------为什么快?因为简单到极致
栈只做一件事:记住谁调用了谁,以及每个方法的局部变量是什么。
每次调用方法,都压入一个栈帧
java
public void a() {
int x = 1; // x 在 a 的栈帧里
b(); // 调用 b,b 的栈帧压到上面
// b 返回后,b 的栈帧被弹出,x 继续使用
}
public void b() {
int y = 2; // y 在 b 的栈帧里
c(); // 调用 c
}
public void c() {
int z = 3; // z 在 c 的栈帧里
}
每次调用一个方法,就在栈顶压入一个新的栈帧,里面放着这个方法的局部变量。方法返回时,这个栈帧直接弹出------没有碎片,不需要垃圾回收,只需要移动栈指针。局部变量为什么快?因为栈在物理内存中的位置是连续的,CPU在访问同一栈帧内的相邻变量时几乎一定能命中L1/L2缓存,这是一种极强的空间局部性。
栈溢出的原因:每个线程的栈大小是固定的(JVM默认1MB),如果递归太深每一层都压一个新栈帧,当栈帧向低地址增长超过了分配的栈空间,就触发栈溢出。
为什么栈这么快?
栈的分配和释放只需要移动一个指针------"栈顶指针"。压栈时指针往上移,出栈时指针往下移。没有内存碎片,不需要垃圾回收,不需要找空闲块。正是这种极简的管理方式,让栈成为程序中最快的数据存储区。
四、堆------为什么相对慢?因为需要管理
堆里放的是不知道什么时候会死的数据。
new Object() 创建的对象不知道这会死还是30分钟后被GC回收------因为多个方法可能共享它,它被赋值给了某个成员变量,或者其他对象引用了它。所以堆需要一套复杂的管理机制------垃圾回收器。
堆的结构
堆被JVM分成两大区域:新生代 和老年代。新生代又细分为一个Eden区和两个Survivor区。
新创建的对象先进入Eden区。经过一次Minor GC后,存活的对象被复制到Survivor区。多次GC后仍然存活的对象最终晋升到老年代。这种分代回收的设计基于一个统计规律------绝大多数对象命短,活过几次GC的对象大概率还会继续活下去,所以优先在新生代回收,成本低效率高。
GC如何找到堆里的垃圾
JVM通过可达性分析判断一个对象是否还活着。从GC Roots出发------包括栈上的局部变量、静态变量、活跃线程的引用------如果能顺着引用链到达某个对象,那它就是活着的;如果从所有GC Roots都无法到达它,它就是垃圾,可以被回收。
为什么堆比栈慢?
堆的分配需要找空闲空间,GC需要扫描存活对象并回收垃圾,大对象可能直接进入老年代触发Full GC。这些都引入了额外的CPU开销。而且堆里的对象分散在内存各处,CPU缓存不容易命中------连续访问多个堆对象时,它们可能在物理内存中隔得很远,L1/L2缓存频繁miss,每次miss都要等主内存把数据送来(多花几十纳秒)。而栈是连续内存区域,CPU提前预取相邻的栈帧数据------这是栈比堆快的硬件级原因。
五、一个变量从创建到消亡------完整的生命周期
现在我们可以完整回答开头的问题了。
java
public class MemoryDemo {
private int age = 25; // ← 这个age存在哪?
public void doSomething() {
int count = 0; // ← 这个count存在哪?
String name = "张三"; // ← "张三"这个对象呢?
Object obj = new Object(); // ← new出来的对象,存在哪?
}
}
| 变量 | 存在哪 | 生命周期 | 由谁管理 |
|---|---|---|---|
age |
堆(对象的一部分) | 和MemoryDemo实例同生共死 | GC |
count |
栈(当前栈帧) | 方法返回即消失 | 编译器自动管理 |
"张三" |
堆中的字符串常量池 | 类加载后一直存在 | GC |
new Object() |
堆(新生代Eden区) | 垃圾回收判定为垃圾时回收 | GC |
为什么不同变量放在不同区域?因为它们的生死规则不同。
count是局部变量,方法结束它就死了------丢在栈上最简单,方法返回时自动弹出。age是成员变量,只要对象还活着它就不能死------只能放在堆上,交给GC判断它什么时候可以回收。"张三"是字符串常量,整个类生命周期内都存在------放在堆的字符串常量池里。new Object()是运行时动态创建的对象------进入堆的新生代,等待垃圾回收。
栈是"临时工",堆是"长租客"。临时工随叫随走不需要管理,长租客需要登记、定期检查还在不在、不在了就清退。
六、一个让你真正感到震撼的对比
回到本文开头的那个问题:一个变量,可能在哪些地方?
-
如果它在栈上(局部变量):分配和释放永远只需要移动一个指针。方法调用时压入栈帧------指针移动;方法返回时弹出栈帧------指针归位。整个过程没有碎片、没有寻找、没有等待
-
如果它在堆上(new出来的对象):需要在线程私有的TLAB上分配以避锁竞争、需要GC定期扫描确定死活、可能被移到Survivor区或拷贝到老年代。这些都不是免费的
但栈虽然快,容量有限(默认1MB)。 这就是为什么递归太深会栈溢出,也是为什么大对象必须放在堆上------你没法把一张百万像素的图片塞进1MB的栈帧里。
性能差距的根源:栈连续分配、连续访问,是缓存友好的数据结构------CPU访问栈上连续几个变量的速度可能比访问堆中两个不相邻的对象快几十倍。堆是碎片化的,GC后存活对象散落在内存各处,CPU缓存命中率远低于栈。这不是"快一点"和"慢一点"的差异,而是"纳秒级"和"微秒级"的差异------数量级的差距。
总结
整个故事串起来是这样的:
当你的程序启动时,操作系统为进程分配了虚拟地址空间------一个从低到高的完整区域。代码段在最下面,数据段在代码段之上,堆从数据段之上向上增长,栈从最高处向下增长。
当你的方法被调用时,一个新的栈帧被创建并压入栈中。栈帧里放着这个方法的局部变量。这些变量的分配没有任何管理开销------只移动一个栈指针,分配已经完成。
当你new一个对象时,JVM在堆的Eden区为它分配空间。如果TLAB可用,线程私下在自己的分配缓存里完成,没有锁开销。对象经过几次垃圾回收后可能被移到Survivor区或老年代。当GC Roots无法再到达它时,它的空间被回收。
CPU要读写一个变量时,先去L1缓存找。L1没有去L2,L2没有去L3,L3没有才去主内存。栈上变量因为内存连续,CPU提前预取相邻的栈帧数据,命中率极高。堆上两个相邻的对象在物理内存中可能隔着老远,CPU缓存miss的频率远高于栈。
理解这一切不是为了背面试题。你写的每行代码,都在支配着栈的变化、堆的分配、缓存的命中率。知道这些底层机制,你就能写出更适合CPU和内存架构的代码。
这个专栏只想说清楚一件事:每行代码由谁执行,怎样执行。配合后端技术内核的五个专栏(Java基础、JVM、并发编程、MySQL、Redis),对你的每一行代码的理解从"怎么用"贯通到"为什么这么运行"。
这是计算机基础专栏的第二篇,讲清楚了内存的层级结构和堆栈的完整运作机制。