JVM

265 阅读49分钟

JVM内存组成

image.png

1. 方法区

image.png 详解JVM常量池、Class常量池、运行时常量池、字符串常量池(心血总结)_祈祷ovo的博客
各个线程共享的内存区域,用于存储已经被虚拟机加载的类型信息、常量、静态变量。

1.1 运行时常量池

Class文件结构
首先简单了解Class文件结构 image.png

其中的常量池顺序存储了常量、字面量和其他部分(字段表、方法表等)需要的符号引用(类名、方法名等)

java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态链接,将符号引用“翻译”为对应的内存地址

方法区的Class文件信息,Class常量池和运行时常量池的三者关系

在这里插入图片描述

Class常量池中的内容在加载Class文件时会将可确定的符号引用转为直接引用,将常量池内信息存放到运行时常量池中,每个Class文件都会有一个对应的运行时常量池。

1.2 字符串常量池

JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间,字符串常量池,字符串常量池由String类私有的维护

1.3 hotspot虚拟机方法区演变

  • jdk7以前

image.png

  1. 方法区物理上存放在堆中的永久代
  2. 字符串常量池存放在方法区中,存放各种”字符串“,i="sd"会返回字符串常量池中sd的地址
  • jdk7

image.png

  1. jdk7将字符串池和静态变量存放在堆中
  2. 字符串常量池存放 “字符串对象的引用”
  • jdk8

image.png

由于方法区占用堆内存,PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM,将方法区从堆中移除,使用本地内存创建元空间实现方法区。

2. 程序计数器

当前线程所执行的行号指示器。通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。
Java虚拟机多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换能恢复到正确的位置,每条线程都需要一个独立的程序计数器,所以它是线程私有的。
唯一一块Java虚拟机没有规定任何OutofMemoryError的区块

3. 虚拟机栈

线程私有,描述的是Java方法执行的线程内存模型。每个方法被执行的时候,JVM会通过创建一个栈帧,存放局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈桢在虚拟机栈中从入栈到出栈的过程

3.1 运行时栈帧结构

image.png
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。 一个线程中方法的调用链可能会很长,很多方法都同时处于执行状态。对于JVM执行引擎来说,在在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法, 定义这个方法的类叫做当前类

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了

调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。
1)局部变量表

局部变量表(Local Variable Table) 是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,局部变量表为每一个变量分配一个槽,装载常量值或对象引用,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。

在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。(最大Slot数量)

虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32位的,则遇到一个64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

2)操作数栈

操作数栈(Operand Stack) 也常称为操作栈,它是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候确定,写入到方法的Code属性的max_stacks数据项中。

操作数栈的每一个元素可以是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度都不会超过max_stacks中设置的最大值。

当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

3)动态连接

在一个class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。

Class文件常量池中符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接(Dynamic Linking)

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)

4)方法返回

当一个方法开始执行时,可能有两种方式退出该方法:

  • 正常完成出口
  • 异常完成出口

正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。

异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。

无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。

无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。

方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压如调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。

一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

5)附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧之中,例如和调试相关的信息,这部分信息完全取决于不同的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其他附加信息一起归为一类,称为栈帧信息。

4. 本地方法栈

与虚拟机栈发挥的作用相似,区别是虚拟机栈为虚拟机执行Java方法服务,本地方法栈则是为虚拟机调用的本地方法服务。

5. 堆

所有线程共享的区域,虚拟机启动时创建,用于存放对象实例。

垃圾回收机制

深入理解 JVM 垃圾回收机制及其实现原理_CG国斌的博客

1. 搜索垃圾

1.1 堆中搜索垃圾

1)引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用他时,计数器就加一;引用失效时,计数器减一;任何时候技术去为零的对象是不可能被引用的。
缺点:难以检测循环引用等例外情况,需要大量额外处理工作才能保证正确的工作。
2)可达性分析算法
基本思路是通过一系列称为GC Roots的根对象作为起始节点,从这些节点开始根据引用关系向下搜索,搜索过程称为"引用链",如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达,则证明该对象是不可能再被使用的

image.png

1.2 方法区中搜索垃圾

