JVM学习笔记
JVM学习笔记
〇. 什么是JVM
定义
Java Virtual Machine ,Java 程序的运行环境(Java 二进制字节码的运行环境)。好处
- 一次编译,处处执行
- 自动的内存管理,垃圾回收机制
- 数组下标越界检查
JVM、JRE、JDK 的关系

- JVM的执行流程

一. 内存结构
1. 程序计数器
Program Counter Register 程序计数器(寄存器)
- 作用:记录当前线程正在执行的字节码指令的地址
- 特点:
- 是线程私有的
- 不会存在内存溢出
2. 虚拟机栈(JVM Stacks)
- 栈:线程运行需要的内存空间
- 栈帧:每个方法运行时需要的内存,包括参数、局部变量和返回地址
- 每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存
2.1 问题分析
- 垃圾回收是否涉及栈内存?
不会。栈内存是方法调用产生的,方法调用结束后会弹出栈。 - 栈内存分配越大越好吗?
不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。 - 方法内的局部变量是否线程安全?
- 共享的变量static需要考虑线程安全
- 私有的变量是线程安全的
- 如果方法内部的变量没有逃离方法的作用访问,它是线程安全的
- 如果是局部变量引用了对象,并逃离了方法的访问,那就要考虑线程安全问题。
2.2 栈内存溢出(java.lang.stackOverflowError)
- 栈帧过多导致栈内存溢出:无限递归调用
- 栈帧过大导致栈内存溢出
3. 本地方法栈
有些时候JAVA代码不能与操作系统直接进行操作,就需要一些C/C++代码进行操作,这些C/C++代码就是本地方法,存储在本地方法栈
4. 堆(Heap)
通过new关键字创建的对象都会被放在堆内存
特点
- 它是线程共享,堆内存中的对象都需要考虑线程安全问题
- 有垃圾回收机制
4.1 堆内存溢出(java.lang.OutofMemoryError)
源源不断产生对象,并且对象一直在被使用,导致无法被回收,从而导致堆内存溢出
4.2 堆内存诊断
- jps 工具
查看当前系统中有哪些 java 进程 - jmap 工具
查看堆内存占用情况jmap - heap 进程id - jconsole 工具
图形界面的,多功能的监测工具,可以连续监测
5. 方法区
5.1 方法区的定义
- 存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法
- 用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的
- 方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它
- 方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展

5.2 方法区内存溢出
- 1.8 之前会导致永久代内存溢出
- 1.8 之后会导致元空间内存溢出
5.3 运行时常量池
- 二进制字节码包含:(类的基本信息,常量池,类方法定义,包含了虚拟机的指令)
- 常量池:就是一张常量表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
- 运行时常量池:
常量池是 .class 文件中的,当该类被加载以后,它的*常量池信息就会被加载成运行时常量池,并把里面的符号地址变为真实地址
- 常量池表中的地址可能对应着一个类名、方法名、参数类型等信息:

- 类方法的每条指令都会对应常量池表中一个地址:

5.4 StringTable(字符串常量池,串池)
5.4.1 常量池和串池的关系
1 | |
5.4.2 StringTable的特性
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
- new出来的放在堆里;直接赋值的放在串池里,虽然串池也在堆里,但是地址不同
5.4.3 intern()方法
调用字符串对象的 intern 方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,则放入成功
- 如果有该字符串对象,则放入失败
1 | |
1 | |
- 如果调用 intern 方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象
5.4.4 StringTable位置
jdk1.6 StringTable 位置是在永久代中,1.8 StringTable 位置是在堆中。
5.4.5 StringTable垃圾回收
如果内存不够了,StringTable会把无用的数据进行垃圾回收
5.4.6 StringTable性能调优
- 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间
- 当重复的字符串过多时,需要考虑是否需要将字符串对象入池:可以通过 intern方法减少重复入池
6. 直接内存(操作系统的内存)
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
6.1 使用直接内存的好处
直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率:

