前段时间看了周志明老师的《深入理解Java虚拟机(第三版)》,加上自己在看的过程中查找的一些资料和理解,做了一些笔记,今天趁着复习,在这里分享一下。希望能帮助到同在复习Java虚拟机的同学,希望大家秋招Offer多多!
在HotSpot虚拟机中,对象在堆内存中的存储布局分为三个部分:
访问过程:通过栈上的reference数据来操作堆上的具体对象,如下图所示:
通过句柄访问对象
通过直接指针访问对象:
对象类型数据和实例区别:
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
强引用: 是指在程序代码之中普遍存在的引用赋值,即类似Object obj=new Object()
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用: 用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用: 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用: 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
(1) 两个分代假说
(2) 垃圾收集器的设计原则: 应该将Java堆划分出不同的区域,然后将对象根据其年龄(熬过垃圾收集过程的次数)分配到不同的区域中存储。
(3) Java虚拟机中的设计: 一般把Java堆分成新生代和老生代两个区域。新生代的未被收集的对象会逐渐跨向老生代。
新生代: 主要是用来存放新生的对象。一般占据堆空间的1/3
,由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代分为Eden区、ServivorFrom、ServivorTo三个区。
老年代: 老年代的对象比较稳定,所以MajorGC不会频繁执行。
Minor GC(新生代GC):简单理解就是发生在年轻代的GC。
Minor GC的触发条件为:当产生一个新对象,新对象优先在Eden区分配。如果Eden区放不下这个对象,虚拟机会使用复制算法发生一次Minor GC,清除掉无用对象,同时将存活对象移动到Survivor的其中一个区(fromspace区或者tospace区)。虚拟机会给每个对象定义一个对象年龄(Age)计数器,对象在Survivor区中每“熬过”一次GC,年龄就会+1。待到年龄到达一定岁数(默认是15岁),虚拟机就会将对象移动到年老代。如果新生对象在Eden区无法分配空间时,此时发生Minor GC。发生MinorGC,对象会从Eden区进入Survivor区,如果Survivor区放不下从Eden区过来的对象时,此时会使用分配担保机制将对象直接移动到年老代。
Major GC(老年代GC)的触发条件:清理老年代,
Full GC(堆清理):整个堆的清理,包括老年代和新生代
枚举GCRoots,利用OopMaps的数据结构,来达到根节点快速枚举,OopMaps其实就是一个映射表,通过映射表知道在对象内的什么偏移量上是什么类型的数据。
只在安全点的位置建立OopMaps,强制到达安全点以后才暂停,进行垃圾收集,通常在方法调用、循环跳转、异常跳转等地方设置安全点。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。
为了解决跨代引用问题,在新生代引入的记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。这样在垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。可以采用不同的记录粒度,以节省记忆集的存储维护成本。如:
卡精度使用"卡表"的方式实现记忆集,卡表使用一个字节数组实现,每个元素对应着其标志的内存区域中一块特定大小的内存块,称为卡页。卡页大小为2的整数次方,HotSpot中是29 ,即512字节。
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。GC时,只要筛选卡表中变脏的元素加入GCRoots。
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。大多收集器使用写后屏障。
(1)可达性分析过程(三色分析): 从GCRoot开始,访问过的对象标记为黑色,未访问的标记为白色,已经被访问过但是至少有一个引用未被访问过的标记为灰色。一直访问,直到所有节点都访问完,那白色的就是需要回收的,黑色的不需要回收。
(2)浮动垃圾问题: 并发情况下,一个对象已经标记成黑色了,但是这个线程后面又不需要了,需要进行回收。这个问题可以在下一次回收中解决。
(3)对象消失问题: 并发情况下,有两种情况会导致对象消失:
(4)对象消失的解决方法
其工作示意图如下:
Serial Old是Serial收集器的老生代版本,使用标记整理算法,主要用于客户端模式下的HotSpot虚拟机使用。服务端用于和Parallel Scavenge搭配使用,或者作为CMS的失败预案。
是Serial收集器的多线程并行版本,主要用于和CMS搭配使用。
Parallel Scavenge收集器更关注吞吐量,适合于不熟悉收集器运作的情况下,使用该收集器配合自适应调节策略,把内存管理的优化任务交给虚拟机去完成。
其老生代收集器为Parallel Old收集器,两者搭配使用
是一种以获取最短回收停顿时间为目标的收集器 ,适合于服务器端,基于标记清除算法。收集过程分为四个步骤:
①初始标记: 主要做两件事:一是遍历CGRoots可直达的老年对象;二是遍历新生代可直达的老年对象。直达指的一级连接,并不会遍历整个对象图。
②并发标记: 遍历初始标记的对象图并进行标记
③重新标记: 为了防止并发中,引用关系发生错误而导致的的错误(与增量更新、原始快照有关)
1.对处理器资源极度敏感,适用于四核以上的处理器
2.会产生浮动垃圾
3.会产生大量内存碎片,需要进行碎片整理
和以前的垃圾收集器不同,G1收集器不再划分新生代、老年代,而是将内存分成一个一个的Region(大小为2的整数),在收集过程中,衡量标准不是属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,就去回收哪个region。每个region都可以根据需要扮演Egen空间,Survivor空间,或者老年代空间。 因为每次垃圾收集的空间都是region的整数倍,可以有计划的避免全区域的垃圾收集,通过使用一个优先级列表,保证在有限时间内取得尽可能高的收集效率。
整个收集器的运作过程分为四个步骤:
(1)与G1不同的地方:
(2)工作过程
(3)转发指针
转发指针,给每个对象布局结构的最前面统一加一个新的引用字段,在正常不处于并发的情况下,该引用指向自己。当需要修改时,只需要将指针指向另一个对象即可,此时,旧对象仍存在,未被清理。Shenandoah通过CAS操作,来保证并发。
(1)特点
(2)染色体指针
将指针的高4位提取出来存储四个标志信息,通过这些标志信息,虚拟机直接可以直接看出其引用对象的三色标记状态、是否进入了重分配集(即是否被移动过),是否只能通过finalize()方法才能访问到等。这样一旦某个region的存活对象全被移走,这个region就能被释放和重用,而不必等其他指向该region的引用都被修正。还可以大幅减少在垃圾回收过程中内存屏障的使用数量。
(3)工作过程
①并发标记:和G1、Shenandoah一样,遍历对象图做可达性分析并标记
②并发预备重分配:根据特定的查询条件统计得出本次手机过程要清理哪些region,将这些region组成重分配集。这里每次都会扫描所以region,以省去记忆集维护成本。、
③并发重分配:这个过程要把重分 配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明 确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次 访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象 上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self- Healing)能力。
④并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所 有 引 用 , 这 一 点 从 目 标 角 度 看 是 与 Sh e n a n d o a h 并 发 引 用 更 新 阶 段 一 样 的 , 但 是 Z G C 的 并 发 重 映 射 并 不 是一个必须要“迫切”去完成的任务,因为旧引用也是可以自愈的,最多只是第 一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束 后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射 阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所
有对象的,这样合并就节省了一次遍历对象图[9]的开销。一旦所有指针都被修正之后,原来记录新旧 对象关系的转发表就可以释放掉了。
一般来说,对象都在新生代Eden区中分配,当Eden区没有足够空间进行分配时,将发起一起Minor GC。
相关参数设置:
通过PretenureSizeThreshold
参数可以设置当对象大于多少时,分配时直接进入老年代。所以应该尽量避免大对象,特别是生存周期很短的大对象(因为会频繁导致Minor GC)。
虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,会被移入Survivor空间中,并将年龄设定为1岁,在Survivor区中每熬过一代,年龄就增加一岁,当熬过一定程度后(默认15岁),就会晋升到老年代。
相关参数设置:
并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。
发生Minor GC之前,虚拟机先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果成立,说明这次Minor GC是安全的。如果不成立,会先查看XX:HandlePromotionFailure
参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。JDK 6 Update 24之 后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则将进行Full GC。
紧接文件版本后的(从第9个字节开始)是常量池入口。具体内容为:
常量池容量计数: 用u2类型的数字,来表示常量的个数(从1开始计数,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。)
常量池中存放的数据: 主要存放以下两大类数据,
常量池中每项常量都是一个表,截止JDK13,一共有17种不同类型的常量。
常量池结束后,紧接着2个字节代表访问标志,用于识别一些类或接口层次的访问信息,包括这个Class是类还是接口,是否为public,是否为abstract,是否申明为final等等。这些信息在2个字节的数字中使用标志位的方式来表示
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。类索 引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
用于描述接口或者类中声明的变量,不包括在方法中声明的局部变量。字段表结构分为以下几个部分:
int m
中,m就是简单名称)以及字段和方法的描述符(用于描述字段的数据类型(如用I来表示基本数据类型int,L表示对象类型,数组在类型前面添加一个[
字符来描述)、方法的参数列表(包括数量、类型及顺序)和返回值)。方发表集合和字段表集合采用了几乎一致的描述方式。方法表结构如下:
所不同的是,access_flag
中增加了去掉了一个属于字段的标志(如volatile),增加了一些属于方法的访问标志(如native等)。
Code
的属性里面。Class文件、字段表、方法表都能有自己的属性表,来描述一些场景专有的信息。一个符合规则的属性表满足以下结构:
一些常见的属性有:
1.Code: 方法体里面的代码编译后,变成字节码存储在Code属性中。接口和抽象类中的方法不存在Code属性,其结构如下所示:
上面需要注意的问题有:
LineNumberTable属性: 用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。非必须,如果不要这个属性,则程序抛出异常时不能显示异常所在的行号,且调试时,也无法按照源码行来设置断点。
LocalVariableTable及LocalVariableTypeTable属性: 用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系。非必须,没有这个属性时,则别人引用这个方法时,所以参数名都将丢失。
SourceFile及 SourceDebugExtension属性: SourceFile属性用于记录生成这个Class文件的源码文件名称。SourceDebugExtension属 性 用 于 存 储 额 外 的 代 码 调 试 信 息 。
ConstantValue属性: 作用是通知虚拟机自动为静态变量赋值,只有被static属性修饰的变量才能使用这项属性。Java对static和非static变量的赋值过程为:
()
方法)中进行的()
方法)中进行;②在ConstantValue中进行(目前的虚拟机中,如果同时用static和final字段修饰,并且这个类型是基本类型或String类型,就会生成ConstantValue属性来初始化)。Deprecated及Synthetic属性: Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用。Synthetic实现了对private级别的字段和类的访问,从而绕开了语言限制
Signature属性: 与Java泛型有关。任何类、接口、构造器方法或字段的声明如果包含了类型变量(type variable)或参数化类型,则Signature属性会为它记录泛型签名信息。泛型最终的信息来源就来自于此属性。
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode) 以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。其中,由于限制操作码的长度为1个字节,故指令集的综述不能超过256条。为了减少指令的数量,故意将指令设计成支持非完全独立的,且部分指令都没有支持整数类型byte、char和short,甚至没有任何指令 支持boolean类型。大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类 型作为运算类型(Computational Type)来进行的。
Java中的指令集分为以下几类:
1.加载和存储指令: 用于将数据在帧栈中的局部变量表和操作数栈之间来回传输
2.运算指令: 用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体分为针对整型和针对浮点数类型的运算两类。
3.类型转换指令: 用于两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作。
4.对象创建与访问指令: 针对数组和对象的指令不同。
6.控制转移指令: 控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下 一条指令继续执行程序,用于各种条件分支。
7.方法调用和返回指令: 用于调用各类方法
8.异常处理指令: 处理异常
9.同步指令: Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成 还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
类加载的七个阶段如下:
1.加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。为支持动态绑定,解析阶段可以在初始化阶段之后再开始。类加载在什么时候开始由虚拟机自己决定。
2.初始化的时间: 分为以下几种情况:
new
实例化对象时main
函数所在的类上面这几种情况,称为对一个类型进行主动引用,其他所有的引用均不会触发初始化,称为被动引用。
加载阶段主要完成三件事情:
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。该阶段的主要目的是保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的要求。
正式为类变量(静态变量) 分配内存并设置初始值。JDK7之前,Hotspot用永久代实现方法区时,静态变量是在方法区中分配的。但JDK8及之后,类变量会随着Class对象一起存在在Java堆中。需要注意的两点:
该阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这七类符号引用进行。
1.类或接口的解析: 在类D
中,要把一个符号N
解析为一个类或接口C
的直接引用,分为3步:
N
的全限定名传递给D
的类加载器去加载类C
,加载过程中,由于继承等关系,可能又会引起其他类的加载。D
是否有对C
的访问权限。2.字段解析: 首先按照字段的类型进行字段所属类的解析。解析成功,假设这个字段属于C
。那后续的解析步骤为:
NoSuchFieldError
异常3.方法解析: 首先解析出方法所属的类或接口的引用,然后去找。与字段解析不同的是,方法解析先去父类中找,再去接口中找。
4.接口方法解析: 和方法解析类似,但是只在本接口和父接口中找,不会去类中找
初始化阶段就是执行类构造器
方法的过程。有关
方法:
()
与类构造方法init()方法
不同,虚拟机会保证在子类的()
执行前,父类的已经执行完毕。所以,Object类的()
方法一定是最先执行的。这也意味着,父类的静态语句块要优先与子类的变量赋值操作。()
方法。()
执行前不需要先执行父类的()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的()
方法。()
方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()
方法,其他线程都需要阻塞等待,直到活动线程执行完毕()
方法。如果在一个类的()
方法中有耗时很长的操作,那就可能造成多个进程阻塞类加载器的作用是通过一个类的全限定名来获取描述该类的二进制字节流。对于任意一个类,必须由加载它的类加载器和这个类本身一起确立其在虚拟机中的唯一性。 比较两个类是否相等,只有在这两个类是由一个类加载器加载的前提下才有意义。否则,如果类加载器不相等,那两个类肯定不相等。
①三层类加载器
启动类加载器(Bootstrap ClassLoader) :负责加载存放在
目录,或者被-Xbootclasspath
参数指定的路径中存放的,且是JVM能够识别的类库。只有这个类加载器是使用C++语言实现,是JVM的一部分,其他类加载器全部由java语言实现,独立存在于JVM外部,且都继承于java.lang.Classloader抽象类。
扩展类加载器(Extension ClassLoader):负责加载
应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上的所有类库,开发者同样可以直接使用此类加载器,如果没有指定,则默认使用此类加载器。
他们之间的协作关系如下所示,此关系称为双亲委派模型,双亲委派模型要求除了启动类加载器,其余的类加载器都应有自己的父类加载器。但是类加载器之间的父子关系一般不是以继承关系实现,而是通过使用组合关系来复用父加载器的代码。
②双亲委派模型:
帧栈是用于支持虚拟机进行方法调用和方法执行背后的数据结构。帧栈存储了方法的局部变量、操作数栈、动态连接和方法返回地址等信息。在编译Java程序码时,帧栈中需要多大的局部变量表多深的操作栈已经被分析计算出来,并写入到方法表的Code属性当中了。故帧栈需要分配多少内存在编译时就确定了。
对于执行引擎,在活动线程中,==只有位于栈顶的方法(当前方法)才是运行的,只有位于栈顶的帧栈(当前帧栈)才是生效的。==执行引擎所运行的字节码指令都只针对当前帧栈操作。帧栈的结构如下:
方法刚开始执行时,操作数栈为空。方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。操作数栈中元素的数据类型必须要和字节码指令的序列严格匹配,编译程序的时候就要保证这一点。
每个帧栈都包含一个指向运行时常量池中该帧栈所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。在将符号引用转换成直接引用时,有一部分会在类加载阶段或者第一个使用的时候就转换成直接引用,这种称谓静态解析。另一部分,将在每一次运行期间,都转换为直接引用,称谓动态连接。
当一个方法开始执行后,有两种方式退出这个方法:
方法退出后,都必须返回方法最初被调用的位置,程序才能继续执行。方法返回时,需要在帧栈保存一些信息,用来帮助恢复它的上层主调方法的执行状态。(方法退出就能相当于把当前帧栈出栈,然后再返回值压到调用者帧栈的操作数栈中,然后PC计数器指向方法调用后的指令)
所有方法调用的目标在Class文件里都是一个常量池中的符号引用,故要调用方法,则需要将符号引用接解析成直接引用;解析指的是在类加载解析阶段,将符号引用转换成直接引用的过程,调用的方法就已经确定的方法。这个过程要求,程序运行之前就有一个可确定调用的版本,并且这个版本运行期间不可改变。
分派用来调用体现Java多态性的方法。
1.静态分派: 依赖静态类型来决定方法执行版本的分派动作,称为静态分派。最典型应用是方法重载。下面代码输出hello,guy
。因为上面的代码在编译时,就能确定调用哪个重载函数。
输出结果为下图
这涉及到invokevirtual
指令的解析过程,过程如下:
找到操作数栈顶的第一个元素所指向的对象的 实际类型 ,记作C。
如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
3.单分派与多分派
宗量: 方法的接受者与方法的参数统称为方法的宗量
根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种
Java是一种静态多分派,动态单分派的语言。静态多分派是指,在静态分派时,可以根据多个宗量选择,而动态分派时,只与该方法的接受者有关(方法的参数已经确定了),只有一个宗量作为选择依据。
4.虚拟机动态分派的实现
为了提高性能,虚拟机为类型在方法区建立了虚拟方法表和接口方法表,以此减少查找目标对象的时间。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方 法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了 这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
动态类型语言,是指数据类型的检查是在运行时做的。用动态类型语言编程时,不用给变量指定数据类型,该语言会在你第一次赋值给变量时,在内部记录数据类型。关键特征是类型检查的主体过程是在云慈宁宫期而不是在编译器进行的。
使用invoke包中的内容,可以做到类似下面的动态类型的操作。
使用反射也能做到类似上面的操作。两者有以下区别:
每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call Site)”, 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7 时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法 (Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和 名称。引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSit e对象,这个对象代表了真 正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到 并且执行引导方法,从而获得一个CallSit e对象,最终调用到要执行的目标方法上。
Java内存模型规定了所有的内存变量都存储在主内存中。 每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据,不同线程之间要交换数据均需要通过主内存来完成。
主内存和工作内存之间的数据的交换,Java内存模型规定了以下8种操作来完成:
①作用:
②内存交互的规则
相比普通变量,有以下规则,这些规则保证了可见性和禁止指令重排
Java内存模型允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数 据类型的load 、store 、read和write这四个操作的原子性 。
先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
Java中天然的线性发生原则:①程序次序控制(按照控制流程顺序,书写在前面的操作先行发生于书写在后面的操作)。②管程锁定规则(unlock肯定发生在lock后)③volatile变量规则(对volatile的写操作先行发生于对这个的读操作)④线程启动规则(start肯定先行与线程中的任何动作)⑤线程终止规则(线程中的所有操作先行发生于对此线程的终止检测)⑥线程中断规则(线程interrupt()方法调用先行与中断检测)⑥对象终结规则(初始化肯定先行于finalize方法)⑦传递性(先行规则具有传递性)
有三种线程的实现方式:
①内核线程实现: 指直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。内核通过调度器对线程调度,并负责映射到各个处理器上。通常通过轻量级进程实现,系统支持的轻量级进程是有限的。
②用户线程的实现: 用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存 在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。线程的所有操作(创建、销毁、切换和调度)需要用户自己处理。
③混合实现: 内核线程与用户线程一起使用的实现。这样线程的调度及映射可以由内核线程实现,而其他需要大规模并发的操作则由用户线程实现。
HotSpot虚拟机的每一个线程都是直接映射到操作系统的原生线程来实现的(即以内核线程的方式实现),自己不用关系线程的调度,全权交给操作系统处理。
两种线程调度:
Java定义了线程的6种状态,分别是:
代码本身对对象封装了所有必要性的保障手段(如互斥同步),调用者无需关心多线程下的调用问题,更无需自己实现任何措施来保证多线程环境下的正确调用
可以将Java语言中,各种操作共享的数据分为以下5类:
1.互斥同步: 互斥同步是最常见也是最主要的并发正确性保障手段,为阻塞同步(悲观并发策略)。其中,互斥是方法(临界区、互斥量、信号量),同步(在同一时刻只被一条(使用信号量时是一些)线程使用)是目的。
synchronized关键字: 最基本的互斥同步手段,是一种块结构同步语法。通过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作 为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减 一 。 一旦计数器的值为零 , 锁随即就 被释放了 。 如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
两个特点: ①被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块 也不会出现自己把自己锁死的情况②被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制 正在等待锁的线程中断等待或超时退出。
重入锁: 与synchronized一样可重入,但有一些高级功能:
2.非阻塞同步: 基于冲突检测的乐观并发策略。基本策略是不管风险,先操作,如果没有其他线程争用数据,那操作成功。否则,一旦产生冲突,则采取补偿措施,最常见的补偿措施是不断重试,直到没有竞争的共享数据为止。
CAS操作: 需要三个操作数,分别是内存位置、旧的预期值、准备设置的新值。执行时,当且仅内存地址处的值符合旧的预期值时,才用新值更新。
CAS操作存在ABA问题:如果变量V初次读取的时候是A值,在准备赋值的时候仍是A值,并不能保证V没被改过,有可能先被修改,然后再改回原值,可以使用AtomicStampedReference避免,其通过控制变量值的版本来保证CAS的正确性。
3.无同步方案: 如果一个方法本身不涉及共享数据,就不需要任何同步措施去保证其正确性。常见的有以下两类:
对于互斥锁,如果资源已经被占用,资源申请者只被挂起,这样对于一些执行时间很短的线程,不断挂起唤醒会很耗费资源。但是自旋锁不会引起调用者挂起,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,
虚拟机现在已经能根据以往的获取锁的过程,自适应地去决定自旋的次数。
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享 数据竞争的锁进行消除。主要通过逃逸分析(在方法中定义的对象,可能被方法外的对象所引用(比如方法返回这个定义的对象,然后在别的地方被使用),那这个对象就逃逸了)实现,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可 以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。
同步时,总推荐将同步块的作用范围限制地尽可能小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等 待锁的线程也能尽可能快地拿到锁。
但是有时候,如果一系列的连续操作都对同一个对象反复加锁和 解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。这时,就需要锁粗化,将加锁的部分粗化。
对象头: HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如HashCode、GC年龄分代等。数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
依据: 对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁就使用CAS操作,而避免使用互斥量的开销。但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
轻量级锁的工作过程: 在代码即将进入 同步块的时候,如果此同步对象没有被锁定(锁标志位为“ 01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),
然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的 最后两个比特)将转变为“ 00”,表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志 的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
锁会偏向于第一个获得它的线 程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
整个偏向锁、轻量级锁的状态转换过程如下所示:
在锁偏向时,Mark Word中的大部分用用来存储threadID,而占用了原有存储对象哈希码的位置。但是一个对象如果计算过哈希码,就应该一直保持不变。这样的话,一旦计算过哈希,就需要写到Mark Word中,一旦写入,那这个对象就再也无法进入偏向锁状态了。当一个对象当前正处于偏向锁状态,又收到计算hashcode请求时,偏向状态会立即被撤销,并且锁会膨胀成重量级锁。如果一个对象经常存在竞争,那就最好不要使用偏向锁。