秋招准备之——Java虚拟机

秋招复习笔记系列目录(不断更新中):

  • 1.数据结构全系列
  • 2.计算机网络知识整理(一)
  • 3.计算机网络知识整理(二)
  • 4. Java虚拟机知识整理
  • 5.计算机操作系统
  • 6.深入理解HashMap
  • 7.深入理解ConcurrentHashMap

前段时间看了周志明老师的《深入理解Java虚拟机(第三版)》,加上自己在看的过程中查找的一些资料和理解,做了一些笔记,今天趁着复习,在这里分享一下。希望能帮助到同在复习Java虚拟机的同学,希望大家秋招Offer多多!

一、Java内存区域与内存溢出异常

1.运行时数据区域

秋招准备之——Java虚拟机_第1张图片

  • 1.程序计数器: 当前线程所执行字节码的行号指示器,线程私有
  • 2.虚拟机栈: 描述方法执行的线程内存模型,每个方法执行的时候,会同步创建一个帧栈存储局部变量等信息。与方法中的局部变量紧密相关,局部变量会存放在局部变量表中,在编译期间,局部变量表所需的空间就确定了。局部变量中的存储空间以局部变量槽表示,long和double占两个变量槽,其他占一个。
    • 出现异常的情况:
      • 1.线程请求的栈深度大于虚拟机允许的深度会StackOverflow
      • 2.如果虚拟机栈可以动态扩容,当扩展无法申请到足够内存时会OutOfMemory
  • 3.本地方法栈: 和虚拟机栈类似,但本地方法栈为native方法服务
  • 4.堆: 所有线程共享,在虚拟机启动时创建,用于存放对象实例(所有的对象实例都在堆上分配),堆是垃圾收集器管理的区域。堆中可以划分出多个线程私有的分配缓冲区(TLAB,在分配内存时,每个线程都有自己的本地线程缓存池,分配时先从缓存池中分配,防止同步操作带来的性能问题),以提升对象分配效率。当堆中无法完成实例分配且堆无法扩展时,会抛出OOM异常。
  • 5.方法区: 所有线程共享,存储已被虚拟机加载的类型信息,常量,静态变量等,无法完成内存分配时,会抛OOM异常
    • 5.1 运行时常量池: 是方法区的一部分,存放字面量及符号引用(比如字符串常量池)
  • 6.直接内存: 堆外内存,受到本机总内存大小以及处理器寻址空间的限制。

2.HotSpot对象探秘

2.1 对象创建的大致步骤:

秋招准备之——Java虚拟机_第2张图片

2.2 对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局分为三个部分:

  • 1.对象头
    • Mark Word: 存储对象自身的运行时数据,如HashCode、GC分代年龄以及锁有关的信息
    • 类型指针: 对象指向它类型元数据的指针,通过这个对象确定类型是哪个类的实例
    • ③数组长度: 如果对象是数组,还需要一块内存记录数组长度。
  • 2.对象信息: 存储对象具体的信息,相同宽度的字段总是被分配到一起存放
  • 3.对齐填充: 仅仅起占位符作用(HotSpot中任何对象的大小必须8字节的整数倍,不足的需要填充)

2.3 对象的访问定位

访问过程:通过栈上的reference数据来操作堆上的具体对象,如下图所示:
通过句柄访问对象
秋招准备之——Java虚拟机_第3张图片
通过直接指针访问对象:
秋招准备之——Java虚拟机_第4张图片
对象类型数据和实例区别:

  • 对象类型数据(方法区):对象的类型、父类、实现的接口、方法等
  • 对象实例数据(堆 ):对象中各个实例字段的数据

二、垃圾收集器与内存分配策略

2.1 对象生命的确定

1.引用计数器算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

2.可达性分析算法

这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

  • 可以作为GCRoot的对象有哪些: 虚拟机栈中本地方法变量表中的引用对象、方法区中的类静态属性引用的对象,方法区中的常量引用的对象,本地方法栈中JNI的引用对象。
  • **为什么要选择这些对象作为GCRoot:**可达性分析的基本思路是,以当前活着的对象为root,遍历出他们(引用)关联的所有对象,没有遍历到对象即为非存活对象,这部分对象可以GC掉。当前帧栈中的引用型变量、静态变量引用的对象、本地方法栈JNI对象,是当前存活的对象,所有他们应该作为GCRoots。方法区中的常量引用对象,在当前可能存活,因此,也可能是GC roots的一部分。还有其他一些对象也可能是GC Roots的一部分,比如被classloader加载的class对象,monitor的对象,被JVM持有的对象等等,这些都需要视当前情况而定。

3.引用类型

  • 强引用: 是指在程序代码之中普遍存在的引用赋值,即类似Object obj=new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象

  • 软引用: 用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用: 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

  • 虚引用: 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

2.2 垃圾收集算法

1.分代收集理论

  • (1) 两个分代假说

    • 弱分代假说:绝大多数对象都是朝生夕灭
    • 强分代假说:熬过越多次垃圾收集过程的对象就难以消亡
    • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。所以新生代GC时,若存在老年代引用时,直接判定对象,若干年龄后进去老年代,存活避免新生代与老年代有GC引用链,导致新生代GC的时候,需要进行老年代GC
  • (2) 垃圾收集器的设计原则: 应该将Java堆划分出不同的区域,然后将对象根据其年龄(熬过垃圾收集过程的次数)分配到不同的区域中存储。

  • (3) Java虚拟机中的设计: 一般把Java堆分成新生代和老生代两个区域。新生代的未被收集的对象会逐渐跨向老生代。

    • 新生代: 主要是用来存放新生的对象。一般占据堆空间的1/3,由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代分为Eden区、ServivorFrom、ServivorTo三个区。

      • Eden区:Java新对象的出生地(如果新创建的对象占用内存很大则直接分配给老年代)。当Eden区内存不够的时候就会触发一次MinorGc,对新生代区进行一次垃圾回收。
      • ServivorTo:保留了一次MinorGc过程中的幸存者。
      • ServivorFrom: 上一次GC的幸存者,作为这一次GC的被扫描者。当JVM无法为新建对象分配内存空间的时候(Eden区满的时候),JVM触发MinorGc。因此新生代空间占用越低,MinorGc越频繁。 MinorGC采用复制算法。
    • 老年代: 老年代的对象比较稳定,所以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(堆清理):整个堆的清理,包括老年代和新生代
      

