JVM概述
简介
JVM全称Java Virtual Machine,也就是Java虚拟机,目前大多数的Java虚拟机都是HotSpot。JVM它就包含在JRE(Java Runtime Environment)中。
说到这就不免想起JDK(Java Development Kit),那JRE里有什么,JDK里除了JRE还有什么?
- JRE中的都是Java程序必须的组件,如JVM,Java核心类库等
- JDK中它不仅有Java程序的必要组件,也有一些诊断工具,如JConsole,Jstack等等
工作过程
那JVM是怎么进行工作的呢?
它把一个.class的字节码文件先加载到Java运行时数据区中,使用PC(Program Counter)的自加来自动执行程序,在它执行程序前会将每一个方法以栈帧的形式放入虚拟机栈里(无论正常结束或异常结束都会弹出当前的栈帧),把一些对象还有静态变量等放入堆区,然后执行。
编译方式
众所周知,.class的字节码形式计算机是不认识它的,计算机只认识二进制,因此这就需要将它编译成机器码然后执行,在HostSpot中存在有以下两种编译方式:
- 解释执行:也就是逐条边解释边执行
- 即时编译(Just-In-Time Compliation,JIT):也就是先将字节码编译为机器代码,然后执行
对比:
- 从启动效率来说:解释执行是优于编译执行的,编译执行需要进行编译,而解释无需等待编译
- 从执行效率来说:编译执行是优于解释执行的,因为编译执行只需要编译一次,而解释执行每次都要重新边解释边执行
- 从内存使用来说:解释执行不产生中间代码,相对于产生中间代码的编译执行是占优的
- 从优化方面来说:解释执行用于更多的动态信息,可以根据程序当前状态来调整优化,而编译执行在优化上就有很大的局限性
那编译执行和解释执行各有优缺点,HostSpot中是根据什么原则来进行协调的呢?
采用计算机领域中常用的2 8原则(比如在汇编指令集中RISC和CISC),8是一些不常用代码,2则是一些热点代码,在HotSpot中采用的是对热点代码使用JIT,对非热点代码使用解释执行。
HotSpot内置的编译器
那么在HotSpot中有几种内置的编译器呢?那它们之间又是怎么相互协同的呢?
在HostSpot中的编译器大致可以分为三种:
- C1,又叫做Client编译器,面向的是对启动性能有要求的客户端GUI程序,优化手段简单,因此编译时间较短
- C2,又叫做Server编译器,面向对峰值性能有要求的服务端程序,优化手段较复杂,因此编译时间长,但代码执行效率高
- Graal,是Java10引入的实验性的编译器
从JDK7开始的HotSpot默认采用的是分层:热点代码首先会让C1去编译,然后热点代码中的热点代码会交给C2进一步处理,并且HotSpot即时编译器线程是在应用正常运行的工作线程外,根据CPU数量设置编译线程数量,并按1:2的比例分给C1和C2.
内存模型和硬件内存架构和运行时数据区
Java内存模型(JavaMemoryModel,JMM)
JMM模型:它是一个抽象的模型,它在内存和用户线程间会有一个双向通道
- 主存:里面基本都是一些共享信息
- 工作内存:里面基本都是线程私有的,如私有信息,基本数据类型分配在工作内存,对象的引用地址放在工作内存,而真实的对象放在堆中(主存)
从JMM看多线程并发(并发编程中的三个重要特性:原子性,有序性,可见性)
- 原子性
- 可见性
- 有序性
名词解释
as if serial:核心思想就是无论怎么从排序都不影响程序执行结果,为了遵守as if seria语义,一般都会把相依赖的数据不会进行指令重排序
happends-before:核心思想就是前一个操作需要对后一个操作保持可见性
硬件内存架构
在硬件内存架构中主要是解决CPU和Memory之间的速度不匹配关系和并发处理,CPU和内存的关系如下图,可以看到使用缓存来环节CPU和内存的速度不匹配问题,但是这又会造成一个新的问题,那就是在并发处理中会出现缓存数据的不一致。
那怎么去解决并发导致的数据不一致性呢?
- 可以采用总线加锁,但是这样又会导致CPU的吞吐量急剧下降
- 采用MESI缓存一致性协议,主要使用的是Cache Line进行数据通报(我理解的是0/1信号),如下图所示
可以看到它有四个状态MESI
- Modify:因修改数据而不一致了
- Exclusive:独享的,只能被缓存在CPU
- Shared:共享的
- Invalid:禁止使用的
它就可以对应到如下四种操作:
- local read:读本地缓存
- local write:写本地缓存
- remote read:读内存
- remote write:写内存
状态之间的转换
M:
当数据被写入内存的时候,为了让其他核共享数据,状态转变为S(remote read)
当写入到内存的时候,如果其他核修改了数据,就变为I(作废)(remote write)
3.要是在缓存中读取(local read)或修改(local write)不变化
S:
如果localread那就不变
如果修改数据(loacl write)的话,那就转为M(修改),在其他核里变为I(作废)。
如果是存到本地页不会改变状态
如果是在本地修改数据(loacal write),那所有关于此数据的Cache Line不能使用了,状态就变为I(作废)。
E:
从CacheLine中读取数据就不改变
别人需要读取,并写入内存并变为S(remote read)。
如果修改(local write)的话变为M
在内存中被修改的话(reomte write)就变为I
I:
1.(laocal read) 如果其他核中没有这个数据的话就从内存中取,并且状态变为E,如果在其他Cache中有这个数据的话(也就是这个数据的状态为S或E),那就从内存中读取,并且将这些缓存行的数据变为S。如果这个数据的状态是M,那就把它写到内存再进行读取,这两个缓存行的状态就变为S。
从内存中取数据并且在Cache中修改,状态变为M。如果其他Cache中有这份数据并且状态为M,那就要将数据更新到内存中。如果其他数据有这份数据并且状态不为M,那就将这些转换为I。
既然是作废的状态那就remote操作就不能执行了。
核心思想就是某个线程会在修改的时候将S状态转为M,并且将所有与S相关的都改为I,然后修改完成后remote write写入内存,通过cache Line保证了数据的一致性。
运行时数据区
但是在JDK1.8后移除掉了方法区,将原本的方法区一分为二,类的原信息数据分到了元数据去,另一部分分到了堆中,如一些静态成员,变量等等
栈帧的结构如下
三者的联系
硬件结构和JMM的联系,如下图所示,它们的关系是相互交叉的,工作内存和内存都可以存在于硬件结构中的CPU(寄存器),Cache和内存中。在此就更能体会到JMM实际上是一个抽象的模型。
JMM和J运行时数据区的联系
JMM模型中对应了内存结构中的线程私有(PC,虚拟机栈,本地方法栈)和线程间共享(堆,方法区),在我看来运行时数据区就是对JMM的进一步实现。
类加载
类加载简单的来说就是讲.class交给JVM去处理,因为一个类中会存在一些静态变量还有外部库函数等等,因此它需要一些流程来讲.class类一步步的加载入内存中。
类加载大致可以分为三个阶段:
- 加载:主要就是借助ClassLoader查找字节流并创建一个类(数组类没有对应的字节流)
- 链接:主要是做类的合成工作
-
- 验证:主要验证类是正确性,确保类能满足JVM的约束条件
-
- 准备:主要是给静态变量分配内存空间,并赋默认值
-
- 解析:主要是将符号引用转换为实际引用,如果符号引用指向一个未加载的一个类或方法,那就会触发这个类的加载,但是未必会触发类的链接和初始化
- 初始化:主要是对静态变量赋初始值。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次
初始化触发的条件:简单来说就是首次主动使用到它(除此之外就是被动使用),如下
- 当虚拟机启动时,初始化用户指定的主类
- 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类
- 当遇到调用静态方法的指令时,初始化该静态方法所在的类
- 当遇到访问静态字段的指令时,初始化该静态字段所在的类
- 子类的初始化会触发父类的初始化
- 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发
该接口的初始化 - 使用反射 API 对某个类进行反射调用时,初始化这个类
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类
类加载器的分类
启动类加载器:用来加载最基本的也是最重要的类,主要加载JRE的lib下jar的类(C++写的,parent为null)
扩展类加载器:用来加载非核心,但也常用的类,主要加载JRE的lib下的ext目录下的类
应用类加载器:也叫上下文加载器,用于加载classpath下的类,一般应用程序中的类就由此类加载
自定义类加载器:自己定义加载类的方式
类加载器还拥有类似于命名空间的作用,使用不同类加载器加载的类会被看做是不同的类
双亲委派模型
委派机制的必要性是为了解决类混乱问题,采用了分层模式,从上到下都有明确的负责区域,以防止自定义或外部的类和内部类产生混淆,也防止对内部的类的篡改。
是不是所有类的加载都采用的是双亲委派?如果不是,那为什么要对双亲委派进行破坏呢?
并不是这样的,比如Driver接口和DriverManager都是在启动类加载器上,但是这些当DriverManager想要去管理那些Driver的实现的时候,启动类加载器并不能加载的到(因为启动类加载器的范围局限,在环境路径(JAVA_HOME)下的lib),这些类只能由应用类加载器来加载,从而破坏了双亲委派,但我认为这并不完全是破坏,也可以看做是扩展。
SPI(Service Provided Interface):由JDK提供接口,不同的厂商提供服务。上述情况就是一个SPI的典型例子
JVM中的方法识别与调用
在JVM中是通过什么来进行方法识别的呢?它通过类名,方法名,和方法描述来进行方法识别,这就为什么不能在同一个类中出现相同的方法名并且方法描述也相同的方法。
我们常常说的重载就是类名,方法名相同的但是方法描述不同的一些方法,如果描述也相同就会报错。
不止是重载,方法的重写也是根据方法描述来判断的(就是子类的描述和父类的非私有,非静态的方法同名且描述相同,这样才能被判断为方法的重写)
因为在编译器已经完成了重载方法的区分,所以将它换为JVM上的概念就可以对应为静态绑定
- 重载就对应的是静态绑定,但是这样并不是完全正确的,因为一个类的子类也有可能对它的重载方法进行了重写
- 重写就对应的是动态绑定
一个类方法查找和调用的流程(符号引用转换为实际引用的过程)
一个接口方法查找和调用过程
然后找到直接引用后,根据方法表(符号表)内容指示去执行!
方法的调用会通过一些列的字节码指令来完成如:
- invokestatic:用于调用静态方法
- invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法
或构造器,和所实现接口的默认方法 - invokevirtual:用于调用非私有实例方法
- invokeinterface:用于调用接口方法
- invokedynamic:用于调用动态方法
类文件结构
这部分主要就是对类的结构进行翻译,可以根据一串串的0 1代码分析出这个类的所有信息。可以照着书上的解释把0 1 代码转换为一些指令,并根据指令的语义翻译出相应的代码。
比如OxCAFEBABE就是是魔数(magic)占用头四个字节,唯一作用就是为了让JVM能识别这是一个可以接受的class文件。
多余的就不细说了,在深入理解JVM里每一条都说的很详细!
垃圾回收机制
堆内存结构
垃圾回收
垃圾回收的主要区域就是在堆里,堆里面有大量的对象,有些对象很快就消亡了,有些对象会一直存活着,那么要思考的问题是怎么去判断对象的死活?怎么去进行对象的迭代?怎么去对合适的地方使用合适的垃圾回收算法?
判断对象的死活
通常用于判断对象的死活有下面两种方法
- 引用计数法:效率高,但是不能解决循环引用问题,并且需要很大空间存储对象计数情况
- GC Root根可达性分析法:以GCRoots为起点,走过的路就是引用链。可能性能稍差与引用计数,但是它较稳定
在Java中采用的是GCRoot根可达性分析法,在Redis中就使用的是引用计数法(追求高性能)。
可以作为GC Root的一些对象:在我看来能作为GC Root的对象对象的可以分为静态属性和正在运行期间的被引用到了的对象 - 虚拟机栈中引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(Native方法)的引用的对象
虽然GCRoot可达性分析看起来简明,但是它在多线程并发的情况下有可能出现误报(已经访问过的对象,将引用设置为null)或者是漏报(已经访问过的对象,将引用设为从未访问过,因为在多线程状态下频繁的状态更新),GC可能当前在回收的其实是一个存活的对象,一旦这个对象被回收,JVM再次访问这个已回收的对象就可能会导致JVM的崩溃。
那怎么去预防这件事情的发生了?
在JVM中采用了Stop-the-world机制,停止所有非垃圾回收的工作,直到垃圾回收结束。它的原理是采用的安全点,JVM收到stop-the-world,只有所有线程到达安全点,才允许stop-the-world独占工作
找到安全点的目的就是让线程的状态稳定下来!一般安全点都在方法调时,方法返回时,基本都是一些临界区域
为什么不采取很多的安全点?
因为安全点太多开销太大,而且它存的东西也比较多占内存空间,因此,一般安全点的设立不会太多。
对象怎么去进行迭代
上一步可以根据GC Root可达性分析法判断对象的死活,下来就要想怎么去进行垃圾对象清理。在JVM中对一个新生对象的迭代过程如下:
由于老年代对象都是历经考验存活下来的,因此一般采用标记整理/清除,而新生代存活下的对象少,使用复制算法效率高!
TALB(Thread Local Allocation Buffer)
但是,如果是并发条件下,多个线程new对象,这有可能会导致内存冲突,多个线程进行使用一块区域?那怎么去解决这个问题呢?
这就要说到TALB(ThreadLocalAllocationBuffer,本地线程缓冲区分配),给那些线程预先分配一段很长的区域(因此在这个划分的时候是需要进行同步的),然后每个线程在自己的区域内通过指针加法(bump the pointer)移动指针给新对象分配空间,如果空间不够就继续申请,这时如果空间不够会产生MirrorGC
卡表(Card Table)
在新生代采用复制算法很高效,因为存活下的对象很少,但是这样也会出现一些问题,比如老年代对象引用了新生代的对象,那在GC时需要判断对象存活是否,那岂不是还有做一次老年代的全表扫描?
答案是不用的,在HostSpot中给出的一项方案是卡表(Card Table),该技术将堆划分为一个个512M的空间,如果此空间有对象引用新生代(设立一个标志位),那么就认为这个这个卡是脏的,因此在Mirror的时候并不需要去全表扫描,只需要对脏卡进行扫描,大大提高了效率。
垃圾回收器
选择垃圾回收器也是比较重要的,针对不同场景选择不同的垃圾回收器。
- 按串并行分:有串行的有并行的
- 按算法分:有复制的,有标记清除的,有标记整理的
- 按清扫范围:新生代收集器,老年代收集器
新生代的垃圾回收器有:Serial Parallel Scavenge ParallelNew 这三个都是采用的标记复制算法
老年代的垃圾回收器有:Serial Old Parallel Old CMS(除少数操作需要Stop-the-world,JDK9被弃用)
通用收集器:G1,分区,如果某个区的死亡对象比较多,会优先回收