杰瑞科技汇

Java中stack和heap的核心区别是什么?

  1. 核心定义与比喻:用简单的比喻让你快速理解。
  2. 详细对比表格:一目了然地展示它们的区别。
  3. 深入解析:分别详细讲解栈和堆。
  4. 一个完整的例子:通过代码和内存图来串联所有知识点。
  5. 常见面试题:总结一些与此相关的经典面试问题。

核心定义与比喻

你可以把计算机的内存想象成一个大型工厂

Java中stack和heap的核心区别是什么?-图1
(图片来源网络,侵删)
  • :就像工厂里的加工流水线

    • 特点:先进后出,你把新的零件(方法)放到流水线末端,加工完成后,最先取下的也是这个新零件,空间小,但速度极快,每个工位(方法)都有自己专属的加工台(栈帧)。
    • :基本数据类型、对象的引用(地址)、方法调用等。
  • :就像工厂里的大型原材料仓库

    • 特点:可以随意存放和取出原材料,空间很大,但找东西(访问)比流水线慢,所有工位(线程)都可以共享这个仓库。
    • new 出来的所有对象实例(包括实例变量)和数组。

简单流程:在流水线(栈)上,工人拿到一张“原材料清单”(对象的引用),然后根据清单去仓库(堆)里找到对应的“原材料”(对象实例)进行加工。


详细对比表格

特性
作用 存储方法调用、局部变量、基本数据类型和对象引用。 存储所有通过 new 关键字创建的对象实例和数组。
内存大小 小且固定,由 -Xss 参数设置,每个线程都有独立的栈。 大且动态,由 -Xms (初始) 和 -Xmx (最大) 参数设置,所有线程共享。
存取速度 ,内存分配和回收速度非常快,类似于数据结构中的栈。 ,需要通过指针来寻找对象,速度相对较慢。
生命周期 方法调用绑定,方法入栈,创建栈帧;方法出栈,栈帧销毁。 垃圾回收器绑定,对象不再被引用时,GC 会在未来某个时间回收它。
线程共享 不共享,每个线程都有自己独立的栈,线程间无法直接访问对方的栈数据。 共享,所有线程都可以访问堆中的对象,因此需要考虑线程安全问题。
内存管理 自动管理,方法调用结束,其对应的栈帧会自动弹出,内存自动回收。 由垃圾回收器 自动管理,开发者无法直接控制回收时机。
碎片问题 不会产生内存碎片,栈帧的分配和回收是连续的、紧密的。 会产生内存碎片,频繁的创建和销毁对象可能导致内存不连续,GC 会进行整理。
异常情况 栈溢出,如果方法调用层级太深(如无限递归),栈空间耗尽会抛出 StackOverflowError 内存溢出,如果堆中没有足够空间来创建新对象,会抛出 OutOfMemoryError

深入解析

栈 的详细工作方式

栈是线程私有的,它的生命周期与线程相同,每个线程在创建时,JVM 都会为其分配一个栈。

Java中stack和heap的核心区别是什么?-图2
(图片来源网络,侵删)

栈的核心是栈帧,每当一个方法被调用时,JVM 会会创建一个栈帧并将其压入栈顶,当方法执行完毕返回时,对应的栈帧就会从栈顶弹出。

一个栈帧中包含了什么?

  1. 局部变量表:存储方法中的所有局部变量和方法的参数,对于基本数据类型,它直接存储值;对于对象引用,它存储的是对象在堆中的内存地址。
  2. 操作数栈:一个临时的数据存储区,用于执行计算,执行 a + b 时,ab 会被从局部变量表弹出,压入操作数栈,执行加法后,结果再压回操作数栈。
  3. 动态链接:指向运行时常量池中该方法的引用,用于支持方法调用中的动态链接。
  4. 方法返回地址:存储该方法执行完成后,应该返回到哪里继续执行。

栈的执行流程示例:

public class Main {
    public static void main(String[] args) {
        int a = 10;
        method1();
    }
    public static void method1() {
        int b = 20;
        method2();
    }
    public static void method2() {
        // ...
    }
}

内存中的栈变化如下:

  1. main 方法启动,JVM 为 main 线程创建栈。
  2. main 方法入栈,创建栈帧 Frame 1,局部变量 argsa 存储在 Frame 1 的局部变量表中。
  3. main 调用 method1method1 入栈,创建栈帧 Frame 2,局部变量 b 存储在 Frame 2 的局部变量表中。
  4. method1 调用 method2method2 入栈,创建栈帧 Frame 3。
  5. method2 执行完毕,Frame 3 弹出。
  6. method1 执行完毕,Frame 2 弹出。
  7. main 执行完毕,Frame 1 弹出,线程结束,栈被销毁。

堆 的详细工作方式

堆是 Java 内存管理中最大的一块区域,它是所有线程共享的一块内存区域。

堆的目的:存放对象实例,几乎所有通过 new 创建的对象实例以及数组都在这里分配内存。

堆的结构(现代JVM,如G1垃圾回收器)

  • 新生代:新创建的对象首先在新生代分配,新生代又分为:
    • Eden 区:新对象诞生的地方。
    • Survivor 区 (From 和 To):经过一次GC后仍存活的对象会被移到这里。
  • 老年代:在新生代中存活了足够多次(经过多次GC)的对象,会被“晋升”到老年代,老年代的对象生命周期更长。
  • 元空间:在 JDK 8 及之后,原本方法区中的运行时常量池类元信息(类的定义、字段、方法等)被移到了元空间,元空间不在虚拟机内存中,而是直接使用本地内存,这解决了方法区的内存溢出问题。

