JVM内存区域
总述
主要分为运行时数据区域(JVM内存)和本地内存。
- 运行时数据区域(即**JVM内存**):JVM 规范中定义的所有内存区域的统称,是个逻辑区域。包含:
- 线程共享区域,包含“堆内存”、方法区(JDK1.7在共享区域,称为永久代;1.8之后被放到本地内存之中,称为元空间,但逻辑上仍属于JVM定义的规范之中)
- 线程私有区域,包含虚拟机栈、本地方法栈、程序计数器

本地内存:除去“运行时数据区域”后其余的物理内存
直接内存:特殊的一种内存缓冲区
元空间:是JVM规范中的“方法区”的一种实现,方法区是认为在JVM内存中的。JVM 的运行时数据区域 有固定大小上限,会受到JVM内存的限制;而放到本地内存后,只限制于系统可用的内存
JVM内存_线程私有区_3个
程序计数器
每个线程都有一个程序计数器,是当前线程所执行的字节码的行号指示器。字节码是JVM能识别的代码,是Java编译后形成的,并由在JVM上解释执行,它的代码是有行号的。可以通过如下命令查看:
1 | javac ClassName.java |

总结来说程序计数器的作用是:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理;
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了;
虚拟机栈_4个
由一个个栈帧组成,每调用1次Java方法,就会产生压入一个栈帧。
每个栈帧由如下部分组成:
局部变量表:方法执行时的局部变量,以Slot为单位存储数据(Slot是32位, Int就是32位, Character和Short都是16位, Long和Double是32位),是个线性的数组结构,包括:
- 方法参数,会按照顺序依次存入局部变量表的起始位置。对于实例方法,局部变量表索引为0的位置通常是this引用;
- 方法内定义的局部变量,包含基本数据类型和对象引用:JVM在编译期会知道这些局部变量在变量表中的索引,每个索引有多少个Slot也是编译时确定的;
long和double是用2个slot,其他基本类型和对象引用都是用1个slot
操作数栈:临时数据存储区,暂存方法执行过程中的操作数,例如最简单的
c = a + b,就是先压a和b,再弹出2个计算加法,并将结果压入;更复杂地,方法调用时也需要使用操作数栈,例如:1
2
3
4
5
6
7public int add(int x, int y) {
return x + y;
}
public void test() {
int result = add(5, 10);
}对应的字节码:
1
2
3
4
50: iconst_5 // 压入5
1: iconst_10 // 压入10
2: aload_0 // 压入this引用(当前对象)
3: invokevirtual #2 // 调用add方法,#2为符号引用
6: istore_1 // 存储返回值(15)到result动态链接:将符号引用转换为调用方法的直接引用。编译期只知道其他方法的符号引用(即一种字符串的描述,这是存储在方法区的常量池中的,因为是类的信息),因此当方法需要调用其他方法时,JVM会调用
invokevirtual、invokestatic等指令,找到该类并把它加载进来,然后查找该类中与符号引用匹配的方法,并沿着继承层次一路向上查找父类。
多态就是依靠动态链接实现的,因为运行时是知道这个对象具体什么类型,而不是草草只用父类型或接口的方法。

- 方法返回地址:当方法被调用时,JVM 会在调用方方法的当前执行点(即下一条指令的地址)记录下来。这个地址就是返回地址。调用方法执行完毕后 JVM 会取出这个返回地址,并将程序的控制权转交给这个地址所指向的指令;
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。栈帧随着方法调用而创建,不管哪种返回方式,都会导致栈帧被弹出,然后继续执行调用方的返回地址的指令。
本地方法栈
虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
在 HotSpot 虚拟机中,本地方法栈和 Java 虚拟机栈,共用1个底层操作系统线程的栈,他们会交错地存储在同一个物理栈上。
JVM内存_线程共享区_4个
堆,也称堆内存、GC堆
Java 虚拟机管理内存中最大的一块,是垃圾收集器管理的主要区域。从分代回收的角度,Java堆还能分为新生代和老年代。
“几乎”所有的对象都在堆中分配,但从 JDK 1.7 开始默认进行逃逸分析,即如果某些方法中的对象引用没有被返回或者未被外面使用,那么对象可以直接在栈上分配内存。
- 下图中的Eden和Survivor(S0和S1)都是新生代,对象首先在Eden区域分配
- 在新生代垃圾回收后,如果对象还存活,则进入S0或S1并年龄+1
- S0或S1区域的对象 在新生代垃圾回收(Minor GC)后,如果仍然存活,则年龄+1并在S0和S1之间切换;
- 当年龄增加到某个值时(即
-XX:MaxTenuringThreshold,默认且最大为15,因为对象的年龄在对象头中用4位存储)则进入下图中的Tenured老年代;