2.标记清除算法

  • 定义: 算法分为标记清除两个阶段,首先标记出要回收的对象(或者标记处不需要回收的对象),然后统一回收。
  • 缺点: ①效率不稳定(对象太多时效率下降);②内存空间碎片化
    秋招准备之——Java虚拟机_第5张图片

3.标记复制算法

  • 定义: 将内存按容量划分成大小相等的两块,每次只适用其中的一块,当一块用完了,就将还存活的对象复制到另一半,将已使用的一半全部清除掉。能避免内存空间碎片化的问题。 此方法一般用于新生代的垃圾回收中
  • 缺点: 可用内存缩小为原来的一半,会产生对象复制的开销
    秋招准备之——Java虚拟机_第6张图片

4.标记整理算法

  • 定义: 标记过程和标记清除算法一样,但后续步骤不是清除,而是所有存活的对象都向内存空间另一端移动,然后直接清理掉边界以外的内存
  • 缺点: 大量老生代存活的对象的移动,耗费时间,且需要全程暂停用户程序。基于此,一种解决方式是,先使用标记清除算法,当内存碎片化严重到不可忍受时,再使用标记整理算法整理一次
    秋招准备之——Java虚拟机_第7张图片

2.3 HotSpot的算法实现细节

1.根节点枚举

枚举GCRoots,利用OopMaps的数据结构,来达到根节点快速枚举,OopMaps其实就是一个映射表,通过映射表知道在对象内的什么偏移量上是什么类型的数据。

2.安全点

只在安全点的位置建立OopMaps,强制到达安全点以后才暂停,进行垃圾收集,通常在方法调用、循环跳转、异常跳转等地方设置安全点。

3.安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。

秋招准备之——Java虚拟机_第8张图片

4.记忆集和卡表

为了解决跨代引用问题,在新生代引入的记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。这样在垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。可以采用不同的记录粒度,以节省记忆集的存储维护成本。如:

  • 字长精度:每个记录精确到一个机器字长(处理器的寻址位数,如常见的 32 位或 64 位),该字包含跨代指针
  • 对象精度:每个记录精确到一个对象,该对象中有字段包含跨代指针
  • 卡精度:每个记录精确到一块内存区域,该区域中有对象包含跨代指针

卡精度使用"卡表"的方式实现记忆集,卡表使用一个字节数组实现,每个元素对应着其标志的内存区域中一块特定大小的内存块,称为卡页。卡页大小为2的整数次方,HotSpot中是29 ,即512字节。
秋招准备之——Java虚拟机_第9张图片
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。GC时,只要筛选卡表中变脏的元素加入GCRoots。

5.写屏障

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。大多收集器使用写后屏障

6.并发的可达性分析(参考博客)

  • (1)可达性分析过程(三色分析): 从GCRoot开始,访问过的对象标记为黑色,未访问的标记为白色,已经被访问过但是至少有一个引用未被访问过的标记为灰色。一直访问,直到所有节点都访问完,那白色的就是需要回收的,黑色的不需要回收。

  • (2)浮动垃圾问题: 并发情况下,一个对象已经标记成黑色了,但是这个线程后面又不需要了,需要进行回收。这个问题可以在下一次回收中解决。

  • (3)对象消失问题: 并发情况下,有两种情况会导致对象消失:

    • ①赋值器插入了一条或多条从黑色对象到白色对象的新引用
      秋招准备之——Java虚拟机_第10张图片
    • ②赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
      秋招准备之——Java虚拟机_第11张图片
  • (4)对象消失的解决方法

    • ①增量更新: 黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象,等第一次扫描结束后再扫描一次
    • ②原始快照: 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
      上面的两种操作,都是通过写屏障实现的

2.4 经典垃圾收集器

经典垃圾收集器之间的关系:
秋招准备之——Java虚拟机_第12张图片

1. Serial收集器

其工作示意图如下:
秋招准备之——Java虚拟机_第13张图片
Serial Old是Serial收集器的老生代版本,使用标记整理算法,主要用于客户端模式下的HotSpot虚拟机使用。服务端用于和Parallel Scavenge搭配使用,或者作为CMS的失败预案。

2. ParNew收集器(新生代)

是Serial收集器的多线程并行版本,主要用于和CMS搭配使用。
秋招准备之——Java虚拟机_第14张图片

3. Parallel Scavenge收集器(新生代)

Parallel Scavenge收集器更关注吞吐量,适合于不熟悉收集器运作的情况下,使用该收集器配合自适应调节策略,把内存管理的优化任务交给虚拟机去完成。
秋招准备之——Java虚拟机_第15张图片
其老生代收集器为Parallel Old收集器,两者搭配使用
秋招准备之——Java虚拟机_第16张图片

4. CMS收集器(老年代)