6.2 直接内存的回收机制
- 直接内存的回收不是通过 JVM 的垃圾回收来释放的,而是通过unsafe.freeMemory 来手动释放。
- 直接内存的回收机制总结
- 使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用freeMemory 方法
- ByteBuffer 的实现内部使用了 Cleaner(虚引用)来检测 ByteBuffer 。一旦ByteBuffer 被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法调用 freeMemory 来释放内存
二. 垃圾回收
1. 如何判断对象可以回收
1.1 引用计数法
当一个对象被引用时,就当引用对象的值+1,当值为 0 时,就表示该对象不被引用,可以被垃圾收集器回收。
- 弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。
1.2 可达性分析算法
- 确定根对象(肯定不能被垃圾回收的对象)
- 每个对象是不是被根对象直接或间接的引用,如果是,则将来不能作为垃圾回收,反之则可以回收
- JVM 中的垃圾回收器通过可达性分析来探索所有存活的对象
- 扫描堆中的对象,看能否沿着 GC Root 对象为起点的引用链找到该对象,如果找不到,则表示可以回收
哪些对象可以作为GC Root对象?
- 系统类对象(System Class)
- 调用操作系统方法中引用的JAVA对象(Native Stack)
- 活动线程的对象(Thread)
- 被加锁的对象,正在加锁的对象
1.3 四种引用

- 强引用
- 只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
- 软引用
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象
- 可以配合引用队列来释放软引用自身
- 弱引用
- 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
- 可以配合引用队列来释放弱引用自身
- 虚引用
- 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队
- 由 Reference Handler 线程调用虚引用相关方法释放直接内存
- 终结器引用
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收);再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象。
2. 垃圾回收算法
2.1 标记清除
- 标记:标记没有被GC Root引用的对象
- 清除:释放对象占用的内存空间

- 速度较快
- 会产生内存碎片
2.2 标记整理
- 标记:标记没有被GC Root引用的对象
- 整理:释放对象空间,之后将内存碎片移动到一块,成为一段连续的空间

- 速度慢
- 没有内存碎片
2.3 复制
- 标记:标记没有被GC Root引用的对象
- 复制:将内存空间复制到另一块内存地址中,然后再交换回来

- 不会有内存碎片
- 需要占用两倍内存空间
3. 分代垃圾回收
- 新生代频繁发生垃圾回收,老年代很少发生垃圾回收

- 新创建的对象首先分配在伊甸园区
- 新生代空间不足时,触发 minor GC,伊甸园区和 from 区存活的对象使用copy复制到to中,存活的对象年龄加一,然后交换 from to
- minor GC会引发 stop the world:暂停其他用户线程,等垃圾回收结束后,恢复用户线程运行
- 当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(4bit)
- 当老年代空间不足时,会先触发 minor GC,如果空间仍然不足,那么就触发 full GC,stop the world停止的时间更长!
- 当新创建的对象比新生代还要大的时候,如果老年代大小能容下,则会直接放到老年代
- 当新创建的对象比新生代和老年代的大小总和都大时,会抛出内存溢出(OutOfMemoryError)
4. 垃圾回收器
4.1 串行
- 单线程
- 堆内存较少,适合个人电脑

- 安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
- 因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态
4.2 吞吐量优先
- 多线程
- 适用于堆内存较大,多核CPU
- 让单位时间内,STW(stop the world)的时间最短

- 当开始垃圾回收时,所有的CPU线程都会进行垃圾回收,此时CPU占有率达到100%
两个参数控制吞吐量:
- XX:MaxGCPauseMillis=ms 控制最大的垃圾收集停顿时间(默认200ms)
- XX:GCTimeRatio=rario 直接设置吞吐量的大小
4.3 响应时间优先
- 多线程
- 适用于堆内存较大,多核CPU
- 尽可能让STW(stop the world)的单次时间最短

4.4 CMS 收集器
- CMS 收集器:Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
- CMS 收集器的内存回收过程是与用户线程一起并发执行的,有些阶段不需要stop the world,可以和用户阶段一起并发运行
CMS 收集器的运行过程分为下列4步:
- 初始标记:标记 GC Roots 能直接到的对象。速度很快但是仍存在 Stop The World 问题。
- 并发标记:进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。
- 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World 问题
- 并发清除:对标记的对象进行清除回收,清除的过程中,可能任然会有新的垃圾产生,这些垃圾就叫浮动垃圾,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,就会退化为 serial Old 收集器,将老年代垃圾进行标记-整理,当然这也是很耗费时间的!
5. G1(Garbage First)
适用场景
- 同时注重吞吐量和低延迟(响应时间),默认暂停目标是200ms
- 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
- 整体上是标记-整理算法,两个区域之间是复制算法
5.1 G1垃圾回收阶段

