JVM知识梳理
一、概述
1.1 虚拟机介绍
- java虚拟机是一个可以执行java字节码的虚拟机进程。java源文件被编译成能被java虚拟机执行的字节码文件(.class)
- 跨平台的是java程序(包括字节码文件),而不是JVM。JVM是用C/C++开发的,是编译后的机器码,不能跨平台,不同平台需要安装不同版本的JVM
1.2 JVM组成部分
-
类加载器:在JVM启动时或者类运行时将需要的class加载到JVM中
-
内存区:将内存划分为若干个区以模拟实际机器上的存储、记录和调度模块。如实际机器上各种功能的寄存器或者PC指针的记录器。
-
执行引擎:负责执行class文件中包含的字节码指令,相当于实际机器上的CPU
-
本地方法调用:调用C或C++实现本地方法的代码返回结果
- 线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 线程共享的
- 堆
- 方法区
- 直接内存
- 线程私有的:
二、类加载器
-
从类被加载到虚拟机内存中开始,到卸除内存为止
-
生命过程
- 加载
- 连接
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
其中加载、验证、准备、初始化、卸载这五个阶段的过程是固定的,在类加载过程中必须按照这种顺序按部就班的进行,而解析阶段则不一定,可以在初始化以后进行,是为了支持java语言的运行时绑定
2.1 加载
三件事情
-
通过一个类的全限定名获取定义此类的二进制字节流
-
将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
-
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据
2.2 验证
这一阶段主要是为了确保Class文件的字节流中包含的信息符合虚拟机的要求,并且不会危害虚拟机自身的安全。
四个校验动作
- 文件格式验证:验证字节流是否符合Class文件格式的规范
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求
- 字节码验证:通过数据流和控制流分析。确定程序语义是合法的、符合逻辑的
- 符号引用验证:确保解析动作能正确执行
2.3准备
是正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都将在方法区分配
进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在java堆中
初始值通常情况下是数据类型默认的零值
2.4 解析
是将虚拟机常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定附
符号引用:简单的理解就是字符串,比如引用一个类,java/util.ArrayList 这就是一个符号引用,字符串引用的对象不一定被加载
直接引用:指针或者地址偏移量。引用对象一定在内存(已经加载)
2.5 初始化
类初始化时类加载的最后一步,处理加载阶段,用户可以通过自定义的类加载器参数,其他阶段都完全由虚拟机主导和控制。到了初始化阶段才真正执行Java代码
类初始化的主要工作时为了静态变量赋程序设定的初值
static int a=100; 在准备阶段a被赋默认值0,在初始化阶段就会被赋值为100
java虚拟机规范中严格规定了有且只有五种情况必须对类进行初始化:
- 使用new创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行初始化。
- 通过java.lang.reflect包的方法对类进行反射调用的时候,要是类没有进行过初始化,则要首先进行初始化
- 当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化
- 当虚拟机启动时,用户需要指定一个主类(包含main()方法的类),虚拟机会首先初始化这个类
- 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。
三、对象的创建过程
java中对象的创建就是在堆上分配内存空间的过程,此处说的对象创建仅限于new关键字创建的普通java对象,不包括数组对象的创建。
当虚拟机遇到一条含有new的指令时,会进行一系列对象创建的操作
3.1 检查类是否被加载
- 检查常量池中是否有即将要创建的这个对象所属的类的符号引用;若常量池中没有这个类的符号引用,说明这个类还没有被定义。抛出ClassNotFoundException
- 进而检查这个符号引用所代表的类是否已经被JVM加载,若该类还没有被加载,就找该类的class文件,并加载进方法区;若该类已经被JVm加载,则准备为对象分配内存
3.2 为对象分配内存
- 根据方法区中该类的信息确定该类所需的内存大小;一个对象所需的内存大小是在这个对象所属类被定义完就能确定的。且一个类所生产的所有对象的内存大小是一样的。JVM在一个类被加载进方法区的时候就知道该类生产的每一个对象所需的内存大小
- 从堆中华根一块对于大小的内存给新的对象
分配对中内存有两种方式
- 指针碰撞
- 如果JVM的垃圾收集器采用复制算法或标记-整理算法,那么堆中空闲内存是完整的区域,并且空闲内存和已使用内存之间由一个指针标记
- 空闲列表
- 如果JVM的垃圾回收机制采用标记-清除算法,则需要一章空闲列表来记录空闲区域
PS:多线程并发时会出现正在给对象A分配内存,还没来得及修改指针,对象B又用这个指针分配内存
- 采用同步的方法:使用CAS来保证操作的原子性
- 每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲,
3.3 为分配的内存空间初始化零值
对象的内存分配完成后,还需要将对象的内存空间都初始化为零值,这样能报账对象即时没有赋初值也可以直接使用
3.4 为对象进行其他设置
设置对象头中的信息
所属类、类的元数据信息、对象的hashcode、GC分代年龄等信息
3.5 执行init方法
调用对象的构造函数进行初始化
顺序:先初始化父类的静态代码—>初始化子类的静态代码–>初始化父类的非静态代码—>初始化父类构造函数—>初始化子类非静态代码—>初始化子类构造函数
四、对象的内存布局
4.1 对象头(markword)
- 第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳、对象分代年龄。
- 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例
- 如果是一个java数组,那么在对象头中还必须有一块用于记录数组长度的数据
4.2 实例数据(Instance Data)
- 是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容
- 分配策略:相同宽度的字段总是放在一起
- 这部分的存储顺序会受到虚拟机分配此类参数和字段在java源码中定义顺序的影响
4.3 对其填充(Padding)
4.4 预估对象大小
五、对象访问
对象的访问方式由虚拟机决定,java虚拟机提供两种主流方式
- 句柄访问对象
- 直接指针访问对象
5.1 句柄访问
java堆划出一块内存作为句柄池,引用中存储对象的句柄地址,句柄中包含对象实例数据、类型数据的地址信息
- 优点:引用中存储的是稳定的句柄地址,在对象被移动时,只需要改变句柄中实例数据的指针,不需要改动引用ref本身
5.2直接指针
与句柄访问不同的是,ref中直接存储的就是对象的实例数据,但是类型数据跟句柄访问方式一样。
- 优点:速度快,相对于句柄访问少了一次指针定位的开销时间
六、JVM内存区域
6.1 虚拟机栈
描述的是方法执行时的内存模型,是线程私有的,声明周期与线程相同,每个方法被执行的同时会创建栈帧,主要保存执行方法时的局部变量表、操作数栈、动态连接和方法返回地址等信息,方法执行时入栈,方法执行完成出栈。
- 出现异常
- 线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError
- 虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常
6.2 本地方法栈
与虚拟机栈功能类似,这块区域也不需要进行GC
- 区别:
- 虚拟机栈为虚拟机执行Java方法时访问
- 本地方法栈为虚拟机执行本地方法时提供服务
6.3 程序计数器
- 是一块很小的内存空间,它是线程私有的,可以认作为当前线程的行号指示器
- 主要作用是记录线程运行时的状态,方便线程被唤醒时能从上一次被挂起时的状态继续执行
- 程序计数器是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域,所以这块区域不需要进行GC
6.4 本地内存
- 线程共享区域,Java 8 中,本地内存,也就是我们通常说的堆内外存,包括元空间和方法区
- 主要存储类的信息,常量,静态变量,及时编译器编译后代码等,这部分是在堆中实现的。
- 如果动态生成类(将类信息放入永久代)或大量执行String.intern(将字段串放入永久代中的常量区),很容易造成OOM。
- 所以在Java 8 中就把方法区的实现移动到了本地内存中的元空间中,这样方法区不受JVM的控制,也不会进行GC,因此也提升了性能,也就不存在由于永久代的限制大小而导致的OOM异常,也方便在元空间中统一管理
6.5堆
- 对象实例和数组都是在堆上分配的,GC也主要对这两类数据进行回收
- java虚拟机规范对这块的描述是:所有对象实例及数组都要在堆上分配内存。
- 堆细分
- 新生代
- 老年代
七、对象存活判断
- 引用计数
- 可达性分析
7.1 引用计数
每个对象都有一个引用计数属性,新增一个引用时计数加一,引用释放时计数减一,计数为0时可以回收。此方法简单,无法解决对象互相循环引用的问题
7.2 可达性分析
从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的
GC Roots 对象:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中的类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI(Native方法)中引用的对象
如何判断无用的类
该类所有实例都被回收(Java堆中没有该类的对象)
加载该类的ClassLoader已经被回收
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何对方利用反射访问该类
7.3 finalize
- finalize()方法,是在释放该对象内存前由GC调用
- 通常建议在这个方法中释放该对象持有的资源,例如持有的堆外内存、远程服务长连接。一般情形下不建议重写该方法。对于一个对象,该方法有且仅会被调用一次
7.4 对象引用类型
-
强引用:必不可少
- 垃圾回收器不会回收,当内存空间不足时,Java会抛出OutOfMemoryError,不会靠随意回收具有强引用的对象来解决内存不足的问题
-
软引用:可有可无
- 内存不足时,就会回收这些对象的内存,只要垃圾回收器没有回收就可以被程序使用,软引用可以用来实现内存敏捷的高速缓存
-
弱引用:
- 具有更短的生命周期,在垃圾回收器线程扫描时,一旦发现只具有弱引用的对象,不管内存足够与否,都会进行回收
-
虚引用
- 不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就跟没有任何引用一样,在任何时候都可能被垃圾回收。
利用弱引用和软引用解决OOM问题,通过软引用实现java对象的高速缓存。
八、垃圾回收算法
8.1 标记-清除
-
过程:
- 标记阶段:通过根节点,标记所有从根节点开始的可达对象。因此,为被标记的对象及时垃圾对象
- 清除阶段:清除所有未被标记的对象
-
缺点:
- 效率:标记和清除两个过程效率都不高
- 空间:标记清除后产生大量不连续的内存空间,导致空间碎片太多
8.2 标记-整理
类似与标记-清除,只是标记完对象后,让所有存活的对象都向一端移动,然后清理掉边界以外的内存
-
优点:
- 解决了内存碎片问题
- 没有内存碎片后,对象创建内存分配也更快速了(可以使用TLAB进行分配)
-
缺点:效率问题
8.3 复制算法
将可用内存划分为大小相等的两块,每次只使用其中一块,当一块内存用完后,就将存活的对象复制到另一块上,然后再把使用过的内存空间一次清理掉。只需要移动堆顶指针,按顺序分配内存
- 优点:效率高没有内存碎片
- 缺点:
- 浪费一半空间
- 复制收集算法在对象存活率较高时由于较多的复制操作,导致效率变低
8.4 分代算法
根据对象的存活周期的不同将内存划分为几块,一般是把java堆分为新生代和老年代。然后根据各个年代的特点采用适当的收集算法
- 新生代:大批死去,少数存活使用复制算法
- 老年代:存活率较高,没有额外空间对它进行分配担保,就必须使用标记清理或者标记整理
九、安全点
9.1 安全点
一些特定的位置:当线程运行到这些位置时,线程的一些状态可以被确定,比如记录OopMap的状态,从而确定GC Root的信息,使JVM可以安全的进行一些操作
特定位置:
- 循环的末尾(防止大循环的时候一直不进入安全点,而其他线程在等待)
- 方法返回前
- 调用方法的Call
- 抛出异常的位置
9.2 安全区域
安全点完美解决了如何进入GC的问题,当程序长时间不执行的时候就需要安全区域
安全区域是指一段代码中,引用关系不会发生变化,在这个区域任何地方GC都是安全的,安全区域可以看做是安全点的一个扩展。线程执行到安全区域的代码时,首先标识自己进入了安全区域,这样GC时就不用管进入安全区域的线层了,线层要离开安全区域时就检查JVM是否完成了GC Roots枚举,如果完成就继续执行,如果没有完成就等待直到收到可以安全离开的信号。
十、JVM垃圾回收机制
收集器 | 串行、并行or并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU环境下的Client模式、CMS的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统 服务端上的Java应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |
ZGC | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |
10.1 Serial(新生代)
- 最基本的单线程垃圾收集器,使用一个CPU或一条收集线程去执行垃圾收集工作
- 工作时会Stop The World,暂停所有用户线程,造成卡顿,适合运行在Client模式下的虚拟机
- 用作新生代收集器,复制算法
10.2 ParNew(新生代)
- 使用了多线程去垃圾收集
- 除了Serial只有它可以和CMS搭配使用的收集器
- 用作新生代收集器,复制算法
10.3 Parallel Scavenge(新生代)
用作新生代收集器,复制算法。关注高吞吐量,可以高效地利用CPU时间,尽快完成程序的运行任务,主要适合在后台运算而不需要太多交互的任务。
参数:
- MaxGCPauseMills:控制最大垃圾收集停顿时间
- GCTimeRatio:直接设置吞吐量大小的
10.4 Serial Old(老年代)
- serial收集器的老年代版本、单线程,标记-整理算法
- 一般用于Client模式的虚拟机
- 当虚拟机是Server模式时,有2个用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用 ,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
10.5 Parallel Old(老年代)
- Parallel Scavenge收集器的老年代版本,使用多线程和 标记-整理 算法。在JDK 1.6中开始提供。 在注重吞吐量的场合,配合Parallel Scavenge收集器使用。
10.6 CMS(老年代)
- 一种以获取最短回收停顿时间为目标的收集器。适合需要与用户交互的程序,良好的响应速度能提升用户体验
- 基于标记-清除算法。适用作为老年代收集器
- 过程:
- 初始标记:只是标记一下GC Roots能直接关联到的对象,速度很快。会暂停
- 并发标记:进行GC Roots Tracing(可达性分析)的过程
- 重写标记:会Stop The -World。为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长些,但远比并发标记的时间短。
- 并发清除:回收内存
- 耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以是并发执行的
- 缺点:
- 并发阶段,虽然不会导致用户线程暂停,但是会占用一部分资源(CPU线程),导致应用变慢,吞吐量降低。默认启动收集线程数是(CPU数量+3)/4。即当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大。
- 无法清除浮动垃圾。并发清除阶段,用户线程还在运行,还会产生新垃圾。这些垃圾不会再此次GC中被标记,只能等到下次GC被回收
- 标记-清除算法会产生大量不连续内存,导致分配大内存时内存不够,提前触发Full GC
10.7 G1
-
在JDK 1.7提供的先见垃圾收集器
-
即使用与新生代,也适用于老年代
-
空间整合:使用标记-整理算法,不产生碎片空间
-
整个java堆被分成多个大小相等的块。新生代和老年代不再是物理隔离而是一部分region块组成的集合
-
默认把堆平均分为2048个region,最小1M最大32M,必须是2的幂次方,可以通过-xx:G1HeapRegionSize参数指定
region
- E:eden区,新生代
- S:survivor区,新生代
- O:old区,老年代
- H:humongous区,用来放大对象,当对象新建大小超过region大小一半时,直接在新的一个或多个连续region中分配
-
可预测的停顿时间:估算每个region内的垃圾可回收空间以及回收需要的时间,记录在一个优先列表中。收集时,优先回收价值最大的region,而不是整个堆进行全区域回收。这样提高了回收效率
-
young GC:新生代eden区没有足够可用空间时触发。存活对象移到survivor区,或晋升old区
-
mixed GC:当old区对象很多时,老年代对象空间占堆总空间的比值达到阈值会触发,它除了回收年轻代,也回收部分老年代
- 回收步骤
- 初始标记:只是标记一下GC Roots能直接关联到的对象,并且修改TAMS的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。这阶段需要停顿线程(STW),但耗时很短,共用YGC的停顿,所以一般伴随着YGC发生。
- 并发标记:进行可达性分析,找出存活对象,耗时长,但可与用户线程并发执行。
- 最终标记:修正并发标记阶段用户线程运行导致的变动记录。会STW,但可以并行执行,时间不会很长。
- 筛选回收:根据每个region的回收价值和回收成本排序,根据用户配置的GC停顿时间开始回收。
- 回收步骤
-
当对象分配过快,mixed GC来不及回收,G1会退化,触发Full GC,它使用单线程的Serial收集器来回收,整个过程STW,要尽量避免这种情况。
-
当内存很少的时候(存活对象占用大量空间),没有足够空间来复制对象,会导致回收失败。这时会保留被移动过的对象和没移动的对象,只调整引用。失败发生后,收集器认为存活对象被移动了,有足够空间让应用程序使用,于是用户线程继续工作,等待下一次触发GC。如果内存不够,就会触发Full GC。
10.8 ZGC
- ZGC是一个并发、基于区域、增量式压缩的收集器,STW阶段只会在根对象扫描阶段发送,这样GC暂停时间不会随着堆和存活对象的数量而增加
- 处理阶段
- 标记
- 重定位/压缩
- 重新分配集的选择
- 引用处理
- 弱引用的清理
- 字符串常量池和符号表的清理
- 类卸载
- 着色指针:
- ZGC利用指针的64位中的几位表示Finalizable、Remapped、Marked1、Marked0(ZGC仅支持64位平台),以标记该指向内存的存储状态。 相当于在对象的指针上标注了对象的信息。注意,这里的指针相当于Java术语当中的引用。 在这个被指向的内存发生变化的时候(内存在Compact被移动时),颜色就会发生变化。
- 读屏障
- 由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),
若请求读的内存在被着色了,那么则会触发读屏障。读屏障会更新指针再返回结果,此过程有一定的耗费,从而达到与用户线程并发的效果。
- 由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),
- 与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。
清理
- 字符串常量池和符号表的清理
- 类卸载
- 着色指针:
- ZGC利用指针的64位中的几位表示Finalizable、Remapped、Marked1、Marked0(ZGC仅支持64位平台),以标记该指向内存的存储状态。 相当于在对象的指针上标注了对象的信息。注意,这里的指针相当于Java术语当中的引用。 在这个被指向的内存发生变化的时候(内存在Compact被移动时),颜色就会发生变化。
- 读屏障
- 由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),
若请求读的内存在被着色了,那么则会触发读屏障。读屏障会更新指针再返回结果,此过程有一定的耗费,从而达到与用户线程并发的效果。
- 由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),
- 与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。