是一种以获取最短回收停顿时间为目标的收集器 ,适合于服务器端,基于标记清除算法。收集过程分为四个步骤:

  • ①初始标记: 主要做两件事:一是遍历CGRoots可直达的老年对象;二是遍历新生代可直达的老年对象。直达指的一级连接,并不会遍历整个对象图

  • ②并发标记: 遍历初始标记的对象图并进行标记

  • ③重新标记: 为了防止并发中,引用关系发生错误而导致的的错误(与增量更新、原始快照有关)

  • ④并发清除: 清理删除掉标记阶段判断已经死亡的对象
    秋招准备之——Java虚拟机_第17张图片
    缺点:

  • 1.对处理器资源极度敏感,适用于四核以上的处理器

  • 2.会产生浮动垃圾

  • 3.会产生大量内存碎片,需要进行碎片整理

5. G1收集器(新生带+老年代)

和以前的垃圾收集器不同,G1收集器不再划分新生代、老年代,而是将内存分成一个一个的Region(大小为2的整数),在收集过程中,衡量标准不是属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,就去回收哪个region每个region都可以根据需要扮演Egen空间,Survivor空间,或者老年代空间。 因为每次垃圾收集的空间都是region的整数倍,可以有计划的避免全区域的垃圾收集,通过使用一个优先级列表,保证在有限时间内取得尽可能高的收集效率。

整个收集器的运作过程分为四个步骤:

  • 初始标记(参考): 这阶段仅仅只是标记GC Roots能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。
  • 并发标记: 从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。
  • 最终标记: 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB (快照搜索)记录。
  • 筛选回收: 负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。此阶段也要暂停用户线程。
    秋招准备之——Java虚拟机_第18张图片

2.4 低延迟垃圾收集器

1. Shenandoah收集器

  • (1)与G1不同的地方:

    • ①支持并发整理

    • ②默认不是用分代收集

    • ③放弃记忆集,改用连接矩阵,降低处理跨代指针的记忆集维护消耗。连接矩阵类似于邻接矩阵
      秋招准备之——Java虚拟机_第19张图片

  • (2)工作过程

    • ①初始标记: 与G1一样,首先标记所有与GC Roots直接相连的对象,需要暂停用户线程
    • ②并发标记: 与G1一样,并发遍历对象图,标记处所有可达的对象
    • ③最终标记: 与G1一样,处理剩余SATB扫描,并统计回收价值最高的Region,构成回收集。
    • ④并发清理: 清理那些整个区域内一个存活对象都没有找到的Region
    • ⑤并发回收: 与其他收集器的核心差别,具体过程为:先将回收集里的存活对象复制一份到其他未被使用的Region中,通过读屏障和转发指针解决。
    • ⑥初始引用更新: 将堆中所有指向旧对象的引用修正到复制后的新地址。
    • ⑦并发引用更新: 真正进行更新操作,此操作不需要按照对象图搜索,只需要按照物理内存地址的顺序,线性搜索引用类型,把旧值改为新值。
    • ⑧最终引用更新: 修正GC Roots中的引用。
    • ⑨并发清理: 并发回收所有Region中不存活的对象,供以后新对象的使用
      秋招准备之——Java虚拟机_第20张图片
  • (3)转发指针

    转发指针,给每个对象布局结构的最前面统一加一个新的引用字段,在正常不处于并发的情况下,该引用指向自己。当需要修改时,只需要将指针指向另一个对象即可,此时,旧对象仍存在,未被清理。Shenandoah通过CAS操作,来保证并发。
    秋招准备之——Java虚拟机_第21张图片
    秋招准备之——Java虚拟机_第22张图片

2.ZGC收集器

  • (1)特点

    • ①虽然也有Region的概念,但Region分为大、中、小三类容量:
      • 小型:固定2MB,用于放置小于256KB的小对象
      • 中型:固定32MB,用于放置256KB~4MB的对象
      • 大型:容量不固定,容量可以动态变化,但必须是2MB的整数倍
    • ②和转发指针不同,采用染色体指针技术
  • (2)染色体指针

    将指针的高4位提取出来存储四个标志信息,通过这些标志信息,虚拟机直接可以直接看出其引用对象的三色标记状态、是否进入了重分配集(即是否被移动过),是否只能通过finalize()方法才能访问到等。这样一旦某个region的存活对象全被移走,这个region就能被释放和重用,而不必等其他指向该region的引用都被修正。还可以大幅减少在垃圾回收过程中内存屏障的使用数量。

    秋招准备之——Java虚拟机_第23张图片

  • (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]的开销。一旦所有指针都被修正之后,原来记录新旧 对象关系的转发表就可以释放掉了。

    秋招准备之——Java虚拟机_第24张图片

2.5 垃圾收集器的选择

收集器的权衡点

  • 应用的主要关注点是什么: 如客户端和嵌入式应用关心内存占用,数据分析等关系吞吐量
  • 使用JDK的发行商,版本号

2.6 内存分配与回收策略

1.对象优先在Eden分配

一般来说,对象都在新生代Eden区中分配,当Eden区没有足够空间进行分配时,将发起一起Minor GC。

相关参数设置:

  • -Xmx: 最大堆大小
  • -Xms: 最小堆大小
  • -Xmn: 年轻代堆大小
  • -XXSurvivorRatio: 年轻代中Eden区与Survivor区的大小比值

2.大对象直接进入老年代

通过PretenureSizeThreshold参数可以设置当对象大于多少时,分配时直接进入老年代。所以应该尽量避免大对象,特别是生存周期很短的大对象(因为会频繁导致Minor GC)。

3.长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,会被移入Survivor空间中,并将年龄设定为1岁,在Survivor区中每熬过一代,年龄就增加一岁,当熬过一定程度后(默认15岁),就会晋升到老年代。