- Young Collection:对新生代垃圾收集
- Young Collection + Concurrent Mark:如果老年代内存到达一定的阈值了,新生代垃圾收集同时会执行一些并发的标记。
- Mixed Collection:会对新生代 + 老年代 + 幸存区等进行混合收集,然后收集结束,会重新进入新生代收集。
5.2 Young Collection
- 存在STW
- 新生代的垃圾回收会将幸存的对象复制放到幸存区
- 当幸存区对象过多了,继续垃圾回收,会放到老年代
5.3 Young Collection + CM
- 在 Young GC 时会进行 GC Root 的初始化标记
- 老年代占用堆空间比例达到阈值时,进行并发标记(不会STW),由下面的 JVM 参数决定
5.3 Mixed Collection
会对 E、S、O 进行全面的回收
- 最终标记 会STW
- 拷贝存活 会STW
问:为什么有的老年代被拷贝了,有的没拷贝?
- 因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)
5.4 Full GC
- G1 在老年代内存不足时(老年代所占内存超过阈值)
- 如果垃圾产生速度慢于垃圾回收速度,不会触发 Full GC,还是并发地进行清理
- 如果垃圾产生速度快于垃圾回收速度,便会触发 Full GC,然后退化成 serial Old 收集器串行的收集,就会导致停顿的时候长。
5.5 Young Collection 跨代引用
新生代回收的跨代引用(老年代引用新生代)问题

- 脏卡:O 被划分为多个区域(一个区域512K),如果该区域引用了新生代对象,则该区域被称为脏卡
- 通过遍历脏卡找到老年代引用,从而加速找到引用标记
5.6 Remark(重新标记阶段)
在垃圾回收时,收集器处理对象的过程中
- 黑色:已被处理,需要保留的
- 灰色:正在处理中的
- 白色:还未处理的(如果没有黑色的强引用,则会被回收)

5.7 JDK 8u20 字符串去重
- 将所有新分配的字符串(底层是 char[] )放入一个队列
- 当新生代回收时,G1 并发检查是否有重复的字符串
- 如果字符串的值一样,就让他们引用同一个字符串对象
与 String.intern() 的区别
- String.intern() 关注的是字符串对象
- 字符串去重关注的是 char[]
- 在 JVM 内部,使用了不同的字符串标
优点与缺点
- 节省了大量内存
- 新生代回收时间略微增加,导致略微多占用 CPU
5.8 JDK 8u40 并发标记类卸载
在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
5.9 JDK 8u60 回收巨型对象
- 一个对象大于region的一半时,就称为巨型对象
- G1不会对巨型对象进行拷贝
- 回收时被优先考虑
- G1会跟踪老年代所有incoming引用,如果老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉

5.10 JDK 9 并发标记起始时间的调整
- 并发标记必须在堆空间占满前完成,否则退化为 FullGC
- JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent
- JDK 9 可以动态调整
- -XX:InitiatingHeapOccupancyPercent 用来设置初始值
- 进行数据采样并动态调整
- 总会添加一个安全的空挡空间
6. GC调优
6.1 调优领域
- 内存
- 锁竞争
- CPU占用
- IO
- GC
6.2 确定目标
低延迟/高吞吐量? 选择合适的GC回收器
- CMS G1 ZGC ParallelGC
6.3 最快的GC是不发生GC
- 数据是不是太多?
- resultSet = statement.executeQuery(“select * from 大表 limit n”)
- 数据表示是否太臃肿
- 是否存在内存泄漏
6.4 新生代调优
- 新生代的特点:所有的 new 操作分配内存都是非常廉价的
- 死亡对象的回收代价是0
- 大部分对象用过即死(朝生夕死)
- Minor GC 所用时间远小于 Full GC
新生代内存越大越好么?
- 新生代内存太小:小了回收频繁,频繁触发 Minor GC ,会 STW ,会使得吞吐量下降
- 新生代内存太大:空间大回收的时间过长,老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长
调优方法:
- 新生代内存设置【并发量*(请求-响应)】的数据为宜
- 幸存区需要能够保存 【当前活跃对象+需要晋升的对象】
- 晋升阈值配置得当,让长时间存活的对象尽快晋升
6.5 老年代调优
以 CMS 为例:
- CMS 的老年代内存越大越好
- 先尝试不做调优,如果没有 Full GC 那么已经可以了,否则先尝试调优新生代。
- 观察发现 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
三. 类加载与字节码技术
1. 类文件结构
根据JVM规范,类文件结构如下:
1 | |
- 魔数:0~3个字节,表示是否为.class类型的文件
- 版本:4~7个字节,表示类的版本号
- 常量池
2. 字节码指令
2.1 javap工具
Java 中提供了 javap 工具来反编译 class 文件
1 | |
2.2 图解方法执行流程
- 代码
1 | |
- 常量池载入运行时常量池