1)回收常量
可使用上述两个算法进行搜寻
2) 回收类
需满足三个条件

  • 该类的所有实例都已经被回收,也就是Java堆中不存在该类及任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的Java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

2. 回收算法

分代收集理论
1)弱分带假说:绝大多数对象都是朝生夕灭的。
2)强分带假说:熬过越多次垃圾收集过程的对象就越难以消亡。

这两个假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域中存储。目前主要分为新生代和老年代。

3) 跨代引用假说: 跨代引用相对于同代引用来说仅占极少数。

依据这条假说,我们就不必为了少量的跨代引用去扫描整个老年代,只需要把老年代划分出一个记录存在跨代引用的小块,每次Minor GC时只要把这块中的对象加入到GC Roots中进行扫描

1)标记-清除算法
首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的可达性分析法中判定垃圾对象的标记过程。

image.png 优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
缺点:标记和清除过程的效率都不高,这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小,对空闲列表的管理会增加分配对象时的工作量;标记清除后会产生大量不连续的内存碎片,虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败,不得不触发另一次垃圾收集动作。

2)标记-复制算法
将内存分块,每次只使用其中一块,当一块用完了,就将还活着的对象复制到另一块上,然后再把已经使用过的内存空间一次性清除。

image.png 现在的商用虚拟机大多优先采用了这种收集算法去回收新生代
Apple式回收的具体做法是把新生代分为一块较大的Eden空间两块较小的Survivor空间(8:1:1)每次分配内存只使用Eden和其中一块Survivor。垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor上,然后清掉Eden和已经用过的那块Survivor空间。
当Survivor空间不足以容纳一次Minor GC 之后存活的对象,会尝试把对象分配到老年代。

3)标记-整理算法
标记过程仍然与”标记-清除“算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都像内存空间一端移动,然后直接清理掉边界以外的边界。

image.png

HotSpot算法细节实现

OopMap

由于目前几乎所有虚拟机都是用可达性分析算法来判定对象是否存活,即通过选定固定的gc roots作为起始节点,像剥洋葱一样往下溜达,只要存在任意节点从gc roots到该节点不可达,那表示这个对象不被任何对象所引用,这个对象最终就要被当做垃圾回收掉。

问题来了,如何找到这些gc roots呢?
从源代码上看,对象引用不是在类中,就是在方法中,如此,通过扫描所有的对象就可以获取到这些gc roots。但是
1) 目前随便一个Java应用相当庞大(低情商叫臃肿),内存中的类,对象,常量数不胜数,每次gc都去扫描一遍,这个性能损耗是不可能接受的
2) 其二为了保证内存的一致性,获取这些gc roots过程中,必须暂停用户线程。用户线程在这个阶段内不能工作

所以能做的就是尽量要缩短用户线程的停顿时间,也就是要尽快完成gc roots的扫描。惯用套路,既然后期处理遍历耗时,那就前期维护一套数据结构,所谓的空间换时间。而OopMap就是这套数据结构,在特定代码位置(安全点)通过OopMap提前记录类、方法的引用信息,查找gc roots时,直接通过OopMap去获取,而不必扫描整个对象。

安全点

虽然OopMap避免了大量扫描内存的消耗,但是内存中对象繁多,对象之间的引用关系也时刻在发生变化,如果每条指令都去记录OopMap,将会消耗大量内存和cpu资源,垃圾回收反而变成了系统的负担,为了解决这个问题,引入了安全点(safe point)的概念。即只在指令流的特定位置记录OopMap,垃圾回收行为发生后,线程如果没有到达安全点,将继续执行,直到到达最近的一个安全点才停下来,等待垃圾回收器完成gc roots的选取。

就像公交车一样,每个乘客到达的地点是不同的,但公交车不会为每一个人去停车,必须等到提前设定的站台才会停下,这个时候乘客才可以下车。

当线程到达安全点后,有两种方式中断线程:
1)抢占(被动)式中断
在发生垃圾回收时,系统将所有线程中断,如果发现有线程还没有到达安全点,则恢复该线程的执行,等待一会儿继续中断,直到到达安全点上,目前几乎没有虚拟机采用这种方案去停止用户线程
2)主动式中断
在特定位置设置一个中断标志位,所有线程在执行过程中不断去轮询这个标志位,如果发现该标志位被置位,就在距离自己最近的安全点主动挂起自己,等待垃圾回收器工作