相关参数设置:

  • MaxTenuringThreshold: 设置对象熬过几代进入老年期

4.动态对象年龄判定

并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。

5.空间分配担保

发生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。

三、类文件结构

3.1 Class类文件的结构

1.基础概念

  • 无符号数: 属于基本的数据类型,以u1、u2、u4、u8来分别表示1、2、4、8个字节的无符号数。可用来描述数字、索引引用、数量值或UTF-8编码构成的字符串值。
  • 表: 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,用于描述有层次关系的复合结构的数据,整个Class文件本质上是一张表。

2.魔数与Class文件的版本

  • 魔数: 每个Class文件的头四个字节被称为魔数,唯一作用是确定这个文件是否是一个能被虚拟机接收的Class文件, 值为0xCAFEBABE。
  • Class文件的版本: 紧接着魔数的4个字节存储的是Class文件的版本号,其中第5和第6个字节是次版本号,第7和第8个字节是主版本号。Java版本号从45开始,高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。

3.常量池

紧接文件版本后的(从第9个字节开始)是常量池入口。具体内容为:

  • 常量池容量计数: 用u2类型的数字,来表示常量的个数(从1开始计数,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。)

  • 常量池中存放的数据: 主要存放以下两大类数据,

    • ①字面量:Java语言层面的常量,如字符串、声明为final的常量值
    • ②符号引用:包括被模块导出或者开放的包、类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。

    常量池中每项常量都是一个表,截止JDK13,一共有17种不同类型的常量。

4.访问标志

常量池结束后,紧接着2个字节代表访问标志,用于识别一些类或接口层次的访问信息,包括这个Class是类还是接口,是否为public,是否为abstract,是否申明为final等等。这些信息在2个字节的数字中使用标志位的方式来表示

5.类索引、父类索引、接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。类索 引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。

6.字段表集合

用于描述接口或者类中声明的变量,不包括在方法中声明的局部变量。字段表结构分为以下几个部分:

  • access_flag: 字段的一些属性(包括作用域(public、private、protected)、可变性(final)、并发可见性(volatile)、能否序列化),使用一个u2类型数来表示,表示方法与类的访问标志类似,都是用标志位来表示。
  • name_index和descriptor_index: 都是对常量池的引用,分别代表字段的简单名称(指没有类型和参数修饰的方法或字段名,如int m中,m就是简单名称)以及字段和方法的描述符(用于描述字段的数据类型(如用I来表示基本数据类型int,L表示对象类型,数组在类型前面添加一个[字符来描述)、方法的参数列表(包括数量、类型及顺序)和返回值)。
  • 属性表集合: 在descriptor_index之后跟随着一个属性表集合用于存储一个额外的信息,字段表可以在附加描述零至多项的额外的信息。

7.方法表集合

方发表集合和字段表集合采用了几乎一致的描述方式。方法表结构如下:
秋招准备之——Java虚拟机_第25张图片
所不同的是,access_flag中增加了去掉了一个属于字段的标志(如volatile),增加了一些属于方法的访问标志(如native等)。

  • 方法中的代码的位置: 方法中的代码被Javac编译成字节码指令之后,存放在方法属性表集合中一个名为Code的属性里面
  • Java函数什么无法仅仅依靠返回值不同来重载: Java语言中,除了要与原方法具有相同的简单名称外,还要求必须有一个与原方法不同的特征签名,特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,因为返回值不包括在特征签名中,所以无法仅通过返回值不同来重载。

8.属性表集合

Class文件、字段表、方法表都能有自己的属性表,来描述一些场景专有的信息。一个符合规则的属性表满足以下结构:
秋招准备之——Java虚拟机_第26张图片
一些常见的属性有:

  • 1.Code: 方法体里面的代码编译后,变成字节码存储在Code属性中。接口和抽象类中的方法不存在Code属性,其结构如下所示:
    秋招准备之——Java虚拟机_第27张图片
    上面需要注意的问题有:

    • ①方法所占用的内存空间并不是简单的方法中用到了多少个变量,就占用多少空间,Java编译层面会进行优化,将局部变量表中的变量槽进行重用,当代码执行超过一个局部变量的作用域时,这个局部变量占用的变量槽可以被重用。

    • ②虽然code_length是一个u4类型的长度,理论可以存储232 条指令,但是《JAVA虚拟机规范》限定一个方法不允许超过65535条指令(一般只在编译JSP文件的时候会出现超过这个限制的情况)。

    • ③异常表的结构如下:
      在这里插入图片描述
      表示从第start_pc行到end_pc行(不包含end_pc行)之间,出现了类型为catch_type或其子类的异常,则转到handler_pc行进行处理。

  • Exceptions属性: 该属性用于列举出方法中可能抛出的异常检查,结构如下:
    秋招准备之——Java虚拟机_第28张图片

  • LineNumberTable属性: 用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。非必须,如果不要这个属性,则程序抛出异常时不能显示异常所在的行号,且调试时,也无法按照源码行来设置断点

  • LocalVariableTable及LocalVariableTypeTable属性: 用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系。非必须,没有这个属性时,则别人引用这个方法时,所以参数名都将丢失

  • SourceFile及 SourceDebugExtension属性: SourceFile属性用于记录生成这个Class文件的源码文件名称。SourceDebugExtension属 性 用 于 存 储 额 外 的 代 码 调 试 信 息 。

  • ConstantValue属性: 作用是通知虚拟机自动为静态变量赋值,只有被static属性修饰的变量才能使用这项属性。Java对static和非static变量的赋值过程为:

    • 非static类型的变量,赋值是在实例构造器(()方法)中进行的
    • static类型的变量,有两种方式可选:①在类构造器(()方法)中进行;②在ConstantValue中进行(目前的虚拟机中,如果同时用static和final字段修饰,并且这个类型是基本类型或String类型,就会生成ConstantValue属性来初始化)。
  • InnerClasses属性: 用于记录内部类和宿主类之间的关联。结构如下:
    秋招准备之——Java虚拟机_第29张图片
    innner_classes表内容如下:
    秋招准备之——Java虚拟机_第30张图片

  • Deprecated及Synthetic属性: Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用。Synthetic实现了对private级别的字段和类的访问,从而绕开了语言限制

  • Signature属性: 与Java泛型有关。任何类、接口、构造器方法或字段的声明如果包含了类型变量(type variable)或参数化类型,则Signature属性会为它记录泛型签名信息。泛型最终的信息来源就来自于此属性。

3.2 字节码指令简介

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode) 以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。其中,由于限制操作码的长度为1个字节,故指令集的综述不能超过256条。为了减少指令的数量,故意将指令设计成支持非完全独立的,且部分指令都没有支持整数类型byte、char和short,甚至没有任何指令 支持boolean类型。大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类 型作为运算类型(Computational Type)来进行的

