基础
虚拟机
什么是 Java 虚拟机?
为什么 Java 被称作是“平台无关的编程语言”?
Java 虚拟机是一个可以执行 Java 字节码的虚拟机进程
Java 源文件被编译成能被 Java 虚拟机执行的字节码文件
Java 被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译
Java 虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性
32位 & 64位
如何判断
怎样通过 Java 程序来判断 JVM 是 32 位 还是 64 位?
你可以检查某些系统属性如 sun.arch.data.model 或 os.arch 来获取该信息
最大堆内存
32 位 JVM 和 64 位 JVM 的最大堆内存分别是多少?
理论上说上 32 位的 JVM 堆内存可以到达 2^32, 即 4GB,但实际上会比这个小很多
不同操作系统之间不同,如 Windows 系统大约 1.5GB,Solaris 大约3GB
64 位 JVM 允许指定最大的堆内存,理论上可以达到 2^64,这是一个非常大的数字,实际上你可以指定堆内存大小到 100GB
甚至有的 JVM,如 Azul,堆内存到 1000G 都是可能的
引用类型
类别
强引用:保证核心对象存活
软引用:平衡内存与性能(如缓存)
弱引用:避免内存泄漏(如监听器)
虚引用:实现资源精准释放(如 JNI 本地内存)
详见:再谈引用(引用的分类)
弱引用
弱引用了解吗?
举例说明在哪里可以用?
弱引用的核心价值在于平衡内存与功能需求
典型应用
缓存系统:
允许内存敏感的自动释放(如 WeakHashMap)
内存泄漏防护:解耦长生命周期对象与短生命周期对象(如监听器)
资源管理:跟踪需释放的外部资源(如文件句柄)
通过合理使用弱引用,可以在不牺牲功能的前提下优化内存使用,提升应用稳定性
内存模型
主要组成
详解 JVM 内存模型-JVM 的主要组成部分及其作用
JVM 包含两个子系统和两个组件
两个子系统
类装载:Class loader
根据给定的全限定名类名(如:java.lang.Object)来装载 class 文件到 Runtime data area 中的 method area
执行引擎:Execution engine
执行 classes 中的指令
两个组件为
本地接口:Native Interface
与 native libraries 交互,是其它编程语言交互的接口
运行时数据区:Runtime data area
这就是我们常说的 JVM 的内存
作用
首先通过编译器把 Java 代码转换成字节码
类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内
而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行
因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令
再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能
堆 & 栈
JVM 内存模型里的堆和栈有什么区别?
可见性
堆:线程共享,所有线程均可访问堆中的对象
栈:线程私有,仅当前线程可访问自己的栈帧(局部变量、方法参数等)
定位
堆:存储对象实例和数组
栈:存储方法调用帧(局部变量、返回地址等)
内存分配
堆:动态分配(运行时决定大小),需垃圾回收(GC)
栈:静态分配(线程创建时固定大小),方法结束自动释放
生命周期
堆:对象存活时间由 GC 决定(可达性分析)
栈:栈帧随方法调用结束立即销毁
性能
堆:分配/回收速度慢(可能触发STW)
栈:分配/回收极快(栈帧压栈/弹栈操作)
异常类型
堆:OutOfMemoryError(无法分配对象)
栈:StackOverflowError(递归过深或栈帧溢出)
设计差异
堆:复杂(分代/分区、GC 策略)
栈:简单(线性分配,无内存碎片)
总结
堆关注对象生命周期和 GC 效率;栈关注方法执行效率和线程隔离性
栈存值
栈中存的到底是指针还是对象?
堆分区
堆分为哪几部分呢?
堆的核心分区
新生代(Eden + Survivor)
老年代
结合分代收集策略,针对不同生命周期对象优化 GC 效率
元数据区独立管理类元信息,不属于堆范畴
注意:
某些 JVM 为大对象分配了专门的区域,如 G1 的 Humongous 区域是专为超大对象设计的特殊分区,通过连续分配减少跨 Region 引用,但可能引发内存碎片和 Full GC 风险。
程序计数器
程序计数器的作用,为什么是私有的?
多线程并发执行的隔离性
线程切换的上下文保存
多个线程可能同时执行不同的代码路径(如不同的方法或循环)
如果共享一个 PC 寄存器,线程切换时会覆盖彼此的执行位置,导致数据混乱
独立执行位置的维护
每个线程必须记录自己的执行进度(下一条指令地址),私有 PC 寄存器确保线程间互不干扰
硬件层面的映射
物理 CPU 的寄存器模拟
在物理 CPU 中,每个线程的上下文(包括 PC)需要独立保存到寄存器或栈中
JVM 模拟这一行为,为每个线程分配独立的 PC 寄存器,以匹配底层硬件的执行模式
简化虚拟机设计
无锁同步的需求
若 PC 是共享的,需要复杂的同步机制(如锁)维护其值,这会降低性能并增加复杂性
私有 PC 寄存器天然支持线程独立执行,无需额外同步
单步执行的语义保证
PC 寄存器是单步执行的基石,私有化确保每条指令按顺序执行(除非遇到跳转指令),避免状态共享引发的逻辑错误
方法区
方法区中还有哪些东西?
类的元数据
类名、父类、实现的接口、修饰符(
public
/final
等)字段信息(名称、类型、修饰符)
方法信息(名称、参数类型、返回值类型、字节码、异常表)
注解信息(类、方法或字段上的注解元数据)
运行时常量池
字面量(如字符串、数值)
符号引用(类名、方法名、字段名)
动态链接所需信息(方法调用的解析依据)
静态变量(Static Variables)
类级别的变量(
static
修饰)基本类型(如
int
)和引用类型(如对象指针)
JIT 编译后的代码
热点代码的本地机器码(如 HotSpot 的 C1/C2 编译器生成)
类加载器元信息
类加载器(ClassLoader)的上下文
类隔离信息(如 Tomcat 自定义类加载器的 Web 应用类)
其他运行时数据
异常表(
try-catch
块范围及处理代码地址)方法区锁信息(类初始化锁,防止重复初始化)
Java 版本差异
Java 7 及之前:永久代(PermGen)存储类元数据,易引发内存溢出(
java.lang.OutOfMemoryError: PermGen
)Java 8 及之后:元空间(Metaspace)替代永久代,存储在本地内存,避免固定大小限制
Java 9+:进一步优化元空间管理,默认移除永久代相关参数(如
-XX:MaxPermSize
)
动态链接支持
方法区中的符号引用在运行时解析为直接引用(如方法调用
invokevirtual
的目标地址)
方法区锁
用于多线程环境下类初始化的同步控制(如双重检查锁定中的类加载锁)
方法字节码
方法的字节码(Code 属性)存储在方法区的
method_info
结构中,包含操作码(Opcode)、操作数、异常表等字节码指令格式由
def()
函数定义,例如_iconst_0
表示将常量 0 压栈,invokevirtual
表示动态方法调用字节码是平台无关的中间表示,需通过 JVM 解释执行或 JIT 编译为本地机器码
符号引用
符号引用存储在方法区的常量池中,包括类/接口全限定名、字段名、方法名及描述符(如
Ljava/lang/String;
、()V
)符号引用在类加载的 解析阶段 转换为直接引用(如内存地址),例如:
类/接口解析:通过类加载器加载符号引用的类
字段/方法解析:递归搜索父类或接口,匹配字段或方法签名
常量池缓存
静态常量池:编译期生成的
.class
文件常量池,包含字面量(如字符串、数值)和符号引用运行时常量池:类加载后,将静态常量池加载到方法区,并动态解析符号引用为直接引用
字符串常量池:
存储字符串字面量的唯一引用(JDK 7 前在方法区,JDK 7+ 移至堆)
通过
intern()
方法将运行时生成的字符串加入池中,避免重复创建
动态常量缓存:运行时生成的常量(如
String.valueOf()
结果)可能被 JVM 缓存优化
分派
静态 & 动态
静态分派
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,其典型应用是方法重载(根据参数的静态类型来定位目标方法)
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的
动态分派
在运行期根据实际类型确定方法执行版本
对象分配规则
对象优先分配在 Eden 区,如果 Eden 区没有足够的空间时,虚拟机执行一次 Minor GC
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)
这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝(新生代采用复制算法收集内存)
长期存活的对象进入老年代
虚拟机为每个对象定义了一个年龄计数器,如果对象经过了 1 次 Minor GC 那么对象会进入 Survivor 区
之后每经过一次 Minor GC 那么对象的年龄加 1,知道达到阀值对象进入老年区
动态判断对象的年龄
如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半
年龄大于或等于该年龄的对象可以直接进入老年代
空间分配担保
每次进行 Minor GC 时,JVM 会计算 Survivor 区移至老年区的对象的平均大小
如果这个值大于老年区的剩余值大小则进行一次 Full GC
如果小于检查 HandlePromotionFailure 设置
如果true 则只进行 Monitor GC,如果 false 则进行 Full GC
对象分配内存
类加载完成后,接着会在 Java 堆中划分一块内存分配给对象
内存分配根据 Java 堆是否规整,有两种方式:
指针碰撞:
如果 Java 堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边
分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作
空闲列表
如果 Java 堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的
这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录
选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
直接内存
Java 直接内存(Direct Memory)
JVM 堆外的内存区域,由操作系统直接分配和管理,不属于 JVM 运行时数据区的一部分
详见 直接内存
大对象
如果有个大对象一般是在哪个区域?
一般都是在老年代,具体如下:
方法区
方法区中的方法的执行过程?
方法区(Method Area)是 JVM 内存模型的一部分(Java 8 之前)
用于存储类的元数据、常量、静态变量、方法字节码等
方法区本身不直接执行方法,而是为方法的执行提供必要的元数据支持
方法的真正执行发生在 虚拟机栈(JVM Stack) 和 执行引擎 中
以下是方法从加载到执行的完整流程:
1. 类加载阶段
加载(Loading)
类加载器(ClassLoader)将类的字节码文件(
.class
)加载到方法区。方法区存储类的元信息:
方法的字节码(Code 属性)
方法签名(参数类型、返回值类型)
访问修饰符(
public
/static
等)常量池(Constant Pool,包含字面量和符号引用)
链接(Linking)
验证(Verification):确保字节码符合 JVM 规范
准备(Preparation):为静态变量分配内存并赋默认值(如
int
初始化为0
)解析(Resolution):将符号引用转换为直接引用(例如方法名的内存地址)
初始化(Initialization)
执行静态代码块(
static{}
)和静态变量的显式赋值此时方法区中的方法字节码已准备好,但尚未执行
2. 方法调用阶段
当调用一个方法时(如 obj.method()
):
解析方法符号引用
JVM 根据方法名和描述符,在方法区的常量池中查找方法的字节码地址
创建栈帧(Stack Frame)
在虚拟机栈中为当前方法分配一个栈帧,包含:
局部变量表:存储方法参数和局部变量
操作数栈:执行字节码指令的临时数据存储区
动态链接:指向方法区中该方法的符号引用
返回地址:方法执行完毕后返回的位置
执行字节码指令
解释执行:解释器逐条读取方法区中的字节码,翻译为机器码执行
即时编译(JIT):热点代码(频繁执行的方法)会被 JIT 编译器编译为本地机器码,直接由 CPU 执行
3. 方法执行中的关键依赖
动态链接(Dynamic Linking)
方法区中的常量池存储方法的符号引用(如invokevirtual
指令),在运行时通过动态链接解析为实际的方法地址静态变量与常量
静态变量(static
)和类常量(final static
)存储在方法区,方法执行时可直接访问异常表(Exception Table)
方法区的字节码中包含异常表,记录try-catch
块的范围和异常处理代码地址
4. 方法执行结束
正常返回
执行引擎将操作数栈顶的值作为返回值,返回到调用者的栈帧
虚拟机栈弹出当前栈帧,继续执行后续代码
异常返回
若发生未捕获的异常,虚拟机会查找异常表,跳转到对应的异常处理代码
若无处理逻辑,线程终止,栈帧被销毁
5. 方法区与执行过程的关系总结
6. 关键区别
方法区:存储类的静态信息(方法字节码、常量、静态变量),是线程共享的内存区域
虚拟机栈:存储方法调用的栈帧(局部变量、操作数栈),是线程私有的,直接支持方法执行
执行引擎:负责解析和执行字节码,依赖方法区的元数据和栈帧的运行时状态
7. 简而言之:方法区提供方法的“设计图纸”(元数据),虚拟机栈和执行引擎根据图纸“施工”(执行)
内存分代
JVM 内存为什么要分成新生代,老年代,持久代?
JVM 将内存划分为新生代、老年代和持久代(现为元空间)
核心目的是:基于对象生命周期的差异,采用分代垃圾回收策略以提升内存管理效率
具体作用如下:
新生代:存放生命周期极短的临时对象(如局部变量),采用复制算法快速回收(Minor GC),避免内存碎片
老年代:存储长期存活对象(如缓存),使用标记-清除/整理算法(Full GC),减少高频回收的性能损耗
持久代/元空间:管理类元数据、常量池等(Java 8 后由元空间替代,使用本地内存),与对象回收解耦以降低堆压力
这种分代设计通过 差异化回收策略(高频小范围回收 vs 低频大范围回收)和对象生命周期隔离,显著减少垃圾回收的停顿时间,并优化内存利用
新生代
新生代中为什么要分为 Eden 和 Survivor?
Eden 区负责快速分配新对象(多数短命对象在此消亡)
两个 Survivor 区通过交替复制存活对象(避免内存碎片)并筛选年龄达标者晋升老年代,从而降低 Full GC 频率
堆分区
堆里面的分区:Eden、survival to、from to,老年代
各自的特点
JVM 中堆空间可以分成三个大区,新生代、老年代、永久代
新生代可以划分为三个区,Eden 区,两个幸存区
在 JVM 运行时,可以通过配置以下参数改变整个 JVM 堆的配置比例:
JVM 运行时堆的大小
-Xms 堆的最小值
-Xmx 堆空间的最大值
新生代堆空间大小调整
-XX:NewSize 新生代的最小值
-XX:MaxNewSize 新生代的最大值
-XX:NewRatio 设置新生代与老年代在堆空间的大小
-XX:MaxNewSize 新生代的最大值
-XX:NewRatio 设置新生代与老年代在堆空间的大小
-XX:SurvivorRatio 新生代中 Eden 所占区域的大小
永久代大小调整
-XX:MaxPermSize
其他
-XX:MaxTenuringThreshold,设置将新生代对象转到老年代时需要经过多少次垃圾回收,但是仍然没有被回收
内存泄漏 & 内存溢出
理解
内存泄漏
Java 会存在内存泄漏吗?
尽管 Java 具备自动垃圾回收机制(GC),但内存泄漏仍然可能发生
其本质是 无用对象因错误的引用关系无法被 GC 回收,导致内存占用持续增加,最终可能引发 OutOfMemoryError
以下是内存泄漏的一些常见原因:
长生命周期对象持有短生命周期对象的引用
场景:静态集合类(如
static List
、static Map
)持续添加对象而未移除示例:
private static List<Object> staticList = new ArrayList<>(); staticList.add(new Object()); // 对象被静态集合长期持有
未关闭的资源
场景:数据库连接、文件流、网络连接等未显式调用
close()
方法解决方案:使用
try-with-resources
语法自动关闭资源
监听器或回调未注销
场景:注册的事件监听器或回调未在对象销毁时移除。
示例:GUI 组件销毁后未移除事件监听器。
线程泄漏
场景:线程池中未正确终止的线程或未关闭的线程池
内部类引用外部类
场景:非静态内部类隐式持有外部类实例的引用,导致外部类无法被回收
内存溢出
JVM 内存结构有哪几种内存溢出的情况?
栈内存溢出
什么情况下会发生栈内存溢出?
案例
有具体的内存泄漏和内存溢出的例子么请举例及解决方案?
实际中遇到的有下面两个内存泄露场景,典型的场内见示例
JPA 内存泄露
JPA 的二级缓存或查询缓存默认将查询结果对象存储在堆内存中
若缓存未配置过期策略(如
hibernate.cache.region.factory_class
未限制大小)或 IN 子句参数频繁变化导致缓存计划失效,会持续积累无用对象
ThreadLocal 泄露
未正确使用 remove() 方法,则会出现内存泄露
线程池中的线程长期存活,若未调用 remove()
已回收的 ThreadLocal 实例对应的 Entry 会持续积累,导致堆内存泄漏
String
存放位置
String 保存在哪里呢?
注意:常量池位置随版本有变化
JDK 6 及之前:字符串常量池位于 方法区(永久代)
JDK 7+:字符串常量池迁移到 堆内存 中
JDK 8+:方法区由元空间(Metaspace)实现,但字符串常量池仍保留在堆中
内存区域
String s = new String(“abc”)执行过程中分别对应哪些内存区域?
默认情况:创建 2 个对象(常量池的
"abc"
+ 堆中的String
实例)
特殊情况:若常量池已存在
"abc"
,则仅创建 1 个对象(堆中的String
实例)
String.intern()
String str2 = new StringBuilder("计算机").append("技术").toString();
System.out.println(str2 == str2.intern()); // 1.7、1.8 true 1.6 false
String s2 = new StringBuilder("计算机技术").toString();
System.out.println(s2 == s2.intern()); // 1.7、1.8 false 1.6 false
该问题要点是要理解 3 个区域,栈、堆,字符串常量池
字符串常量池在不同版本的 JDK 中变化如下
Jdk1.6 及之前:JVM 存在永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池
Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里
Jdk1.8 及之后:无永久代,变成了元空间,运行时常量池在元空间,字符串常量池里依然在堆里
String 中的 intern 方法是一个 native 的方法,我们忽略运行时常量池,只关心字符串常量池就行了,字符串常量池在 JDK1.7 后都在堆中
JDK1.7(含) +
当调用 intern 方法时
如果字符串常量池已经包含一个等于此 String 对象的字符串(用 equals 方法确定)
则返回池中的字符串, 否则,将 intern 返回的引用指向当前字符串
Jdk1.6 版本需要将 s2 复制到字符串常量池里
分析如下:
JDK 1.6 的 str2.intern() 和 s2.intern() 指向永久代的字符串常量池,str2 和 s2 指向堆中两个不同的引用,所以两个都是 false
JDK 1.7 的 str2.intern(),字符串常量池中无该字符串,此时执行堆中的 str2,因此第一个是 true,第二个 s2 堆中的引用不等于 str2.intern() 引用,自然就是 false 了
详细带截图见 JVM - 一个案例反推不同 JDK 版本的 intern 机制以及 intern C++ 源码解析open in new window
并发安全
处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的
可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况
解决这个问题有两种方案:
对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性)
把内存分配的动作按照线程划分在不同的空间之中进行
即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
哪个线程要分配内存,就在哪个线程的 TLAB 上分配
只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁
通过
-XX:+/-UserTLAB1
参数来设定虚拟机是否使用 TLAB
评论区