- JDK1.7之前方法区是永久代,属于堆内存的一部分,但后来被元空间取代;元空间使用本地内存,不受JVM内存大小限制,但也属于JVM逻辑内存区域。
其实对象的年龄是动态计算的,这是为了避免有太多年轻的对象撑满Survivor区域。所以会从小到大对年龄进行累加,直到超过Survivor的一半;此时取min(MaxTenuringThreshold, 最大的年龄),让进入老年代的阈值变低;不然要多次进行GC,让这些年轻对象的年龄都增大,才能进入GC
怎么从老年代传入永久代/元空间,在后续垃圾回收板块细说。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串类专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
- JDK 1.7 为什么要将字符串常量池从永久代(方法区)移动到堆中?
永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。通常会有大量字符串等待回收,堆中能够更高效及时地回收。
方法区,也称元空间/永久代
方法区类似接口,永久代是JDK1.8之前的实现,元空间Metaspace是JDK1.8之后的实现。
方法区存储的是已被虚拟机加载的 类信息、字段、方法、常量、静态变量、即时编译器编译后的代码缓存等数据,具体可以见类加载过程部分。
为什么要用元空间替换永久代?
- 永久代受到 JVM 的大小上限限制,元空间则只收到系统内存的限制,从而能加载更多类的元信息;
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低;
- 设置
MaxMetaspaceSize来调整最大元空间大小,设置MaxPermSize来调整最大永久代大小;
运行时常量池
运行时常量池是方法区的一部分。方法区除了存放类的字段方法接口外,还有编译期的各种字面量(Literal)和符号引用(Symbolic Reference)的常量池表。下面是一个例子:
1 | // HelloWorld.java |
使用javac HelloWorld.java && javap -v HelloWorld.class命令反编译字节码,得到如下运行时常量池:
1 | Constant pool: |
其中Methodref是方法的符号引用,Class是类的符号引用,Fieldref是字段的符号引用。
Utf8 类型的常量池项存储的是字符串字面量,这些字符串通常是类名、字段名、方法名、方法描述符等。像 Methodref、Fieldref 这些复杂的符号引用,它们内部会通过索引指向这些 Utf8 项来获取具体的名称和描述,例如#1这个方法引用指向#2 类引用和#3类名和类型引用,然后#2.#3指向的则都是Utf8项,即字符串字面量。
本地内存
元空间
这是JDK1.7之后的方法区;JDK1.7之前的方法区是堆中的永久代
直接内存 / 堆外内存
不属于JVM内存,不是虚拟机规范中定义的内存区域。
本地内存在线程之间是共享的,如果多线程共享访问,则需要进行并发控制。
好处:它不会受到 GC 的管理,避免 GC 暂停,减少延迟;避免在 Java 堆和 OS 缓冲区之间的复制,提升性能。
坏处:内存泄漏;分配和回收成本高(但是使用时性能高)
一般通过java.nio.ByteBuffer的allocateDirect()来分配;当ByteBuffer对象被回收后,或手动调用Cleaner 进行释放。这块区域往往在如下场景中会用到:
- NIO(New IO)文件。创建一个缓冲区来读写大文件。
1 | public static void main(String[] args) throws IOException { |
- 网络操作。使用NIO和Channel进行网络数据传输,例如 Netty 框架大量使用直接内存,并且使用内存池来管理,避免频繁向 OS 分配和释放内存。下面是一个示例:
1 | import java.io.IOException; |
类加载和对象创建
类加载过程-22223
本质是加载.class文件到方法区
1. 加载 Loading
主要是通过类加载器完成的。类加载器包括
- BootStrapClassLoader
jvm中用C++实现的,负责加载最核心的类库 (jre/lib下的jar包),例如String类 - ExtClassLoader
Java实现,加载jdk提供的扩展类 (jre/lib/ext目录下的类) - AppClassLoader
主要加载classpath所指定的目录
- 一个类由哪个类加载器加载,是由双亲委派决定的:当用AppClassLoader去加载一个类时,AppClassLoader有一个parent属性指向了ExtClassLoader,当我们用AppClassLoader去加载一个类时,会先委托给ExtClassLoader去加载,而ExtClassLoader没有parent属性,所以会委派给BootstrapClassLoader去加载。只有BootstrapClassLoader没有加载到,才会由ExtClassLoader去加载,也只有ExtClassLoader没有加载到,才会由AppClassLoader来加载,这就是双亲委派。
每个类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
2/3/4. 连接 / 链接 Linking
2. 验证 Verification
确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全
包含四个校验阶段,有待研究⚠️
3. 准备 Preparation
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这时候进行内存分配的仅包括类变量,而不包括实例变量(注意对象在分配内存的时候会根据垃圾回收机制的不同,有不同的分配方法,即指针碰撞和空闲列表)。
从概念上讲,类变量所使用的内存都应当在方法区中进行分配。在 JDK 7 之前使用永久代来实现方法区时,符合“在方法区”的逻辑;但而在 JDK 7 及之后,原本在永久代的字符串常量池、静态变量等移动到了堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
总之,这些类变量始终在堆,只不过之前方法区也在堆中,所以也算在方法区了;而后来方法区在本地内存,但这些类变量仍然在堆中。
注意:为类变量分配内存使用的是类加载器,同一时间只有一个线程在执行某个类的加载过程。
4. 解析 Resolution
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。

解析分为静态解析和动态解析,动态解析是多态的关键。例如某个类 TreeA 在某个方法中调用了 TreeB 的 funcB() 方法:
在编译阶段,
TreeA的字节码会包含一个指向TreeB.funcB的符号引用,存储在TreeA类的运行时常量池中。在运行时,当第一次执行到这个调用点时,JVM 会对这个符号引用进行解析。
如果
b方法是虚方法(非final、非static、非private),JVM 会根据当前实际调用对象的运行时类型(例如MyTreeC),查找其虚方法表(VTable)。在
MyTreeC的虚方法表中,找到b方法对应的槽位,该槽位存储了指向实际b方法实现的直接引用。这个直接引用随后会用来替换掉
MyTreeA运行时常量池中原来的符号引用。这样,后续再次调用时,就可以直接使用这个已解析的直接引用,而无需重新进行查找和解析过程,提升效率。
5. 初始化 Initialization
执行类的 <clinit>() 方法(class-init),该方法是由编译器按顺序收集以下两部分内容而产生的:1. 静态变量的赋值操作;2. 静态代码块。
其内部执行顺序按照源代码的定义顺序展开。
1 | public class MyClass { |
6. 卸载
卸载类,即该类的 Class 对象被 GC,往往发生在Full GC,需要满足下面三个条件:
- 该类的所有的实例对象都已被GC。
- 对应的 Class 对象没有被引用,无法通过反射访问该类的。
- 该类的类加载器 ClassLoader 的实例已被GC
对象创建过程-54556
1. 类加载检查
当遇到 new 指令时,首先检查是否能在该类的运行时常量池中定位到这个类的符号引用,并且检查该符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2. 分配内存
对象所需的内存大小在类加载完成后便可确定,需要从 Java 堆中划分一块确定大小的区域。
内存分配的两种方式:指针碰撞、空闲列表
内存分配并发问题:对象的创建是频繁的,不同线程分配给对象的空间区域可能重叠。
- TLAB(Thread-Local Allocation Buffer): 每个线程预先在 Eden 区分配一块内存,JVM 优先使用 TLAB 内存,当不够用时,再采用 CAS 机制。
- CAS + 失败重试: CAS 是乐观锁的一种实现方式,即每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
具体地,比如有一个top指针指向当前可用的内存起始位置。线程A先读取top旧值,然后计算分配完内存后top新值,在设置top新值的时候看旧值是否被改变;如果不符,则重试。
3. 初始化零值
把这块内存空间的所有类型全部初始化为零值,保证对象的实例字段可以不赋初始值就直接使用。常见零值如下:

4. 设置对象头
对象头包括两部分信息:
- 标记字段 (Mark Word)
用于存储对象的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,标记清除的标记 - 类型指针 (Klass pointer)
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,在动态链接或多态的时候会用到





