JVM组织架构主要有三个部分:类加载器、运行时数据区和字节码执行引擎
- 类加载器:负责从文件系统、网络或其他来源加载class文件,将class文件中的二进制数据加载到内存中
- 运行时数据区:运行时的数据存放的区域,分为方法区、堆、虚拟机栈、本地方法栈和程序计数器
- 字节码执行引擎:用来运行Java字节码,主要包括解释器和JIT编译器
虚拟机栈

Java 虚拟机栈的生命周期与线程相同。
当线程执行一个方法时,会创建一个对应的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,然后栈帧会被压入虚拟机栈中。当方法执行完毕后,栈帧会从虚拟机栈中移除。
++一个空方法如果没有任何变量和参数,局部变量表一定为空吗?++
**不一定,**对于非静态方法,局部变量表中会有一个this引用,指向当前实例;对于静态方法,由于不需要通过this访问实例,所以局部变量表中的变量数量为0(在字节码中体现为locals=0)
本地方法栈
和虚拟机栈类似,但是本地方法栈是为了非Java语言编写的方法(C/C++)服务的,每个栈帧中保存了局部变量表,动态链接,方法出口(没有操作数栈)
本地方法栈的运行场景?
当Java应用需要和操作系统底层或硬件进行交互式,需要用到本地方法,比如内存管理,文件操作,系统时间、系统调用
还有JVM自身的一些底层功能也需要用到本地方法,比如过Object里的hashCode(), clone()
native方法
native方法在Java语言中用来修饰本地方法,用来调用非Java语言编写的方法,Java可以通过JNI(Java Native Interface)来和操作系统底层、硬件或本地库进行交互
堆内存

堆中的实例如果不再被任何变量引用,最后会被垃圾收集器回收
方法区
方法区只是一个逻辑概念,并非实际存在。主要存放已被加载的类信息,常量,静态变量。
在JVM的HotSpot实现中,方法区被替换为永久代,但在Java8以后的版本,已经被元空间替代。
JDK 1.6、1.7、1.8内存区域变化
JDK1.6中,方法区的实现是永久代,存放的内容是已被加载的类信息,静态变量(常量)、常量池

JDK1.7中,仍然是永久代,但字符串常量池和静态变量被移动到堆空间中去了,其他信息仍然在永久代中

JDK1.8之后,方法区的实现替换为了元空间(直接内存),并将运行时常量池、类常量池都移动到了元空间。

