JVM - 虚拟机原理
虚拟机是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java虚拟机屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
# 1. 类加载器
# 1.1 类加载器子系统
加载: 在硬盘上查找并通过IO读入字节码文件
连接:
- 验证:校验字节码文件的正确性
- 准备:给类的静态变量分配内存,并赋予默认值
- 解析:将符号引用转为直接引用,类装载器装入类所引用的其他所有类
初始化:对类的静态变量初始化为指定的值,执行静态代码块
# 1.2 类加载器体系架构
- Bootstrap ClassLoader:引导(启动)类加载器,负责加载JRE的核心类库,如JRE目标下的rt.jar,charsets.jar等
- Extension ClassLoader:扩展类加载器,负责加载JRE扩展目录ext中JAR类包
- Application ClassLoader:系统类加载器,负责加载ClassPath路径下的类包
- Custom ClassLoader:用户自定义加载器,负责加载用户自定义路径下的类包
# 1.3 类加载机制
双亲委托机制:指先委托父类加载器寻找目标类,在找不到的情况下采用自己的路径中查找并载入目标类
# 2. 运行时数据区
# 2.1 Heap 堆
虚拟机启动时创建,用于存放对象实例。几乎所有的对象(包含常量池)都在堆上分配内存,当对象无法再该空间申请到内存时将抛出OutOfMemoryError异常。同时也是垃圾收集器管理的主要区域。
可通过 -Xmx –Xms 参数来分别指定最大堆和最小堆。该区域线程共享。
# 2.2 VM Stack 虚拟机栈
编译器可知的各种基本数据类型(boolean
、byte
、char
、short
、int
、float
、long
、double
)、对象引用(引用指针,并非对象本身)
栈是Java方法执行的内存模型:
每个方法被执行的时候,都会创建一个“栈帧”用于存储局部变量表(包括参数)、操作栈、动态链接、方法出口等信息。该区域线程独占。
每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。(局部变量表:存放了编译器可知的各种基本数据类型(boolean
、byte
、char
、short
、int
、float
、long
、double
)、对象引用(引用指针,并非对象本身),
其中64位长度的long和double类型的数据会占用2个局部变量的空间,其余数据类型只占1个。
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间)
栈的生命期是跟随线程的生命期,线程创建时创建,线程结束栈内存也就释放,是线程私有的。
JVM对该区域规范了两种异常:
1,线程请求的栈深度大于虚拟机栈所允许的深度,将抛出StackOverFlowError异常。
2,若虚拟机栈可动态扩展,当无法申请到足够内存空间时将抛出OutOfMemoryError。通过jvm参数–Xss指定栈空间,空间大小决定函数调用的深度。
# 2.3 本地方法栈
为虚拟机执行native方法,其他规范与java栈类似。不同类型的虚拟机对该区域可自由实现。该区域线程独占。
# 2.4 PC寄存器(程序计数器)
用来存储待执行指令的地址。分支,循环,跳转,异常处理,线程恢复等功能都需要依赖pc寄存器。该区域线程独占。
若线程执行的是一个java方法,则pc寄存器中保存的是待执行指令的地址。若执行的是一个native方法,则pc寄存器中为空。
# 2.5 元数据区(Metaspace)
元数据区取代了永久代,本质和永久代类似,都是对JVM规范中方法区的实现,区别在于元数据区并不在虚拟机中,而是使用本地内存。元数据区在频繁使用,也会发生OutOfMemory异常。
# 3. 执行引擎
执行引擎读取运行时数据区的字节码并逐个执行
字节码解释器:解释器更快地解释字节码,但执行缓慢,解释一句执行一句。
JIT编译器:JIT编译器消除了解释器的缺点。执行引擎通过解释器转换字节码,当它发现重复的代码时,将使用JIT编译器,它编译整个字节码并将其更改为本地代码。这个本地代码将直接用于重复的方法调用,这提高了系统的性能。
- 中间代码生成器(Intermediate Code Generator):生成中间代码
- 代码优化器(Code Optimizer):负责优化上面生成的中间代码
- 目标代码生成器(Target Code Generator):负责生成机器代码或本地代码
- 分析器(Profiler):一个特殊组件,负责查找热点(被多次调用的方法)
# 4. 垃圾收集
# 4.1 垃圾判定
- 引用计数法:给每个对象添加一个计数器,当有地方引用该对象时计数器加1,当引用失效时计数器减1。用对象计数器是否为0来判断对象是否可被回收。缺点:无法解决循环引用的问题
- 可达性分析法:通过“GC ROOTs”的对象作为搜索起始点,向下搜索引用,所走过的路径称为引用链。通过对象是否有到达引用链的路径来判断对象是否可被回收。可作为GC ROOTs的对象:虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象
# 4.2 垃圾收集算法
# 按回收策略划分:
- 标记-清除算法:分为两阶段“标记”和“清除”。首先标记出哪些对象可被回收,在标记完成之后统一回收所有被标记的对象所占用的内存空间。不足之处:1.无法处理循环引用的问题;2.效率不高;3.产生大量内存碎片(ps:空间碎片太多可能会导致以后在分配大对象的时候而无法申请到足够的连续内存空间,导致提前触发新一轮gc)
- 标记-整理算法:分为两阶段“标记”和“整理”。首先标记出哪些对象可被回收,在标记完成后,将对象向一端移动,然后直接清理掉边界以外的内存。
- 复制算法:把内存空间划为两个相等的区域,每次只使用其中一个区域。gc时遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。不足之处:1.内存利用率问题;2.在对象存活率较高时,其效率会变低
# 按分区划分:
- 增量收集:实时垃圾回收算法,即:在应用进行的同时进行垃圾回收,理论上可以解决传统分代方式带来的问题。增量收集把对堆空间划分成一系列内存块,使用时先使用其中一部分,垃圾收集时把之前用掉的部分中的存活对象再放到后面没有用的空间中,这样可以实现一直边使用边收集的效果,避免了传统分代方式整个使用完了再暂停的回收的情况
- 分代收集:(商用默认)基于对象生命周期划分为新生代、老年代、元空间,对不同生命周期的对象使用不同的算法进行回收
# 按系统线程划分:
- 串行收集:使用单线程处理垃圾回收工作,实现容易,效率较高。不足之处:1.无法发挥多处理器的优势;2.需要暂停用户线程
- 并行收集:使用多线程处理垃圾回收工作,速度快,效率高。理论上CPU数目越多,越能体现出并行收集器的优势。不足之处:需要暂停用户线程
- 并发收集:垃圾线程与用户线程同时工作。系统在垃圾回收时不需要暂停用户线程
# 4.3 垃圾收集器
# 串行:
- Serial 收集器:主要针对新生代的收集,是最基本最古老的收集器,它是单线程收集器,工作时必须暂停所有用户线程。该收集器采用复制算法
- Serial Old收集器:主要针对老年代收集,采用标记-整理算法,实现简单高效,但会停顿
# 并行:
- ParNew 收集器:Serial的多线程版本,针对新生代采用复制算法使用多线程进行垃圾收集(并行收集器,响应优先)
- Parallel Scavenge****收集器:ParNew的升级版本,采用复制算法针对新生代的多线程收集器(并行收集器,吞吐优先)。可控制吞吐量和停顿时间,即吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
- Parallel Old收集器:Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和标记-整理算法
# 并发:
- CMS(Current MarkSweep)**收集器:针对老年代**,是一种并发收集器,采用的是标记-清除算法,追求最短停顿
# 并行与并发:
G1 收集器:
- 新生代:类似于ParNew,采用复制算法,当新生代占用达到一定比例的时候,开始收集
- 老年代:类似于CMS,不同点是采用标记-整理算法
# 4.4 与CMS相比G1的特点:
- 空间整合。G1收集器采用标记-整理算法,不会产生内存空间碎片。分配大对象(直接进Humongous区,专门存放短期巨型对象,不用直接进老年代,避免Full GC的大量开销)不会因为无法找到连续空间而提前触发下一次GC。(年轻代拷贝、老年代转移对象无空闲分区、巨型对象无连续分区时触发Full GC,开销极大应该避免)
- 可预测停顿。降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间内,消耗在垃圾收集上的时间不得超过N毫秒,几乎达到Java实时系统(RTSJ)级的垃圾收集器
- G1将Java堆划分为多个大小相等的独立区域(Region),虽保留新生代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合