轮询标志的地方和安全点是重合的,还需要加上所有创建对象和其他需要在对上分配空间的地方,这是为了检查是否要发生gc,避免没有足够的内存分配对象

安全点既不能太多,也不能太少,如果安全点过多,会对虚拟机资源产生更多的挤压,如果安全点太少,则会导致垃圾回收器等待时间过长,因此,需要在这两者之间取其平衡。
适合插入安全点的地方:

  • 方法(栈帧)结束前,但并不意味着一个方法只能有一个安全点
  • 非计数循环末尾,避免循环体执行时间太长,导致长时间无法到达安全点
  • 每条Java编译后的字节码边界

安全区

安全区域可以理解是对安全点的存在问题的补充,上边说到线程会执行到附近的安全点停下来等待垃圾回收器介入处理,但如果线程没有执行呢,换句话就是说没有获得cpu执行权,比如某一个线程正在sleep或者等待磁盘输入,那么这个线程是不会走到安全点挂起自己的。

这个时候就要引入安全区概念了,顾名思义,安全区就是一段指令域,在这个域中的指令不会对当前内存中的引用造成修改,当线程进入该区域后,会主动将自己的状态标记为“进入安全区”,这个时候如果发生gc,垃圾回收器发现该线程处于安全区域内,认为该线程不会对内存安全造成影响,便会跳过该线程,不会等待该线程到达安全点。

而线程在到达安全区边界时,同样也会检查当前gc是否在工作,如果gc正在工作,这个时候线程便会主动停下来,等待gc动作完成后再继续执行。

卡表

为了解决在垃圾回收算法中,无论哪种算法,都需要先对对象进行标记,然后再进行回收操作。标记过程中,存在跨代引用问题,为了完整的标记对象引用链,将不得不对跨代内存中的对象进行遍历,尤其是老年代对象,对象存活率相对高,遍历的性价比极低。

于是就引入了记忆集的概念,即将老年代内存划分为若干个小块,同时在新生代特定位置维护一块数据区域用来标记老年代中的哪个小块内存中存在跨代引用,当发生gc时,只需要检查这块数据区域中哪些老年代内存块中存在跨代引用,然后再对这一小块内存进行遍历。

三色标记法

可达性算法一般用三色标记法,把遍历对象图过程中遇到的对象,按照是否访问过这个条件标记为以下三种颜色

  • 白色:表示对象尚未被垃圾收集器访问过。若在分析结束的阶段,仍然是白色的对象,即代表不可达
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象持有的所有引用都已经扫描过。
  • 灰色:表示对象已经被垃圾回收器访问过,但这个对象持有的所有引用中至少存在一个引用还没有被扫描过。

查找引用像波浪一样从根节点向所引用的对象传播。

因为查找引用链的过程与用户线程是并发执行的,所以可能有两个问题:
1)浮动垃圾
某个可达对象在查找引用时已经被访问过,但在垃圾回收前被抛弃(不可达),这个垃圾不会被回收
2)错误回收(严重)
某个在垃圾回收开始时的可达对象,查找引用传到到他前被解除引用标记为白色,查找结束前又被某个对象引用,但这时查找引用的已经过去了,没能被标记到,导致垃圾回收时错误删除

要解决错误回收有两种方案
1)增量更新
黑色对象一旦新插入指向白色对象的引用,会变成灰色对象,重新扫描一次
2)原始快照
无论关系删除与否,按照刚刚扫描那一刻的对象图快照进行搜索

CMS使用增量更新,G1使用原始快照

垃圾回收器

CMS

并发低停顿收集器,是一种以获取最短回收时间为目标的收集器。基于标记-清除算法实现。

运行流程
1)初始标记
标记一下GC Root能直接关联到的对象,速度很快,需要Stop The World
2)并发标记
从GC Root的直接关联对象开始遍历整个对象图的过程,耗时较长但不需要停顿用户线程
3)重新标记 修正并发标记期间,因用户线程操作而导致标记产生变动的那一部分对象的标记记录(增量更新)
4)并发清除
清除掉死亡对象,由于使用标记清除算法,不需要移动存活对象,所以可以与用户线程并发操作

