java虚拟机
强烈推荐阅读深入理解java虚拟机第三版,并可以反复观看
JVM
JDK = JRE+开发调试诊断工具
JRE = JVM + JAVA标准库
JVM的运行时数据区
1.程序计数器
程序计数器占用一块较小的内存空间。当前线程所执行字节码的指示器。就像是汇编语言中保存了执行的地址。程序计数器是线程独立的,当每个线程执行到某个位置时可能会阻塞,线程切换后能够恢复到正确的执行位置都是利用程序计数器来得到。它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器 的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
2.java虚拟机栈
Java虚拟机栈线程私有。每个方法的创建,java虚拟机栈都会创建一个栈帧(存储局部变量表,操作数栈,动态链接,方法出口等信息)方法调用执行完就会出栈。这也就是我们递归调用的时候就相当于不断的将自己这个方法压栈,当超出容量的时候就会发生栈溢出的异常。
3.本地方法栈
调用本地的方法时使用到的地方,我们java语言中很多底层的实现都调用来c语言的实现。
4.java堆
Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所 有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。也是这里拥有者jvm最火爆的垃圾回收机制。常见主流的java8默认parallel并行收集器,parnew + cms的垃圾回收机制,java11默认的g1垃圾回收机制,以及未来趋势的shenandoah和zgc收集器。
5.方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
6.运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
7.直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Nat ive函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。
HotSpot虚拟机对象
对象的创建
当java虚拟机遇到一个new 的指令时,首先去检查能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表类是否已被加载、解析和初始化过,如果没有则需要先执行相应的类加载。
类加载检查通过后就需要为新生对象分配内存,假设java堆中的内存时绝对规整的,被使用过内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲方向挪动与分配对象大小相等的距离,这种分配叫“指针碰撞”。如果java堆中的内存并不是规整的,虚拟机就必须维护一个列表,这种分配方式称为”空闲列表”。选择那种分配方式由java堆中所采用的的垃圾收集器是否带有空间压缩整理的能力决定。
对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充
对象头部分包含两类信息:一类是用于存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等另一类是类型指针即对象指向它的类型元数据指针,java虚拟机通过这个指针来确定该对象是哪个类的实例。
实例数据真正存储的有效信息,程序代码中所定义的各种类型的字段内容
对齐填充,hotspot虚拟机的自动内存管理系统要求对象的大小都必须是8字节的整数倍,当实例数据部分没有对齐就需要对齐填充
OutOfMemoryError
java堆溢出
虚拟机栈和本地方法栈溢出
方法区和运行时常量池溢出
本机直接内存溢出
垃圾收集器与内存分配策略
判断对象的死亡策略
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。会有循环依赖的问题,java虚拟机并不是通过引用计数算法来判断对象是否存货的
可达性分析算法
通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的
在java技术体系中,固定可作为GC Roots的对象包括以下几种:
1.在虚拟机栈中引用的对象(正在运行的方法所使用到的参数、局部变量、临时变量等)
2.在方法区中类静态属性引用的对象(java类的引用类型静态变量)
3.在方法区中常量引用的对象(字符串常量池里的引用)
4.在本地方法栈中引用的对象
5.java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象等还有系统类加载器。
6.所有被同步锁(synchronized关键字)持有的对象
7.反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
四种引用类型
强引用:最传统的“引用”定义,在程序代码中普遍存在的引用赋值。无论任何情况下只要强引用还在,垃圾收集器就永远不会回收掉被引用的对象
软引用:描述一些还有用,但非必须的对象。在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中。SoftReference类来实现。
弱引用:描述那些非必须对象,只能生存到下次垃圾收集发生为止。WeakReference类来实现。
虚引用:为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。PhantomReference类来实现虚引用。
拯救自己
当可达性分析算法中判定为不可达的对象,这时候暂时处于“缓刑”阶段,要真正宣告一个对象死亡,最多会经历两次标记过程,如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。随后进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。如果没有覆盖此方法或者已经被调用过一次那么就不需要执行了。
回收方法区
常量的条件比较简单,假如“java”字符串进入常量池,当系统又没有任何一个字符串对象的值是“java”。这个“java”常量就会被系统清理出常量池。常量池中其他类、方法、字段的符号引用也一样。
要判断一个类型是否属于“不再使用的类”的条件比较苛刻:
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收算法
分代收集理论
当前商业虚拟机的垃圾收集器大多数都遵循了“分代收集”,两个分代假说:
1.弱分代假说:绝大多数对象都是朝生夕灭的。
2.强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
因为存在老年代引用新生代区域,遍历整个老年代所有对象的方案虽然理论可行但会给内存回收带来很大的性能负担。为了解决这个问题就对分代理论添加第三条:
跨代引用假说:跨代引用相对于同代引用来说仅占极少数
标记-清除算法
标记出所有需要回收的对象,标记完成后统一回收掉所有被标记的对象,也可以反过来标记存活的对象,回收未被标记的
缺点:执行效率不稳定,要进行大量的标记和清除动作,内存空间的碎片化
标记-复制算法
将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把这块需要清理的内存一次清理掉。缺点:一半的空间被浪费了
标记-整理算法
主要针对老年代,老年代的存活对象一般比较多,使用标记复制算法不仅复制的效率会低而且还要浪费空间,但是标记清除算法会产生大量的内存碎片。标记整理则是不直接清除对象,让所有的存活的对象都向空间一端移动,然后直接清除掉边界以外的。这也会发生常说的“stop the world”
HotSpot算法实现
GCRoot节点枚举
由于Java对象太多,在进行根节点枚举的时候,需要遍历找到所有对象显然不太现实(GCRoot的枚举在收集器算法当中一般都需要Stop The World)在HopSpot解决方案当中,采用的是成为OopMap的数据结构,避免扫描所有对象。 JVM可以做到在类加载完成后,就能获取对象的数据信息。将这些信息放到OopMap中,避免了扫描整个程序上下文。
安全点
在方法执行的过程中, 可能会导致引用关系发生变化,那么保存的OopMap就要随着变化。如果每次引用关系发生了变化都要去修改OopMap的话,这又是一件成本很高的事情。所以这里就引入了安全点的概念。
OopMap的作用是为了在GC的时候,快速进行可达性分析,所以OopMap并不需要一发生改变就去更新这个映射表。只要这个更新在GC发生之前就可以了。所以OopMap只需要在预先选定的一些位置上记录变化的OopMap就行了。这些特定的点就是SafePoint(安全点)。由此也可以知道,程序并不是在所有的位置上都可以进行GC的,只有在达到这样的安全点才能暂停下来进行GC。
在垃圾收集发生时让所有线程跑到最近的安全点的方法:
抢断式中断
抢断式中断就是在GC的时候,让所有的线程都中断,如果这些线程中发现中断地方不在安全点上的,就恢复线程,让他们重新跑起来,直到跑到安全点上。
主动式中断
主动式中断在GC的时候,不会主动去中断线程,仅仅是设置一个标志,当程序运行到安全点时就去轮训该位置,发现该位置被设置为真时就自己中断挂起。所以轮训标志的地方是和安全点重合的,另外创建对象需要分配内存的地方也需要轮询该位置。
1、循环的末尾
2、方法临返回前
3、调用方法之后
4、抛异常的位置
安全区域
有些线程执行到某些代码之后就不再执行,而是挂起,这时候对象之间的引用关系不会发生改变,在这时候进行垃圾收集是安全的。这类区域就叫做安全区域。
安全区域可以是安全点的一个扩展。
当线程处于安全区域内之后,将自己标记为处于安全区之中。当线程在安全区域当中需要离开的时候,需要判断是否在进行根节点的枚举,如果是处于根节点枚举过程序,则等待垃圾收集器给出信号才可以离开。
记忆集
在进行垃圾收集的时候,可能存在跨代引用。如老年代引用新生代对象。这时候,为了避免直接扫描整个老年代对象区域,可以建立一个数据结构,用于记录其他区域指向当前区域的引用,从而避免了扫描全部区域。 卡表是记忆集的一种实现方式.
写屏障
当有其他区域对象指向当前区域对象的时候,需要有一个方法将对象卡表变脏。具体实现方式是在虚拟机层面进行对象引用赋值操作的时候,形成类似于AOP切面可以在赋值前后进行相应操作。赋值前叫做写前屏障,赋值后叫写后屏障。
并发的可达性分析
基本上所有垃圾收集器的垃圾回收都会有标记这一步(标记对象是否是可以到达的)。由于对象区域很大,在这个区域可以与用户线程并行执行,将会是很高效的。
在三色原理过程中得出结论,对象消失仅有以下两种情况:
赋值器插入了一条或者多条从黑色对象指向白色对象的引用。
赋值器删除了全部从灰色对象指向白色对象的引用。
如果能破坏两个条件中的一个,就可以解决并发扫描对象消失的问题。带来的方案也对应的有以下两种:
增量更新
增量更新要破坏的是第一个条件(赋值器插入了一条或者多条从黑色对象到白色对象的新引用),当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
可以简化的理解为:黑色对象一旦插入了指向白色对象的引用之后,它就变回了灰色对象。
原始快照
原始快照要破坏的是第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用),当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
这个可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照开进行搜索。
经典垃圾收集器
Serial收集器
Serial收集器是最基础、历史最悠久的收集器。是一个单线程工作的收集器。在它进行垃圾收集时必须暂停其他所有工作线程。
ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为和Serial收集器基本一致
目前只有它能够与CMS收集器配合工作
Parallel Scanvenge收集器
Parallel Scanvenge是新生代收集器,使用标记-复制,并行收集,与ParNew非常相似。特点是CMS等收集器关注的尽可能缩短垃圾收集时用户线程的停顿时间。而Parallel Scanvenge收集器的目标是达到一个可控制的吞吐量。吞吐量就是运行用户代码和处理器总耗时时间。停顿时间越短越适合与用户交互或者需要保证服务响应质量的程序,高吞吐量则可以最高效率地用处理资源,尽快完成程序的任务。经常被称作“吞吐量优先收集器“
Serial Old收集器
Serial Old是Serial收集器的老年代版本,同样是单线程收集器,使用标记-整理算法。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现.
CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法。
运作过程分为四个步骤:初始标记,并发标记,重新标记,并发清除
由于CMS收集器无法处理“浮动垃圾”有可能出现”Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生
Garbage First 收集器
Garbage First收集器是垃圾搜集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式
在G1收集器出现之前,垃圾收集的目标范围要么是整个新生代,要么就是整个老年代,要么就是整个java堆。而G1它可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
如果不去计算用户线程运行过程中的动作G1收集器的运作过程大致可分为四个步骤:
初始标记:仅仅标记一下GCRoots能直接关联到的对象,并且修改TAMS指针的值,需要停顿线程但耗时很短。
并发标记:从GC Root开始对堆中对象进行可达性分析,当对象图扫描完以后还要重新处理SATB记录下的并发时有引用变动的对象。
最终标记:对用户线程做另一个短暂的暂停。用于处理并发阶段遗留下少量的SATB记录
筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划
类文件结构
java虚拟机平台无关性”一次编译,到处运行“。java虚拟机提供语言无关性。其他语言的程序编译成.class的字节码文件皆可以在java虚拟机中运行。
魔数与Class文件
每个Class文件的头4个字节被称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数值为0xCAFEBABE。紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号,第7和第8个字节是主版本号。java版本号从45开始。
常量池
紧接着主、次版本号之后的是常量池的入口,常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据。由于常量池中的常量数量是不固定的,所以常量池的入口需要放置一个u2类型的数据,这个容量计数是从1而不是0开始的。常量池中主要存放的两大类常量:字面量和符号引用
访问标志
在常量池结束以后紧接着的2个字节代表访问标志,用于识别一些类或者接口层次的访问信息包括这个Class是类还是接口;是否定义为public类是否定义为abstract类;如果是类的话是否被声明为final等等
类索引、父类索引与接口索引集合
Class文件中由这三项数据来确定该类型的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。接口索引集合就用来描述这个类实现了哪些接口。
字段表集合
字段表用于描述接口或者类中声明的变量。
方法表集合
描述方法,与字段的描述基本类似。方法里的java代码经过javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为”Code“的属性里面。
属性表集合
Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息
1.Code属性
java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。
2.Exceptions属性
Exceptions属性的作用是列举出方法中可能抛出的受查异常,也就是方法描述时在throws关键字后面列举的异常。
3.LineNumberTable属性
用于描述Java源码行号与字节码行号之间的对应关系。如果选择不生成,堆栈将不会显示出错的行号,在调试程序时也无法按照源码行来设置断点。
4.LocalVariableTable及LocalVariableTy p eTable属性
LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系。在JDK 5引入泛型之LocalVariableTable属性增加了一个“姐妹属性”—— LocalVariableTy p eTable。这个新增的属性结构与LocalVariableTable非常相似,仅仅是把记录的字段描述 符的descrip tor_index替换成了字段的特征签名(Signature)。对于非泛型类型来说,描述符和特征签名能描述的信息是能吻合一致的,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉[3],描 述符就不能准确描述泛型类型了。因此出现了LocalVariableTy peTable属性,使用字段的特征签名来完 成泛型的描述。
5 . SourceFile及SourceDebugExtension
SourceFile属性用于记录生成这个Class文件的源码文件名称。为了方便在编译器和动态生成的Class中加入供程序员使用的自定义内容,在JDK 5时,新增了SourceDebugExtension属性用于存储额外的代码调试信息。
6.ConstantValue属性
作用是通知虚拟机自动为静态变量赋值。
7.InnerClasses属性
用于记录内部类与宿主类之间的关联。
8.Deprecated及Synthetic属性
Deprecated及Synthetic都属于标志类型的布尔属性,只存在有和没有的区别没有属性值。Deprecated属性用来表示某个类、字段或方法
Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自己添加的
9.StackMapTable属性
StackMapTable属性在JDK 6增加到Class文件规范之中,它是一个相当复杂的变长属性,位于Code 属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Ty p e Checker)使用目的在于代替以前比较消耗性能的基于数据流分析的 类型推导验证器。
10.Signature属性
Signature属性在JDK 5增加到Class文件规范之中,它是一个可选的定长属性,可以出现于类、字段 表和方法表结构的属性表中。之所以要专门使用这样一个属性去记录泛型类 型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,字节码(Code属性)中所有的泛型信息编 译(类型变量、参数化类型)在编译之后都通通被擦除掉。使用擦除法的好处是实现简单(主要修改 Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport,运行期也能够节省一些类型 所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的 普通类型同等对待,例如运行期做反射时无法获得泛型信息。Signat ure属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取的泛型类型,最终的数据来源也是这个属性。
11.BootsrapMethods属性
BootstrapMethods属性在JDK 7时增加到Class文件规范之中,它是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedy namic指令引用的引导方法限定符。
12.MethodParameters属性
MethodParameters是在JDK 8时新加入到Class文件格式中的,它是一个用在方法表中的变长属性。MethodParameters的作用是记录方法的各个形参名称和信息。
13.模块化相关属性
DK 9的一个重量级功能是Java的模块化功能,因为模块描述文件(module-info.java)最终是要编 译成一个独立的Class文件来存储的,所以,Class文件格式也扩展了Module、ModulePackages和 ModuleM ainClass三个属性用于支持Java模块化相关功能。
14.运行时注解相关属性
jdk8之后提供了6种属性,比如RuntimeVisibleAnnotations主要是记录了类、字段或方法的声明上记录运行时可见注解,当我们使用反射API来获取类、字段或方法上的注解时,返回值就是通过这个属性来取得的。
虚拟机类加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需 要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成 的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销, 但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
类加载的时机
一个类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期会经历加载、验证、准备、解析、使用和卸载七个阶段
在什么情况下需要开始类加载过程的第一阶段“加载”,这点可以交给虚拟机的具体实现来自由把握。
遇到了以下六种情况必须立即对类进行“初始化”
1.遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。
2.使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3.当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4.当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会初始化这个主类。
5.当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_p utStatic、REF_invokeStatic、REF_newInvokeSp ecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
6.当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
1.通过子类引用父类的静态字段,不会导致子类初始化
2.通过数组定义来引用类,不会触发此类的初始化
3.常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
类加载过程
加载
加载阶段java虚拟机需要完成三件事情:
1.通过一个类的全限定名来获取定义此类的二进制字节流
2.将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
文件格式验证、元数据验证、字节码验证和符号引用验证
准备
准备阶段是正式为类中定义的变量(即静态变量、被static修饰的变量)分配内存并设置类变量初始值的阶段。这里的初始值是零值。如果是final所修饰则会有ConstantValue那么值就为所设置的初始。
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
初始化
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
类加载器
java虚拟机设计团队有意把类加载阶段中的”通过一个类的全限定名来获取描述该类的二进制字节流“这个动作放到java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。
类和类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于 任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每 一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
双亲委派模型
站在Java虚拟机的角度来看,只存在两种不同的类加载器,一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都是由Java语言实现,独立存在于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader
启动类加载器
负责加载存放在lib目录下,或者被-Xbootclasspath参数所指定的路径中存放的,而且是java虚拟机能够识别的类库
扩展类加载器
负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库
应用程序类加载器
负责加载用户类路径上所有的类库
双亲委派机制
自定义类加载器 -> 应用程序类加载器 -> 扩展类加载器 -> 启动类加载器
加载类时会不断先向上抛,看父类加载器是否能加载当前类,只有当顶层的类加载器不能加载时才会由下面的类加载器来加载
遗留问题:OSGi相关破坏了双亲委派机制,java9之后的模块化机制
虚拟机字节码执行引擎
执行引擎是Java虚拟机核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机 器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层 面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执 行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,”栈帧“则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。
局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索 引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐 含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据 方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变 量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用 域,那这个变量对应的变量槽就可以交给其他变量来重用。
操作数栈
操作数栈也被成为操作栈,它是一个后入先出栈。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种 字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过 将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作 数栈来进行方法参数的传递。
动态链接
每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用,持有这个引用是为了支持方 法调用过程中的动态连接(Dynamic Linking)。我们知道Class文件的常量池中存 有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号 引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接
方法返回地址
一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法 返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种 退出方法的方式称为“正常调用完成”
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理
方法调用
Class文件的编译过程中不包含传统程序语言编译的 连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局 中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使 得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法 的直接引用。
解析
所有方法调用的目标方法在Class文件里面都是一个常量池中的符 号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前 提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不 可改变的。在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两 大类,前者与类型直接关联,后者在外部不可被访问。
分派
静态分派
静态分派典型的应用则是重载。所有依赖静态类型来决定方法执行版本的分派动作,称为静态分派,静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”的版本。
动态分派
Java语言里动态分派的实现过程,它与Java语言多态性的另一个重要体现-重写有着很密切的关联
invokevirtual指令的运行时解析过程大致分为以下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对 目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。我看起来就是重载加重写的数量有多个。当父类出现重载,子类继承并重写,那么进行方法调用的时候根据目标方法的依据有两种,静态类型是父类还是子类,方法参数是不同重载方法的哪个,两个宗量进行选择,所以是静态分派多分派。而动态分派的时候已经确定了重载的目标方法是哪个,只需要关注实际接受者是父类还是子类,那么这个宗量选择就只有一个就称为单分派。
动态类型语言支持
Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增 过一条指令,它就是随着JDK 7的发布的字节码首位新成员——invokedynamic指令。
动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的
JDK 7时新加入的java.lang.invoke包[1]是JSR 292的一个重要组成部分,这个包的主要目的是在之前 单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称 为“方法句柄”(Method Handle)
确实,仅站在Java语言的角度看,M ethodHandle在使用方法和效果上与Reflection有众多相似之 处。不过,它们也有以下这些区别:
Reflection和M ethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次 的方法调用,而M ethodHandle是在模拟字节码层次的方法调用。在M ethodHandles.Lookup上的3个方法 findStatic( ) 、 findVirtual( ) 、 findSpecial( ) 正是为了对应于invokestatic、invokevirtual( 以 及 invokeinterface)和invokesp ecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API时是不需要关心的。
Reflection中的java.lang.reflect.M ethod对象远比MethodHandle机制中的
java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法 的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而 后者仅包含执行该方法的相关信息。用开发人员通俗的话来讲,Reflection是重量级,而M ethodHandle 是轻量级。
由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化 (如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善 中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。
MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度看”之后:Reflection API的设计目标是只为Java语言服务的,而MethodHandle 则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主角。
JAVA内存模型与线程
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来 更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。在多路处理器系统中,每 个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存 多核系统(Shared Memory Multiprocessors System)。“内存模型”,在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可 以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型。
JAV内存模型
主内存和工作内存
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到 内存和从内存中取出变量值这样的底层细节。Java内存模型规定了所有的变量都存储在主内存中(此处的主内存与物理硬件时提到的主内存可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(可与处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本[2],线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据[3]。不同的线程之间也无法直接访问对方工作内存中的变 量,线程间变量值的传递均需要通过主内存来完成
内存间交互操作
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的 变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的 变量中。
ava内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内 存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回 主内存。
不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。
一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 a s s i gn ) 的 变 量 , 换 句 话 说 就 是 对 一 个 变 量 实 施 u s e 、 s t o r e 操 作 之 前 , 必 须 先 执 行 a s s i gn 和 l o a d 操 作 。
一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量 前,需要重新执行load或assign操作以初始化变量的值。
如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
对于volatile型变量的特殊规则
当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见 性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知 的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如, 线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对 主内存进行读取操作,新变量值才会对线程B可见。
使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程 中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的 执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的 所谓“线程内表现为串行的语义”
只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且, 只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量 V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其 他线程对变量V所做的修改。
只 有 当 线 程 T 对 变 量 V 执 行 的 前 一 个 动 作 是 assign的 时 候 , 线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以 看到自己对变量V所做的修改。
假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动 作,假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W 实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的 对变量W的read或write动作。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序 相同。
针对long和double型变量的特殊规则
许虚拟机将没有 被volat ile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”
原子性、可见性与有序性
原子性
Java内存模型来直接保证原子性变量操作包括read、load、assign、use、store、write这六个,大致认为基本数据类型的访问、读写都是具备原子性例外就是long和double的非原子性协定,但几乎不会发生
可见性
除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronize和final。
有序性
Java语言提供了volat ile和synchroniz ed两个关键字来保证线程之间操作的有序性,volatile关键字本 身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对 其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。