Java中的指令集分为以下几类:

  • 1.加载和存储指令: 用于将数据在帧栈中的局部变量表和操作数栈之间来回传输

  • 2.运算指令: 用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体分为针对整型和针对浮点数类型的运算两类。

  • 3.类型转换指令: 用于两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作。

  • 4.对象创建与访问指令: 针对数组和对象的指令不同。

  • 5.操作数栈管理指令: 用于直接操作操作数栈
    秋招准备之——Java虚拟机_第31张图片

  • 6.控制转移指令: 控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下 一条指令继续执行程序,用于各种条件分支。

  • 7.方法调用和返回指令: 用于调用各类方法

  • 8.异常处理指令: 处理异常

  • 9.同步指令: Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。

    当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成 还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

四、虚拟机类加载机制

4.1 类加载的时机

类加载的七个阶段如下:

秋招准备之——Java虚拟机_第32张图片

  • 1.加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。为支持动态绑定,解析阶段可以在初始化阶段之后再开始。类加载在什么时候开始由虚拟机自己决定

  • 2.初始化的时间: 分为以下几种情况:

    • ①遇到new、getstatic、putstatic、invokestatic四条字节码指令时,如果没有初始化,则需要先触发初始化阶段。具体场景有:
      • 使用new实例化对象时
      • 读取或设置一个类型的静态字段(除被final修饰放到常量池中的字段)
      • 调用一个类型的静态方法时
    • ②反射调用时,如未初始化,则先初始化
    • 类初始化时,如父类未初始化,则先初始化父类
    • ④虚拟机启动时,先初始化main函数所在的类

    上面这几种情况,称为对一个类型进行主动引用,其他所有的引用均不会触发初始化,称为被动引用

4.2 类加载的过程

1.加载

加载阶段主要完成三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流(并没有规定从哪里获取二进制流,所以可以从ZIP压缩文件中获取(JAR、WAR的基础)、网络中获取(applet的基础)、运行时计算(动态代理技术)、从其他文件生成(JSP))
  • 将这个字节流所代表的静态存储结构转换成方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2.验证

该阶段的主要目的是保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的要求

3.准备

正式为类变量(静态变量) 分配内存并设置初始值。JDK7之前,Hotspot用永久代实现方法区时,静态变量是在方法区中分配的。但JDK8及之后,类变量会随着Class对象一起存在在Java堆中。需要注意的两点:

  • ① 此阶段只给类变量分配内存,不给实例变量分配内存,而且分配的是系统默认的初始值,而不是代码赋的值(如static int a=1;准备阶段后,不会赋值为1,而是0)具体的赋值操作,会在初始化阶段
  • ②当给类变量赋值的时候,赋值过程会在类构造器方法中进行

4.解析

该阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

  • 符号引用: 用一组符号来描述所引用的目标(相当于名字),可以是任何形式的字面量。
  • 直接引用: 可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这七类符号引用进行。

  • 1.类或接口的解析: 在类D中,要把一个符号N解析为一个类或接口C的直接引用,分为3步:

    • ①如果C不是数组类型,则将N的全限定名传递给D的类加载器去加载类C,加载过程中,由于继承等关系,可能又会引起其他类的加载。
    • ②如果C是数组类型,并且数组类型为对象,则按照①加载对象,然后由虚拟机生成一个代表数组未读和元素的数组对象
    • ③进行符号引用验证,确认D是否有对C的访问权限。
  • 2.字段解析: 首先按照字段的类型进行字段所属类的解析。解析成功,假设这个字段属于C。那后续的解析步骤为:

    • ① 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引 用,查找结束
    • ② 如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找 结束。
    • ③如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父 类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • ④查找失败,抛出NoSuchFieldError异常
  • 3.方法解析: 首先解析出方法所属的类或接口的引用,然后去找。与字段解析不同的是,方法解析先去父类中找,再去接口中找。

  • 4.接口方法解析: 和方法解析类似,但是只在本接口和父接口中找,不会去类中找

5.初始化

初始化阶段就是执行类构造器()方法的过程。有关()方法:

  • ①该方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,收集顺序由语句在源文件中出现的顺序决定,静态语句块中只能访问 到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
    秋招准备之——Java虚拟机_第33张图片
  • ()与类构造方法init()方法不同,虚拟机会保证在子类的()执行前,父类的已经执行完毕。所以,Object类的()方法一定是最先执行的。这也意味着,父类的静态语句块要优先与子类的变量赋值操作
  • ③如果类中没有静态语句块,也没有对变量进行赋值操作,编译器可以不为这个类生成()方法
  • ④接口中的()执行前不需要先执行父类的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的()方法。
  • ⑤Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个进程阻塞