缺点
1) 占用一个线程导致处理器响应变慢
2) 无法处理浮动垃圾
3) 基于标记清除算法,有大量空间碎片产生

G1

特点:
1)回收区域
可以面向堆内存中任何部分回收,衡量标准不是属于哪个分代,而是那块内存中存放的垃圾数量最多,回收收益最大
2)内存布局
G1仍然遵循分代收集理论,但不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小不等的独立区域(Region),每一个(Region)区域都可以根据需要扮演Eden、Survivor、老年代。 新生代和老年代是一系列区域(不连续)的动态集合。

将Region作为单次回收的最小单元,G1收集器会跟踪每个Region里面的垃圾堆积的价值大小。价值即为回收所获得的空间大小以及回收所需的时间的值,然后在后台维护一个优先级列表,每次根据用户设定的允许的停顿时间优先回收价值收益最大的哪些Region。

回收步骤:
1)初始标记
标记一下 GC Roots 能直接关联到的对象
2)并发标记
从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回 收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在 并发时有引用变动的对象。
3)最终标记
对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
4)筛选回收
更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

类加载过程

image.png

1. 加载时机

  • 创建类的实例,也就是new一个对象
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(Class.forName("com.lyj.load"))
  • 初始化一个类的子类(会首先初始化子类的父类)
  • JVM启动时标明的启动类,即文件名和类名相同的那个类

2. 前端编译

JavaC(前端编译器) 将*.java文件编译为*.class文件的过程。

2.1 准备过程

初始化插入时注解解析器

2.2 解析与填充符号表过程

语法、词法分析。将源代码的字符流变为标记集合, public static int a = 1 + 2 可分解为8个标记,使用这些标记构造抽象语法树, 填充符号表,产生符号地址和符号信息。 后续基于构造好的抽象语法树和符号表进行操作。

2.3 插入式注解处理器的注解处理过程

对特定的注解进行处理,在处理过程中,允许读取、修改、添加抽象语法树中的任意元素。如果处理注解期间语法树被修改,百年一起将回到解析及填充符号表的过程中重新处理。Lombook注解就是在此时被解析,生成对应方法。

2.4 分析与字节码生成过程

1)标注检查
对语法的静态信息进行检查,包括变量使用前是否已被生命、变量与赋值之间的数据类型是否能够匹配等。还会进行常量折叠,例如编译后int a= 1+3 转换为 int a = 4
2)数据流及控制流分
对程序动态运行过程进行检查,可以检查出诸如局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
3)解语法糖
将简化代码编写的语法糖(lamda表达式、方法引用、泛型、自动装箱等)还原为原有的形式。
4)字节码生成
将前面各个步骤所产生的信息转化为字节码,添加类构造器<clinit>和实例构造器<init>等少量代码。

3. 加载

加载阶段,虚拟机需要完成三件事:
1) 通过一个类的全限定名来获取定义此类的二进制字节流
虚拟机规范没有指定必须从class文件取得二进制流,可以自行实现,动态代理就用ProxyGenerator.generateProxyClass()来为接口生成形式为*$Proxy的代理类的二进制字节流。
2)将这个字节流所代表的静态数据存储结构转化为方法去的运行时数据结构
3) 在内存中生成一个代表这个类的的Java.lang.class对象,作为方法区这个类的各种数据的访问入口

3.1 类加载

可以通过虚拟机内置的启动类加载器来完成,也可以由用户自定义的类加载器去完成。

3.1.1 类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在JVM中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否相等,只有在这两个类是由同一个类加载器的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个JVM加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

Jdk提供了三个类加载器
1)启动类加载器
由C++代码实现,是JVM一部分。 负责加载存放在<JAVA HOME>\lib目录,或者特定路径的类库加载到虚拟机内存中,启动类无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器去处理,那直接使用null代替即可。
2) 拓展类加载器
这个加载器是以Java代码中实现的。负责加载<JAVA_HOME>\lib\ext目录及指定路径中所有类库。
3) 应用程序类加载器
负责加载用户类路径ClassPath上所有类库,开发者可以在代码中直接使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

3.1.2 双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,这里类加载器之间的父子关系一般不是继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