垃圾回收

堆是垃圾回收的主要区域,GC 会不断扫描堆中的对象,找到那些不再被任何引用指向的“垃圾”对象,然后回收它们的内存,以供新对象使用。

对象访问流程

栈中的引用变量(如 Person p)存储的是堆中对象(new Person())的内存地址,通过这个地址,JVM 可以在堆中定位到对象的具体位置,并访问其内部的实例变量。


一个完整的例子

让我们通过一个例子来串联所有概念。

public class Person {
    private String name; // 实例变量,存储在堆中
    private int age;     // 实例变量,存储在堆中
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public static void main(String[] args) {
        // 1. main 方法入栈,创建栈帧 Frame 1
        String localName = "Alice"; // 局部变量,存储在栈的 Frame 1 中
        int localAge = 30;         // 局部变量,存储在栈的 Frame 1 中
        // 2. new Person(...) 在堆中分配内存
        //    - 创建一个 Person 对象实例。
        //    - 对象内部的 name 和 age 字段也存储在这个堆内存块中。
        //    - 引用变量 p 存储在栈的 Frame 1 的局部变量表中,其值是堆中 Person 对象的内存地址。
        Person p = new Person("Bob", 25);
        // 3. 调用 modifyPerson 方法
        modifyPerson(p, localName); // p 和 localName 作为参数传递
    }
    public static void modifyPerson(Person personRef, String newName) {
        // 4. modifyPerson 方法入栈,创建栈帧 Frame 2
        //    - 参数 personRef 和 newName 是局部变量,存储在栈的 Frame 2 中。
        //    - personRef 持有和 main 方法中 p 相同的地址,指向堆中的同一个 Person 对象。
        //    - newName 是一个新的 String 对象,存储在堆的常量池或新生代中。
        // 5. 修改堆中对象的实例变量
        personRef.setAge(26); // 通过 personRef 找到堆中的对象,修改其 age 字段。
                             // 这个修改对 main 方法中的 p 也是可见的,因为它们指向同一个对象。
        // 6. 创建一个新的局部变量,它指向堆中的另一个对象
        String anotherName = new String("Charlie"); // anotherName 在栈的 Frame 2 中,存储新 String 对象的地址。
        // 7. modifyPerson 方法执行完毕,Frame 2 弹出
        //    - 局部变量 personRef, newName, anotherName 被销毁。
        //    - 但它们指向的堆中的对象(Person 和两个String)仍然存在,因为 main 方法中的 p 和 localName 还在引用它们。
    }
}

内存图解:


常见面试题

  1. 问:String s = new String("abc"); 这句话创建了几个对象?

    • :可能创建 1 个或 2 个。
      • 1个:如果字符串常量池中已经存在 "abc"new String("abc") 只会在堆中创建一个新的 String 对象。
      • 2个:如果字符串常量池中没有 "abc",那么会先在常量池中创建一个 "abc" 对象,然后再在堆中创建一个新的 String 对象。
    • 引用s 是一个引用变量,它存储在栈中,指向堆中那个新创建的 String 对象。
  2. 问: 和 equals() 的区别?

      • 比较的是栈中的值
        • 如果比较的是基本数据类型(int, char 等),比较的是它们的值是否相等。
        • 如果比较的是引用类型(对象),比较的是它们在栈中存储的内存地址(引用)是否相同,即是否指向堆中的同一个对象。
      • equals():是 Object 类的方法,默认行为也是比较内存地址(与 相同)。
        • 很多类(如 String, Integer, ArrayList)都重写了 equals() 方法,用来比较对象是否相等。
  3. 问:什么是栈溢出?什么是内存溢出?如何排查?

    • 栈溢出StackOverflowError,原因是线程请求的栈深度超过了 JVM 所允许的深度,最常见的原因是无限递归调用
      • 排查:使用 jstack 工具分析线程堆栈,找到导致无限递归的方法。
    • 内存溢出OutOfMemoryError,原因是 JVM 没有足够的内存来分配新的对象,常见于内存泄漏或堆内存设置过小。
      • 排查
        1. 使用 jmap 生成堆转储文件(Heap Dump)。
        2. 使用 MAT (Memory Analyzer Tool)VisualVM 等工具分析该文件,找到内存中占用最大的对象,并查看其引用链,定位导致对象无法被回收的代码(内存泄漏点)。
  4. 问:Java 的对象都创建在堆上吗?

    • :不完全是,绝大多数是的,但有一种例外:逃逸分析
      • 通过 JIT(即时编译器)的逃逸分析,如果一个对象在方法内被创建,并且它的引用没有“逃逸”出这个方法(即没有被外部引用),JVM 可能会优化这个对象的内存分配,将其直接分配在栈上
      • 这样一来,当方法执行结束时,对象会随着栈帧的弹出而自动销毁,无需 GC 回收,提高了效率,这种对象被称为“栈上分配”或“标量替换”,但这是一种 JVM 优化技术,不是开发者可以显式控制的。
角色 方法执行的“舞台” 对象的“家”
速度
大小
生命周期 方法结束即销毁 由GC管理,不确定
线程安全 独立,安全 共享,不安全

理解栈和堆的区别是成为一名优秀 Java 开发者的基石,它能帮助你写出更高效的代码,并更好地排查内存相关的疑难杂症。

分享:
扫描分享到社交APP
上一篇
下一篇