持续更新
内存结构与对象创建
请详细介绍JVM内存结构的整体布局,各个区域的作用和特点?
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域
堆:存放对象实例与数组,是 GC 管理的核心区域。它线程共享,是JVM 中最大的一块内存,分为新生代和老年代
- 堆中包含字符串常量池,是 JVM 为了提升性能和减少内存消耗,针对字符串专门开辟的一个区域,主要目的是为了避免字符串的重复创建。因为同一个字面量的字符串,在常量池中只会保留一个引用
元空间:也叫方法区,其中包含运行时常量池,其中垃圾回收行为较少
- 运行时常量池存储 Class 文件编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)
虚拟机栈:线程私有,描述 Java 方法执行的内存模型。每个方法执行时创建一个栈帧,除了一些 Native 方法,其他所有的 Java 方法的调用都是通过栈来实现的。
- 关于栈帧,栈由一个个栈帧组成,栈帧是方法调用的最小单位,每调用一个 Java 方法,就会创建一个栈帧并压入栈,每个栈帧中都拥有,局部变量表、操作数栈、动态链接、方法返回地址
本地方法栈:为 Native 方法 服务,它线程私有,功能与虚拟机栈几乎一样
程序计数器:记录当前线程执行的字节码行号,用于线程切换后恢复执行位置。它线程私有,而且不会出现 OOM
直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。它是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
堆放对象,栈管运行,方法区存类信息,程序计数器保证线程切换不乱。
堆内存的作用和组成
堆是 JVM 所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 中几乎所有的对象实例和数组都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么 “必须” 了
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆。从垃圾回收的角度来讲,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
Java堆和栈的区别?存储内容和生命周期的差异?结合JVM内存结构详细说明
Java 堆和栈是 JVM 内存结构中的两个核心区域,它们在存储内容和生命周期上有本质区别。
- 栈内存:存储方法调用时的局部变量、方法参数和返回地址。每个线程都有独立的栈空间,当方法被调用时会在栈中创建栈帧,方法执行完毕后栈帧立即销毁。比如你在方法中声明的
int count = 10,这个 count 变量就存储在栈中,方法结束后立即回收,生命周期非常短暂。 - 堆内存:存储所有对象实例和实例变量,是所有线程共享的区域。当你使用
new User()创建对象时,User 对象就分配在堆中。堆中的对象生命周期由垃圾回收器管理,只有当对象不再被任何引用指向时才会被GC回收,生命周期相对较长。
从访问速度来看,栈采用后进先出的数据结构,访问速度快;堆需要通过引用访问,速度相对较慢。栈空间相对较小,如果递归调用过深会导致StackOverflowError;堆空间较大,但如果创建对象过多会引发OutOfMemoryError。
简单说一下 Java 对象的创建过程
JVM(HotSpot 虚拟机)中对象的创建过程主要分为以下五步:
- 类加载检查:虚拟机执行 new 指令时,先检查常量池中对应类的符号引用是否已加载、解析和初始化,未完成则先执行类加载过程。
- 分配内存:类加载通过后,根据类加载确定的对象大小从 Java 堆划分内存
- 初始化零值:将分配的内存空间,除了对象头外,初始化为零值,确保 Java 代码中未赋初始值的实例字段可直接使用对应类型的零值。
- 设置对象头:在对象头中记录类元数据信息、哈希码、GC 分代年龄、锁状态等必要信息,具体设置依虚拟机运行状态(如是否启用偏向锁)而定。
- 执行 init
方法:虚拟机视角下对象已创建,但需执行
<init>方法,将对象初始化到程序员期望的状态,最终生成可用对象。
垃圾回收
JVM垃圾回收算法有哪些?各自的优缺点和适用场景?
JVM垃圾回收算法主要包括标记-清除、复制算法、标记-整理和分代收集四种核心算法。
- 标记-清除算法是最基础的回收算法,先标记需要回收的对象,再统一清除。它的优点是实现简单,缺点是会产生大量内存碎片,影响大对象分配,而且通常需要暂停应用程序清理。
- 复制算法将内存分为两块,每次只使用一块,GC 时候将存活对象复制到另一块区域。这种算法避免了内存碎片,回收效率高,但内存利用率只有50%,所以适合新生代这种存活对象较少的场景。
- 标记-整理算法在标记阶段后,将存活对象向内存一端移动,然后清理边界外的内存。它既避免了内存碎片,又不浪费内存空间,但移动对象的成本较高,更适合老年代这种存活率高的区域。
- 分代收集算法是目前主流的垃圾回收策略,根据对象存活周期的不同将堆内存划分为新生代和老年代,然后使用不同的算法,新生代采用复制算法,老年代采用标记-清除或标记-整理算法。这种组合策略充分发挥了各算法的优势,在Hotspot虚拟机中被广泛应用,能够在不同场景下提供最优的回收性能。
如何判断对象是否死亡
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡,即不能再被任何途径使用的对象
一般就是引用计数法和可达性分析算法两种情况
引用计数法:
给对象中添加一个引用计数器:
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。
所谓对象之间的相互引用问题,如下面图所示:除了对象 objA
和 objB
相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为
0,于是引用计数算法无法通知 GC 回收器回收他们。
可达性分析算法:
从一系列称为 GC Roots 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
下图中的
Object 6 ~ Object 10之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。
哪些对象可以作为 GC Roots 呢?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行
finalize 方法。当对象没有覆盖 finalize
方法,或 finalize
方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
垃圾回收算法有哪些?
标记-清除算法
标记-清除(Mark-and-Sweep)算法分为 标记(Mark)和 清除(Sweep) 阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:
- 效率问题:标记和清除两个过程效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片。
复制算法
为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。
它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
虽然改进了标记-清除算法,但依然存在下面这些问题:
- 可用内存变小:可用内存缩小为原来的一半。
- 不适合老年代:如果存活对象数量比较大,复制性能会变得很差
标记-整理算法
标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与 标记-清除 算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存
由于多了整理这一步,因此效率也不高,因此它适合老年代这种垃圾回收频率不是很高的场景
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
至于 HotSpot 为什么要分为新生代和老年代?也可以根据上述内容进行回答
类的文件结构和类的加载过程
类加载器
Java 的类加载过程是怎样的?
类加载就是把 .class
字节码文件的二进制数据读进内存,经过校验、转换,最终变成 JVM 能用的
Class 对象。
二进制流不一定非得来自 .class
文件,也可以是字节码工具动态生成的、或者从网络传过来的,只要格式对,JVM
都认。
整个类加载流程分为三大阶段:加载、连接、初始化。连接又能拆成验证、准备、解析三步,所以细分下来是 5 个阶段:
- 加载:把二进制流读入内存,在方法区生成类的运行时数据结构,同时在堆中创建一个 Class 对象作为访问入口并分配内存
- 连接:
- 校验:校验二进制流是否符合 Class 文件规范,防止恶意代码搞崩 JVM。
- 准备:给类变量(static)分配内存并且设置零值初始化
- 解析:把常量池里的符号引用替换成直接引用。符号引用就是一个字符串形式的标识,比如
java/lang/Object;直接引用是真正的内存地址或偏移量,能直接定位到目标。
- 初始化:执行类构造器
<clinit>()方法,这时候才真正执行static int a = 123这种赋值操作,静态代码块也是在这个阶段跑的。
JVM 规范规定了 6 种情况必须立即对类进行初始化
- 遇到
new、getstatic、putstatic、invokestatic这 4 条字节码指令时。- 使用
java.lang.reflect包对类进行反射调用时。- 初始化子类时发现父类还没初始化,先把父类初始化了。
- JVM 启动时指定的主类
- 接口中定义了
default方法,实现类初始化前要先初始化这个接口。
什么是JVM类加载器?Java提供了哪些默认的类加载器?各自的层次结构
JVM 类加载器是负责将.class字节码文件加载到内存并转换为
Class
对象的组件,它是连接磁盘上的.class文件和运行时对象的桥梁。
没有类加载器,整个Java程序就无法运行。Java采用双亲委派模型实现类加载的层次结构,确保了类加载的唯一性和安全性。
从顶层到底层,默认类加载器包括:
- 启动类加载器:负责加载
$JAVA_HOME/lib目录下的核心类库如rt.jar中的java.lang.Object、java.util.HashMap等基础类 - 扩展类加载器:加载
$JAVA_HOME/lib/ext目录下的扩展类库 - 应用程序类加载器:加载 classpath 路径下的应用程序类和第三方依赖
双亲委派机制确保类加载请求首先向上委托给父加载器,只有父加载器无法加载时子加载器才会尝试加载。比如当你的系统尝试加载java.lang.String时,请求会一直委派到启动类加载器完成加载,而不会使用你自定义的同名类,这种设计有效避免了核心类被恶意替换。
JVM 监控和调优
常用的JVM监控和调优命令?jstat、jmap、jstack、jhat的使用?
在JVM监控调优中,这几个命令各有专门用途。
jstat:实时性能监控,例如,通过jstat -gc pid能持续观察垃圾回收频率和耗时,jstat -gcutil pid 1000每秒输出 GC统计信息,帮你快速定位 GC 性能问题。jmap:专门处理内存分析,jmap -histo pid显示对象实例统计,也能使用jmap dump生成堆转储文件,这在排查内存泄漏时特别有效jstack:负责线程分析,jstack pid输出所有线程堆栈信息,当应用出现死锁或CPU占用异常时,通过线程状态能快速定位问题线程。jhat:堆分析工具,常用就是用它分析堆转储文件,jhat heap.hprof启动Web服务器分析 dump 文件。
实际使用就是,先用jstat观察 GC
行为确定是否存在内存问题,发现异常后用jmap生成 dump
文件详细分析,如果是 CPU
问题则用jstack查看线程状态。现在更推荐用 JProfiler
替代jhat进行深度内存分析,功能更强大且界面友好。