image.png 工作过程: 如果一个类加载器收到了类加载器的请求,他首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求都最终应该传送到做顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。

优点:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,保证每个类使用固定的某个加载器,避免重复加载。Object类在程序的各种类加载环境中都能保证是同一个类。

3.2 数组加载

数组类本身不通过类加载器创建,它是由Java虚拟机在内存中动态构造出来的。但数组类与类加载器仍然有很密切的联系,因为数组类的元素类型最终还是要靠类加载器来完成加载。

一个数组类创建过程遵循以下原则:

  • 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组将被表示在加载该组件类型的类加载器的空间上。
  • 如果数组的组件类型不是引用类型,Java虚拟机蒋桂把数组标记为与启动类加载器关联。
  • 数组类的可访问性与他的组件类型的可访问性一致,如果组件类型不是引用类型,他的数组类的可访问性将默认为public,可被所有的接口和类访问

4. 验证

验证时连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合规范,确保这些信息被当作代码运行后不会伤害虚拟机自身的安全,主要包括四个阶段
1)文件格式验证
2)元数据验证
对字节码属性的信息进行语义分析,例如是否有父类、是否继承了不允许被继承的类、字段方法是否与父类产生矛盾。
3)字节码验证
通过数据流分析和控制流分析,确定程序语法是合法的、符合逻辑的。
4)符号引用验证
发生在虚拟机将符号引用转化为直接引用时,对各类信息进行匹配性校监,查看该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

5. 准备

正式为类中定义的变量(静态变量)分配内存并设置类变量初始值的阶段

  • 这时进行的内存分配仅包括类变量,不包括实例变量,实例变量会在对象实例化时随着对象一起分配在java堆中。
  • 这时分配的初始值是实例对象的零值static int a = 434在准备阶段会赋值为0
  • static final 会使字段属性表生成ConstantValue属性(final 存在时可能生成,只可用于static变量),告知虚拟机在初始化阶段为静态变量赋值为ConstantValue属性。如static final int value = 142,那在准备阶段变量值就会被初始化为ConstValue属性指定的初始值 142。
  • 你知道Java中final和static修饰的变量是在什么时候赋值的吗?_Archie_java的博客
    必看,对2、3点的详解

6. 解析

JVM将Class常量池内的可确定的符号引用(类的全限定名)替换为直接引用(内存地址)的过程

7. 初始化

初始化阶段就是执行类构造器<clinit>方法的过程。<clinit>并不是程序员在Java代码中直接编写的方法,他是Javac编译器的自动生成物。

  • <clinit> 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器手机的顺序是由语句在源文件中出现的顺序决定的,静态语句块中和i能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
  • <clinit>方法与类的构造函数不同,它不需要显示的调用父类构造器,JVM保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经被执行完毕。
  • 执行接口的<clinit>方法不需要先执行父接口的<clinit>方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clint>方法。
  • JVM保证<clinit>的加锁同步。

对象

1. 对象的内存布局

在HotSpot虚拟机中,对象在堆中的存储布局可以分为三个部分:对象头、实例数据和对齐填充。
1)对象头,包括两类信息

  • 存储对象自身的运行时数据
    如哈希码、GC分带年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32和64位的虚拟机中分别为32和64比特,官方称为Mark Word 。Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间中存储更多的数据,根据对象的状态复用自己的存储空间。

  • 类型指针
    即对象指向它的类型元数据的指针,JVM通过这个指针来确定该对象是哪个类的实例 如果对象是一个java数组,那在对象头中还必须有一块用来记录数组长度的数据。

2)实例数据
是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容。
3)对齐填充
起到占位符作用。HotSpot虚拟机要求对象起始地址必须是8字节的整数倍,即任何对象的大小必须是8字节的整数倍。

2. 对象创建过程

2.1 检查

当JVM遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有则先执行类加载。

2.2 分配内存

对象所需的内存的大小在类加载完成后便可以完全确定。
内存分配有两种方式
1)指针碰撞:所有被使用过的内存都被放到一边,空闲的内存被放到另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
2)空闲列表 : 记录哪些内存块是可用的,在分配时从列表中找一块足够大的空间划分给对象实例,并更新列表上的记录。