4.3 类加载器

1.类与类加载器

类加载器的作用是通过一个类的全限定名来获取描述该类的二进制字节流。对于任意一个类,必须由加载它的类加载器和这个类本身一起确立其在虚拟机中的唯一性。 比较两个类是否相等,只有在这两个类是由一个类加载器加载的前提下才有意义。否则,如果类加载器不相等,那两个类肯定不相等。

2.双亲委派模型

  • ①三层类加载器

    • 启动类加载器(Bootstrap ClassLoader) :负责加载存放在\lib目录,或者被-Xbootclasspath参数指定的路径中存放的,且是JVM能够识别的类库。只有这个类加载器是使用C++语言实现,是JVM的一部分,其他类加载器全部由java语言实现,独立存在于JVM外部,且都继承于java.lang.Classloader抽象类。

    • 扩展类加载器(Extension ClassLoader):负责加载\home\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库,开发者可以直接使用此类加载器加载Class文件。

    • 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上的所有类库,开发者同样可以直接使用此类加载器,如果没有指定,则默认使用此类加载器。

      他们之间的协作关系如下所示,此关系称为双亲委派模型,双亲委派模型要求除了启动类加载器,其余的类加载器都应有自己的父类加载器。但是类加载器之间的父子关系一般不是以继承关系实现,而是通过使用组合关系来复用父加载器的代码
      秋招准备之——Java虚拟机_第34张图片

  • ②双亲委派模型:

    • 工作流程: 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加 载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请 求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
    • 优点: Java中的类随着它的类加载器一起具备了带有优先级的层次关系,这样能保证在各种类加载环境中都是同一个类

五、虚拟机字节码执行引擎

5.1 运行时帧栈结构

帧栈是用于支持虚拟机进行方法调用和方法执行背后的数据结构。帧栈存储了方法的局部变量、操作数栈、动态连接和方法返回地址等信息。在编译Java程序码时,帧栈中需要多大的局部变量表多深的操作栈已经被分析计算出来,并写入到方法表的Code属性当中了。故帧栈需要分配多少内存在编译时就确定了

对于执行引擎,在活动线程中,==只有位于栈顶的方法(当前方法)才是运行的,只有位于栈顶的帧栈(当前帧栈)才是生效的。==执行引擎所运行的字节码指令都只针对当前帧栈操作。帧栈的结构如下:
秋招准备之——Java虚拟机_第35张图片

1.局部变量表

  • 局部变量表用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方 法所需分配的局部变量表的最大容量。以变量槽为最小容量单位
  • 虚拟机通过索引定位的方式使用局部变量表,索引值从0到局部变量表的最大变量槽数量。32位的数据,N代表使用第N个变量槽。64位使用N和N+1两个变量槽。
  • 当一个方法调用时,虚拟机会使用局部变量表来完成实参到形参的传递。实例方法(static方法)中,第一个参数用于传递方法所属对象实例的引用。方法参数结束后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
  • 局部变量表中,变量槽是可以重用的,一些超出作用域范围的变量槽能重用。

2.操作数栈

方法刚开始执行时,操作数栈为空。方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。操作数栈中元素的数据类型必须要和字节码指令的序列严格匹配,编译程序的时候就要保证这一点

3.动态连接

每个帧栈都包含一个指向运行时常量池中该帧栈所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。在将符号引用转换成直接引用时,有一部分会在类加载阶段或者第一个使用的时候就转换成直接引用,这种称谓静态解析。另一部分,将在每一次运行期间,都转换为直接引用,称谓动态连接

4.方法返回地址

当一个方法开始执行后,有两种方式退出这个方法:

  • 1.遇到任意一个返回字节码指令正常退出
  • 2.执行过程中遇到异常,并未处理,会异常退出

方法退出后,都必须返回方法最初被调用的位置,程序才能继续执行。方法返回时,需要在帧栈保存一些信息,用来帮助恢复它的上层主调方法的执行状态。(方法退出就能相当于把当前帧栈出栈,然后再返回值压到调用者帧栈的操作数栈中,然后PC计数器指向方法调用后的指令)

5.2 方法调用

1.解析

所有方法调用的目标在Class文件里都是一个常量池中的符号引用,故要调用方法,则需要将符号引用接解析成直接引用;解析指的是在类加载解析阶段,将符号引用转换成直接引用的过程,调用的方法就已经确定的方法。这个过程要求,程序运行之前就有一个可确定调用的版本,并且这个版本运行期间不可改变

2.分派

分派用来调用体现Java多态性的方法。

  • 1.静态分派: 依赖静态类型来决定方法执行版本的分派动作,称为静态分派。最典型应用是方法重载。下面代码输出hello,guy。因为上面的代码在编译时,就能确定调用哪个重载函数
    秋招准备之——Java虚拟机_第36张图片

    • ①静态类型:上面的Human就是静态类型
    • ②实例类型:上面的Man是实例类型
  • 2.动态分配:与重写(Override)有关,下面的代码
    秋招准备之——Java虚拟机_第37张图片

