JVM结构简图
JVM的组成部分主要是类加载器、运行时数据区(方法区、堆、程序计数器、本地方法栈、虚拟机栈)、执行姻亲、本地方法栈组成。
类加载器
负责加载 .class 文件到内存中,即将编译生成的字节码文件加载进 JVM,供后续使用。JVM不会一次性加载所有类。如果一次性加载,那么会占用很多的内存。
ClassLoader只是负责加载,并不负责执行,加载完成以后能不能执行是由执行引擎决定的。

类加载过程

加载(Loading):通过类的全类名获取此类的二进制字节流,将这个字节流所代表的静态存储结构转换为方法区的运行时数据对象,在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证(Verification):确保 .class 文件的字节流符合 JVM 规范,从文件格式、元数据、字节码逻辑到符号引用层层校验,过滤非法内容(如格式错误、类型不匹配、恶意指令等 ),既保证字节码能被 JVM 正确解析执行,也避免恶意代码攻击、破坏虚拟机运行安全。
准备(PreParation):为类变量分配内存并设置初始值,这些变量所使用的内存都将在方法区中进行分配。
解析(Resolution):将常量池中的符号引用转换为直接引用的过程。
初始化(Initialization):执行类的 静态代码块(static {}) 和静态变量的 "显式赋值",这是类加载阶段唯一会执行代码的步骤。执行顺序:静态变量显式赋值 → 静态代码块(按代码编写顺序执行);若有父类,先初始化父类(再初始化子类)。
类加载器分类
启动类加载器(Bootstrap ClassLoader):由C/C++实现的JVM原生类加载器,不属于Java类(无法通过Java代码直接引用)。加载核心类库,诸如 java.lang、java.util 等位于jre/lib目录下的核心jar包(rt.jar)。
扩展类加载器(Extend ClassLoader):Java语言实现,继承自 java.lang.ClassLoader。加载Java扩展类库,位于 jre/lib/ext 目录下或系统属性 java.ext.dirs 指定的路径下的类。
应用程序类加载器(Application ClassLoader):Java语言实现,继承自 java.lang.ClassLoader。加载用户类路径(classpath)上所指定的类库,例如:我们自己编写的类或第三方的 jar 包。
自定义加载器类:在 Java 中,开发者可以继承 java.lang.ClassLoader 类,重写 findClass 等方法来自定义类加载器。

为什么要自定义类加载器
- 隔离类加载
- 修改类的加载方式
- 扩展加载源
- 防止源码泄露
双亲委派
双亲委派的工作原理:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一层的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器(Bootstrap ClassLoader)中。只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

双亲委派的优势:
1、避免重复加载:通过委派机制,可以确保一个类在 JVM 中只会被加载一次(由同一个加载器实例),避免了重复加载,保证了类的唯一性。
2、安全性 :这是最重要的优点。它可以防止核心API被篡改。
- 例如,如果有人自定义了一个
java.lang.String
类并放在 ClassPath 中,如果没有双亲委派,这个类可能会被应用程序类加载器加载,从而篡改核心 Java 库,造成巨大的安全风险。 - 但在双亲委派模型下,加载
java.lang.String
的请求会一路委派到顶层的启动类加载器。启动类加载器会在核心 Java 库中找到真正的String
类并加载它,而用户自定义的String
类永远不会被加载,从而保证了 Java 核心库的安全性和稳定性。
运行时数据区

PC寄存器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。简单来说就是告诉Jvm下一条应该执行什么样的指令。

- 线程私有 (Thread-Private)
- 这是它最重要的特点之一。每个线程都有自己独立的程序计数器,各条线程之间的计数器互不影响,独立存储。
- 为什么需要这样设计?因为 JVM 的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
- 因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,用来记录该线程正在执行的字节码指令地址。
- 控制程序执行流程
- 分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
- 执行引擎的工作方式:执行引擎会读取程序计数器中的地址,找到对应的字节码指令并执行,执行完成后,程序计数器会更新为下一条指令的地址。
- 唯一一个没有规定任何
OutOfMemoryError
情况的区域
- 程序计数器是 JVM 规范中唯一一个没有定义任何内存溢出(OutOfMemoryError)情况的区域。
- 这是因为它的生命周期与线程相同,随着线程的创建而创建,随着线程的结束而死亡。它占用的内存空间非常固定,几乎可以忽略不计,所以不存在内存溢出的问题。
虚拟机栈
栈的存储单位,每一个线程都有自己的栈,栈中的数据是以栈帧存储的(Stack Frame),每一个方法都对应这一个栈帧。
栈帧的内部结构包含,局部变量表、操作数栈、动态链接、方法返回地址。

