JVM
The Java Virtual Machine (JVM) is a crucial component of the Java programming language. It is an abstract computing machine that enables a computer to run Java programs. The JVM is platform-independent, meaning it can run on any device or operating system that has a compatible JVM implementation
总的来说,分为五部分:程序计数器,虚拟机栈,本地方法栈,方法区,堆
程序计数器
什么是程序计数器
程序计数器:是线程私有的,记录当前虚拟机正在执行的线程指令地址。首先实现了代码的基本控制流程,比如顺序、选择、循环等,其次是因为记录了当前线程指令地址,在当前线程下次被切换回来时,可以知道它上次执行到的位置
虚拟机栈
虚拟机栈是什么
- 虚拟机栈:由很多栈帧组成(一个方法有一个栈帧),每个栈帧包含:局部变量表、操作数栈、动态链接、返回地址等信息
- 局部变量表:存储当前方法参数和当前方法内部 的局部变量。局部变量表的最小容量用变量槽来表示,其中64位长度的long和double会占用两个变量槽。如果执行的是实例方法,那么当前第0个变量槽存放的是用于传递方法所属对象实例的引用,在方法中可以用“this”来访问。此外,还通过变量槽复用达到减少内存空间的使用,比如当前PC计数器超出的这个变量的作用域,那么这个变量的变量槽就可以被复用
- 操作数栈:为当前方法运行提供了一个临时计算过程结果的存储
- 动态链接:在运行期间部分符号引用转变为直接引用,为了支持java多态
- 返回地址:
- 遇到方法返回的字节码指令
- 出现了异常,有异常处理则交给异常处理器,否则抛异常
本地方法栈
本地方法栈是什么
先介绍虚拟机栈,然后:类似虚拟机栈,本地方法栈为java虚拟机提供native方法的服务。native方法一般是由C/C++编写
方法区
方法区: 首先它是虚拟机规范的抽象概念。 他是所有线程共享的内存区域,在hotspot jkd1.8之前,方法区的实现是永久代。而在jdk 1.8,采用的是元空间
对于永久代和元空间主要有以下两个区别:
永久代位于java虚拟机内,元空间位于本地内存。因此永久代受限于JVM可用内存,但是元空间使用的是直接内存,受本地内存的限制,因此内存溢出的可能性更小
永久代本身是面向堆来设计的,所以存储在永久代的对象不是内存连续的,所以需要额外的存储信息和额外的对象查找机制来定位对象,所以比较麻烦。(最开始使用永久代是为了进行一定程度的代码复用)
那么方法区到底存储了什么东西?
在类加载的第一个阶段,会将类的类型信息加载进入方法区(包括类签名、属性、方法)
运行时常量池:常量池,存储了符号引用和部分直接引用,字面量等信息,运行时常量池主要负责动态解析符号引用,将符号引用转换为直接引用,以及字节码生成的字面量。以及还有一些字符串常量池,在jdk1.8之前字符串常量池在永久代中,1.8时在堆中。因此对于某些方法,比如intern方法,如果目标字符串在字符串常量池中存在,就返回其引用,否则创建并返回其在字符串常量池中的引用,那么在1.8时,由于字符串常量池在堆中,即使字符串常量池中没有对应的字符串对象,也只会创建一个指向堆中的该字符串对象的引用
既然提到了类加载机制,不妨谈谈类加载机制?
类加载机制是将javac编译产生的.class对象中的二进制数据读入到方法区中的常量池,然后在堆区创建一个此类的class对象,通过这个class对象可以访问到方法区中的类信息
分为加载、链接(验证、准备、解析)、初始化这几个步骤
- 加载:
- 通过类的全限定名(包名 + 类型名)获取此类的二进制流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个该类的Class对象,作为方法区类信息的访问入口,方法区中的是klacc
- 验证:
确保class文件的字节流所包含的class类信息符合虚拟机规范- 文件格式验证:字节流是否符合标准
- 元数据验证:验证数据是否合理,比如所有类应该有父类
- 字节码验证:验证字节码是否会危害虚拟机,因此java具有安全性
- 符号引用验证(发生在解析阶段,因此链接不仅仅是准备阶段前):检查常量池中引用的外部类是否存在,可以正常访问
- 准备:为类变量分配内存并设置类变量初始值的阶段(局部变量不存在准备阶段,不能被赋值就不能被使用)。如果是final修饰,意味者在Class文件中,该字段的属性表中存在Constantvalue属性,此时初值设置为代码里写的。如果不是,就设置成零值,等到初始化阶段再赋值
- 解析:虚拟机将常量池的符号引用转化为直接引用
- 初始化:开始执行类中的java代码,调用类构造器
java中的klass和class
- jvm加载的字节码,也就是.class文件,被加载到方法区里面,叫Kclass,是一个C++对象,含有类的信息、虚方法表等
- JVM在加载了字节码后,在堆里创建的Class对象,这个对象和方法区的Kclass相互指向,也就是说我们可以通过Kclass找到这个对象
- 我们new 一个对象,对象头里面会有一个指针,指向方法区的Kclass
- new Object().getClass()流程, 对象头里面的指针–>方法区kClass–>堆Class对象
- 反射也是拿到堆里的Class对象
- 所有我们比较两个对象类型是否相等,实际上就是比较两个Class对象是否一样
- JVM可以通过Class中的Kclass指针找到真正的Klass,然后再用这个Klass创建一个真正的对象
类加载器有哪些?
首先类加载器是属于JVM规范,是抽象概念
在规范中类加载器分为Bootstrap ClassLoader和Other,也就是启动类加载器和非启动类加载器
在hotspot实现中,bootstrap classloader是C/C++实现(加载
类加载模型主要是什么?
在默认情况下,一个限定名的类只会被一个类加载器加载,这样的话在程序中它就是唯一的,因此需要双亲委派模型
一个类加载器收到一个类的加载请求时,会先给他的父亲类去请求,这样最终一直请求到bootstrap classloader,然后再从上到下,如果父亲类加载器不能被加载,则委派给儿子类,如果没有可以被加载的,则会报ClassNotFoundException错误。越核心的类库会越被上层的类加载器加载,而某限定名的类一旦被加载过了,此后就不会被加载,这样就能有效避免类加载混乱
但是双亲委派模型由于存在设计缺陷,因此也有可能被打破:
因为java类加载器在加载第一个类的时候,这个类所引用的其他类也是由于这个类加载器去加载。这样的话,比如jdbc是没办法实现的,因此需要打破双亲委派模型
- 打破的情况:
- 自定义类重写java.lang.loadClass方法,以实现自己的类加载逻辑
- OSGi
- SPI
- 哪些框架破坏了双亲委派模型?
- Tomcat
- Springboot
- OSGi
堆
堆的基本结构
堆存放对象实例,可以分为新生代和老年代。新生代又分为伊甸园、from区、to区
也是虚拟机中管理内存的最大一块,被线程共享,物理上不连续(因此速度相比慢,由于cache命中率低),逻辑上连续
堆也可以通过参数-Xms -Xmx设置堆的最小容量和最大容量
同时也是GC的主要地方
详细解释一下GC
为什么要GC?
GC就是垃圾回收,java提供的GC可以自动监测对象是否超过作用域从而达到自动回收内存的目的,防止内存占用过多导致内存溢出
可以作为GC roots的对象有哪些?
- 虚拟机栈中引用的对象,例如方法堆栈中的参数,局部变量,临时变量等
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法引用的对象
- Java虚拟机内部的引用对象,比如基本数据类型对应的Class对象、线程等
什么情况下会被回收?
当对象不存活的时候会被回收,判断对象存活与否有两种:
- 第一点是引用计数法,给对象添加一个引用计数器。当一个对象被引用,计数器就加1,当一个对象被取消引用,就-1。计数器为0的对象是会被回收的。但是有弊端:循环引用造成对象不可能会被回收
- 第二点是可达性分析,以GC roots为起点,从这些节点向下搜索,通过引用链看能不能找到
如何可达性分析?
不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响
分为强引用、软引用、弱引用、虚引用
- 强引用:只要还有强引用指向该对象,就说明还存活
- 软引用:只有当JVM内存不足时,才会回收软引用的对象
- 弱引用:不管内存够不够,都会回收只有弱引用的对象
- 虚引用:等于没有被引用,在任何时候都可能被回收,目的是为了在对象被垃圾回收时起到一个通知作用
GC根据作用域划分:
- Minor GC:只是回收新生代
- Major GC: 只是回收老年代
- Full GC: 整堆回收
- Mixed GC: 收集整个新生代和部分老年代的垃圾(目前只有G1 GC会有这种行为)
堆分配策略
首先大部分情况分配在伊甸园区,如果伊甸园内存不足,触发Minor GC,当然大对象直接进入老年代。接着长期存活的对象进入老年代(在新生代存活时间超过一定阈值的对象,每经过一次Minor GC 都会增长一次年龄)
此外,还有一些机制保证GC:
动态年龄判定以及空间分配担保
- 动态年龄判定:当Survivor区中相同年龄对象的总和大于Survivor空间的一半时,则年龄大于或等于该年龄的对象可以直接进入老年区,不用直接达到阈值
- 空间分配担保:在发生Minor GC之前,虚拟机要看老年代最大可用的连续空间是否大于新生代所有的对象空间,如果是,则Minor GC是安全的,否则虚拟机会看老年代最大的连续空间是否大于历次晋升到老年代的平均大小,如果大则担保成功,触发Minor GC。否则,触发Full GC
四大回收算法
- 标记清除算法:将需要回收的对象标记,然后清除。缺点是会产生内存碎片
- 复制算法:为了解决内存碎片问题。将内存分为相同大小的两块,每次只使用其中的一块,当其中一块用完了则复制到另一块,并且这一块全部回收。缺点是内存占用大
- 标记整理算法:和标记清除算法一样,但是不同的是后续不是直接清理,而是让所以存活的对象移动到同一端
- 分代收集算法:严格来说不是一套理论,而是综合以上三种算法根据不同情况选择不同的回收算法。一般来说,新生代使用复制算法,老年代使用标记清除算法或者标记整理算法
为什么需要Survivor 区?
如何没有Survivor区,那么会直接进入Old区,这样会导致Old区很快被填满,但是由于虽然一次Minor GC没有回收,但也不会存活几次。因此放在Survivor区可以减少Major GC的发生。当存活年龄达到16次时再进入Old区
Survivor 区为什么需要两个?
因为一个的话由于标记清除会导致内存碎片问题,但是两个区域的话,将Eden区和From区复制到To区,第二次GC时再交换From和To的职责,将Edge和To区复制到From区。这样永远有一个Survivor区是空的,另一个非空的是无碎片的。为什么不继续分多一点?因为再细分下去,每一块的Survivor的空间会更小,两块综合考虑更好
那么有哪些垃圾回收器?
主要可以分为四类垃圾收集器:
串行垃圾收集器:
- serial gc:单线程垃圾收集器,使用时必须先stop the world(进入一个安全点,其他用户线程均阻塞),然后直到他收集结束。采取标记复制算法。通常在单核cpu或较小的应用中去使用
- serial old gc:serial gc的老年代版本,单线程垃圾回收,采取标记整理算法
吞吐量优先的垃圾回收器:
- Parallel gc:并行垃圾回收器.工作在新生代,采用标记复制算法,多线程垃圾回收算法,线程数量与CPU核数相关。以吞吐量优先意味着总的时间花费少
- Parallel old gc:并行垃圾回收器.区别在于工作在老年代
响应时间优先
CMS GC:并发垃圾收集器,工作在老年代,如果并发失败,会退化成serial old。采用的是并发标记清除算法
当触发垃圾回收机制时,有以下4个阶段:
- 初始标记:短暂地stop the world,标记直接与gc roots能直接关联的对象
- 并发标记:从gc roots开始进行可达性分析,标记存活对象,用户线程不需要暂停。由三色标记算法保证
- 重新标记:由于并发标记阶段可能引用发生了变化,发生错标、漏标,需要stop the world来重新标记
- 并发清理:清理未标记对象,用户线程不需要暂停
整个过程消耗时间最长的是并发标记和并发清除阶段,这两个阶段垃圾收集线程可以和用户线程一起工作。
优点是并发收集,停顿时间短
缺点是标记清除算法,会导致大量内存碎片以及会产生浮动垃圾,因为并发清理阶段还有用户线程在运行,就会产生新的垃圾
ParNew GC:与之配合,工作在新生代,基于复制算法的垃圾回收器
- 同时注重吞吐量和低延迟的G1垃圾收集器,JDK9默认
G1将堆分为相同大小的分区,每个分区大小是2的幂次方。有4种不同类型的分区,eden,survivor,old,humongous
整体上是标记整理算法,两个区域之间采取的是标记复制算法
整体分为以下几个步骤:
- 初始标记。stop the world,记录直接与gc roots相连的对象
- 并发标记。从gc roots开始进行可达性分析,找出要回收的对象,耗时较长,不过可以与用户程序同时执行。
- 最终标记。需要stop the world,用于处理并发标记阶段对象出现引用变化的区域
- 筛选回收。stop the world,对整个分区的回收价值和成本进行排序,然后根据用户所期望的停顿时间来制作回收计划。把回收的分区的存活对象复制到空的分区中,再清理掉整个旧的分区的全部空间。
三色标记算法?
整个标记过程:
将标记对象分为三种颜色:
- 白色——该对象没有被标记过(垃圾)
- 灰色——对象已经被标记过,但他的属性没有被标记完
- 黑色——对象已经被标记过,他的属性也标记完了
初始时,所有对象都在白色集合里面,然后从gc roots开始进行可达性分析,将gc roots直接引用的对象移动到灰色集合,然后再从灰色集合中根据属性不断再取出新的标记对象,放到灰色集合里面,然后将本对象放到黑色集合里面。如此反复直到没有灰色对象
优点:用于垃圾回收,将stw升级为并发标记。然后避免重复标记,提高了效率
存在的问题:
由于并发标记阶段对象引用发生变化,所以会出现多标和错标的情况(这里的标是标记为黑色或者灰色的意思)
- 浮动垃圾(多标):将原本应该被清除的对象,误标记为存活对象。后果是垃圾回收不彻底,不过影响不大,可以在下个周期被回收
- 对象消失(漏标):将原本应该存活的对象,误标记为需要清理的对象。后果很严重,影响程序运行,是不可容忍的
漏标必须要同时满足以下两个条件:
- 赋值器插入了一条或者多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
因此漏标问题一定要解决,对于不同的垃圾回收器处理策略也不一样
增量更新:增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,就将这个新加入的引用记录下来,待并发标记完成后,重新对这种新增的引用记录进行扫描
原始快照 stab:原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,也是将这个记录下来,并发标记完成后,对该记录进行重新扫描
HotSpot 虚拟机中,不管是新增还是删除,这种记录的操作都是通过写屏障实现的。我们可以将写屏障理解为 JVM 对引用修改操作的一层 AOP,注意它与内存屏障是两个不同的东西
cms:写屏障+增量更新
g1:写屏障+原始快照 stab
zgc:读屏障
为什么G1用SATB?CMS用增量更新?
- 增量更新:黑色对象新增一条指向白色对象的引用,那么要进行深入扫描白色对象及它的引用对象。
- 原始快照:灰色对象删除了一条指向白色对象的引用,实际上就产生了浮动垃圾,好处是不需要像 CMS 那样 remark,再走一遍 root trace 这种相当耗时的流程
- SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象。而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描
什么是跨代引用?
java中不同代存在引用,比如新生代引用老年代
问题:比如在minor gc时,存在老年代指向新生代的引用,但是由于是minor gc,将会产生漏标问题
解决:minor gc时,将整个老年代的对象也加入扫描范围,但是这样做效率太低,因此引入记忆集的数据结构
记忆集是在新生代开辟一个空间来存储一个集合,用来存放老年代对新生代的引用,因此在minor gc时不需要扫描整个老年代,只需要扫描新生代+记忆集。在hotspot中采用一种卡表的方式实现。卡表是使用一个字节数组实现,每个元素对应着其标识的卡页
full gc触发条件?
- System.gc() 主动
- 老年代空间不足
- 空间分配担保失败
- jdk1.7及之前的永久代空间不足