输出结果为下图
秋招准备之——Java虚拟机_第38张图片
这涉及到invokevirtual指令的解析过程,过程如下:

  • 找到操作数栈顶的第一个元素所指向的对象的 实际类型 ,记作C。

  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。

  • 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。

  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

  • 3.单分派与多分派

    • 宗量: 方法的接受者与方法的参数统称为方法的宗量
      根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种

      • 单分派: 根据一个宗量对目标方法进行选择
      • 多分派: 根据多个宗量对目标方法进行选择

      Java是一种静态多分派,动态单分派的语言。静态多分派是指,在静态分派时,可以根据多个宗量选择,而动态分派时,只与该方法的接受者有关(方法的参数已经确定了),只有一个宗量作为选择依据。

  • 4.虚拟机动态分派的实现

    为了提高性能,虚拟机为类型在方法区建立了虚拟方法表和接口方法表,以此减少查找目标对象的时间。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方 法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了 这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
    秋招准备之——Java虚拟机_第39张图片

5.3 动态类型语言支持

1.动态类型语言

动态类型语言,是指数据类型的检查是在运行时做的。用动态类型语言编程时,不用给变量指定数据类型,该语言会在你第一次赋值给变量时,在内部记录数据类型。关键特征是类型检查的主体过程是在云慈宁宫期而不是在编译器进行的

2.java.lang.invoke包

使用invoke包中的内容,可以做到类似下面的动态类型的操作。
秋招准备之——Java虚拟机_第40张图片
使用反射也能做到类似上面的操作。两者有以下区别:

  • ①反射模拟代码层面的调用,而invoke模拟字节码层面的调用
  • ②反射中的Method比invoke包含的信息多得多,包含执行权限等信息
  • ③使用MethodHandle理论上,虚拟机可以在这方面做优化(如方法内联)

3.invokedynamic指令(参考博客)

每一处含有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内存模型与线程

6.1 Java内存模型

1.主内存和工作内存

Java内存模型规定了所有的内存变量都存储在主内存中。 每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据,不同线程之间要交换数据均需要通过主内存来完成。
秋招准备之——Java虚拟机_第41张图片

2.内存间交互操作

主内存和工作内存之间的数据的交换,Java内存模型规定了以下8种操作来完成:

  • lock: 作用于主内存的变量,用于把一个变量标志位线程独占
  • unlock: 与lock相对,只有unlock的变量才能被其他线程锁定
  • read: 作用与主内存变量,将一个变量值从主内存传输到线程的工作内存
  • laod: 作用于工作内存变量,将read得到的变量,放入工作内存的变量副本中
  • use: 作用于工作内存变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign: 作用于工作内存变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store: 作用由于工作内存变量,用于将工作内存中的一个变量的值传送到主内存中,以便随后的write
  • write: 用于主内存变量,将store操作的变量值放入主内存变量中
    上述操作必须满足以下规则:
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它最近的assign操作(工作内存赋值后必须同步到主内存)
  • 不允许未assign的数据同步到主线程
  • 新变量只能在主内存中产生
  • 同一时刻只允许一条线程对其lock,但lock可以执行多次,多次执行后,必须执行同样次数的unlock解锁
  • 执行lock会清空工作内存中该变量的值,执行引擎使用这个变量之前,需要重新load和assign初始化该变量的值
  • 如果变量没有被lock,则不允许unlock
  • 对一个变量unlock之前,必须先将其同步回主内存中

3.对volatile类型变量的特殊规则

  • ①作用:

    • ①保证变量对所有线程的可见性,当一条线程修改了这个变量的值,其他线程能立马知道。这并不能保证被volatile修饰的变量是线程安全的,原因是Java中的运算符并不是原子操作的。比如多个线程对volatile修饰的变量进行自增操作,每个线程在拿到变量时,是正确的,但是自增后,再写回的时候,别的线程可能已经改了这个变量。
    • ②禁止指令重排优化
  • 内存交互的规则

    相比普通变量,有以下规则,这些规则保证了可见性和禁止指令重排

    • 1.要use,前面必须是load(变量可见的原理)
    • 2.要store,前面必须是assign(每次修改后,必须立即同步到主内存)
    • 3.代码中变量的顺序还操作的顺序一致(防止指令重排)

4.long和double的非原子性协定

Java内存模型允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数 据类型的load 、store 、read和write这四个操作的原子性 。

5.先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

Java中天然的线性发生原则:①程序次序控制(按照控制流程顺序,书写在前面的操作先行发生于书写在后面的操作)。②管程锁定规则(unlock肯定发生在lock后)③volatile变量规则(对volatile的写操作先行发生于对这个的读操作)④线程启动规则(start肯定先行与线程中的任何动作)⑤线程终止规则(线程中的所有操作先行发生于对此线程的终止检测)⑥线程中断规则(线程interrupt()方法调用先行与中断检测)⑥对象终结规则(初始化肯定先行于finalize方法)⑦传递性(先行规则具有传递性)

6.2 Java与线程

1.线程的实现

有三种线程的实现方式:

  • ①内核线程实现: 指直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。内核通过调度器对线程调度,并负责映射到各个处理器上。通常通过轻量级进程实现,系统支持的轻量级进程是有限的。

  • ②用户线程的实现: 用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存 在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。线程的所有操作(创建、销毁、切换和调度)需要用户自己处理

  • ③混合实现: 内核线程与用户线程一起使用的实现。这样线程的调度及映射可以由内核线程实现,而其他需要大规模并发的操作则由用户线程实现。

2.Java线程的实现

HotSpot虚拟机的每一个线程都是直接映射到操作系统的原生线程来实现的(即以内核线程的方式实现),自己不用关系线程的调度,全权交给操作系统处理。

3.Java线程的调度

两种线程调度:

  • 1.协同式: 线程执行时间由线程本身控制,线程自己的工作执行完后,通知系统切换到另一个线程上去。
  • 2.抢占式: 每个线程由系统分配执行时间,线程切换不由线程本身决定。在Java中使用抢占式的线程调度。可以通过设置线程优先级,来“建议”操作系统多分配资源给线程。