局部变量表(Local Variables)
slot 变量槽
- 局部变量表,最基本的存储单元是slot变量槽
- 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
- 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
- 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
- byte、short、char 在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
- JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
- 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
- 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或doub1e类型变量)
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
举例:生活中的一个例子公寓出租
Jvm的一个方法栈帧就相当于是出租了一间公寓,公寓里面有一个柜子(局部变量表),柜子里面有大小相同的储物格(slot变量槽)
可以往这些储物格里面放私人物品,比如首饰(基本数据类型)、衣服(引用类型)、被子(long、double占2个格子)。
而且这些柜子上有编号,往柜子里面放物品的时候记一下编号,我们找东西的时候就可以直接根据编号来找。
每一个柜子是可以复用的,比如说一开始1号柜子里面放的是金首饰,最近金价涨的厉害就把金子给出售了,这个时候1号柜子腾出来以后就可以放其他物品。
操作数栈(Operand Stack)
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也叫做表达式栈。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop),主要是用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。是Jvm执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧会被创建出来,这个时候方法的操作数栈是空的。
操作数栈是有栈的深度的在编译期间确定下来,保存在方法的code属性中,为max_stack的值。
栈中的任何一个元素都是可以任意的Java数据类型,
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
代码案例
ini
public void testAddOperation(){
byte i = 15;
int j = 8;
int k = i + j;
}
字节码指令
ruby
public void testAddOperation();
Code:
0: bipush 15
2: istore_1
3: bipush 8
5: istore_2
6:iload_1
7:iload_2
8:iadd
9:istore_3
10:return
动态链接(Dynamic Linking)
每一个栈帧内部包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在Java源文件编译到字节码文件中时,所有的变量和方法都会作为符号引用(Symbolic Reference) 保存在class文件的常量池里面,比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是将这些符号引用转换未调用方法的直接引用。

符号引用:本质上是一种字符串形式的地址或者标志,存在class文件的常量池中,例如类的全限定名字、字段名、方法名等,特点就是不直接指向内存中的方法或对象,就像快递单上的地址。
直接引用:指针或者句柄,可以定位到内存里的对象,方法或字段的入口地址,存在jvm运行时,比如已经加载到内存中的某个类的实例对象地址,某个方法的入口地址,特点是能直接找到目标马上使用,就想房子的钥匙,可以直接开门进去,而不是拿着地址找。
既然有动态链接那么相对的静态链接是什么?
静态链接 :当字节码被装载进JVM的时候,如果被调用的方法在编译期间就知道了,并且在运行时保持不变的,这种情况下方法的符号引用转换为直接引用的过程就是静态链接。
动态链接 :被调用的方法在编译期间没有办法确定下来,只能在程序运行的时候将调用的方法的符号转换为直接引用,这种引用转换过程具备动态性,因此也被称之为动态链接。
虚方法和非虚方法 :编译期间就确定了具体调用那个方法,并且在运行时是不可变的,这样的方法是非虚方法例如静态方法、私有方法、final方法、实例构造器、父类方法等,反之在编译期间没有办法确定下来调用那个方法,这样的方法是虚方法。
方法返回地址(return address)
就是调用方法后的下一条字节码指令的未知,假设方法A调用方法B时,Jvm会给方法B新建一个栈帧,等待方法B执行完毕,就需要回到方法A继续执行。
生活举例:就好比看一本书, 你读到第 50 页时,看到提示:"去附录看看公式",这个时候翻到目录( 相当于进入另一个方法 ),看完以后回来接着读51页,这个51的页码就是返回地址,JVM会在调用时候把他记录下来。
地址返回的几种情况:正常返回和异常返回
正常返回:return指令,方法正常执行完
异常返回:方法没有执行到return而是向上抛出了异常,jvm会在调用链上寻找匹配catch, 如果找到了,PC 寄存器跳到异常处理器地址;如果找不到,一直向上抛 。
本地方法栈
是一个Java调用非Java代码的接口。主要是管理本地方法的调用,同时也是线程私有的。
堆区
一个Jvm实例只存在一个堆区,是 JVM 所管理的内存中最大 的一块,被所有线程共享, 它在 JVM 启动时被创建,我们用new关键字创建出来的所有对象和数组,几乎都放在这里。所有的线程共享java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer ,TLAB)
JDK7堆区结构

