JVM 面试题
JVM 是 Java Virtual Machine(Java 虚拟机)的缩写,JVM 是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java 虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM 屏蔽了与具体操作系统平台相关的信息,使 Java 程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM 在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。
一.JAVA 内存区域与内存溢出
1.运行时数据区
- 1.1 程序计数器
- 线程私有、占用较小的内存,作为线程所执行字节码的行号指示器,字节码解释器工作时通过改变这个计数器的值来选取下一条字节码指令。分支、循环、跳转、异常、线程恢复都需要依赖计数器完成(因为多线程是通过时间片分配切换线程实现,保证切换线程后还可以恢复正确的执行位置)
- 1.2 虚拟机栈
- 线程私有、生命周期同线程相同,虚拟机栈描述的是 java 方法执行的内存模型:每个方法被执行时都会创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息,每个方法直至执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程
- 1.3 本地方法栈(native 堆)
- 线程私有、与虚拟机栈的作用相似,区别是虚拟机栈是为运行时期间的 java 方法(字节码)服务,而本地方法栈是为了原生的 native 方法服务
- 1.4 java 堆
- 线程共享、是 java 虚拟机内存管理中最大的一块,虚拟机启动时创建,其唯一目的是存放 java 实例,几乎所有的 java 实例都会在这里分配内存(JIT 编译器的发展和逃逸技术的发展将在堆上分配变得不是绝对(栈上分配、标量替换))、java 对是垃圾收集器的主要区域,也被成为 GC 堆,基于现在收集器的回收都是分代手机算法,所有 java 堆也被分为新生代和老年代,针对新生代再细致一点的有 Eden 空间、From Survivor、To Survivor(一般新生代和老年代的分配为 1:2, Eden、From Survivor To Survivor 为 8:1:1)
- 1.5 方法区
- 线程共享、用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,HotSpot 方法区很多人也被人称为永久代
- 1.6 运行时常量池
- 是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到运行时常量池
- 1.7 直接内存
- 不是虚拟机运行时数据区的一部分,主要作用是为了使用 native 函数库直接分配堆外内存,然后通过 Java 堆里的一个 DirectByteBuffer 对象作为这块内存的引用,避免 java 堆和 native 堆中来回复制数据
2.对象访问
- 2.1 eg Object obj = new Object();
Object obj 会在 java 栈的本地变量表中作为一个 reference 类型数据出现
new Object()会在 java 堆中以一块结构化内存出现,存储了 Object 类型的对象中各个字段数据,另外 java 堆中还查找到此对象类型数据(对象类型、父类、实现的接口、方法等)的地址信息,类型数据信息存储在了方法区
reference 类型在虚拟机中只规定了一个指向对象的引用,方式不固定。
目前主流的对象访问方式有两种: 句柄和直接指针
- 1.句柄方式
- java堆会划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄池中包括对象的实例数据和对象的类型数据各自的指针
优点: reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,不会影响到 reference 本身
- 2.直接指针
- reference中存储的就是对象地址,对象中会有一个指针指向方法区的对象类型数据
优点: 最大的好处就是速度快,节省了一次指针定位的开销
3.OutOfMemoryError 异常(除程序计数器外都会出现 OOM)
- 3.1 JAVA 堆溢出 可以通过设置-Xms 和 Xmx 设置堆大小
- 3.2 虚拟机栈和本地方法栈溢出
- 3.3 运行时常量池溢出
- 3.4 方法区溢出
- 3.5 本机直接内存溢出
二.垃圾收集器和内存分配策略
1.概述
- GC 需要完成的三件事:
哪些内存需要回收
什么时候回收
如何回收
2. 对象已死?
- 2.1 垃圾收集器回收前需要去确认哪些对象还活着,哪些已经死去了
- 2.2 引用计数算法
- 给对象中添加一个引用计数器,每当有地方引用时,计数器的值就加 1 引用失效时减 1,为 0 的计数器都不可能再被使用,但是 java 没有用这个方法,主要原因是他很难解决对象之间互相循环引用的问题,会导致一些对象永远无法 GC,导致内存没法释放
- 2.3 根搜索算法
- GC Roots Tracing 的思想是通过以 GC Roots 为根节点向下搜索,所走过的路径称为引用链(reference chain) 当一个对象到 GC Roots 没有任何引用链时,证明此对象不可达,可以被回收
- 2.4 引用(强度依次减弱)
- 强引用
- 普遍存在程序代码中 Object obj = new Object()
- 软引用
- 描述一些还有用但是非必须的对象
- 弱引用
- 非必须对象,只能生存到下一次垃圾回收之前
- 虚引用
- 强引用
- 2.5 对象-生存还是死亡
- 不可达的对象并不是非死不可,宣告一个对象的死亡,至少经历两次标记,不可达后会被标记一次并进行一次筛选,筛选条件是此对象是否有必要执行 finalize()方法,如果有,则会被放到 F-queue 队列中去被一个低优先级的 Finalizer 线程执行(这里执行的意思虚拟机会触发这个方法,但不会等待执行完,避免队列后续对象的等待),finalize()方法是对象逃脱死亡的最后一次机会,如果在此方法中与 GC Roots 重新建立联系,eg: 把自己(this 关键字)赋值给某个类中的其他对象的成员变量或者变量。但一个对象的 finalize()方法只会被系统自动调用一次,第二次时就会被自救失败
- 2.6 回收方法区
- 方法区(永久代)主要是回收两部分内容: 废弃常量和无用的类
3. 垃圾回收算法
- 3.1 标记-清除算法
- 先统一标记,再统一清除被标记的对象
主要缺点: 1.效率问题 标记和清除效率都不高,2 空间问题 标记清除后会产生大量不连续的内存碎片 可能导致对象找不到连续的内存,从而再次触发垃圾回收
- 3.2 复制算法
- 将可用内存分成容量大小等同的两份,每次只使用其中一块,当一块的内存用完,就将还存活的对象复制到另一块,再把已经使用的内存清理,不用担心内存碎片化的问题,只需要移动堆顶指针,但是代价是可用内存缩小了一半,商业虚拟机都是采用这种算法回收,但是采用的是,Eden、From Survivor To Survivor 为 8:1:1 且有时候需要用老年代做担保
- 3.3 标记-整理算法
- 标记过程和标记-清除算法一样,但是后续不对对象清理,而是让所有存活对象向一端移动,然后清理掉边界以外的内存
- 3.4 分代收集算法
- 只是根据对象的存活周期不同将内存划分为几块,一般是吧 java 堆划分为新生代和老年代 在根据各个年代采取合适的收集算法,新生代中大量对象死去,少量存活,就采用复制,老年代中对象存活率高,就采用标记-整理或者标记-清除
4. 垃圾收集器
- 4.1 serial-新生代(client 模式首选)
- 单线程收集器,并且会停止用户的线程
- 4.2 ParNew 收集器-新生代(server 模式首选)
- 多线程并行收集器,会停止用户的线程,搭配 CMS 收集器
- 4.3 Parallel Scavenge-新生代(注重吞吐量)
- 多线程并行收集器,会停止用户的线程(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))
- 4.4 Serial Old-老年代(client 模式)
- 如果在 server 模式下与 parallel scavenge 收集器搭配使用
- 4.5 Parallel Old-老年代
- 可以与 parallel scavenge 收集器搭配使用,实现新老代并行处理的机制
- 4.6 CMS 收集器-老年代
- 处理时分为 4 步
- 初始标记(会停止用户线程),仅仅标记 GC Roots 直接关联的对象 2.并发标记(会停止用户线程),进行 GC Roots Tracing 的过程 3.重新标记,修正并发标记期间因用户程序导致的变动对象吧 4.并发清除
- 4.7 G1 收集器-整个 java 堆(新生代和老年代)
- 采用标记-整理算法,将新生代和老年代划分为多个大小固定的独立区域(region),并跟踪这些区域中的垃圾堆程度,在后台维护一个列表,每次根据允许的时间,优先收集垃圾最多的区域
5.内存分配与回收策略
- 5.1 对象优先在 Eden 分配,当 Eden 区域没有足够的空间时,虚拟机将发起一次 Minor GC
- 5.2 大对象直接进入老年代
- 虚拟机提供了一个-XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存拷贝
- 5.3 常期存活的对象将进入老年代
- 虚拟机给每个对象定义了一个年龄计数器,如果对象出生在 Eden 区并且经过一次 Minor GC 后仍然存活且能被 Survivor 容纳,将被移动到 Survivor 区将年龄设置为 1,当年龄在这种情况下增加到一定程度时(默认是 15)时,就会晋升到老年代,晋升阈值可以通过 -XX:MaxTenuringThreshold 来设置
- 5.4 动态对象年龄判定
- 为了更好的适应不同程度的内存状态,虚拟机并不总是要求对象年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于此年龄的对象就可进入老年代,无需等待阈值
- 5.5 空间分配担保
- 主要是为了新生代中 Survicor 空间无法容纳的大对象进入老年代(用老年代做担保),如果老年代的剩余空间也不足容纳,则需要进行一次 Full GC
三. 虚拟机性能监控与故障处理工具
1. JDK 命令行工具
- 1.1 jps 虚拟机进程状况工具 jsp [options] [hostid]
- 1.2 jstat 虚拟机统计信息监控工具 jstat [ option vmid [interval[s|ms] [count]] ]
- 1.3 jinfo java 配置信息工具 jinfo [option] pid
- 1.4 jmap java 内存映像工具 jmap [option] vmid
- 1.5 jhat 虚拟机堆转储快照分析工具
- 1.6 jstack java 堆栈跟踪工具 jstack [option] vmid
2.JDK 可视化工具
四. 类文件结构
1.无关性基石
- 将各种语言编译成字节码文件再去通过 java 虚拟机和操作系统以及指令集交互
2. class 类文件的结构
- class 文件是一组以 8 位字节为基础单位的二进制流,class 文件格式采用一种类似于 C 语言结构体的伪结构来存储,其中只有两种数据类型: 无符号数和表。
无符号数: 属于基本数据类型 以 u1,u2,u4,u8 来分别代表 1 个字节,2 个字节,4 个字节,8 个字节的无符号数,可以描述数字,索引引用,数量值,或者按照 UTF-8 编码构成字符串值
表: 由多个符号数或者其他表作为数据项构成的复合数据类型,所有的表都习惯性以"_info "结尾,整个 class 文件就是一张表
- 2.1 魔数与class文件的版本
- 每个class文件的头四个字节称为魔数,(Magic Number),他的唯一作用是用于确定这个文件是不是一个能被虚拟机接受的class文件,很多文件储存标准中都用魔数来进行身份识别,因为文件格式制作者可以自由选择魔数,而且 没有被广泛应用,不会混淆也不会被变更
紧接着魔数的第五、第六字节是次版本号,第七和第八是主版本号
- 2.2 常量池
- 紧接着主版本号之后是常量池入口,常量池是class文件结构中与其他项目关联最多的数据类型,也是class文件中占用空间最大的数据项目之一,也是class文件中出现的第一个表类型数据,常量池入口需要先放置一项u2类型数据,记录常量池的数据容量计数值(从1开始并非0)
常量池主要存放两大类常量:字面量和符号引用
字面量接近于 java 中的常量概念,如文本字符串、被声明的 final 常量值。
符号引用则属于编译原理方面的的概念:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
- 2.3 访问标志
- 常量池结束之后,紧接着是2个字节代表访问标志,这个标志用于识别一些类或接口层次的访问信息,包括这个class是类还是接口,是否定义为public类型,是否定义为abstract类型,如果是类的话,是否被声明了final ~~
- 2.4 类索引、父类索引与接口索引集合
- 类索引和父类索引都是u2类型的数据,集合索引是一个u2类型的数据集合,class文件由这三项数据来确定这个类的继承关系。
类索引用于确定这个类的全限定名
父类索引用于确定这个类父类的全限定名
集合索引用于描述这个类实现了哪些接口
- 2.5 字段表集合
- 用于描述接口或者类中声明的变量,字段包括了类级变量或实例级变量,但不包括在方法内部声明的变量
通过描述符来描述字段的数据类型、方法的参数列表和返回值
- 2.6 方法表集合
- class文件中存储格式对方法描述与对字段的描述基本采用了完全一致的方式,访问标志、名称索引、描述符索引、属性表集合
- 2.7 属性表集合
- 1. Code属性
- 2. Exceptions属性
- 3. LineNumberTable属性
- 4. LoaclVariableTable属性
- 5. SourceFile属性
- 6. ConstantValue属性
- 7. InnerClasses属性
- 8. Deprecated和Syntheic属性
3. Class 文件结构的发展
4. 小结 class 文件是 java 虚拟机执行引擎的数据入口,也是 java 技术体系的基础支柱之一
五. 虚拟机类加载机制
1.概述
- 虚拟机把类的数据从 class 文件中加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机使用的 java 类型,这就是 java 虚拟机的类加载机制
2. 类加载的时机
- 类从被加载到虚拟机内存开始,到卸载出内存,整个生命周期包括了: 加载、验证、准备、解析、初始化、使用和卸载七个阶段
加载、验证、准备、初始化、卸载这 5 阶段顺序是确定的,
- 2.1 加载
- 2.2 验证
- 2.3 准备
- 2.4 解析
- 2.5 初始化
- 虚拟机规定这四种情况必须立即对类进行初始化:
1).遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码指令时
2). 使用 java.lang.reflect 包的方法对类进行反射调用时,如果没有进行初始化,则需先触发初始化
3). 当初始化一个类的时候,如果发现其父类还没有初始化,则需要先触发父类的初始化
4). 虚拟机启动时,用户需要先指定一个要被执行的主类(包含 mian()方法的那个类),虚拟机会先初始化这个类
- 2.6 使用
- 2.7 卸载
3. 类加载的过程
- 3.1 加载
- "加载"是"类加载"过程的一个阶段。在此阶段虚拟机需要完成三件事
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在 java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口
- 3.2 验证
- 验证是连接阶段的第一步,这一阶段是为了确保 class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全
- 文件格式验证---对魔数、主次版本号等等进行验证
- 元数据验证---是否有父类、父类是否继承了不允许被继承的类(final 修饰的类)
- 字节码验证---主要是进行数据流和控制流的分析
- 符号引用验证---可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验
-
3.3 准备
- 是正式为类变量分配内存并设置类变量(被 static 修饰的变量,实例变量会在对象实例化时分配到 Java 堆)初始值的阶段,这些内存都将在方法区进行分配
-
3.4 解析 - 解析阶段是将虚拟机常量池内的符号引用替换为直接引用的过程
1)符号引用: 是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义的定位到目标即可,符号引用和虚拟机内存布局无关,可以是没有加载到内存的引用目标
2)直接引用: 可以是直接指向目标的指针,相对偏移量,或者是一个能间接定位到目标的句柄,直接引用是和虚拟机的内存布局相关的,如果一个目标的直接引用已存在,那么这个目标一定加载到了虚拟机内存中
- 3.4.1 类或接口的解析
- A类中引用了B类,需要对B类的符号引用N进行解析,共有3步
-
如果这个 B 不是数组类型,则虚拟机会把代表 N 的全限定名传递给 A 的类加载器加载 B 类,并进行 3.1-3.3 的操作
-
如果是一个数组,并且数组的元素类型为对象,也就是 N 的描述符会是类似"Ljava.lang.Interger"的形式,会按照第一点去加载数组元素类型,如果是单纯的数组,需要加载的就是"java.lang.Interger",然后由虚拟机去生成一个代表此数组维度和元素的数组对象
-
上述步骤没有异常,A 就会在虚拟机中有一个有效的类或接口了,但还需进行验证 A 对 B 是否有访问权限,没有会抛出 java.lang.IllegalAccessError
- 3.4.2 字段解析 - 解析一个未被解析过的字段引用,需要先对字段表中的class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用,若解析成功,则将这个类或接口用A 表示,然后虚拟机会对A 进行后续字段的搜索
-
若 A 本身的简单名称和字段描述符都与目标字段相匹配,则直接返回这个字段的直接饮用,查找结束。
-
若在 A 中实现了接口,将会按照继承关系从上到下进行递归搜索各个接口和他的父接口,若接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
-
若 A 不是 java.lang.Object 的话,将会按照继承关系递归搜索其父类,如果父类中包含目标字段的简单名称和字段描述符,则返回这个字段的直接引用,查找结束
-
否则,查找失败,抛出 java.lang.NoSuchFieldError 异常
最后返回直接引用后,若发现不具备对此字段的访问权限,则会抛出 java.lang.IllegalAccessError 异常
- 3.4.3 类方法解析
- 和解析字段引用一样,在类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析ok,虚拟机将会按照进行后续的类方法搜索
1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现 class_index 索引的 A 是一个接口,则直接抛出 java.lang.IncompatibleClassChangeError 异常 2) 如果第一步通过了,在类 A 中查找是否有简单名称和描述符都与目标匹配的方法,若有则返回这个方法的直接引用,return 3) 若在类 A 的父类中递归查找到有简单名称和描述符都与目标方法相匹配,则返回这个方法的直接饮用,return 4) 若在类 A 的实现接口列表及他们的父接口之中递归查找到有简单名称和描述符都与目标方法相匹配的,说明类 A 是一个抽象类,查找结束,抛出 java.lang.AbstractMethodError 5) 否则,宣告查找失败,抛出 java.lang.NoSuchMethodError.
最后,若查找到了直接引用,将会对这个方法进行权限验证,若返现不存在对此方法的访问权限,则会抛出 java.lang.IllegalAccessError 异常
- 3.4.4 接口方法解析
- 接口方法解析也是先解析出接口方法表中的class_index项中索引的方法所属的类或接口的符号引用,解析成功后,虚拟机将会按照接口方法搜索
- 与类解析相反,如果在接口方法表中发现 class_index 中的索引 A 不是一个接口而是一个类,则抛出 java.lang.IncompatibleClassChangeError 异常
- 若接口 A 中查找有与目标相匹配的简单名称和方法描述符,则返回这个方法的直接引用,结束
- 若在接口 A 的父接口(直接 java.lang.Object)中递归查找到与目标匹配的简单名称和方法描述符,则返回这个方法的直接饮用,结束
- 否则,宣告查找失败,抛出 java.lang.NoSuchMethodError 异常
由于接口方法所有的都是默认 public,所以不存在访问权限的问题,因此方法符号解析不会抛出 java.lang.IllegalAccessError 异常
- 3.5 初始化
- 这是类加载过程的最后一步,开始真正的执行类中定义的 java 程序代码(或者说是字节码),准备阶段有一次系统要求的初始值,初始化阶段,会根据代码中的指定的主观计划去初始化类变量和其他资源(执行类构造器的
()方法过程)
- 这是类加载过程的最后一步,开始真正的执行类中定义的 java 程序代码(或者说是字节码),准备阶段有一次系统要求的初始值,初始化阶段,会根据代码中的指定的主观计划去初始化类变量和其他资源(执行类构造器的
4. 类加载器--起初是为了实现通过全限定名获取描述此类的二进制流
- 4.1 类与类加载器
- 对于任意一个类,都需要有它的类加载器和类本身来在 java 虚拟机中确定唯一性,即使两个来源于同一个 class 文件,只要加载他们的类加载器不同,那么他们就必然不相等,这里的"相等",包含类的 class 对象的 equals()方法,isAssignableFrom()方法,isInstance()方法的返回结果
- 4.2 双亲委派模式
- 站在虚拟机的角度,只存在两种不同的类加载器:
- 启动类加载器:这个加载器由 C++实现,是虚拟机自身的一部分,
- 所有其他的类加载器,这些加载器全都由 java 实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
站在 java 开发人员角度: 绝大部分 java 程序会使用到以下三种系统提供的类加载器:
- 启动类加载器
- 扩展类加载器
- 应用程序类加载器(系统类加载器),没有在应用程序中自定义自己的类加载器的话,这个就是默认程序的类加载器
- 双亲委派模式除了顶层的启动类加载器之外,其余的类加载器都需要有自己的父类加载器,类加载器之间的父子关系不是以继承的方式来实现的,而是以组合的关系来复用父加载器的代码
工作过程: 一个类加载器收到一个类的加载请求后,不会自己去尝试加载这个类,而是会请求委派父加载器去加载,每一层都是如此,直到顶层启动类加载器,只有当父加载器反馈加载不了时,自身才会去尝试加载
- 4.3 破坏双亲委派模式
六. 虚拟机字节码执行引擎
1. 概述
- 执行引擎是 java 虚拟机中最核心的组成部分之一,执行引擎的工作流程大致为 输入字节码文件,处理字节码解析,输出执行结果
2. 运行时栈帧结构
- 栈帧是支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区的虚拟机栈的栈元素,栈帧中存储了局部变量表,操作数栈,动态连接和方法返回地址等信息,每一个方法的执行开始到结束,都对应这栈帧的入栈到出栈
- 2.1 局部变量表
- 是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,在 java 程序被编译成 class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需分配的最大局部变量表的容量
- 2.1 局部变量表
局部变量表以变量槽 Slot 为最小单位,一个 slot 可以存放一个 32 位以内的数据类型,对于 64 位数据类型,虚拟机会以高位在前的方式分配两个连续的 slot
- 2.2 操作数栈
- 也被称为操作栈,是一个后入先出栈,同局部变量表一样,他的最大深度也是在编译时被写到code表的max_stacks数据项中,32位数据类型栈容量为1, 64位为2,任何方法执行时,都不会超过max_stacks设定的最大值
- 2.3 动态连接
- 每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,,其作用是为了支持方法调用过程中的动态连接,
- 2.4 方法返回地址
- 方法执行后的退出方式有两种,一种是遇到任何一个方法返回字节码指令,另一个是遇到异常
- 2.5 附加信息
- 虚拟机规范中还允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧中
3. 方法调用
- 方法调用不等同与方法执行,只是确定被调用方法的版本(即调用哪个方法),暂时还不会涉及到方法内部的具体运行过程
- 3.1 解析
- 3.2 分派
- 3.2.1 静态分派
- 主要是提现在多态性的重载上
- 3.2.1 动态分派
- 主要体现在多态性的重写上
- 3.2.3 单分派与多分派
- 3.2.4 虚拟机动态分派的实现
- 3.2.1 静态分派
4. 基于栈的字节码解释执行引擎
- 4.1 解析执行
- 4.2 基于栈的指令集与基于寄存器的指令集
- java 编译器输出的指令流,基本上是一种基于栈的指令集架构,与之相对的另一套常用的指令集架构是基于寄存器的指令集
两者之间的区别:
基于栈的指令集架构最主要的优点就是可移植性,寄存器是基于硬件的,不具备可移植性,但是基于栈的指令集架构缺点是执行速度比较慢
- 4.3 基于栈的解释器执行过程
七. 运行期优化
1. 概述
- 1.1 解释器与编译器
- 大多数 java 虚拟机内都采用编译器与解释器并存的架构,需要程序快速启动和执行时,解释器可以首先发挥作用,省去编译时间,程序运行后,编译器逐渐发挥作用,将代码编译成本地代码,获取更高的执行效率
- 1.2 编译对象和触发条件
- 运行期间被编译器编译的热点代码有两类: 1.被多次调用的方法 2.被多次执行的循环体
要知道一段代码是不是热点代码,采用的方式是热点探测,其方式有两种:
- 基于采样的热点探测
采用这种方法虚拟机会周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是"热点方法",这种方式的好处是,简单高效,还可以很容易的就发现方法的调用关系(将方法调用堆栈展开即可),缺点是不够精确 - 基于计数器的热点探测
需要对每个方法(甚至是代码块)设置一个计数器,统计方法的执行次数,,执行次数超过一定的阈值就认定他是一个"热点方法",统计相对麻烦,也不可以获取方法的调用关系,但是可以精确的知道统计结果
HotSpot 虚拟机采用的是第二种方式进行的热点探测,而且准备了两个计数器,方法计数器和回边计数器,回边计数器的阈值会触发 OSR 编译器,对循环体进行编译
2. HotSpot 虚拟机内的即时编译器
3. 编译优化技术
- 3.1 逃逸分析
- 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法里被定义后,他可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种行为称为方法逃逸,甚至还有可能被外部线程访问,这种叫做线程逃逸
对此变量的优化方法:
1). 栈上分配:
2). 同步消除
3). 标量替换
java 与 C/C++的编译器对比
八. java 内存模型与线程
1. 硬件的效率与一致性
2. java 内存模型(JMM)
-
2.1 主内存与工作内存
- java 内存模型的主要目标是定义程序中各个变量访问规则,即虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节
此处“变量”的含义:包括实例字段、静态字段、构成数组的元素,但是不包括局部变量和方法参数,因为他们是线程私有的
- java 内存模型的主要目标是定义程序中各个变量访问规则,即虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节
-
2.2 内存间交互操作 - java 内存模型中定义了 8 种操作来完成变量和内存间的交互
1).lock(锁定):作用于主内存的变量,他把变量标识为一条线程独占的状态
2)unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
3)read(读):作用于主内存的变量,他把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
4)load(载入):作用于工作内存的变量,他把 read 操作从主内存中得到的变量值放入工作内存的变量副本中
5)use(使用): 作用于工作内存的变量,他把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
6)assign(赋值): 作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
7)store(存储): 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用
8)write(写入): 作用于主内存的变量,它把 store 操作从工作内存中的到的变量的值放到主内存的变量中
-
2.3 对 long 和 double 型变量的特殊规则
- 对于 64 位的数据类型,在 JMM 中定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read、、write 这四个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定
-
2.4 原子性、可见性和有序性
- 2.4.1 原子性
JMM 来保证的原子性操作包括 read、load、assign、use、store、write。且基本数据类型的访问是原子性的(long 和 double 除外),如果需要更大范围的原子性保证,需要用到 lock 和 unlock 满足
- 2.4.2 可见性
可见性就是线程修改了一个共享变量的值,其他线程能够立即得知这个修改,工作内存 1->主存->工作内存 2
关键字实现可见性 volatile、synchronize、final
- 2.4.3 有序性
在本线程内观察,所有操作都是有序的,在一个线程中观察另一个线程,所有操作都是无序的,前半句内表现为串行的语义,后半句是指"指令重排序"现象和“工作内存和主内存之前的同步延迟”现象
保证有序性,volatile 和 synchronize
- 2.5 先行发生原则
判断数据是否存在竞争,线程是否安全的依据,两个操作是否存在冲突的问题
- 2.5.1 程序次序规则:
一个线程内,按照程序代码顺序,书写在前端的操作先行发生于书写在后边的操作,准确的说是控制流顺序而不是代码顺序,因为需要考虑分支、循环体结构
- 2.5.2 管程锁定规则:
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作,这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序
- 2.5.3 volatile 变量规则:
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序
- 2.5.4 线程启动规则:
Thread 对象的 start()方法先行发生于此线程的任何动作
- 2.4.5 线程终止规则
线程中所有的操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join()方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行
- 2.5.6 线程中断规则:
对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过 Thread.interrupt()方法检测到是否有中断发生
- 2.5.7 对象终结规则:
一个对象的初始化完成(构造函数执行结束)先行发生于他的 finallize()方法开始
- 2.5.8 传递性:
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那可以得出操作 A 先行发生于操作 C 的结论
3. java 与线程
- 3.1 线程的实现
- 3.1.1 使用内核线程实现
- 是由操作系统内核直接支持的线程,由内核完成线程切换,通过调度器来对线程调度,并负责将线程映射到各个处理器上,可将内核线程看成内核的分身,
- 3.1.1 使用内核线程实现
程序一般不会去使用内核线程,而是用内核线程的高级接口---轻量级进程(是通常意义上的线程)
轻量级进程的局限性: 创建、析构、同步都需要系统调用,代价较高,需要在用户态和内核态中间来回切换,且每一个进程都需要一个内核线程支持,消耗内核资源
- 3.1.2 使用用户线程实现
- 广义上讲,如果一个线程不是内核线程,那么就可以认为是用户线程。
狭义上的用户线程是完全简历在用户空间的线程库上的,系统内核感知不到用户线程存在的实现,用户线程的创建、同步、销毁、调度都是在用户态完成的,快速低耗,支持的线程数量也更加大
- 3.1.3 使用用户线程+轻量级进程混合实现
- 用户线程通过调用内核线程去和轻量级进程之间进行搭桥,这样可以使用内核线程调度和处理器映射,降低了被阻塞的风险,他们之间的比例是M:N,多对多的模型
- 3.1.4 java线程的实现
- jdk1.2之前,是通过"绿色线程"的用户线程实现的
- 3.2 java 线程调度
- 是指系统为线程分配处理器的使用权的过程,
主要分为两种:协同式线程调度和抢占式线程调度
- 是指系统为线程分配处理器的使用权的过程,
协同式线程:线程的执行时间是由线程本身去控制的,在把自己本身的工作做完之后,会通知系统切换到另一个线程上去,好处是简单好实现,坏处是时间不可同,遇到线程编写有问题情况就会一直阻塞线程
抢占式线程: 由系统来分配时间,线程切换不由自己决定,不会有一个线程阻塞导致整个进程阻塞的情况,java 的线程调度就是抢占式的
-
3.3 状态转换 - 5 种进程状态:
1.新建(new)
2.运行(running)
- 无期限等待(timed waiting)
- 阻塞(blocked)
5.结束(terminated)
9. 线程安装与锁优化
9.1 线程安全
- 在多线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那么这个对象就是线程安全的(其实就是代码本身封装了正确性的保证手段(同步互斥等))
- 9.1.1 java 语言中的线程安全
- 不可变
- 不可变对象一定是安全的,因为值不会变,多线程的操作不会影响到不可变对象
- 2.绝对线程安全
- java.util.Vector 是一个线程安全的容器,因为他的 add()、get()、size()、都是被 synchronize 修饰的,尽管效率很低,但是确实安全
- 3.相对线程安全
- 4.线程兼容
- 线程对立
- 不可变
- 9.1.2 线程安全的实现方法
- 1.同步互斥
- 2.非阻塞同步
- 无同步方案
- 可重入代码
- 线程本地存储
- 9.1.1 java 语言中的线程安全
9.2 锁优化
- 自旋锁和自适应自旋
- 不会代替阻塞,会占用处理器的资源,锁占用的时间短自旋比较好,但是时间长就会浪费很多处理器性能
- 锁销除
- 锁消除是指虚拟机在即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,锁消除的主要判断依据是逃逸分析的数据支持,只要认为当前数据是线程私有,那么同步加锁自然就不需要
- 锁粗化
- 虚拟机探测到有一串零碎的操作都对同一个对象进行加锁,就会把加锁同步的范围扩展(粗化)到整个操作序列的外部(eg:对于一个对象反复加锁,甚至加锁出现在循环体)
- 轻量级锁
- 本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,
轻量级锁:需从 hotspot 虚拟机的内存布局开始介绍,虚拟机中的对象头分为两部分,第一部分(mark word)用于存储对象自身运行时数据,如 hashCode、GC 分代年龄等,另一部分用于存储指向方法区对象类型数据的指针,如果对象是数组,还会有额外的部分用于存储数组长度
大致加锁流程: 同步对象没有被锁定->在当前线程的栈帧中建立锁记录(lock record)空间(存储所对象的 mark word 的拷贝---displaced mark Word)->虚拟机通过 CAS 将对象的 mark word 更新为指向 lock record 的指针(若成功,轻量级加锁成功,若失败,会先检查当前对象的 mark word 是否指向当前线程的栈帧,如果是,说明当前线程已经有了对象的锁,锁标志为“00”,可以直接进行同步操作,否则就是当前对象已被其他线程占用)如果有两个线程同时竞争同一个锁,那么轻量级锁不再有效,会升级为重量级锁,锁标志为“10”
加锁的过程是使用 CAS 操作的
- 偏向锁
- 在线程无竞争的时候将同步操作都消除掉,都不用 CAS 操作
10. java 虚拟机家族
1. 商用高性能虚拟机
- Sun HotSpot
- BEA JRockit
- IBM J9
2. 其他影响较大的虚拟机
- Sun Classic 虚拟机
- Sun Exact 虚拟机
- Apache Harmony 虚拟机
3. 嵌入式虚拟机
- Dalvik 虚拟机
- KVM 虚拟机
CAS 的全称是 compare and swap (比较相同再交换)
是现在 cpu 广泛支持的一种对内存中共享数据进行操作的一种特殊指令
作用: CAS 可以将比较和交换转换为原子操作,这个原子操作直接由 cpu 保证
CAS 客户保证共享变量赋值的时候原子操作,CAS 操作依赖于 3 个值:内存中的 V,旧的预估值 X,要修改的新值 B ,如果旧的预估值 X 等于内存中值 V,就将新的值 B 保存到内存中
1.指定 jvm 启动模式
jvm 启动时,通过-server 或-client 参数指定启动模式。
2.cilent 模式与 server 模式的区别
1)编译器方面:
当虚拟机运行在 client 模式时,使用的是一个代号为 c1 的轻量级编译器,而 server 模式启动时,虚拟机采用的是相对重量级,代号为 c2 的编译器;c2 编译器比 c1 编译器编译的相对彻底,服务起来之后,性能更高。
2)gc 方面:
cilent 模式下的新生代(Serial 收集器)和老年代(Serial Old)选择的是串行 gc
server 模式下的新生代选择并行回收 gc,老年代选择并行 gc
3)启动方面:
client 模式启动快,编译快,内存占用少,针对桌面应用程序设计,优化客户端环境的启动时间
server 模式启动慢,编译更完全,编译器是自适应编译器,效率高,针对服务端应用设计,优化服务器环境的最大化程序执行速度
注:一般来说系统应用选择有两种方式:吞吐量优先和停顿时间优先,对于吞吐量优先的采用 server 默认的并行 gc(Parallel Scavenge),对于暂停时间优先的选择并发 gc(CMS)。