JVM概述
JVM
概述
JVM是Java平台无关的保障,虚拟机上是平台无关的,虚拟机下是平台相关的
各种虚拟机
虚拟机始祖:Sun Classic
1996年,Sun发布JDK 1.0, Java语言首次拥有了商用的正式运行环境, 这个JDK中所带的虚拟机就是Classic VM。这款虚拟机只能使用纯解释器方式来执行Java代码。
Exact VM
JDK1.2时, 在Solaris平台上发布Exact VM的虚拟机, 它的编译执行系统已经具备现代高性能虚拟机雏形。(外挂编译器)
使用最广泛的JVM:HotSpot VM
JDK1.3成为默认虚拟机, JDK6、JDK8等均为默认虚拟机。
HotSpot虚拟机的热点代码探测,通过执行计数器找出最有编译价值的代码, 再通知即时编译器以方法为单位进行编译Open-JDK
2006年, JDK的各个部分在GPLv2协议下开放了源码, 形成了Open-JDK项目, 其中当然也包括HotSpot虚拟机
BEA JRockit
专门为服务器硬件和服务端应用场景高度优化的虚拟机。
2008年BEA被Oracle收购,JDK8在HotSpot的基础上植入了部分JRockit的优秀特性。IBM J9
IBM J9虚拟机的职责分离与模块化做得比HotSpot更优秀。
2016年起IBM逐渐将J9开源,并捐赠给Eclipse基金会管理,随后重新命名为OpenJ9。
虚拟机规范
运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
这些区域有各自的用途, 各自的创建和销毁的时间。有的区域随着虚拟机进程的启动而一直存在, 有的区域则是依赖用户线程的启动和结束而建立和销毁。
类文件结构
平台无关性,语言无关性。
Class文件格式采用一种类似于C语言结构体的伪结构来存储数据。
类加载子系统
Java虚拟机把描述类的数据从Class文件加载到内存, 并对数据进行校验、 转换解析和初始化, 最终形成可以被虚拟机直接使用的Java类型, 这个过程被称作虚拟机的类加载。
执行引擎
执行引擎是Java虚拟机核心的组成部分之一。
虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
运行时数据区
程序计数器(PC寄存器)
PC寄存器(程序计数器)可看作是当前线程所执行的字节码的行号指示器。
在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令,分支、 循环、 跳转、 异常处理等都依赖于它完成。每个线程有一个独立的程序计数器,线程之间互不影响。(线程独享)
运行时数据区中唯一不会出现OOM(Out Of Memory)的区域,没有垃圾回收。
使用 javap -v xxx.class 指令可以将文件解析成指令集
Java虚拟机栈
Java虚拟机栈描述的是Java方法执行的线程内存模型
每个方法被执行的时候, Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
Java虚拟机栈不存在垃圾回收,但是会有OOM。
Java虚拟机规范允许 Java 虚拟机堆栈具有固定大小或根据计算需要动态扩展和收缩。
- 如果是可动态扩展,当无法申请到足够内存时会出现OOM。
- 如果是固定大小,当线程请求的栈容量超过固定值,则出现StackOverflowError。
-Xss设置栈内存的大小,设置的栈的大小决定了函数调用的最大深度。
未调优之前:
1 | public class JvmTest { |
控制台OOM错误
调优:
参考:https://blog.csdn.net/qq_32907195/article/details/106852973
再次运行后
栈帧内部结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。
局部变量
局部变量是一个数组,在编译时其长度就被定义好。主要用于存储方法参数、定义在方法内的局部变量。
局部变量的数据类型可以包括:基本数据类型、引用数据类型和返回值地址。
操作数栈也常被称为操作栈,后入先出(Last In First Out,LIFO)。
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。操作数栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度。
操作数栈并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,以支持方法代码的动态链接。
在Java源文件被编译成字节码文件时,所有的变量、方法引用都作为符号引用,保存在class文件的常量池中
描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的。动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
返回地址
执行引擎遇到一个方法返回的字节码指令(例如:ireturn), 此时可能会有返回值传递给上层调用的方法。
一个方法因有未处理的异常而退出,称之为异常调用完成。一个方法因异常而退出, 是不会给它的上层调用者提供任何返回值的。
本地方法栈
本地方法栈(Native Method Stacks)与Java虚拟机栈作用是非常相似的,只不过他是为虚拟机使用本地(Native)方法而服务。
《 Java虚拟机规范》对本地方法栈中方法使用的语言、 使用方式与数据结构并没有任何强制规定,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
方法区
jdk7以前,方法区的实现是永久代,jdk8开始方法区的实现使用元空间取代了永久代。
元空间和永久代最大的区别是:元空间不在虚拟机设置的内存中,而是使用本地内存
方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
方法区在JVM启动的时候被创建,它的实际的物理内存空间都是可以不连续的。
JDK8之后,元空间即是对方法区的具体实现。存储了以下内容:
类型信息
- 比如类Class,接口Interface,枚举Enum,注解annotation等类型的完整名称(包名.类名)。
- 类型的修饰符,比如public、abstract、final等。
- 直接父类的完整名称。
- 直接接口的有序列表。
属性(域)信息
- 属性(域)名称、属性(域)类型。
- 属性(域)修饰符,public、private、protected、final、static、transient、volattile的一个子集。
- 属性(域)的声明顺序
方法信息
- 方法名称、方法参数、方法的返回值类型或void。
- 方法的修饰符,public,private,protected,static,final,synchronized,native,abstract的一个子集。
- 方法的字节码bytecodes,操作数栈,局部变量表及大小。
- 异常表(abstract和native方法除外),每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引。
- 方法的声明顺序。
运行时常量池
(1)方法区,内部包含了运行时常量池
(2)字节码文件,内部包含了常量池
参考:https://blog.csdn.net/qq_42181724/article/details/115035236
- 字节码文件中的常量池,包括各种字面量(数值、字符串值)和对类型、属性(域)、方法的符号引用。
- 将字节码文件加载后,其中的常量池就会被加载到方法区,就是运行时常量池。而运行时常量池中的符号引用在运行期就被解析为了真实地址(动态链接)
- JVM为每个已加载的类型(类,接口)维护一个常量池,池中的数据项像数组一样,可以直接通过索引访问
- 方法区,内部包含了运行时常量池
- 字节码文件,内部包含了常量池
- JDK1.8之后:无永久代,字符串常量池、静态变量保存在堆中。类型信息、属性(域)信息、方法信息、常量保存在本地内存的元空间(元空间代替了永久代,元空间位于本地内存中,而永久代位于虚拟机内存中,解决了OOM问题)
堆
- 堆是Java内存管理的核心区域,所有线程共享Java堆,“几乎”所有的对象实例都在这里分配内存。
- 默认情况下,堆的初始内存大小是物理内存的1/64,堆的最大内存大小是物理内存的1/4。
- 一个JVM实例只存在一个堆,JVM启动时堆即被创建,其空间大小也就确定,当然内存大小是可调节的。
- 堆在物理上可以是不连续的,但在逻辑上应该被视为连续的。
- 堆也是垃圾回收的重点区域
默认情况下,堆的初始内存大小是物理内存的1/64,堆的最大内存大小是物理内存的1/4。
-Xms(-XX:InitialHeapSize):堆(新生代+老年代)的初始内存。
-Xmx(-XX:MaxHeapSize):堆(新生代+老年代)的最大内存。
-XX:NewSize和-Xmn(-XX:MaxNewSize):新生代的初始内存和新生代的最大内存。
-XX:SurvivorRatio:新生代中1个Eden区与1个Survivor区的大小比值。
-XX:NewRatio:指定老年代/新生代的堆内存比例。
虚拟机栈、方法区、堆的关系如下
类加载机制和垃圾回收
类文件的结构
平台无关性,语言无关性。
Class文件是一组以8位为一个字节,作为基础单位的二进制流,各数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。
根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。
- 无符号数:
- 它属于基本数据类型。
- 它以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数。
- 它可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
- 表:
- 它是由多个无符号数或者其他表作为数据项构成的复合数据类型。
- 它习惯性地以“_info”结尾。
- 它用于描述有层次关系的复合结构的数据,整个Class文件本质上可被视作一张表。
- 无符号数:
结构
- 魔数:每个Class文件的头4个字节成为魔数。其唯一的作用:确定该文件是否为一个能被虚拟机接受的Class文件。
- 版本号:第5、6字节为次版本号,第7、8字节为主版本号。
- 常量池:常量池中常量的数量不是固定的,所以会有一个常量池容量的计数器;常量池中主要存放两大类常量:字面量和符号引用
- 访问标志:用于识别一些类或者接口层次的访问信息
- 类索引:用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。除java.lang.Object类外,所有类的父类索引均不为0。
- 接口索引:集合就用来描述这个类实现了哪些接口
类加载机制
一个Java类型从被加载到虚拟机内存,直到卸载出内存,就是它的整个生命周期。这一过程包括:
加载(Loading)
验证(Verification)
准备(Preparation)
解析(Resolution)
初始化(Initialization)
使用(Using)
卸载(Unloading)
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各种数据的访问入口。
验证
准备
解析
将类、接口、字段和方法的符号引用转为直接引用;也就是得到类、字段、方法等在内存中的指针或者偏移量。
初始化
获取到Class对象初始化锁的线程才能只能初始化,其他线程都要阻塞等待。主要工作:
- 给类变量(静态变量)赋定义的值
- 执行静态代码块(静态代码块只能访问定义在静态代码块之前的静态变量,定义在静态代码块之后的静态变量,可以赋值,但是不能访问)
对于加载,java 虚拟机规范中没有进行强制约束,交给虚拟机的具体实现来自由把握。但对于初始化阶段,虚拟机规范则是严格规定了有且只有5种 情况必须立即进行“初始化”(而加载、验证、准备自然在此之前开始)
- 遇到 new 、getstatic 、putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化 。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及凋用一个类的静态方法的时候。
- 使用 java-lang 、 reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化 。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化 。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invokeMethodHandle 实例最后的解析结果 REF-getStatic 、 REF_putStatic 、 REF invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化
对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5个场景中的行为称为对一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
由于Java 虚拟机规范中没有进行强制约束所以可以将类的初始化时机看作是类的加载时机 ?
类加载器
类加载器负责加载所有的Class,在标准Java程序中,JVM会创建三种类加载器:
Bootstrap ClassLoader
用于加载Java核心类库,比如存放在JRE的lib目录下jar包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。
出于安全考虑,只加载包名为java\javax\sun等开头的类。
加载扩展类加载器和应用类加载器,并指定成为他们的上层加载器。
使用C/C++语言实现,嵌套在JVM内部。
不继承于java.lang.ClassLoader,没有父加载器。
Extension ClassLoader
从java.ext.dirs系统属性所指定的目录中加载类库,或从jre/lib/ext子目录下加载类库。
使用Java语言实现,派生于ClassLoader,由sun.misc.Launcher$ExtClassLoader实现。
上层加载器是启动加载器。
App ClassLoader
- 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库。
- 使用Java语言实现,派生于ClassLoader,由sun.misc.Launcher$AppClassLoader实现。
- 上层加载器是扩展加载器。
- 该加载器是程序中默认的类加载器。
类加载的过程
参考:https://www.jianshu.com/p/1e4011617650
双亲委派机制:简单来说是子加载器让父加载器去加载,若父加载器加载不到才回到子加载器,让子加载器加载
作用:
1、防止重复加载同一个.class
。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
2、保证核心.class
不能被篡改。通过委托方式,不会去篡改核心.class
,即使篡改也不会去加载,即使加载也不会是同一个.class
对象了。不同的加载器加载同一个.class
也不是同一个Class
对象。这样保证了Class
执行安全。
垃圾回收
判断对象死亡的方法
引用计数法
- 基本思路:对每个对象保存一个整型的引用计数器属性,用于记录被对象引用的情况,被引用了就+1,引用失效就-1,0表示不可能再被使用,可进行回收。
- 优点:实现简单;垃圾对象便于判断、标识。
- 缺点:需要额外的空间存储计数器;更新计数器的数值,增加了时间的开销;无法处理循环引用。
可达性分析法
- 基本思路:是以根对象(GCRoots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达。只有能够被根对象集合直接或者间接连接的对象才是存活的对象;反之则是不可达的,可以标记为垃圾对象。
- 优点:解决了循环引用的问题。
- 缺点:分析结果必须要在一个能保障一致性的快照中进行,否则准确性就无法保证。比如在多线程程序下某线程更新对象的引用,有可能造成误报或者漏报。
GCRoots包括
- 虚拟机栈中引用的对象。
- 本地方法栈内JNI引用的对象。
- 方法区中静态属性引用的对象、常量引用的对象。
- 所有被同步锁synchronized持有的对象。
Finalization机制
Java语言提供了finalization机制来允许开发人员在对象被销毁之前的自定义处理逻辑。
- 当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
- finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放,通常在这个方法中进行一些资源释放和清理的工作。
- finalize()方法只能被调用一次
垃圾回收算法
标记清除算法
- 步骤:1.标记需要回收的对象,2.将标记的对象统一回收。
- 常用于老年代,因为老年代中大部分对象不会被清理。
- 优点:算法简单,只需要维护一个空闲列表。
- 缺点:
- 执行效率不稳定,该算法效率会随着对象的数量增长而降低。
- 内存碎片化问题。
- 需要停止整个应用程序,用户体验差。
标记压缩算法
- 步骤:1.标记需要回收的对象,2.将存活的对象向内存空间的一端移动,3.直接清理掉边界以外的内存。
- 常用于老年代。
- 优点:没有碎片化问题。
- 缺点:如果存活对象较多,复制时开销较大。
复制算法
- 步骤:1.按照容量分为二个大小相等的内存区域(例如:A和B区域),2.当A区域回收时,将依然存活的对象复制到B区域,3.将A区域内存清理。
- 常用于新生代,因为新生代区域中对象只有极少数存活。
- 优点:实现简单、没有碎片化问题。
- 缺点:空间浪费较大;如果存活对象较多,复制时开销较大,效率低。
对象和执行引擎
创建对象的方式
- new关键字。
- Class.newInstance()。
- Constructor.newInstance()。
- Clone方法。
- 反序列化。
反射机制创建对象
1 | public class Student{ |
clone方法创建对象
1 | public class Student implements Cloneable{ |
创建对象的步骤
判断对象对应的类是否被加载、链接、初始化。
- 当执行new指令时,首先检查能否在常量池中定位到类的符号引用,并且检查这个符号引用代表的类是否被加载解析初始化过。
- 如果没有,则采用双亲委派模式,查找类加载器+包名+类名的class文件,再加载。
为对象分配内存。
虚拟机实现的Java对象包括三个部分:对象头、实例字段和对齐填充字段。
注意:实例字段包括自身定义的,和从父类继承下来的(即使父类的实例字段被子类覆盖或者被private修饰,都照样为其分配内存)。
分配方式:
指针碰撞
空闲列表
分配内存时处理线程并发安全的问题:
- 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS和失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小块内存,哪个线程要分配内存,就在该线程的TLAB(Thread Local Allocation Buffer即本地线程分配缓冲)上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。有些类似于ConcurrentHashMap的实现原理
初始化分配的空间
虚拟机将分配到的内存空间(不包括对象头)初始化为零值。设置对象的对象头
例如这个对象是那个类的实例、对象的GC分代年龄等等信息。
执行
方法。 - 从虚拟机角度看,之前的4步已经完成了对象的创建。
- 但从程序角度看,对象创建才刚开始,对象各属性还都是零值,还未执行构造方法
。 - new指令之后执行
方法,开始进行对象的初始化。该方法由编译器命名,由虚拟机的invokespecial指令调用,不能通过程序编码实现
对象的内存布局
对象被创建好后,在堆中的布局分为三部分:对象头、实例数据和填充。
对象的访问定位
对象被创建后,是为了被使用。我们则是通过操作数栈中的reference(引用类型)来找到对象的位置。
JVM对于reference引用有2种主流的实现方式:
句柄访问
- 优点:reference中存放的是稳定句柄地址,在对象被移动(例如垃圾回收)时只改变句柄中实例数据指针,reference本身不用改变。
- 缺点:多了一道访问流程,故速度较慢。
直接指针访问
- 优点:速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,所以积少成多也是一项可观的执行成本。
- HotSpot主要是用指针,进行对象访问。
JVM执行引擎
执行引擎是Java虚拟机的核心组成部分之一
执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令。
执行引擎概述
解释器
- 当JVM启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
- 字节码解释器,早期的解释器,效率低下。
- 模板解释器,目前普遍使用
JIT(Just In Time即时编译器)
- JVM将字节码直接编译成和本地机器平台相关的机器码
热点探测技术
- 是否需要启动JIT编译器共存,则需要根据代码被调用执行的频率而定。那些需要被编译为本地代码的字节码,也被称之为“热点代码”。
- 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”。
- 目前HotSpot VM采用的是基于计数器的热点探测。
- HotSpot VM将会为每一个方法都建立2个不同类型的计数器:
- 方法调用计数器(Invocation Counter),用于统计方法的调用次数。
- 回边计数器(BackEdge Counter),用于统计循环体执行的循环次数。
HotSpotVM设置执行方式
缺省情况下HotSpotVM是采用解释器与即时编译器并存的架构。但也可以通过参数设置:
-Xint:完全采用解释器模式执行程序。
-Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
-Xmixed:采用解释器+即时编译器的混合模式共同执行程序。