JDK8堆区结构

年轻代和老年代

存储在Jvm中的Java对象可以分成两类:
- 一类是生命周期比较短的,另外一种是生命周期比较长的
- 近一步细分的话可以分成年轻代和老年代,年轻代又分成eden空间,和Survivor0(幸存者0)区和Survivor1(幸存者1)区
年轻代和老年代的内存分配比是:(1:2)
年轻代的Eden、Survivor0、Survivor1内存分配比是:(8:1:1)
年轻代和老年代的内存比例调整参数是:-XX:NewRatio
默认值是-XX:NewRatio=2,表示新生代占1,老年代占2,即新生代占整个堆区的1/3,老年代占整个堆区的2/3
新生代eden和Survivor区的比例调整参数是:-XX SurvivorRatio
默认值是-XX SurvivorRatio=8,表示Eden占8,Survivor0占1,Survivor1占1
对象的分配过程

new一个对象的时候,优先分配在年轻代的eden区中,如果eden中放不下则触发Young GC,Young GC 以后看eden区域内是否能放下,能放下则进行分配对象内存,eden放不下则往old区里面方,old放不下则触发full gc, full gc以后old区域还是放不下,则触发OOM。
在进行Young GC 的时候,会清理Eden区和其中一个Survivor区,存活的对象会被移动到另一个Survivor区,如果Survivor区空间不足,部分对象可能会直接晋升到Old区。
大对象直接进入Old区。例如 大数组,字符串等。
大对象的参数设置 -XX:PretenureSizeThreshold
ini
-XX:PretenureSizeThreshold=524288 // 设置512KB以上的对象直接进入老年代
长期存活的对象进入Old区,晋升规则是
新创建的对象在eden,初始年龄为0,触发Young GC 的时候存活的对象移动到Survivor区,年龄为1,每一次的Young GC 存活的对象年龄+1,当年龄到达15的时候,晋升到Old区。可以通过-XX:MaxTenuringThreshold参数来设置晋升到old区的年龄。
Minor GC、Major GC、Full GC
JVM在进行GC的时候,并不是每次都回收新生代、老年代、方法区域,大部分回收的是新生代。
针对Hotspot VM,它里面的GC按照回收区域可以分成两大类型,一种是部分收集器(Partial GC),另外一种是(Full GC)。
部分收集
- 新生代收集minor gc / young gc 回收 eden、s0/s1 区域
- 老年代收集major gc
整堆收集(full gc)收集整个java堆和方法区域的垃圾收集器
年轻代GC触发机制
- 当年轻代的eden区域满了以后,会触发minor gc,survivor区域满了以后不会触发GC
- 因为大多数对象是朝生夕死的,所以minor gc 比较频繁,但是回收的速度也快
- minor gc会导致stw,暂停用户其他线程,等垃圾回收完毕以后,用户线程才能继续执行
老年代GC触发机制
- 对象从老年代消失,则是触发major gc 或者 full gc
- major gc 以后也会触发stw,但是它的清理速度要比minor gc慢得多,用户线程等待的时间也就越久
- major gc 以后内存还是不足,这个时候就会触发OOM。
Full GC的触发机制
- 调用System.gc()
- 老年代/方法区空间不足
- minor gc 以后进入老年代的平均大小大于老年代的可用内存
TLAB(Thread Local Allocation Buffer)
为什么要有TLAB?
因为堆区是共享的,任何线程都能访问到堆区中的共享数据,由于对象的创建在Jvm中非常的频繁,因此在并发环境下面从堆区域中划分内存空间是线程不安全的。为了避免多个线程操作同一个地址,需要使用加锁,但是加了锁以后又会影响效率。
什么是TLAB?
从内存分配的角度来说,对Eden区域进行继续划分,Jvm为每一个线程分配了一个私有的缓冲区,这样可以避免掉一系列的线程安全问题,同时还能够提成内存分配的吞吐量。使用-XX:+UseTLAB开启TLAB空间,TLAB空间是非常小的,仅仅占用内存的1%,可以通过参数-XX:TLABWasteTargetPercent来设置TLAB空间所占用的Eden空间的百分比大小。TLAB分配失败的时候Jvm会进行尝试加锁的机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
逃逸分析
分析对象是否发生逃逸,当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸,如果有外部方法引用则认为发生逃逸。没有发生逃逸的对象就可以分配到栈上面。
csharp
public class EscapeAnalysis {
public EscapeAnalysis obj;
/**
* 方法返回EscapeAnalysis对象,发生逃逸
* @return
*/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}
/**
* 为成员属性赋值,发生逃逸
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}
/**
* 对象的作用于仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
/**
* 引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
}
}
逃逸分析参数设置
- 选项"
-XX:+DoEscapeAnalysis
"显式开启逃逸分析 - 通过选项"
-XX:+PrintEscapeAnalysis
"查看逃逸分析的筛选结果
逃逸分析代码优化
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
- 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或者标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
标量指的是无法再次进行分解的数据,Java中的原始数据类型就是标量,相对的还可以继续分解的叫做聚合量,一个对象就是就是聚合量,因为它还可以细分成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
arduino
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}
经过标量替换以后
csharp
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
标量替换参数设置-XX:EliminateAllocations
,默认情况下标量替换是打开的
堆空间大小设置
-Xms 设置堆的起始内存
-Xmx 用于表示堆区的最大内存
-Xms -Xmx 通常设置相同的大小,目的是为了java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
默认情况 初始内存大小:物理电脑内存大小 / 64,
最大内存大小:物理电脑内存大小 / 4
方法区/元数据区
在HotSpot中,方法区是一个逻辑上的概念,也被叫做非堆区,一般用来存储类加载信息、静态变量、JIT实时编译缓存的代码、运行时常量池。
不同的Jdk版本实现方法区的方式不一样,JDK8之前采用的是"永久代",JDK8以后采用的是元空间(MetaSpace)。
方法区的特点:
- 共享性,是各个线程共享的一块区域
- 溢出问题,方法区的大小决定了系统可以存储多少个类,如果类过多,方法区会溢出,例如
java.lang.OutOfMemoryError: PermGen space
或java.lang.OutOfMemoryError: Metaspace
- 释放,关闭Jvm的时候会释放方法区的内存空间
- 大小可以调整,方法区的大小是可以通过参数设置的
- 创建内存空间,在Jvm启动的时候,会分配一块空间给方法区,这块空间可以是不连续的。
内存大小设置
-XX:Permsize 设置永久代初始分配空间(jdk7)
-XX:MaxPermsize 设置永久代最大可分配的空间(jdk7)
-XX:MetaspaceSize 设置元数据区初始分配空间(jdk8)
-XX:MaxMetaspaceSize 设置元数据区最大可分配的空间(jdk8)
方法区的内部结构

类元信息:类的完整限定名、父类信息、实现接口、类的修饰符等
字段信息:成员变量的名称、类型、修饰符等
方法信息:方法名、方法参数的类型,返回值、修饰符、方法的字节码
运行时常量池:字面量、符号引用
静态变量:属于类的静态变量
即时编译器JIT编译后的代码:JIT优化后生成的机器码缓存
方法区的演变
jdk6:方法区域的实现和JVM的定义几乎完全一样,实现方式是永久代,是完全独立于堆的一块内存区域,其中类信息、静态变量、运行时常量池、static变量以及字符串常量等内容都是存储在方法区的。但是,由于StringTable存在于方法区,当Java程序中大量生成较大的字符串对象的时候,就可能造成方法区OOM。

jdk7:为了避免方法区频繁溢出,在jdk7的时候把StringTable移动到了堆里面,因为永久代的回收频率很低,在触发full gc的时候才会进行方法区的资源回收,而full gc的触发条件比较苛刻,只有当老年代空间不足,永久代空间不足的时候才会触发,这就导致了StringTable的回收率不高。在开发过程中会有大量的字符串被创建,回收频率过低会导致永久代空间不足,放到堆里面,可以能够及时的回收。

jdk8:jdk 8之后就没有永久代的概念了,而且方法区也完全成为了逻辑概念。方法区在JDK 8之后的对应实现应该是MetaSpace,MetaSpace是由Native内存实现的由操作系统直接管理。类型信息、字段、方法、常量保存在本地内存MetaSpace当中,但字符串常量池、静态变量仍在堆中。这样以来MetaSpace 的空间上限和操作系统相关,当大量加载类的时候不用担心方法区溢出。