除了分配方式外,还需要考虑多线程内存同步问题,有两种同步方式
1) 加锁保证原子性
2)把内存分配的动作按照线程划分在不同的空间中
即每个线程在java堆中有一小块内存,用尽时同步锁定内存,获取新的内存空间。

一些特殊分配规则
1)对象优先在 Eden 区分配
多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。

这里我们提到 Minor GC,如果你仔细观察过 GC 日常,通常我们还能从日志中发现 Major GC/Full GC。

Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所有 Minor GC 非常频繁,一般回收速度也非常快; Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。
2)大对象直接进入老年代
所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。
前面我们介绍过新生代使用的是标记-清除算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。
3)长期存活对象将进入老年代 虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。

2.3 设置对象头信息

2.4 执行构造函数

从虚拟机视角看,一个新的对象已经产生了。但从Java程序视角来看才刚刚开始——构造函数,即Class文件中的<init>方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也没按照预定的意图构造好,一般来说 new指令后会接着执行<init>方法,按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。

3. 对象的访问定位

java程序会通过栈上的reference数据来操作堆上的具体对象,它只是一个对象的引用。 主流的访问方式有两种方法
1) 通过句柄访问对象
image.png Java堆中将可能划分出一块内存最为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的地址信息
优点: reference中存储的是稳定的句柄地址,在对象移动时只会改变句柄中的实例数据指针,reference本身不需要被修改
2) 通过直接指针访问对象
image.png
将类型数据指针放到实例数据中,reference中存储的就是对象的实例地址 优点: 速度更快,节省了一次指针定位的时间开销。

方法调用

方法调用不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪个方法),确定后交由字节码执行引擎执行。

1. 方法类型

1)非虚方法
方法在程序运行前就有一个可确定的调用版本,编译器可知,运行期不可变的方法。
包括 静态方法、私有方法、实例构造器、父类方法、final修饰的方法。这5类方法嗲用会在类加载时就可以把符号引用解析为该方法的直接引用。
2)虚方法
除了非虚方法都是虚方法,类加载时不能确定要调用哪个版本,需要在运行时分派才能确定。

2. 对象类型

Human human = new Man()这段代码中,Human称为静态方法或者叫外观类型,后面的Man称为实际类型或者叫运行时类型
静态类型和实际类型在程序中都可能会发生变化,区别是

  • 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的。
  • 实际类型的变化结果在运行时才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
//实际类型变化
Human human = (判断条件)?new Man():new Woman();

//静态类型变化
Class.重载方法1((Man)human);
Class.重载方法2((Woman)human);

对象Human的实际类型是依据判断条件决定的,编译期间无法确定对象的实际类型,必须等到实际运行时才能确定,而human的静态类型是编译期间可知的。

3. 分派

3.1 静态分派(重载)

虚拟机在重载是是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期间可知,所以在编译阶段,Javac编译器就依据参数的静态类型决定了会使用哪个重载版本。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法的重载。静态分配发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

需要注意的是Javac编译器算然能确定出方法的重载版本,但在很多情况下这个重载版本不是唯一的,往往只能确定一个”相对更合适的“版本。

3.2 动态分派(重写)

运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
动态分派与重写有很密切的联系。动态分派依据调用者的实际类型和参数的静态类型作为判定依据,JVM是如何寻找的?
JVM使用invokevirtual指令调用虚方法,invokevirtual指令的运行时解析过程大致分为以下几步:
1) 找到操作数栈顶的第一个元素所指向对象的实际类型,记作C。
2) 如果在类型C中找到与常量中的描述符和简称都相等的方法,则进行访问权限校监,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3) 否则,按照继承关系从下往上一次堆C的各个父类进行第二步的搜索和验证过程。
4) 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以在两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会依据方法接收者(调用者)的实际类型选择方法版本,这个过程就是Java语言中方法重写的本质。

3.3 虚拟机动态分派的实现

JVM为类型在方法区建立一张虚方法表,与此对应的在invokeinterface执行时也会用到接口方法表,使用虚拟方法表代替元数据查找以提高性能。

image.png

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为子类实现版本的入口地址。

4. 字段