4.状态转换

Java定义了线程的6种状态,分别是:

  • 新建: 创建后尚未启动的线程处于这种状态
  • 运行: 包括操作系统线程状态中的Running和Ready ,也就是处于此状态的线程有可 能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  • 无限期等待: 处理器不会分配时间,要等待被其他线程唤醒。
  • 限期等待: 处理器不会分配时间,不过无需等待其他线程唤醒,在一定时间后会由系统自动唤醒。
  • 阻塞: 与”等待状态“的区别是:阻塞状态等待着获取一个排它锁,而等待则是在等待一段时间或者唤醒动作。
  • 结束: 已经终止或者执行结束的线程处于这种状态
    这6种状态的转化关系如下:
    秋招准备之——Java虚拟机_第42张图片

七、线程安全与锁优化

7.1 线程安全

代码本身对对象封装了所有必要性的保障手段(如互斥同步),调用者无需关心多线程下的调用问题,更无需自己实现任何措施来保证多线程环境下的正确调用

1.Java中的线程安全

可以将Java语言中,各种操作共享的数据分为以下5类:

  • 1.不可变: Java语言中,不可变的对象一定是线程安全的
  • 2.绝对线程安全: 表示在任何情况下,都是线程安全的,但很难做到,一般说的线程安全的Java工具类都不是绝对线程安全的
  • 3.相对线程安全: 保证对这个对象单次的操作是线程安全的,在调用时不需要进行额外的保障措施。Java中大部分声称线程安全的类都属于相对线程安全。
  • 4.线程兼容: 指对象本身不是线程安去的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全使用。
  • 5.线程对立: 线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

2.线程安全的实现方法

  • 1.互斥同步: 互斥同步是最常见也是最主要的并发正确性保障手段,为阻塞同步(悲观并发策略)。其中,互斥是方法(临界区、互斥量、信号量),同步(在同一时刻只被一条(使用信号量时是一些)线程使用)是目的

    • synchronized关键字: 最基本的互斥同步手段,是一种块结构同步语法。通过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作 为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁

      在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减 一 。 一旦计数器的值为零 , 锁随即就 被释放了 。 如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

      两个特点: ①被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块 也不会出现自己把自己锁死的情况②被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制 正在等待锁的线程中断等待或超时退出。

    • 重入锁: 与synchronized一样可重入,但有一些高级功能:

      • ①等待中断
      • ②公平锁
      • ③可绑定多个条件:condition条件
  • 2.非阻塞同步: 基于冲突检测的乐观并发策略。基本策略是不管风险,先操作,如果没有其他线程争用数据,那操作成功。否则,一旦产生冲突,则采取补偿措施,最常见的补偿措施是不断重试,直到没有竞争的共享数据为止。

    • CAS操作: 需要三个操作数,分别是内存位置、旧的预期值、准备设置的新值。执行时,当且仅内存地址处的值符合旧的预期值时,才用新值更新。

      CAS操作存在ABA问题:如果变量V初次读取的时候是A值,在准备赋值的时候仍是A值,并不能保证V没被改过,有可能先被修改,然后再改回原值,可以使用AtomicStampedReference避免,其通过控制变量值的版本来保证CAS的正确性。

  • 3.无同步方案: 如果一个方法本身不涉及共享数据,就不需要任何同步措施去保证其正确性。常见的有以下两类:

    • ①可重入代码: 指可以在代码执行的任何 时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。判断原则为,如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返 回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
    • 线程本地存储: 如果一段代码中所需要的数据必须与其他代码共享,那就 看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可 见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。ThreadLocal就是线程本地存储,从表面上看ThreadLocal相当于维护了一个map,key就是当前的线程,value就是需要存储的对象。当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用ThreadLocal。

7.2 锁优化

1.自旋锁与自适应自旋

对于互斥锁,如果资源已经被占用,资源申请者只被挂起,这样对于一些执行时间很短的线程,不断挂起唤醒会很耗费资源。但是自旋锁不会引起调用者挂起,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,

虚拟机现在已经能根据以往的获取锁的过程,自适应地去决定自旋的次数。

2.锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享 数据竞争的锁进行消除。主要通过逃逸分析(在方法中定义的对象,可能被方法外的对象所引用(比如方法返回这个定义的对象,然后在别的地方被使用),那这个对象就逃逸了)实现,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可 以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

3.锁粗化

同步时,总推荐将同步块的作用范围限制地尽可能小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等 待锁的线程也能尽可能快地拿到锁。

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

4.轻量级锁

  • 对象头: HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如HashCode、GC年龄分代等。数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
    秋招准备之——Java虚拟机_第43张图片

  • 依据: 对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁就使用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中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

    具体的轻量级锁膨胀为重量级锁的过程如下:
    秋招准备之——Java虚拟机_第44张图片

5.锁偏向

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

整个偏向锁、轻量级锁的状态转换过程如下所示:
秋招准备之——Java虚拟机_第45张图片
在锁偏向时,Mark Word中的大部分用用来存储threadID,而占用了原有存储对象哈希码的位置。但是一个对象如果计算过哈希码,就应该一直保持不变。这样的话,一旦计算过哈希,就需要写到Mark Word中,一旦写入,那这个对象就再也无法进入偏向锁状态了。当一个对象当前正处于偏向锁状态,又收到计算hashcode请求时,偏向状态会立即被撤销,并且锁会膨胀成重量级锁。如果一个对象经常存在竞争,那就最好不要使用偏向锁。

你可能感兴趣的:(复习)