1 初识JVM
1.1 什么是JVM
概念:JVM 全称是 Java Virtual Machine,中文译名 Java虚拟机。
本质:JVM 本质上是一个运行在计算机上的程序,它的职责是运行 Java字节码文件。
1.2 JVM的功能
1)解释和运行
- 对字节码文件中的指令,实时的解释成机器码,让计算机执行;
2)内存管理
- 自动为对象、方法等分配内存;
- 自动的垃圾回收机制,回收不再使用的对象;
3)即时编译
- 对热点代码进行优化,提升执行效率。
- Java 需要实时解释,主要是为了支持跨平台性。
- Java字节码文件 ------> 热点代码字节码指令 ------> Java虚拟接解释并优化为汇编和机器码 ------> JVM 将其保存到内存 ------> 当再次执行的时候直接调用
1.3 常见的JVM
所有开发的Java虚拟机都必须遵守《Java虚拟机规范》;
- 《Java虚拟机规范》由 Oracle 制定,内容主要包含了 Java虚拟机 在设计和实现时需要遵守的规范,主要包含class字节码文件的定义、类和接口的加载以及初始化、指令集等内容;
- 《Java虚拟机规范》是对虚拟机设计的要求,而不是对 Java设计 的要求,也就是说虚拟机可以运行在其他的语言比如Groovy、Scala生成的class字节码文件之上。
-
名称 作者 支持版本 社区活跃度
(github star)特性 使用场景 HotSpot
(Oracle JDK版)Oracle 所有版本 高(闭源) 使用最广泛,稳定可靠,社区活跃,JIT支持,Oracle JDK默认虚拟机 默认 HotSpot
(Open JDK版)Oracle 所有版本 中(16.1k) 同上,开源,Open JDK默认虚拟机 默认
对JDK有二次开发需求GraalVM Oracle 11,17,19
企业版支持8高(18.7k) 多语言支持Ruby、Python、C++等高性能、JIT、AOT等 微服务、云原生架构需要多语言混合编程 Dragonwell JDK
龙井Alibaba 标准版 8,11,17
扩展版11,17低(3.9k) 基于OpenJDK的增强、高性能、bug修复、安全性提升,JWarrmup、ElasticHeap、Wisp特性支持 电商、物流、金融领域对性能要求比较高 Eclipse OpenJ9
(原IBM J9)IBM 8,11,17,19,20 低(3.1k) 高性能、可扩展,JIT、AOT特性支持
微服务、云原生架构
注:JDK17自带HotSpot的虚拟机
HotSpot的发展历程
- 1999年4月,HotSPort虚拟机初次在JDK中使用,在JDK1.2中作为附加功能存在;JDK1.3之后作为默认的虚拟机;
- 2006年12月,JDK6发布,并在虚拟机层面做了大量的优化;
- 2009-2013,JDK7中首次推出了G1垃圾收集器,JDK8中引入了JMC等工具,去除了永久代;
- 2018-2019,JDk11优化了G1垃圾收集器。同时推出了ZGC新一代的垃圾回收器;JDK12推出Shenan-doah垃圾回收器;
- 2019-至今,以HotSpot为基础的GraalVM虚拟机诞生。
2 字节码文件详解
2.1 Java虚拟机的组成
1)类加载器(ClassLoader):加载class字节码文件中的内容到内存中;
2)运行时数据区域(JVM管理的内存):负责管理JVM使用到的内存,比如创建对象和销毁对象;
3)执行引擎(即时编译器、解释器、垃圾回收器等):将字节码文件中的指令解释成机器码,同时使用即时编译器优化性能;
4)本地接口:调用本地已经编译的方法,比如虚拟机中提供的c/c++的方法。
注:解释器和本地接口属于虚拟机底层源码的实现,程序员无法去修改,只需要知道存在于虚拟机中就行了。
2.2 字节码文件的组成
1)字节码文件的组成-应用场景:
- 面试;
- 解决工作中的实际问题 - 版本冲突;
- 解决工作中的实际问题 - 系统升级后bug为什么还存在;
2)打开字节码文件的方式
- 字节码文件中保存了源代码编译之后的内容,以二进制的方式存储,无法直接用记事本打开阅读;
- 推荐使用 jclasslib 工具查看字节码文件。
- https://github.com/ingokegel/jclasslib
3)字节码文件的组成
1. 基础信息:魔数、字节码文件对应的Java版本号访问标识(public final等等)父类和接口;
魔数:起到文件头的作用,class文件固定为前四个字节为0xCAFFBABE,不会改变。
主副版本号:指的是编译字节码文件的 JDK 版本号;
主版本号用来标识大版本号,JDK1.1 - 1.1 使用了45.0 - 45.3,JDK1.2是46之后每升级一个大版本就加1;
副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。
版本号的作用主要是判断 当前字节码的版本和运行时的JDK是否兼容。
注:1.2之后大版本号计算方法就是:主版本号 - 44 ,eg:主版本号52就是JDK8
案例:主版本号不兼容导致的错误
需求:解决以下由于主版本号不兼容导致的错误;
解决方案:1)升级运行时的JDK版本;(容易引发其他的兼容性问题,并且需要大量的测试)
2)将第三方依赖的版本号降低或者更换依赖,以满足JDK版本的要求。(√ 建议采用)
2. 常量池:保存了字符串常量、类和接口名、字段名,主要在字节码指令中使用;
字节码文件中常量池作用:避免相同的内容重复定义,节省空间。
常量池中的数据都有一个编号,编号从1开始。在字段或者字节码指令中通过编号可以快速的找到对应的数据。
字节码指令中通过编号引用到常量池的过程称之为符号引用。
3. 字段:当前类或接口声明的字段信息;
4. 方法:当前类或接口声明的方法信息,字节码指令;
5. 属性:类的属性,比如源码的文件名、内部类的列表等。
4)玩转字节码常用的工具
1. javap -v
-
- javap -v:javap 是 JDK 自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。
- 直接输入 javap 查看所有参数;
- 输入 javap -v字节码文件名称 查看具体的字节码信息。(如果是jar包需要先使用 jar-xvf命令解压)
2. jclasslib 插件(IDEA版本)
3. 阿里 arthas
-
- Arthas是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率。
- 官网:https://arthas.aliyun.com/doc/
- 功能:监控面板、查看字节码信息、方法监控、类的热部署、内存监控、垃圾回收监控、应用热点定位
2.3 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程。
2.3.1 生命周期概述
01-加载-->02-连接(2.1验证、2.3准备、2.3解析)-->03初始化-->04使用-->05加载
2.3.2 加载阶段
- 加载阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。程序员可以使用java代码拓展不同的渠道。
- 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中。注:方法区是虚拟的
- 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。生成一个InstanceKlass对象,保存类的所有信息(有基本信息、常量池、方法、字段等),里面还包含实现特定功能比如多态的信息。
- 同时,Java虚拟机还会在堆区生成一份与方法区中数据类似的Java.lang.Class对象。作用是当使用反射的时候需要拿到类的信息。JDK8之后静态变量也存在此处
- 对于开发者而言,只需要访问堆中的Class对象而不需要访问方法区中的C++写的所有信息,这样java虚拟机就能很好的控制开发者访问数据的范围。
总结:类加载器将类的信息加载到内存中,Java虚拟机在方法区和堆区中各分配一个对象保存类的信息,而程序员使用的则是堆区中的Java.lang.Class对象。
2.3.3 连接阶段
2.3.3.1 验证
验证内容是否满足《Java虚拟机规范》;
2.3.3.2 准备
给静态变量(static)分配内存并设置初始值;
2.3.3.3 解析
将常量池中的符号引用替换成指向内存的直接引用
2.3.4 初始化阶段
- 初始化阶段会执行静态代码块中的代码,并为静态变量赋值;
- 初始化阶段会执行字节码中的clinit部分的字节码指令。
- clinit方法中的执行顺序与Java中编写的顺序是一致的。
-
//value 的最终结果为 1; //clinit 先执行static 代码块,在执行下面的 public class Demo1 { static { value = 2; } public static int value = 1; public static void main(String[] args) { } }
- 一下几种方式会导致类的初始化
- 访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化;
- 调用Class.forName(String className);
- new一个该类的对象时;
- 执行Main方法的当前类。
//main 方法时主方法,是一种特殊的方法,如果方法名不是main的话就会提前执行,这里是main,所以未提前执行
//局部代码块(也可以叫做构造代码块),每次创建对象,调用构造器之前,都会执行该代码块中的代码
//答案:DACBCB
public class Test1 { public static void main(String[] args) { System.out.println("A"); new Test1(); new Test1(); } public Test1() { System.out.println("B"); } { System.out.println("C"); } static { System.out.println("D"); } }
- clinit指令在特定情况下不会出现,比如以下几种情况是不会进行初始化指令执行的
- 无静态代码块且无静态变量赋值语句;
- 有静态变量的声明,但是没有赋值语句。//public static int a;
- 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。//public final static int a = 10;
- 直接访问父类的静态变量,不会触发子类的初始化;
- 子类的初始化clinit调用之前,会先调用父类的clinit初始化方法去初始化父类,然后再初始化子类。
2.4 类加载器
作用:类加载器(ClassLoader)负责在类加载过程中的字节码获取并加载到内存这一部分。通过加载字节码数据放入内存转换成byte[],接下来调用虚拟机底层方法将byte[]转换成方法区和堆中的数据。
分类:
- 启动类加载器(Bootstrap ClassLoader)加载核心类;
- 扩展类加载器(Extension ClassLoader)加载扩展类;
- 应用程序类加载器(Application ClassLoader)加载应用classpath中的类,也可以加载项目引用的jar包中的类;
- 自定义类加载器,重写findClass方法。
JDK9及之后扩展类加载器(Extension ClassLoader)变成了平台类加载器(Platform ClassLoader)
类加载器的双亲委派机制
- 双亲委派机制的核心是解决一个类到底是由谁加载的问题。
- 双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载。
- 向下委派加载起到了一个加载优先级的作用。
- 作用:
- 保证类加载的安全性,通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性;
- 避免重复加载,该机制可以避免同一个类被多次加载。
- 面试题:类的双亲委派机制是什么?
- 当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载;
- 应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器;
- 双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。
打破双亲委派机制
- 方式一:自定义类加载器并且重写loadClass方法;
- 方式二:利用上下文类加载器加载类,比如JDBC和JNDI等。
- 方式三:Osgi框架在类加载器(了解即可)
3 JVM的内存区域
- 运行时数据区分成哪几部分,每一部分的作用是什么?
- 程序计数器(线程不共享):每个线程会通过程序计数器记录当前要执行的字节码指令的地址,程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。这部分不会出现内存溢出。
- Java虚拟机栈(线程不共享)
- 本地方法栈(线程不共享):虚拟机栈采用栈的数据结构来管理方法调用中的基本数据(局部变量、操作数等),每一个方法的调用使用一个栈帧来保存。有可能会出现内存溢出。
- 方法区(线程共享):方法区中主要存放的是类的元信息,同时还保存了常量池,也有可能产生内存溢出
- 堆(线程共享):堆中存放的是创建出来的对象,这也是最容易产生内存溢出的位置。
4 JVM的垃圾回收
运行时数据区-总览
- Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。
Java的内存管理和自动垃圾回收
- 线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。
方法区的回收
- 方法区中能回收的内容主要就是不再使用的类。判断一个类可以被卸载,需要同时满足下面三个条件:
- 此类所有实例对象都已经被回收,在堆中不存在任何类的实例对象以及子类对象;
- 加载类的类加载器已经被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用。
如何判断堆上的对象可以回收?
- Java中的对象是否能被回收,是根据对象是否被引用来决定的。如果对象被引用了,说明该对象还在使用,不允许被回收。
如何判断堆上的对象没有被引用?
- 常见的有两种判断方法:引用计数法和可达性分析法;
- 引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用是减1;
- 可达性分析算法:指的是如果从某个对象到GC Root对象是可达的,则对象不可被回收,反之,则可被回收。
- 可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象;
- GC Root主要包含以下四类;
-
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等;
- 系统类加载器加载的java.lang.Class对象;
- 监视器对象,用来保存同步锁synchronized关键字持有的对象;
- 本地方法调用时使用的全局对象。
垃圾回收算法-核心思想
- 垃圾回收要做的两件事:1、找到内存中存活的对象;2、释放不再存活对象的内存,使得程序能再次利用这部分空间。
常见的垃圾回收算法
Java垃圾回收过程会通过单独的GC线程来完成,每种GC算法,都会有部分阶段需要停止所有的用户线程,这个过程被称之为Stop The World简称STW。如果STW时间过长则会影响用户的使用。
- 标记-清除算法
- 核心思想共两个阶段:1)标记阶段:将所有存活的对象进行标记;2)清除阶段:从内存中删除没有被标记也就是非存活对象。
- 优点:实现简单;
- 缺点:1)碎片化问题;2)分配速度慢
- 复制算法
- 核心思想:1)准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中的一块空间(From空间);2)在垃圾回收GC阶段,将From中存活对象复制到To空间。
- 示例:1)将堆内存分割成两块From空间和To空间,对象分配阶段,创建对象;2)GC阶段开始,将GC Root搬运到To空间;3)将GC Root关联的对象,搬运到To空间;4)清理From空间,并把空间名称互换(即对象存在的空间一直都是From空间)。
- 优点:吞吐量高;不会发生碎片化;
- 缺点:内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
- 标记-整理算法也叫标记压缩算法,是对标记清理算法容易产生内存碎片问题的一种解决方案。
- 核心思想:1)标记阶段:将所有存活的对象进行标记;2)整理阶段,将存活对象移动到堆的一端,清理掉存活对象的内存空间。
- 优点:内存使用率高;不会发生碎片化;
- 缺点:整理阶段的效率不高。
- 分代GC(当前应用最广泛的垃圾回收算法):
垃圾回收算法的评价标准
- 吞吐量:吞吐量指的是CPU用于执行用户代码的时间与CPU总执行时间的比值,即 吞吐量 = 执行用户代码时间 / (执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
- 最大暂停时间:即垃圾回收过程中STW时间最大值。
- 堆使用效率:不同垃圾回收算法,堆堆内存的使用方式是不同的。
垃圾回收器的组合
- JDK8及之前
- ParNew + CMS(关注暂停时间)、Parallel Scavenge + Parallel Old(关注吞吐量)、G1(JDK8之前不建议,较大堆并且关注暂停时间)
- JDK9之后:G1(默认)
- 从JDK9之后,由于G1日趋成熟,JDK默认的垃圾回收器已经修改为G1,所以强烈建议在生产环境上使用G1。
总结
- Java中有哪几块内存需要进行垃圾回收?
- 线程不共享的内存区域是跟随线程的生命周期随着线程的回收而回收;
- 线程共享区的方法区一般不需要回收,JSP等技术会通过回收类加载器去回收方法区中的类;
- 堆由垃圾回收器负责进行回收。
- 有哪几种常见的引用类型?
- 强引用,最常见的引用方式,由可达性分析算法来判断;通过GC Root能找到这个对象,那就是被强引用了,不会被回收。
- 软引用,对象在没有强引用情况下,内存不足时会回收;
- 弱引用,对象在没有强引用情况下,会直接回收;
- 虚引用,通过虚引用知道对象被回收了;
- 终结器引用,对象回收时可以自救,不建议使用。
- 常见的垃圾回收器有哪些?
- Serial + Serial Old :单线程回收,主要适用于单核CPU场景;
- ParNew + CMS:暂停时间较短,适用于大型互联网应用中与用户交互的部分;
- Parallel Scavenge + Parallel Old :吞吐量高,适用于后台进行大量数据操作。
- G1:适用于较大的堆,具有可控的暂停时间。