多态性的根源在于虚方法调用指令invokvirtual的指令逻辑,那自然我们得出的结论就只会对方法有效,对字段是无效的,因为字段不使用这条指令。事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,字段不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是定义这个方法的类能看见的那个字段。当子类型声明与父类型同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的字段,父类的方法看不见子类重写的字段。

多线程

内存模型

image.png Java内存模型规定所有变量(这里的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数)都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

volatile

Java并发编程:volatile关键字解析 - Matrix海子
volatile 关键字,你真的理解吗?
volatile有两个特性
1)保证被修饰的变量的可见性 每次使用volatile变量时都必须从主内存刷新最新的值,用于保证能看见其他线程对变量做的修改。
每次修改volatile变量后必须立即同步回主内存中,用于保证其他线程可以看到当前线程对volatile变量的修改。
2)禁止指令重排序优化
使用 volatile 修饰变量时,根据 volatile 重排序规则表,Java 编译器在生成字节码时,会在指令序列中插入内存屏障指令来禁止特定类型的处理器重排序。
volatile关键字禁止指令重排序有两层意思:

  • 在当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

synchronized

实现原理

每个对象生成时都会自动生成一个对应的monitor对象
java并发系列-monitor机制实现 - 青衫执卷 - 博客园 (cnblogs.com)

synchronized有三个特性

1)原子性
synchronized修饰的类或对象的所有操作都是原子的,一个变量在同一时刻只允许一条线程对其进行lock操作,这个规则决定了出于同一个锁的两个同步代码块只能串行进入。
2)可见性
在进入同步代码块时,会清空工作内存中的缓存,重新加载主内存中变量值到工作内存。
在退出代码块时,会把修改的变量值同步回主内存,保证其他线程能看见修改的变量值。
3)有序性
synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized的有序性是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了出于同一个锁的两个同步代码块只能串行进入。

锁优化

自旋锁与自适应锁

重量级锁对性能最大的影响是阻塞的实现,需要转入内核态切换上下文。但在一些应用中,锁定状态只会持续很短一段时间,为此挂起线程并不值得。所以让线程执行一个忙循环(自旋)直到获得锁。
CAS虽然避免了线程切换的开销,但要占用处理器时间,如果占用的时间很短则效果较好,如果长时间占用就会浪费处理器资源。 因此CAS有次数限制,默认为10次,10次后没有获得锁则挂起。

JDK6中引入了自适应锁,自旋时间不再是固定的,而是依据历史获得锁所需的自选次数和锁拥有者的状态,评估所需自旋次数。

锁消除

锁消除是指虚拟机即时编译器在运行时检测到操某段需要同步的代码根本不可能存在共享数据竞争而实施的一种对所进行消除的优化策略。
再清楚不过了,JVM逃逸分析,你一定得知道

锁粗化

for(int i=0;i<1000;i++){
  sychronized(this){
      //do some thing
  }
}

如果一系列的连续操作都会同一个对象反复进行加锁和解锁,甚至加锁操作是出心啊在循环体之中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能消耗。这时会将锁粗化,一次性加锁。

sychronized(this){
  for(int i=0;i<1000;i++){
      //do some thing
  }
}

HotSpot对象头内存布局

HotSpot对象头分为两部分
1)运行时数据
第一部分存储对象自身运行时数据,如哈希码,GC分带年龄等。这部分数据的长度在32位和64位JVM中分别会占用32或64比特,官方称它为Mark Work。这部分是实现轻量级锁和偏向锁的关键。

image.png 由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到JVM的空间使用效率,Mark Word被设计成一个非固定的动态数据类型,以便在极小的空间内存储尽量多的数据。

2)对象数据类型指针

轻量级锁

轻量级锁不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的技帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。 image.png 然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record 的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word的锁标志位(MarkWord的最后 2bit)将转变为“00”,即表示此对象处于轻量级锁定状态。

image.png 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级 锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”, Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

偏向锁也是JDK1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiasedLocking)。那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。

因为hash码会占用对象头中位置,且hash码要留存在对象头中保证每次hash计算得到同一个hash值,所以以下两种情况会退出偏向锁模式

  • 当一个对象计算过hash,他就再也无法进入偏向锁状态。
  • 当一个对象处于偏向锁状态,又收到需要计算hash值,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。

偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。