- 方法字节码载入方法区

- main线程开始运行,分配栈帧内存

2.3 构造方法
- 私有、构造、被 final 修饰的方法,在调用时都使用 invokespecial 指令
- 普通成员方法在调用时,使用 invokevirtual 指令。因为编译期间无法确定该方法的内容,只有在运行期间才能确定
- 静态方法在调用时使用 invokestatic 指令
2.4 多态原理
在执行 invokevirtual 指令时:
- 先通过栈帧中对象的引用找到对象
- 分析对象头,找到对象实际的 Class
- Class 结构中有 vtable
- 查询 vtable 找到方法的具体地址
- 执行方法的字节码
2.5 异常处理
- try-catch;多出来一个 Exception table 的结构,通过查表进行异常处理
- finally:
- 看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流程
- 虽然从字节码指令看来,每个块中都有 finally 块,但是 finally 块中的代码只会被执行一次
- 如果在 finally 中出现了 return,会吞掉异常
3. 编译期处理
语法糖:其实就是指 java 编译器把 .java 源码编译为 .class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利
3.1 自动拆装箱
基本类型和其包装类型的相互转换过程,称为拆装箱
1 | |
3.2 泛型集合取值
java 在编译泛型代码后会执行泛型擦除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理.
3.3 可变参数
可变参数 String… args 其实是一个 String[] args ,从代码中的赋值语句中就可以看出来
1 | |
3.4 方法重写时的桥接方法
方法重写时对返回值分两种情况:
- 父子类的返回值完全一致
- 子类返回值可以是父类返回值的子类
1 | |
对于子类,java 编译器会做如下处理:
1 | |
4. 类加载阶段
4.1 加载
- 将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法表
- 如果这个类还有父类没有加载,先加载父类
- 加载和链接可能是交替运行的

4.2 链接
- 验证:验证类是否符合 JVM规范,安全性检查
- 准备:为 static 变量分配空间,设置默认值
- static变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成将常量池中的符号引用解析为直接引用
- 解析:将常量池的符号引用解析为直接引用
4.3 初始化
<cinit>()V:初始化即调用<cinit>()V,虚拟机会保证这个类的『构造方法』的线程安全发生的时机(类初始化是懒惰的,用到了才会初始化)
- 会导致类初始化的:
- main方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
- 不会导致类初始化的:
- 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
- 类对象.class 不会触发初始化
- 创建该类的数组不会触发初始化
- 会导致类初始化的:
5. 类加载器
| 名称 | 加载的类 | 说明 |
|---|---|---|
| Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
| Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
| Application ClassLoader(应用程序类加载器) | classpath | 上级为Extension |
| 自定义类加载器 | 自定义 | 上级为Application |
5.1 启动类加载器
用Bootstrap类加载器加载类
5.2 扩展类加载器
如果 classpath 和 JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载。
5.3 双亲委派模式
双亲委派模式,即调用类加载器ClassLoader 的 loadClass 方法时,查找类的规则。
5.4 自定义类加载器
使用场景
- 想加载非 classpath 随意路径中的类文件
- 通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤
- 继承 ClassLoader 父类
- 要遵从双亲委派机制,重写 findClass 方法
- 不是重写 loadClass 方法,否则不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
6. 运行期优化
6.1 即时编译
分层编译
为什么代码循环运行时,遇到相同的代码块,运行的速度会越来越快?
JVM 将执行状态分成了 5 个层次:
- 0层:解释执行,用解释器将字节码翻译为机器码
- 1层:使用 C1 即时编译器编译执行(不带 profiling)
- 2层:使用 C1 即时编译器编译执行(带基本的profiling)
- 3层:使用 C1 即时编译器编译执行(带完全的profiling)
- 4层:使用 C2 即时编译器编译执行
prfiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的回边次数】等
- 即时编译器与解释器的区别
- 解释器
- 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- 是将字节码解释为针对所有平台都通用的机器码
- 即时编译器
- 将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
- 根据平台类型,生成平台特定的机器码
- 解释器
6.2 反射优化
- 一开始if条件不满足,就会调用本地方法 invoke0
- 随着 numInvocation 的增大,当它大于 ReflectionFactory,inflationThreshold 的值 16 时,就会本地方法访问器替换为一个运行时动态生成的访问器,来提高效率
- 这时会从反射调用变为正常调用,即直接调用 Reflect1.foo()