为什么用元空间替换永久代?
因为永久代使用JVM内存 ,而元空间使用本地内存 。永久代收到JVM内存大小的限制,容易造成内存溢出,而元空间是在直接内存中开辟的,不容易造成内存溢出。其次,永久代的GC触发条件苛刻,回收频率很低
对象创建过程
当使用new创建一个对象时,JVM会首先检查new指令 (字节码)的参数 (比如说#2)是否能在运行时常量池 中的对应索引 处找到符号引用 (如 "java/lang/String"),然后检查这个符号引用是否经过了类加载,如果没有,就先执行类加载。

如果已经加载,则
- 分配内存
- 完成对象内存初始化(赋初始值)
- 设置对象头(包括类元数据指针,GC年龄分代等信息)
- 执行init方法完成赋值操作
对象的销毁过程
如果对象不再被任何强引用所指向,也就是说JVM用可达性算法判断出这个对象无法通过强引用链 到达该,就会被GC垃圾回收器销毁,有标记清除、标记复制、标记整理三种回收算法。
堆内存如何分配?
指针碰撞
如果堆内存完美的分为了两部分:已使用和未使用,有一个指针指向了下一个可分配内存位置的起始地址,然后向后移动新对象大小的空间,如果没有发生碰撞(没有超过堆内存边界)原来指针和新指针之间的部分就是为该对象分配的内存
指针碰撞适合没有内存碎片的情况。
空闲列表
Java维护一个列表 ,列表中记录了还未被使用的空闲的内存块,每个内存块都有大小和地址信息。
当有新对象要分配内存时,JVM会遍历空闲列表找出能够放下新对象的内存块。
如果内存块空间未被完全利用,则会作为一个新的内存块放入空闲列表中。
空闲列表适用于内存碎片化较严重 或对象大小差异较大的场景如老年代。
new对象时,堆会发生抢占吗?
会。比如用指针碰撞分配内存时:
- 两个线程同时读取当前指针位置P
- 线程A计算:P + 50(s的大小)
- 线程B计算:P + 100(l的大小)
- 两个线程可能同时更新指针
两个对象被分配到相同或重叠的内存区域
JVM的解决办法是为每个线程预留了一小块内存空间,称为TLAB,用于存放该线程创建的对象。如果TLAB的最大阈值已经不够新对象存放,才会使用全局指针在堆中分配。
java
// 伪代码流程
if (对象大小 <= TLAB剩余空间) {
在TLAB中分配;
} else if (对象大小 > TLAB最大阈值) {
直接在堆共享区域分配;
} else {
申请新TLAB并在其中分配;
}
对象的内存布局
不同的JVM实现不同,以HotSpot为例:
对象在内存中包括三部分:
- 对象头:包括类的元数据指针(指向方法、字段信息)和对象标记Mark Word(包括哈希码 、GC分代年龄信息、锁状态标志、线程持有的锁,偏向锁ID等)
- 实例数据:也就是对象成员变量的值,JVM可能会对这些数据进行重排/对齐,以提高访问效率
- 对齐填充:确保是八字节的整数倍,因为CPU一次寻址的指针大小是8字节,正好是L1缓存行的大小,如果不进行对齐,那么可能会导致跨行访问,导致额外的缓存行加载,导致访问效率降低。是一种以空间换时间的方式
类的元数据指针 可能被压缩,压缩默认开启,压缩后占4个字节,压缩前占8个字节。
++new Object()的对象内存大小是多少?++

Object对象没有实例数据,所以占用内存大小为8字节的对象标记Mark Word + 4字节的压缩后的类元数据指针 + 0字节的数组对象专用字段 + 0字节的实例数据 + 4字节的对齐填充 = 16字节
++JOL查看内存对象布局++
引入依赖:
java
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
java
public class JOLSample {
public static void main(String[] args) {
// 打印JVM详细信息(可选)
System.out.println(VM.current().details());
// 创建Object实例
Object obj = new Object();
// 打印Object实例的内存布局
String layout = ClassLayout.parseInstance(obj).toPrintable();
System.out.println(layout);
}
}
可以看到有 OFFSET、SIZE、TYPE DESCRIPTION、VALUE 这几个信息。
- OFFSET:偏移地址,单位字节;
- SIZE:占用的内存大小,单位字节;
- TYPE DESCRIPTION:类型描述,其中 object header 为对象头;
- VALUE:对应内存中当前存储的值,二进制 32 位;
++对象的引用大小++
Object o = new Object();
64位JVM上,未开启压缩指针时,对象的引用大小是8字节;开启压缩指针后,对象的引用大小是4字节。
引用类型的成员变量中,引用存放在该成员变量对象内存的实例数据中
- 基本类型成员变量 → 直接存储值
- 引用类型成员变量 → 存储引用(对象地址)

Object o = new Object();大小?
java
class MyClass {
Object o = new Object(); // o引用存储在MyClass对象的实例数据中
}
MyClass对象:
├── 对象头
├── 实例数据
│ └── o引用 (4字节) → 指向Object对象
└── 对齐填充
Object对象:
├── 对象头 (12字节)
├── 实例数据 (0字节)
└── 对齐填充 (4字节)
总计:16字节
总占用:4 + 16 = 20字节
JVM如何访问对象?
句柄和直接指针。
句柄
通过一个中间的句柄表来访问对象:

优点是对象移动时只需要改变句柄池中的地址,而不需要改变引用本身的指向。
直接指针访问
引用直接存储的是对象内存的地址,直接通过该对象内存的地址访问到类型信息,实例数据等。
优点是访问速度更快,缺点是如果对象移动需要更新访问地址。
HotSpot默认使用直接指针。
对象有哪几种引用?
四种,强引用、软引用、弱引用和虚引用

强引用是 Java 中最常见的引用类型。使用 new 关键字赋值的引用就是强引用,只要强引用关联着对象,垃圾收集器就不会回收这部分对象,即使内存不足。
软引用于描述一些非必须对象,通过 SoftReference 类实现。软引用的对象在内存不足时会被回收。
弱引用用于描述一些短生命周期的非必须对象,如 ThreadLocalMap 中的 Entry,就是通过 WeakReference 类实现的。弱引用的对象会在下一次垃圾回收时会被回收,不论内存是否充足。
虚引用主要用来跟踪对象被垃圾回收的过程 ,通过 PhantomReference 类实现。虚引用的对象在任何时候都可能被回收。 无法通过虚引用直接获取对象,必须配合ReferenceQueue
Java堆内存分区

分为新生代和老年代两个区域。新生代又分为Eden区,Survivors From和To
新创建的对象都分配在Eden区,当Eden区填满 会经历一次Minor GC,存活的对象会被移动到Survivor区。
任何时刻只有一个Survivor区有对象, 因为新生代的垃圾收集主要采用标记-复制 算法(Eden到Survivor采用简化的复制算法,仅通过引用链追踪),因为新生代的存活对象比较少,每次复制少量的存活对象效率比较高 。
• 这个有对象的区叫from区
• 空的那个区叫to区
• 每次GC将存活对象从from复制到to
• GC完成后,原to变成新from,原from被清空变成新to
如果新生代的对象经过多次GC后仍然存活,会被移动到老年代。
当老年代内存不足时,会触发一次Major GC,对老年代垃圾回收。
对象什么时候进入老年代?

++怎么算长期存活?++
JVM维护一个"年龄计数器",每次新生代中Minor GC未被回收的对象,年龄计数器+1,当年龄超过一个特定阈值(默认15),就会被放入老年代
++怎么算大对象?++
阈值大小由 JVM 参数 -XX:PretenureSizeThreshold
控制,但在 JDK 8 中,默认值为 0,也就是说默认情况下,对象仅根据 GC 存活的次数来判断是否进入老年代。
G1 垃圾收集器 中,大对象会直接分配到 HUMONGOUS 区域。当对象大小超过一个 Region 容量的 50% 时,会被认为是大对象。

++什么是动态年龄判断?++
如果Survivor区中所有对象的总大小超过了一定比例(通常是50%),那么年龄较小的对象也可能提前进入老年代。
这是因为如果年龄较小的对象在 Survivor 区中占用了较大的空间,会导致 Survivor 区中的对象复制次数增多,影响垃圾回收的效率。
STW
STW是指Stop The World,会暂停所有用户线程的执行。这是为了保证对象引用在移动过程中不会被修改。
++GC前如何暂停线程?++
JVM会使用一个叫做安全点(safe point)的机制确保线程能够安全的被暂停,包括四个步骤:
- JVM发出暂停信号
- 线程执行到安全点后,自行挂起并等待GC完成
- 垃圾回收器完成GC操作
- 线程继续执行
++什么是安全点?++
收到暂停信号后,线程执行到特定位置就暂停执行,挂起自身,这个特定位置叫做安全点,保证线程暂停执行时的数据一致性和状态完整性(引用关系完整可追踪,不会是中间状态) 。通常位于方法调用、循环跳转、异常处理等位置。
逃逸
分为方法逃逸和线程逃逸
方法逃逸是指某个对象是否由于返回、复制到全局变量导致逃逸到方法之外,如果逃逸则必须分配到堆中,没有逃逸就进行栈上分配 / 标量替换。
线程逃逸是指某个对象被另一个线程引用,生命周期超出了当前线程,那么该对象也应该分配到堆中。
++逃逸分析的好处++
- 降低垃圾回收的压力
- 线程逃逸分析可以避免对不逃逸的对象加锁,因为变量不会逃逸出线程,不会被其他线程修改
- 如果对象的字段在方法中独立使用,那么可以进行标量替换,避免对象分配(对象甚至都不会在栈上分配)
内存溢出和内存泄漏
内存溢出(OOM)是指程序请求分配内存时没有新的空间了,或者说空间不够了。
内存泄漏是指本应该短期存活的对象没有被及时释放,导致占用的内存无法被使用,久而久之造成内存溢出。
内存泄漏是缺陷,内存溢出是最终结果
手写内存溢出例子
java
public class HeapSpaceErrorGenerator {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
try {
while (true){
byte[] bytes = new byte[10 * 1024 * 1024];
list.add(bytes);
}
} catch (OutOfMemoryError e) {
System.out.println("OutOfMemoryError 发生在 " + list.size() + " 对象后");
throw e;
}
}
}
每一次的bytes引用都存放到了list中,导致其指向的对象的引用链一直存在,无法被垃圾回收,导致内存泄露
内存泄漏的原因
- 一旦经过了类加载,静态的集合成员变量的生命周期就和应用程序相同,添加强引用时,其内部的元素都具有强引用链,如果没有主动释放集合中的引用,导致其内部元素无法被垃圾回收,可能导致内存泄漏。
- 单例模式 下对象持有的外部引用无法及时释放;单例对象在整个应用程序的生命周期中存活,如果单例对象持有其他对象的引用且没有主动释放这些引用,这些对象将无法被回收。
- 数据库、IO、Socket 等连接资源没有及时关闭;
- ThreadLocalMap的key被清理后,仍然持有value的强引用。在线程执行完后,要调用 ThreadLocal 的 remove 方法进行清理。
处理过内存泄露问题吗?
处理过ThreadLocal 没有及时清理导致出现的内存泄漏问题。
- jsp -l查看运行的进程id
- top -p [pid]查看进程的CPU和内存占用情况
- top -Hp [pid]查看进程下所有线程占用的CPU和内存情况
- jstack -F [pid] > [pid].txt抓取线程,查看有无死锁、死循环或长时间等待的问题
- jstat -gcutil [pid] 5000 10每隔 5 秒输出 GC 信息,输出 10 次,查看 YGC 和 Full GC 次数。如果YCG增长缓慢,Full GC快速增长,那就可能存在内存泄露
- jmap -dump:format=b,file=heap.hprof 10025生成dump文件
- 使用VisualVM 图形化工具装入dump文件,在结果中观察占用内存最多的对象
处理过内存溢出问题吗?
使用jmap -dump命令生成Heap Dump文件,使用工具进行分析,查看内存中对象占用情况
检查是否有未关闭的资源 ,是否有长生命周期对象
什么情况下会发生栈溢出?
程序调用栈的深度超过JVM的限制时,本质是线程的栈空间不足,无法再为新的栈帧分配内存,最常见的场景是递归调用,递归返回条件设置不当。