JVM概述
虚拟机
所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机
- 系统虚拟机:完全对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台(VMware)
- 程序虚拟机:专门为执行某个单个计算机程序而设计(java虚拟机)在 java 虚拟机中执行的指令我们称为 java 字节码指令
Java 虚拟机是一种执行 java 字节码文件的虚拟计算机,它拥有独立的运行机制,是Java 技术的核心,因为所有的 java 程序都运行在 java 虚拟机内部
JVM作用
Java 虚拟机就是二进制字节码(.class)的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器码指令执行,每一条 java 指令,java 虚拟机中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪儿
特点:
- 一次编译到处运行
- 自动内存管理
- 自动垃圾回收功能
现在的 JVM 不仅可以执行 java 字节码文件,还可以执行其他语言编译后的字节码文件,是一个跨语言平台
JVM的位置
组成
- 类加载器
- 运行时数据区
- 执行引擎
- 本地方法接口
各个组成部分的用途
程序运行前先把java代码转成字节码(class文件)
类加载器:把文件加载到运行时数据区
执行引擎:将字节码翻译成底层系统指令,交给cpu,这个过程需要调用本地接口
JVM架构模型
Java 编译器输入的指令流基本上是一种基于栈的指令集架构
由于不同的cpu架构不同,基于寄存器式架构需要完全依赖硬件,不能实现跨平台性,而基于栈式架构可跨平台,指令集小,编译更容易实现,缺点是实现同样的功能需要更多的指令,性能下降
基于栈式架构的特点
- 设计和实现更简单,适用于资源受限的系统.
- 使用零地址指令方式分配,其执行过程依赖于操作栈,指令集更小,编译器容易实现.
- 不需要硬件支持,可移植性好,更好实现跨平台
基于寄存器式架构特点
- 指令完全依赖于硬件,可移植性差.
- 性能优秀,执行更高效.
- 完成一项操作使用的指令更少
类加载
类加载子系统
类加载子系统负载从文件中或者网络中加载class文件
只负责加载类,是否运行由执行引擎决定
加载的类信息存放于一块称为方法区(元空间)的内存空间
类加载的角色
类加载器(ClassLoader)扮演一个运输者的角色,将.class文件从硬盘上加载到方法区
类加载过程
加载
根据类的地址,从硬盘上读类的信息,
将信息读入到方法区,生成Class类的对象,作为这个类各种数据的访问入口
连接
验证:
- 验证字节码文件格式是否被当前虚拟机所支持
- 元数据验证:验证是否符合java语言规范,例如是否继承final修饰的类
准备:
为静态成员分配默认值(int:0)
不包含static final修饰的常量(在编译时进行初始化)
解析:
将字节码中的符号引用替换成直接引用
例如:方法1 中调用 方法2 (符号引用)
把符号引用地址 换成 内存地址的引用
初始化
类在什么时候初始化?
- 创建对象
- 访问静态变量或方法
- 反射 Class.forName(“”)
- 加载一个类的子类(会首先初始化子类的父类)
类初始化顺序
父类 static –> 子类 static –> 父类构造方法–> 子类构造方法
- 先初始化静态,多个静态自上而下
- 如果有父类先初始化父类静态
- 如果创建对线,先调用父类构造方法,然后子类
类加载器分类
站在jvm的角度划分:
- 启动类加载器(C++写的)
- 其他加载器(Java语言写的,派生与ClassLoader类)
站在开发者的角度:
- 引导类加载器
- 扩展类加载器
- 应用程序类加载器
引导类加载器
由C++实现的,不继承于 java.lang.ClassLoader 没有父加载器
加载java核心类库(JAVAHOME/jre/lib/rt.jar
)例如 lang、util包下的这些类
负责加载扩展类加载器和应用类加载器,并为他们指定父类加载器.
扩展类加载器
Java 语言编写的,派生于 ClassLoader 类
根据名字可以知道,是加载Java扩展的类的,比如 javax.xxx
如果把自己创建的jar包放在 JDK扩展目录 jre/lib/ext
中,也会加载
应用程序类加载器
Java 语言编写的,派生 ClassLoader 类.
加载我们自己定义的类,用于加载用户类路径(classpath
)下所有的类.
该类加载器是程序中默认的类加载器
这里的父类加载器并不是直接的继承关系,而是通过一个parent变量维护的
双亲委派机制
工作过程:
如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时,子加载器才会尝试自己去加载
为什么使用双亲委派机制?
防止内存中出现多份同样的字节码
如果没有双亲委派,那么用户是不是可以自己定义一java.lang.String的同名类,并把它放到ClassPath中,那么类之间的比较结果及类的唯一性将无法保证
怎么打破双亲委派模型?
要继承ClassLoader类,重写loadClass和findClass方法
类的主动使用 / 被动使用
区别在与类是否被初始化
主动使用:每个类或者接口被首次使用的时候会进行 类初始化
- new
- 访问静态变量
- 访问静态方法
- 对某个类进行反射操作
- 子类初始化导致父类初始化
- 执行该类的mian()
被动使用:
-
引用静态常量(static final),需要计算出结果得到的常量会初始化类
public class DemoB extends DemoA{ static final int num1 = 1+3; static final double num2 = Math.E+Math.PI; static final double num3 = new Random().nextDouble(); static { System.out.println("bbb"); } } public class Test { public static void main(String[] args){ int num1 = DemoB.num1; System.out.println(num1); double num2 = DemoB.num2; System.out.println(num2); double num3 = DemoB.num3; System.out.println(num3); } } ------------------------------------------------------- 4 5.859874482048838 aaa bbb 0.14917459157914092
-
构造某个类的数组时不会导致该类的初始化
DemoB[] demoBs = new DemoB[10];
运行时数据区
线程私有:程序计数器,本地方法栈,虚拟机栈
线程共享:方法区,堆
程序计数器
为了提高CPU利用率,采用是超线程结构,也就是CPU中一个ALU(逻辑运算单元)对应两个寄存器,一个寄存器对应一个线程,所以CPU就得不断进行上下文切换;这时候每个线程中就要记录执行到的位置,以便线程切换后能恢复到正确的执行位置;也是程序流程控制的指示器,循环、分支、跳转都依赖它。
程序计数器是一块较小的内存空间;是线程私有的;是唯一一块不会出现内存溢出的区域。
本地方法栈
与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用Native 方法服务的
当我们在程序中调用本地方法时,会将本地方法加载到本地方法栈中执行
线程私有,会出现栈溢出错误
虚拟机栈
Java为了跨平台,将运行程序设计成基于栈式指令集架构
所以栈就是为了解决程序运行,这个程序该如何处理数据
每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次方法的调用
栈帧结构
- 局部变量表:是用来存储我们临时8个基本数据类型、对象引用地址、returnAddress类型
- 操作数栈:用来运算局部变量的,例如代码中有个 i = 1 + 2,先将 i 方入局部变量表,再在操作数栈中运算1+2,然后赋给 i;
- 动态链接:方法中存在调用其他方法,存储链接的地址
- 方法返回地址:当一个方法执行完毕之后,记录 要返回之前调用它的地方
栈的运行
- 对于每个线程都会有一个虚拟机栈
- 一个方法执行,将栈帧入栈
- 该方法如果调用其他方法,就创建新的栈帧,执行引擎就会切换到这个栈帧运行
- 这个方法执行完了将结果返回到调用他的方法栈帧
Java 虚拟机栈是线程私有的. 生命周期和线程一致
虚拟机堆
堆解决的是数据存储问题,存放对象实例,是 Java 虚拟机中内存最大的一块,也是垃圾回收主要区域;是被所有线程共享的;会出现内存溢出异常
堆内存区域划分
为了把不同生命周期的对象存储在不同区域,对应不同的垃圾回收算法,实现分代收集。提高垃圾回收效率
堆各区域的占比
对象在堆内存中的过程:
- 新建的对象 存放在伊甸园区, 伊甸园区填满时,进行 Minor GC,垃圾对象直接被回收掉, 存活下来的对象,会把他存放到幸存者0 / 幸存者1.
- 再次垃圾回收时,把在幸存者0区 存活的对象 移动到幸存者1区,然后将幸存者0区清空,依次交替执行。每次保证有一个幸存者区域是空的
- 当对象经过15(对象头中4个bit位存放回收次数)次垃圾回收后,依然存活的,将被移动到老年区
- 老年代满了清理(Major GC)
- 若养老区执行了 Major GC 之后发现依然无法进行对象保存,就会产生 OOM 异常(堆内存溢出)
分代收集思想
- 新生代收集 Minor GC
- 老年代收集 Major GC
- 整堆收集(堆+方法区) Full Gc
- System.gc ()
- 老年区空间不足
- 方法区空间不足
垃圾回收都会STW,只是Full GC占用的时间较长,所以开发中要尽量避免
什么时候进入老年代?
- 分代年龄大于15岁
- 对象分配内存大于一定值(大对象)
- 伊甸园区向幸存者区复制时,幸存者区内存不足
- 幸存者区域中相同年龄段的对象所占空间占多数
TLAB 机制
线程本地分配缓存区
在多线程情况,在堆空间中为线程开辟一块空间,用来存储线程中产生的一些对象,避免空间竞争,提高分配效率
字符串常量池位置
jdk7之前,将字符串常量池位置在 方法区(永久代)中存储
jdk8之后,将字符串常量池的位置 放到了堆空间. 因为方法区只有触发FUll GC时才会回收,回收效率低
因为程序中大量的需要使用字符串,所以将字符串常量池的位置改变到了堆中,可以及时回收无效的字符串常量
方法区
用于存储类信息、常量、静态变量、即时JIT编译后代码缓存 等元数据;逻辑上属于堆,称为元空间
方法区的内部结构
运行时常量池:存放 编译期间赋值的常量、运行时解析出的方法物理地址引用。
方法区的垃圾回收
对方法区的约束是非常宽松的,可以不要求虚拟机在方法区中实现垃圾收集,类卸载要求苛刻,但满了时由必须回收(Full GC)
主要回收 运行时常量池中废弃的常量 和 不再使用的类的信息
判断 “不再被使用的类” 的三个条件:
- 该类以及子类的对象没有被引用
- 加载该类的类加载器已经被回收
- 该类的 Class 对象没有在任何地方被引用
本地方法接口
Java调用非java代码 的接口,这个方法由C或C++实现;Java中由native表示的方法
当我们的java程序 需要与计算机硬件资源进行数据交互( 例如hashCode() read() start() unsafe类下的所有方法 )
JVM解释是用C写的,可以更好的与本地方法交互
执行引擎
将字节码指令解释 / 编译成对应平台上的本地机器指令
- 前端编译:.java——>.class
- 后端编译:执行引擎中的编译(.class——>机器指令)
解释器:当 Java 虚拟机启动时对规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行
JIT编译器:就是虚拟机将源代码一次性直接编译成和本地机器平台相关的机器语言,但并不是马上执行
为什么 Java 是半编译半解释型语言?
解释启动快,执行效率低
编译需要时间,执行效率高
程序启动后,解释器可以立即执行,响应速度快,而编译需要消耗时间
JVM(HotSpot VM)会采用计数的热点探测方式,筛选出高频代码(多次调的方法,循环体)进行编译,并缓存起来,以此提高执行效率
所以JVM刚启动后,可以先通过解释器解释执行代码,之后再用编译器编译执行,两者结合
垃圾回收
垃圾回收概述
java语言与C++的区别之一就是可以自动垃圾回收,提高了效率
垃圾回收三个经典问题:
-
哪些内存需要回收? :频繁回收堆内存,较少回收方法区
-
什么时候回收?
-
如何回收?
什么是垃圾
垃圾是指在运行程序中没有任何引用指向的对象,这个对象就是需要被回收的垃圾
为什么要垃圾回收
- 如果不进行垃圾回收,内存迟早都会被消耗完,可能会导致内存溢出
- 清除内存里的记录碎片,碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象(连续的空间)
- 业务庞大,又不能停机,运行时就要回收
早期的垃圾回收
在早期的 C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用 new关键字进行内存申请,并使用 delete 关键字进行内存释放
- 频繁申请和释放内存的管理负担
- 倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏
java垃圾回收机制
自动内存管理的优点:
- 无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
- 将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发
关于自动内存管理的担忧
- 方便了程序员的开发,降低处理内存问题的能力
- 此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 OutofMemoryError 时,快速地根据错误异常日志定位问题和解决问题
- 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节
内存溢出&内存泄漏
内存溢出:(out of memory)通俗理解就是内存不够,通常在运行大型软件或游戏时,软件或游戏所需要的内存远远超出了你主机内安装的内存所承受大小,就叫内存溢出
内存泄漏:(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放(GC不能回收),造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果,例如dataSourse.getConnection()没有close(),IO读写完没有关闭,ThreadLocal用完未释放。
垃圾回收相关算法
标记阶段
就是判断一个对象是否被使用(被引用)
引用计数算法
如果一个引用指向此对象,就计数+1,为0判为垃圾
缺点:增加存储空间,增加时间开销,循环引用问题
对象相互引用,计数器都为1,都不能被标记为垃圾,都不能被回收,造成内存泄漏
可达性分析算法
根搜索算法,追踪性垃圾回收
以一些活跃的对象为起始点(GCRoots),按照从上至下的方式搜索 被根对象集合所连接的目标对象是否可达,能够被根对象集合直接或者间接连接的对象才是存活对象,否则就标记成垃圾
GC Roots 可以是那些元素?
- 虚拟机栈、本地方法栈中引用的对象
- 方法区中静态属性引用的对象
- 被synchronized 当作锁的对象
- 系统类加载器
- 常驻异常对象
对象的 finalization 机制
java允许对象在销毁前去调用finalize(),去处理一些逻辑,一个对象只会调用一次
finalize()可以被重写,用来在该对象被回收时作一些释放资源的操作,比如关闭文件、套接字和数据库连接等
一般不建议手动调用
- 在 finalize()时可能会导致对象复活
- finalize()方法的执行时间是没有保障的,它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize()方法将没有执行机会
- 一个糟糕的 finalize()会严重影响 GC 的性能。比如 finalize 是个死循环
生存还是死亡?
由于finalization 机制,虚拟机中的对象一般处于三种可能的状态:
- 可触及的:从根节点开始,可以到达这个对象
- 可复活的:对象的所有引用都被释放,但没有调用finalize()方法
- 不可触及的:对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize()只会被调用一次
标记是否被回收过程
- 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记
- 进行筛选,判断此对象是否有必要执行 finalize()方法
- 如果对象 objA 没有重写 finalize()方法,或者 finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的
- 如果对象 objA 重写了 finalize()方法,且还未执行过,那么 objA 会被插入到
F-Queue 队列
中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize()方法执行 ,并二次标记 - 一个对象的 finalize() 方法只会被调用一次,对象会再次出现没有引用存在的情况,直接变为不可触及状态
垃圾回收阶段算法
标记-清除算法
标记可达的对象
将是垃圾的对象的区域维护在一张空闲列表当中,之后如果有新的对象产生,就覆盖这块区域
- 不需要移动对象,效率还算高
- 会产生内存碎片
- 需要维护一个空闲列表
标记-复制算法
将可用内存分为大小相等的两块,每次只使用其中的一块,在回收时,将非垃圾对象复制到另一块内存中排放整齐,然后将原来的内存块清空;一般在新生代中幸存者0/1这两个区域使用
- 没有碎片问题
- 需要两倍的内存空间
- 需要移动存活对象,垃圾越多效率越高,适用于年轻代
对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小
标记-压缩算法
将存活的对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间
与标记-清除区别:需要移动存活对象;没有空闲列表记录非垃圾区域,而是直接清除;
- 没有内存碎片
- 不需要内存减半
- 需要移动和清除对象,效率太低
分代收集
为什么要使用分代收集
一般是把 Java堆分为新生代和老年代,不同区的对象的生命周期不同,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率
长:Http请求中Session对象,线程,Socket连接
短:String对象
年轻代
区域相对老年代较小,对象生命周期短、存活率低,回收频繁
复制算法,速度快,而内存利用率不高,通过两个幸存者区缓解
老年代
区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
标记- 清除或者是标记-清除与标记-整理的混合实现
垃圾回收中相关概念
System.gc ( )
调用这个放发,会触发Full GC ,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存
但是调用后不一定生效,因为垃圾回收是自动的,一般情况下不要再项目中显示调用
STW
Stop-the-World,在垃圾回收时,会导致整个应用程序的暂停,没有任何响应,时间很短
为什么需要停顿所有 Java 执行线程:
分析工作必须在一个能确保一致性的快照中进行,如果出现分析过程中对象引用关系还在不断变化,则分析结果就不准确,会有误标和漏标现象。
STW会影响用户的体验,所有程序中要尽量减少它的出现,这也是垃圾回收器的性能指标之一
对象引用
将对象分等级(强,软,弱,需)当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象
强引用不是垃圾,其余三个都是
强引用
代码中的引用赋值,就是可达性分析触及的对象;无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。宁可报 OOM,也不会 GC 强引用
软引用
软引用是用来描述一些还有用,但非必需的对象,比如高速缓存。当内存足够使用时,先不回收这类对象,当虚拟机内存不够用时,要回收此类对象.
弱引用
描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止, 只要发生垃圾回收,就会回收此类对象.
虚引用
发现即回收,随时都可能被垃圾回收器回收
垃圾回收器
垃圾回收器分类
按线程分
串行垃圾回收器 & 并行垃圾回收器
串行:同一时间段内只一个 CPU进行垃圾回收;适用于硬件设施较差的环境。
并行:多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量
按工作模式分
并发式垃圾回收器 & 独占式垃圾回收器
并发式:垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
独占式:一旦进行垃圾回收,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束
按工作的内存区间分
年轻代垃圾回收器 & 老年代垃圾回收器
GC性能指标
- 吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间:程序的运行时间+内存回收的时间)
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 垃圾收集开销:垃圾收集所用时间与总运行时间的比例。
- 收集频率:相对于应用程序的执行,收集操作发生的频率。
- 内存占用:Java 堆区所占的内存大小。
- 快速:一个对象从诞生到被回收所经历的时间
HotSpot 垃圾收集器
图中展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器
- 串行回收器:Serial,Serial old
- 并行回收器:ParNew,Parallel old ,Parallel scavenge
- 并发回收器:CMS、G1
CMS
是一种以获取最短回收停顿时间为目标的老年代 并发垃圾回收器
工作步骤
- 初始标记:可达性分析算法标记GCRoot直接关联的对象;STW;快
- 并发标记:进行整条链路的标记;不阻塞工作线程;不会STW
- 重新标记:修改并标记因应用程序变动的内容(对象依赖关系发生变化)STW
- 并发清除:并发清除垃圾对象;不阻塞工作线程
优点:并发收集 ,低停顿
缺点
1.标记清除算法导致产生大量空间碎片,并发阶段会降低吞吐量;
2.无法清除浮动垃圾(并发标记和并发清除期间产生的新垃圾)
G1
降低暂停时间,解决空间碎片问题,同时兼顾良好的吞吐量。
面向局部收集的思想,它把堆内存分割为约2048个物理地址不连续的区域(Region)。使用不同的 Region 来表示 空闲空间、Eden、幸存者、老年代、Humongous等
- 分代收集
- 空间整合:使用标记整理算法,不会导致空间碎片
- 可预测停顿:我们可以设置垃圾回收的最长时间,到时间后不管有没有清除完都会停止
工作步骤
前三个步骤和CMS一样
- 初始标记:可达性分析算法标记GCRoot直接关联的对象,STW
- 并发标记:进行整条链路的标记
- 最终标记:修改并标记因应用程序变动的内容(对象依赖关系发生变化)STW
- 筛选回收:对各Region的回收价值和成本排序,回收价值最高的区域;
三色标记
对于CMS和G1这种并发回收器,会有并发标记过程,这个过程中如果有引用关系的改变就会出现多标和漏标现象
所以会使用三色标记算法,将对象分为三种颜色;相当于引入了一个中间状态(灰色)
- 白色:该对象还没有被访问过
- 灰色:该对象被访问过,但其直接关联对象 至少有一个未被访问过
- 黑色:该对象和其直接关联的对象都被访问过了
标记过程
- 将GC Root标记为黑色
- 将GC Root直接关联的对象标记为灰色
- 遍历灰色对象的所有引用,将自身标为黑色,将引用标为灰色
- 重复,直到没有灰色
- 黑色存活;白色回收
漏标和错标
漏标
:A引用B,GC标记完B后 其之间的引用断开,这样B本来应该回收但没有回收,产生浮动垃圾
错标
:A引用B引用C,B和C的引用断开,C被标记为白色,A和C建立新的引用;但C和C引用下的对象不会被扫描到,把理应存活的对象删除掉了
错标产生有两个必要条件;所以破坏一个条件就可以解决错标问题
- 灰色指向白色的引用全部断开
- 黑色和白色间建立新的引用
原始快照:破坏第一个条件;当灰色和白色间的引用断开,就把白色对象记录下来,重新标记阶段再将这些对象为根对象,重新扫描一遍
增量更新:破坏第二个条件;在一个黑色对象下插入一个新的引用,将黑色对象标记为灰色,重新扫描一遍