JVM 从入门到入土 ④:JVM 结构
JVM 组成
JVM 的结构基本上由 4 部分组成:
- _类加载器:_在 JVM 启动时或者类运行时将需要的 class 加载到 JVM 中
- _运行时数据区:_将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块
- 执行引擎:执行引擎的任务是负责执行 class 文件中包含的字节码指令,相当于实际机器上的 CPU
- 本地方法调用:调用 C 或 C++ 实现的本地方法的代码返回结果
运行时数据区域
PC 程序计数器
记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)
用于存放指令位置
虚拟机的运行,类似于这样的循环:
1 | while (not end) { |
栈
Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息
从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程
栈结构:
Frame:每个方法对应一个栈帧
- Local Variable Table
- Operand Stack
对于 long 的处理(store and load),多数虚拟机的实现都是原子的
jls 17.7,没必要加 volatile - Dynamic Linking
java Dynamic Linking
jvms 2.6.3 - return address
a () -> b (),方法 a 调用了方法 b,b 方法的返回值放在什么地方
例:i = i++;
的栈实现
常用字节码指令:
store、load、pop、mul、sub
invoke:
- InvokeStatic:调用静态方法
- InvokeVirtual:普通方法,自带多态
- InvokeInterface:调用接口的方法
- InovkeSpecial:
可以直接定位,不需要多态的方法
private 方法 , 构造方法 - InvokeDynamic:
lambda 表达式或者反射或者其他动态语言 scala kotlin,或者 CGLib ASM,动态产生的 class,会用到的指令
本地方法栈
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。
本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。
堆
所有对象都在这里分配内存,是垃圾收集的主要区域
堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常
堆是所有 线程共享 的
默认新生代:老年代 = 1 : 2
新生代(Young Generation)
分为 伊甸园(Edne)和两个 幸存区(Survival),默认比例为 Eden:from:to = 8:1:1,保证了内存的利用率达到 90%
Eden:
- 线程共享: 由于堆是所有线程共享的,因此在堆上分配内存 需要加锁
- TLAB: 为提升效率,每个新建的线程在 Eden 上分配了一块独立的空间由该线程独享,这块空间称为 TLAB(Thread Local Allocation Buffer)
- 在 TLAB 上分配内存 不需要加锁,因此 JVM 在给线程中的对象分配内存时会尽量在 TLAB 上分配
- 如果对象过大或 TLAB 用完,则仍然在堆上进行分配。如果 Eden 区内存也用完了,则会进行一次 Minor GC(young GC)
Survival(from & to):
jvm 误区 – 动态对象年龄判定
- 在发生 Minor GC 时,Eden 区和 Survival from 区会把一些仍然存活的对象复制进 Survival to 区,并清除内存
- 将此时在 Survivor to 区存活下来的对象的年龄设置为 1,以后这些对象每在 Survivor 区熬过一次 GC,它们的年龄就加 1,当对象年龄达到某个年龄(默认值为 15)时,就会把它们移到老年代中
调整年龄: -XX:MaxTenuringThreshold
默认年龄:- ·Parallel Scavenge 15
- CMS 6
- G1 15
- s1 -> s2 超过 50%:把年龄最大的放入老年代
- 将此时在 Survivor to 区存活下来的对象的年龄设置为 1,以后这些对象每在 Survivor 区熬过一次 GC,它们的年龄就加 1,当对象年龄达到某个年龄(默认值为 15)时,就会把它们移到老年代中
- 在发生一次 Minor GC 后,from 区就会和 to 区互换
分配担保:
JVM 内存分配担保机制
如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象
老年代(Old Generation)
年老代里存放的都是存活时间较久的,大小较大的对象。
Full GC: 当年老代容量满的时候,会触发一次 Major GC(Full GC),回收年老代和年轻代中不再被使用的对象资源
方法区
用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
垃圾回收: 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
永久代(PermSpace):
JDK < 1.8
方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是 Hotspot 虚拟机对其的一种实现。
永久代的大小受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变
- 字符串常量位于 PermSpace
- FGC 不会清理
- 大小启动的时候指定,不能变
元空间(MetaSpace):
JDK >= 1.8
- 移除永久代,并把方法区移至元空间(位于本地内存中,而不是虚拟机内存中)
- 字符串常量存放到堆内存中
- 会触发 FGC 清理
- 元空间大小默认没有限制,一般根据系统内存的大小。JVM 会动态改变此值。
内存分配策略
- 栈上分配: 无需手动调整
- 线程私有小对象
- 无逃逸
- 支持标量替换
- 线程本地分配 TLAB(Thread Local Allocation Buffer): 无需手动调整
- 占用 Eden,默认 1%
- 多线程的时候不用竞争 Eden 就可以申请空间,提高效率
- 小对象
- 大对象直接进入老年代:
- 大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组
- 经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象
- XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制
- 对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC
- 长期存活的对象进入老年代: 为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中
- 空间分配担保:
- 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的
- 如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败
- 如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC
Full GC 的触发条件
- 调用 System.gc (): 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存
- 老年代空间不足:
- 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等
- 为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败: 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC
- JDK 1.7 及以前的永久代空间不足:
- 当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
- 为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
- Concurrent Mode Failure: 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。