深入理解Java虚拟机(全)

  • 垃圾回收,类加载,线程安全问的比较多
  • 2,3,6,7,12,13

第二章 Java内存区域与内存溢出异常

2.2 运行时数据区域

深入理解Java虚拟机(全)_第1张图片

3个区域线程私有(不需要垃圾回收,因为它们随着线程结束而自动销毁),2个区域所有线程共享(需要垃圾收集回收)

  1. 程序计数器(Programmer Counter Register):一块很小的内存,可以看做当前线程所执行的字节码的行号计数器。

    • 线程隔离的数据区(线程私有)
    • 为了多线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间计数器互不影响,独立存储
    • 如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
    • 如果执行的是一个本地(Native)方法,这个计数器的值则为空(Undefined)
    • 唯一一个没有规定任何OutOfMemory情况的区域
  2. Java虚拟机栈(Java Virtual Machine Stack)

    • 我们常说的 ”堆“ 和 “栈” 中的 栈

    • 描述的是Java方法执行的线程内存模型:每个方法执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

      1. 局部变量表:存放编译期间可知的各种Java虚拟机基本数据类型(8种)、对象引用和returnAddress类型(指向了一条字节码指令的地址)
        • 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,64位的long和double占两个变量槽,其余占一个变量槽
        • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。(大小指槽数,有具体虚拟机决定)
    • 线程私有,生命周期与线程一样

    • 两类异常情况

      • StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度
      • OutOfMemoryError异常:Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存
        • HotSpot虚拟机的栈容量不可动态扩展。只要申请栈空间成功了就不会由于虚拟机栈无法扩展而导致OOM异常
  3. 本地方法栈(Native Method Stacks)

    • 与虚拟机作用相似,区别是:
      • 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务
      • 而本地方法栈则是为虚拟使用到的本地方法服务
    • 具体虚拟机可自由实现它
      • HotSpot直接将本地方法栈和虚拟机栈合二为一
    • 与虚拟机栈一样,本地方法栈会在栈深度溢出和栈扩展失败时分别抛出StackOverflowError异常、OutOfMemoryError异常
  4. Java堆(Java Heap)

    • 虚拟机所管理的内存最大的一块
    • 所有线程共享,在虚拟机启动时创建
    • 唯一目的:存放对象实例(对象实例加数组都应当在堆上分配)
    • Java堆是垃圾收集器管理的内存区域
    • 在Java中,堆内存被分为两个不同的区域:新生代和老年代
      • 新生代又被划分为三个区域:Eden,FromSurvivor,ToSurvivor
      • 这样划分的目的是是JVM更好地管理堆内存中的对象,包括内存的分配以及回收。
    • 从内存分配的角度看,所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),以提升对象分配效率
    • Java堆可以内存上不连续,但逻辑上应该视为连续的(一般数组要求连续的内存空间)
    • Java堆可以实现成固定大小的,也可以是可扩展的(主流,通过参数-Xmx和Xms设定)
    • 如果Java堆没有内存完成实例分配,且堆也无法再扩展时,会出现OutOfMemoryError异常
  5. 方法区(Method Area)

    • 各个线程共享

    • 用于存储已被虚拟机加载的类信息常量、静态变量、即时编译器编译后的代码等数据

    • 不需要连续内存空间

    • 可选择固定大小或者可扩展

    • 可选择不实现垃圾收集,垃圾收集行为在这个区域出现得比较少

    • 内存回收目标主要针对:常量池回收(废弃常量),对类型的卸载(不再使用的类型)

    • 当方法区无法满足新的内存分配需求时,会出现OutOfMemoryError异常

      运行时常量池(Runtime Constant Pool)

    • 方法区的一部分

    • (Java文件被编译成class文件)Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表

    • 常量池表用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载后存放到**方法区的运行时常量池(**每个类都有一个运行时常量池)中!!!!!!

    • 动态性:Java语言不要求常量只有编译期才能产生。即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中(如String类的intern()方法)

    • 深入理解Java虚拟机(全)_第2张图片

直接内存(DirectMemory)

不属于虚拟机运行时数据区的一部分,但也会被频繁使用


2.3 虚拟机对象(以Java堆中的对象为例)

1.对象的创建

  1. 类加载检查:遇到一个new指令时,首先检查这个指令参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有则执行相应的类加载过程
  2. 为新生对象分配内存
    • 指针碰撞(Bump The Pointer):使用带压缩整理过程的收集器时
    • 空闲列表(Free List):维护一个列表,记录内存是否可用。使用CMS这种基于清除整理过程的收集器时
  3. 将分配到的内存空间(但不包括对象头)都初始化为零值。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,使程序能直接访问到这些字段的数据类型所对应的零值
  4. 对对象进行必要的设置:例如该对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存在对象的对象头中。

2. 对象的内存布局

在HotSpot中,对象在堆内存中的存储布局划分为:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  • 对象头:
    • 存储对象自身的运行时数据(如hashCode,GC分代年龄,锁状态标志,线程持有的锁等)-----MarkWord
    • 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(如果对象是数据,还必须在对象头中存储数组大小)
      • Java虚拟机可以通过普通Java对象的元数据信息确定Java对象大小,但是如果数组的长度不确定的话,就无法通过元数据中的信息推断出述责的大小
  • 实例数据:对象真正存储的有效信息
  • **对齐填充:**不是必然存在的,仅仅起着占位符的作用。HotSpot虚拟机中要求所有对象的大小必须是8字节的整数倍。对象头部分已经是8字节的整数倍了,如果对象实例数据部分没有对齐的话,就需要通过对齐方式填充来补全。

3.对象的访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象。

对象访问方式由具体的虚拟机实现而定

访问方式:

  • 使用句柄:reference中存储的是对象的句柄地址

    好处:reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普通的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改

  • 直接指针:reference中存储的是对象地址

    好处:速度快,节省一次指针定位的时间开销(HotSpot中主要使用这种方式进行对象访问)

深入理解Java虚拟机(全)_第3张图片 深入理解Java虚拟机(全)_第4张图片

2.4 OutOfMemoryError

1. Java堆溢出

dump:转储。动态(易失)的数据,保存为静态的数据(持久数据)

1、为什么要dump(dump的目的)?

因为程序在计算机中运行时,在内存、CPU、I/O等设备上的数据都是动态的(或者说是易失的),也就是说数据使用完或者发生异常就会丢掉。如果我想得到某些时刻的数据(有可能是调试程序Bug或者收集某些信息),就要把他转储(dump)为静态(如文件)的形式。否则,这些数据你永远都拿不到。

解决方法:通过内存影响分析工具堆Dump出来的堆转储快照进行分析

  1. 确认内存中导致OOM的对象是否是必要的(分清楚是出现了MemoryLeak还是MemoryOverflow)
  2. 如果是内存泄漏,则通过工具查看泄漏对象到GC Roots的引用链,找到磁轭楼对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们
  3. 如果不是内存泄漏(内存中的对象都是必须存活的),则应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与及其的内存对比,看看是否还有向上调整的空间。再从代码上检查是否某些对象的生命周期过长、持有状态时间过长、存储结构设计不合理等情况,应尽量减少程序运行期间的内存消耗

2. 虚拟机栈和本地方法栈溢出

HotSpot虚拟机中不区分虚拟机栈和本地方法栈,栈容量大小仅由-Xxs参数设定

两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出StackOverflowError异常
  2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常
    • HotSpot虚拟机不支持扩展,只会在线程创建申请内存时就因无法获得足够内存而出现OutOfMemoryError,否则在线程运行时时不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError。
    • 无论是栈帧太大 或者 虚拟机栈容量太小,当心的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常

操作系统分配给每一个进程的内存时有限制的,譬如32位Windows的单个进程最大内存是2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两个部分的内存的最大值,那剩下的内存限制即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果再把直接内存虚拟机进程本身消耗的内存也去掉的话,剩下的内存就由虚拟机栈本地方法栈来分配。

所以每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易将剩下的内存耗尽。

3.方法区和运行时常量池溢出

image-20210424151059057
  • JDK6之前,HotSpot虚拟机的常量池都是分配在永久代中,可通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的大小
  • JDK7起。原本存放在永久代的字符串常量池被移至Java堆中(-Xmx参数限制最大堆大小)
  • JDK8使用 “元空间” 代替 “永久代”
深入理解Java虚拟机(全)_第5张图片

java在加载sun.misc.Version这个类的时候进入常量池中。

4. 本机直接内存溢出

image-20210424154335043


第三章 垃圾收集器与内存分配策略

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

3.1 概述

垃圾收集器(Garbage Collection, GC)

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每个栈帧中分配多少内存基本上是在类结构确定下来是就已知的。因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就随着回收了。

Java堆和方法区则有着显著的不确定性

深入理解Java虚拟机(全)_第6张图片

3.2 对象已死?

堆中存放这几乎所有的实例对象,垃圾收集器在对堆进行回收之前,首先要确定哪些还“存活”着,哪些已经“死去”。

(死去: 即不可能再被任何途径使用到的对象)

1. 引用计数算法(Reference Counting)

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

优点:原理简单,判定效率也很高

缺点:这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作。譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。(在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存)

testGC()方法:对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。

2. 可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的

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

深入理解Java虚拟机(全)_第7张图片

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 方法区类静态属性引用的对象,譬如Java类的引用类型静态变量。

  • 方法区常量引用的对象,譬如字符串常量池(String Table)里的引用。

  • 本地方法栈JNI(Java Native Interface,即通常所说的Native方法引用的对象

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。

3. 引用

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为:

  • 强引用(Strongly Reference)

    • 传统的引用定义,指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。
  • 软引用(Soft Reference)

    • 一些还有用,但非必须的对象。
    • 只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
    • 在JDK 1.2版之后提供了SoftReference类来实现软引用。
  • 弱引用(Weak Reference)

    • 那些非必须对象,但是它的强度比软引用更弱一些

    • 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够都会回收掉只被弱引用关联的对象

    • WeakReference类来实现弱引用

  • 虚引用(Phantom Reference)

    • 为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。

    • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

    • PhantomReference类来实现虚引用。

Phantom [fæntəm] n.

这4种引用强 度依次逐渐减弱。

4. 生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段

真正宣告一个对象死亡,至少要经历两次标记过程

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记

  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法

    • 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
    • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。

    对象可以在被GC时自我拯救

    这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次

    如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

5. 回收方法区

方法区的垃圾收集主要回收两部分内容:

  • 废弃的常量
  • 不再使用的类型。

回收废弃常量与回收 Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

**判定一个类型是否属于“不再被使用的类”**需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.3 垃圾收集算法

1. 分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了**“分代收集”(Generational Collection)的理论**进行设计

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡。
  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

**设计原则:**收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域

“Minor GC”“Major GC”“Full GC”

  1. 标记-复制算法

  2. 标记-清除算法

  3. 标记-整理算法

新生代 & 老年代

新生代:标记-复制算法

老年代:标记-清除 或者 标记-整理算法

对象不是孤立的,对象之间会存在跨代引用。

存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

(1)解决跨代引用:

我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方 法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

深入理解Java虚拟机(全)_第8张图片

通常能单独发生收集行为的只是新生代,所以这里“反过来”的情况只是理论上允许,实际上除了CMS收集器,其他都不存在只针对老年代的收集。

2. 标记-清除算法

  • 标记------判断对象是否属于垃圾
  • 清除

法1:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象

法2:也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

缺点:

  1. 执行效率不高。大量标记和清除操作
  2. 内存空间碎片化问题。内存空间碎片化太多可能会提前触发另一次垃圾收集动作(需要分配较大对象时无法找到足够的连续内存)

3. 标记-复制算法(新生代垃圾收集)

半区复制:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

特点:实现简单,运行高效

缺点:空间浪费严重

更优化的版区复制策略—Apple式回收

新生代 = Eden空间 + From Survivor空间 + To Survivor空间 (8 : 1 : 1)

**or **新生代 = Eden空间 + 2 * Survivor空间

每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

**HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,**即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

**当Survivor空间不足以容纳一次Minor GC之后存活的对象时,**就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。 (一般就将它们复制到老年代去)

缺点:

  1. 对象存活率较高时就要进行较多的复制操作,效率将会降低
  2. 如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

4. 标记-整理算法

首先标记出所有存活的对象,是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

缺点如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂内存分配器和内存访问器来解决。内存访问是用户最频繁的操作(大量内存访问势必会直接影响应用程序的吞吐量)

移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟CMS收集器则是基于标记-清除算法的,这也从侧面印证这点

一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

JNI----Java Native Interface

3.4 HotSpot的算法具体细节实现

6. 并发的可达性分析

保障一致性

收集器在对象图上标记颜色,同时用户线程在修改引用 关系——即修改对象图的结构,这样可能出现两种后果。

  1. 一种是把原本消亡的对象错误标记为存活, 这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理 掉就好。
  2. 另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误
深入理解Java虚拟机(全)_第9张图片

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用;

  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。

由此分别 产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

增量更新:要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

3.5 经典垃圾收集器

深入理解Java虚拟机(全)_第10张图片

如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。

1. Serial收集器

serial:adj:一系列的,连续的,顺序排列的,单线程的

  • 单线程
  • 新生代收集器
  • 标记-复制算法

深入理解Java虚拟机(全)_第11张图片

Serial收集器依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率

2. ParNew收集器

  • 多线程
  • 新生代收集器
  • 标记-复制算法

深入理解Java虚拟机(全)_第12张图片

Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余与Serial收集器完全一致

它是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器

除了Serial收集器外,目前只有ParNew收集器能与CMS收集器(老年代的收集器)配合工作

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

3. Parallel Scavenge收集器

Parallel: adj.平行的,并行的

Scavenge: [ˈskævɪndʒ] v. (从废弃物中)觅食; 捡破烂; 拾荒;

  • 新生代收集器
  • 标记-复制算法
  • 能够并行收集的多线程收集器
  • 关注点不同:Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)(“吞吐量优先收集器”)
    • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间
  • 自适应的调节策略(GC Ergonomics):把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验

高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务

吞吐量:就是处理器用于运行用户代码的时间与处理器总消耗时间的比值

深入理解Java虚拟机(全)_第13张图片

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

ratio:n.比率,比例

是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作

4. Serial Old收集器

  • Serial的老年代版本
  • 单线程收集器
  • 标记-整理算法

深入理解Java虚拟机(全)_第14张图片

供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

5. Parallel Old收集器

直到JDK 6时才开始提供的。

Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的

  • Parallel Scavenge收集器的老年代版本
  • 支持多线程并发收集
  • 基于标记-整理算法实现

深入理解Java虚拟机(全)_第15张图片

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合

6. CMS收集器

Concurrent Mark Sweep

  • 标记-清除算法(可有名字看出)
  • 并发收集
  • 低停顿

是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验

  1. 初始标记(CMS initial mark) ------“Stop the World”
    • 仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
  2. 并发标记(CMS concurrent mark)
    • 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
  3. 重新标记(CMS remark)-------------“Stop the World”
    • 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
  4. 并发清除(CMS concurrent sweep)
    • 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说CMS收集器的内存回收过程是与用户线程一起并发执行的

深入理解Java虚拟机(全)_第16张图片

缺点:

  1. **CMS收集器对处理器资源非常敏感。**事实上,面向并发设计的程序都对处理器资源比较敏 感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
  2. CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”(并发失败)失败进而导致另一次完全“Stop The World”的Full GC的产生
    • 浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉
    • 必须预留一部分空间供并发收集时的程序运作(用户线程)使用
  3. 碎片化空间。(标记-清除算法)空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。
    • 深入理解Java虚拟机(全)_第17张图片

7. Garbage First收集器

简称G1:开创了收集器面向局部收集的设计思路和基于Region的内存布局形式

  • 面向服务端
  • 为CMS收集器的替代者和继承人
  • 将堆内存“化整为零”
  • 可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这

JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器

Deprecate : [ˈdeprəkeɪt] v.不赞成,强烈反对

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。G1不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,

深入理解Java虚拟机(全)_第18张图片
  • 记忆集
  • 原始快照(解决用户线程改变对象引用关系的问题)

如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

深入理解Java虚拟机(全)_第19张图片

G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的

深入理解Java虚拟机(全)_第20张图片

可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。

它默认的停顿目标为两百毫秒。

3.6 低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:

  1. 内存占用(Footprint)
  2. 吞吐量(Throughput)
  3. 延迟(Latency)

延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。

浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的

深入理解Java虚拟机(全)_第21张图片

Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定 的,与堆的容量、堆中对象的数量没有正比例关系。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。


第六章 类文件结构

6.1概述

6.2 无关性基石

各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——**字节码(**Byte Code)是构成平台无关性的基石

时至今日,商业企业和开源机构已经在Java语言之外发展出一大批运行在Java虚拟机之上的语言,如Kotlin、Clojure、Groovy、JRuby、JPython、Scala等

实现语言无关性的基础仍然是虚拟机和字节码存储格式

Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。

作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品的交付媒介。

深入理解Java虚拟机(全)_第22张图片

6.3 Class类文件的结构

Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没

任何一个Class文件都对应着唯一的一个类或接口的定义信息[1],但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)。

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符

Class文件格式

  • 无符号数

    • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
    • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名

      都习惯性地以“_info”结尾。

1. 魔数与Class文件的版本

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是版本号(Minor Version),第7和第8个字节是版本号(Major Version)。

2. 常量池

紧接着主、次版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

  • 由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。

  • 常量池容量:0x0016(16进制),即十进制的22,这代表常量池中有21项常量,索引值范围是1~21。

  • 常量池的容量计数是从1而不是0开始的。目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。

常量池的存储的两大类常量

  1. 字面量(Literal): 比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值。
  2. 符号引用(Symbolic References):属于编译原理方面的概念
    • 被模块导出或者开放的包(Package)
    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符
    • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
    • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

在虚拟机加载Class文件的时候进行动态连接

在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

深入理解Java虚拟机(全)_第23张图片

tag是标志位,它用于区分常量类型;name_index是常量池的索引值,它指向常量池中一个 CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,本例中的name_index值(偏移地址:0x0000000B)为0x0002,也就是指向了常量池中的第二项常量。

深入理解Java虚拟机(全)_第24张图片

length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。

3. 访问标志 access_flags

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等

  • 16个标志位,当前只定义了9个,没有使用到的标志位要求一律为零

4. 类索引、父类索引与接口索引集合

  • 类索引(this_class)和父类索引(super_class)都是一个u2类型的数据

    • 类索引用于确定这个类全限定名
    • 父类索引用于确定这个类的父类的全限定名
    • 类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量
  • 接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。

    • 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。
深入理解Java虚拟机(全)_第25张图片

5. 字段表集合

深入理解Java虚拟机(全)_第26张图片

  • 访问标志
  • 简单名称索引 name_index
  • 描述符索引 descriptor_index
  • 属性表集合(attributes)------属性表计数器

字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量

字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称

6. 方法表集合

深入理解Java虚拟机(全)_第27张图片
  • 访问标志(access_flags)
  • 名称索引(name_index)
  • 描述符索引(descriptor_index)
  • 属性表集合(attributes)

方法的定义可以通过访问标志、名称索引、描述符索引来表达清楚

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为**“Code”的属性**里面,属性表作为Class文件格式中最具扩展性的一种数据项目,

7. 属性表集合

Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。

深入理解Java虚拟机(全)_第28张图片


太多了 不想看


6.4 字节码指令简介

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。

Java虚拟机采用面向操作数栈而不是面向寄存器的架构


第七章 类加载

7.1 概述

虚拟机的类加载机制:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制

与那些在编译时需要进行连接的语言不同

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的

Java天生可以动态扩展的语言特性就是依赖运行期动态加载动态连接这个特点实现的

深入理解Java虚拟机(全)_第29张图片

7.2 类加载时机

类的生命周期(从被加载到虚拟机内存开始,到卸载出为止):

  1. 加载(Loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)

验证、准备、解析三个部分统称为连接(Linking)

深入理解Java虚拟机(全)_第30张图片

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

必须初始化的场景(主动引用)

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

被动引用

  1. 通过子类引用父类的静态字段,不会导致子类初始化
    • 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  2. 通过数组定义来引用类,不会触发此类的初始化
  3. 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

7.3 类加载的过程

加载、验证、准备、解析和初始化5个步骤

1. 加载

1)通过一个类的全限定名来获取定义此类的二进制字节流。 (它并没有指明二进制字节流必须得从某个 Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取)

2)将这个字节流所代表的静态存储结构转化为方法区运行时数据结构。

3)在内存(堆)中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

数组类型和非数组类型有区别。

深入理解Java虚拟机(全)_第31张图片

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:(略)P365

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口

  1. 类 和 数组加载过程的区别

数组也有类型,称为“数组类型”。如:

String[] str = new String[10];

这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。

当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类,再由类加载器创建数组中的元素类。

而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。

2. 验证

  • 验证是连接阶段的第一步

  • 目的:确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

    • 原因Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出Class文件在内的任何途径产生。上述Java代码无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。

四个阶段:

  • 文件格式验证: 要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
    • 主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了
  • 元数据验证对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求
    • 主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息
    • 对元数据信息中的数据类型校验
  • 字节码验证
    • 最复杂
    • 主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的
    • 对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为
  • 符号引用验证
    • 最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候
    • 这个转化动作将在连接的第三阶段——解析阶段中发生
    • 是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验
    • 主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

3. 准备

准备阶段:正式为类中定义的静态变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

  1. 为已经在方法区中的类中的静态成员变量分配内存
    • 类的静态成员变量也存储在方法区中。
  2. 为静态成员变量设置初始值
    • 初始值为0、false、null等。

(这里所说的初始值“通常情况”下是数据类型的零值。如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值)

深入理解Java虚拟机(全)_第32张图片

概念上讲,这些变量所使用的内存都应当在方法区中进行分配,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;

在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆

  • 准备阶段,进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中
public static int value = 123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把 value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值 为123的动作要到类的初始化阶段才会被执行

public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置将value赋值为123。

4. 解析

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

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在

《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个用于操作符号引用的字节码指令之前先对它们所使用的符号引用进行解析

解析动作主要针对接口字段、类方法、接口方法、方法类型、方法句柄调用点限定符这7类符号引用进行(共8种)

(1)类或接口的解析

深入理解Java虚拟机(全)_第33张图片

针对上面第3点访问权限验证,在JDK 9引入了模块化以后,一个public类型不再意味着程序任何位置都有它的访问权限,我们还必须检查模块间的访问权限

深入理解Java虚拟机(全)_第34张图片
(2)字段解析

前提:解析字段所属的类或者接口的符号引用。

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。

java.lang.NoSuchFieldError异常

深入理解Java虚拟机(全)_第35张图片

(3)方法解析

前提:解析方法所属的类或者接口的符号引用。是需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用

深入理解Java虚拟机(全)_第36张图片

java.lang.NoSuchMethodError

(4)接口方法解析

前提:解析接口方法所属的类或者接口的符号引用。是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用

深入理解Java虚拟机(全)_第37张图片

​ 5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

在JDK 9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。但在JDK 9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError异常。

5. 初始化

在编译生成class文件时,会自动产生两个方法,一个是类的初始化方法clinit(), 另一个是实例的初始化方法init()

clinit():在jvm第一次加载class文件时调用,包括静态变量初始化语句和静态块的执行

init():在实例创建出来的时候调用,包括调用new操作符;调用Class或java.lang.reflect.Constructor对象的newInstance()方法;调用任何现有对象的clone()方法;通过java.io.ObjectInputStream类的getObject()方法反序列化。

类的初始化阶段是类加载过程的最后一个步骤,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源

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

clinit()方法是由编译器自动收集中的所有类变量的赋值动作静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

初始化阶段就是执行类构造器clinit()的过程。
clinit()方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。??(显式初始化)

public class Test { 
    static { 
        i = 0; // 给变量复制可以正常编译通过 		  
        System.out.print(i); // 这句编译器会提示“非法向前引用”  
    }
   static int i = 1;
}
  • ()方法与类的构造函数(即在虚拟机视角中的实例构造器**()方法**)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行 完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object。

  • 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

  • ()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。

  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 ()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法。

  • Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步

同一个类加载器下,一个类型只会被初始化一次

7.4 类加载器

类加载阶段:通过一个类的全限定名来获取描述该类的二进制字节 流。

把这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

1. 类与类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

2. 双亲委派模型

  • 启动类加载器(Bootstrap Class Loader)
    • 器负责加载存放在 \lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。
    • 启动类加载器无法被Java程序直接引用
  • 扩展类加载器(Extension Class Loader)
    • 负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。
    • 扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件
  • 应用程序类加载器(Application Class Loader):
    • 负责加载用户类路径(ClassPath)上所有的类库
    • 开发者可以直接在代码中使用这个类加载器
    • 如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
深入理解Java虚拟机(全)_第38张图片

各种类加载器之间的层次关系被称为类加载器的**“双亲委派模型**(Parents Delegation Model)”。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用 组合(Composition)关系来复用父加载器的代码

**双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,**每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载

好处

  • Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系
  • 保证Java程序的稳定运作

双亲委派模型的实现:

java.lang.ClassLoader的loadClass()方法之中

先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败,抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

3. 破坏双亲委派模型

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。

7.5 Java模块化系统

JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重要升级,为了能够实现模块化的关键目标——可配置的封装隔离机制,Java虚拟机对类加载架构也做出了相应的变动调整,才使模块化系统得以顺利地运作。

Java的模块定义包含以下内容:

  1. 代码
  2. 依赖其他模块的列表。
  3. ·导出的包列表,即其他模块可以使用的列表。
  4. ·开放的包列表,即其他模块可反射访问模块的列表。
  5. ·使用的服务列表。
  6. ·提供服务的实现列表

可配置的封装隔离机制首先要解决

  • JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。

    image-20210427205146253

  • 还解决了原来类路径上跨JAR文件的public类型的可访问性问题

    • JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类加载过程中完成的

1. 模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与“类路径”(ClassPath)相对应的“模块路径”(ModulePath)的概念。简单来说,就是某个类库到底是模块还是传统的JAR包,只取决于它存放在哪种路径上

  • 只要是放在类路径上的JAR文件,无论其中是否包含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;

  • 只要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文件,它也仍然会被当作一个模块来对待。

深入理解Java虚拟机(全)_第39张图片

3. 模块化下的类加载器

模块化下的类加载器的变动:

  • 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。
  • 平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全**都继承于jdk.internal.loader.BuiltinClassLoader,**在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中 加载的逻辑,以及模块中资源可访问性的处理。
深入理解Java虚拟机(全)_第40张图片

JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。

第十二章 Java内存模型与线程

12.1 概述

12.2 硬件的效率与一致性

计算机的存储设备与处理器的运算速度有着几个数量级的差距

现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)作为内存与处理器之间的缓冲

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)

多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)

多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、 Synapse、Firefly及Dragon Protocol等

深入理解Java虚拟机(全)_第41张图片

12.3 Java内存模型

Java Memory Model,JMM

1. 主内存和工作内存

Java内存模型的主要目的定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

  • 主内存:所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)
  • 工作内存:每条线程 还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比)
    • 线程的工作内存中保存了被该线程使用的变量的主内存副本线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
    • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
深入理解Java虚拟机(全)_第42张图片

这里所讲的主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的

如果两者一定要勉强对应起来

从定义上看:

  • 主内存主要对应于Java堆中的对象实例数据部分
  • 工作内存则对应于虚拟机栈中的部分区域

从更基础的层次上说

  • 主内存直接对应于物理硬件的内存
  • 而为了 获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

2. 内存间交互操作

交互

  1. 把一个变量从主内存拷贝到工作内存:按顺序执行read和load操作
  2. 把变量从工作内存同步回主内存:按顺序执行store和write操作

Java内存模型中定义了以下8种操作

  1. lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load(载入):作用于***工作内存***的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于***工作内存***的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于***工作内存***的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于***工作内存***的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

规则:

  1. 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  2. 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  4. 一个新的变量只能在主内存中“诞生”不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
  5. 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  6. 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  7. 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  8. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)

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

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制

volatile [ˈvɒlətaɪl] adj.易变的; 无定性的; 无常性的; 不稳定的;

原子性是世界上最小单位,具有不可分割性。比如a=0;(a非long和double类型)这个操作是不可分割的,那么我们说这个操作是原子操作。再比如:a++;这个操作实际上是a=a+1;是可分割的,所以他不是一个原子操作。

非原子操作都会存在线程安全问题,需要我们使用**同步技术(**sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们就称它具有原子性。

一个变量被定义成volatile之后,它将具备两项特性

  1. 保证此变量对所有线程的可见性

    • 这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
    • 普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成
    • 只能保证可见性,不能保证原子性
  2. 禁止指令重排序优化

    • 保证代码的执行顺序与程序的顺序相同

    • **重排序优化:**机器级的优化操作,提前执行是指这条语句对应的汇编代码被提前执行

      • 为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,虚拟机会按照自己的一些规则(这规则后面再叙述)将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU
    • 指令之间的依赖性

    • 关键变化在于有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障(volatile修饰的变量,在翻译成汇编语言的时候,会有一个LOCK前缀的指令

    • 内存屏障 (Memory Barrier或Memory Fence):指重排序时不能把后面的指令重排序到内存屏障之前的位置

    • 只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了


volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。

深入理解Java虚拟机(全)_第43张图片

深入理解Java虚拟机(全)_第44张图片

lock addl$0x0,(%esp)

lock前缀,查询IA32手册可知,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存,这种操作相当于对缓存中的变量做了一次前面 介绍Java内存模式中所说的“store和write”操作。所以通过这样一个空操作(“addl$0x0,(%esp)”(把ESP寄存器的值加0)),可让前面volatile变量的修改对其他处理器立即可见

  • volatile变量读操作的性能消耗与普通变量几乎没有什么差别
  • 写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
  • 不过即便如此,大多数场景下volatile的总开销仍然要比锁来得更低。

假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:

  • 求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改。
  • 在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。
  • 要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同

volatile关键字介绍

在java中提供了volatile关键字,通过volatile关键字修饰内存中的变量,该变量在线程之间共享。

volatile关键字是轻量级的锁(synchronized)。在使用的时候,消耗的成本比synchronized小很多。volatile用于修饰变量。

volatile实现原理

volatile修饰的变量,在翻译成汇编语言的时候,会有一个LOCK前缀的指令。

LOCK前缀的指令在多核处理器下会引发两件事情。

  • 将当前处理器缓存行的数据会写回到系统内存。
  • 这个写回内存的操作会引起在其他CPU里缓存该内存地址的数据无效。

如果对声明了volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

volatile的适用范围

volatile变量固然方便,但是存在着限制,volatile修饰的变量,并不能保证是原子操作的,所以多处理器操作数据时,会导致数据重复。所以volatile关键字通常被当作完成、中断的状态的标识使用。


//DCL单例模式
instance = new Singleton();
//这个new 并且赋值的语句在jvm中其实可以抽象成三条指令
memory = allocate();    //1:给对象开辟一块内存
initInstance(memory);   //2:初始化对象
instance = memory;      //3:instance指向分配好的内存

当线程A执行到对象引用执行分配好的内存时,这时对象还未初始化,线程B此时调用getInstance()方法,判断引用已经不为null,因此直接返回,此时对象是半初始化状态,使用会导致异常出现。
解决该问题的方法可以使用volatile修饰成员变量instance,volatile可以通过内存屏障防止上述的指令重排序问题。
硬件层面的内存屏障分为Load Barrier 和 Store Barrier即读屏障和写屏障。

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

下面是基于JMM内存屏障的插入策略:

  1. 在每个volatile写操作的前面插入一个storestore屏障。

  2. 在每个volatile写操作的后面插入一个storeload屏障。

  3. 在每个volatile读操作的后面插入一个loadload屏障。

  4. 在每个volatile读操作的后面插入一个loadstore屏障。


3.1 volatile导致哪条代码指令重排?
instance = new Singleton();  

会被编译器编译成如下JVM指令:

  1. memory = allocate(); //分配对象的内存空间
  2. ctorInstance(memory); //初始化对象
  3. instance = memory; //设置instance指向刚分配的内存空间

但是这些指令排序并非一成不变,有可能会经过JVM和CPU的优化,指令重排为如下顺序:

  1. memory = allocate(); //分配对象的内存空间
  2. instance = memory; //设置instance指向刚分配的内存空间
  3. ctorInstance(memory); //初始化对象

当线程A执行完1,3,时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行 if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象。

实例化对象实际会分为3个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将对象指向刚分配的内存空间

但有的编译器由于性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:

  1. 分配内存空间
  2. 将对象指向刚分配的内存空间
  3. 初始化对象
深入理解Java虚拟机(全)_第45张图片

使用了volatile关键字之后,重排序被禁止,所有的写操作(write)都发生在读操作(read)之前。

4. 针对long和double型变量的特殊规则

允许虚拟机将没有被volatile修饰的64位数据(long,double)的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。

5. 原子性、可见性与有序性

原子性:是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。

可见性是指:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

有序性:即程序执行的顺序按照代码的先后顺序执行。

  1. 原子性(Atomicity)
    • 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个, 我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性 协定)
    • 原子性保证:lock和 unlock操作
      • 尽管虚拟机把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。
      • 这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
  2. 可见性(Visibility)
    • 是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改
    • Java内存模型是通过在变量修改后将新值同步回主内存在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。
    • 普通变量与volatile变量的区别是:volatile的特殊规则保证了新值 能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
    • Java还有两个关键字能实现可见性,它们是synchronized和final
      • synchronized关键字的可见性:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)
      • final关键字的可见性:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。
  3. 有序性(Ordering)
    • 如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

6. 先行发生原则

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

深入理解Java虚拟机(全)_第46张图片

深入理解Java虚拟机(全)_第47张图片

深入理解Java虚拟机(全)_第48张图片

衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准

12.4 Java与线程

1. 线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。

实现线程主要有三种方式:

  • 使用内核线程实现(1:1实现)

    • 内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上
    • 程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
    • 局限性:
      • 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
      • 每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的
  • 使用用户线程实现(1:N实现)

    • 狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的
    • 不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量
    • 缺点:于没有系统内核的支援,所有的线程操作都 需要由用户程序自己去处理
  • 使用用户线程加轻量级进程混合实现(N:M实现)

    • 将内核线程与用户线程一起使用的实现方式,被称为N:M实现
      • 用户线程还是完全建立在用户空间中
      • 操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁
      • 用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险
  • Java线程的实现

    • 从JDK 1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型
2. 线程调度

线程调度:指系统为线程分配处理器使用权的过程

  1. 协同式(Cooperative Threads-Scheduling)线程调度

    • 线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去
    • 缺点:时间不可控,如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。只要有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃
  2. 抢占式(Preemptive Threads-Scheduling)线程调度

    • 每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定

    • Java使用的线程调度方式

    • 设置线程优先级,优先级越高的线程越容易被系统选择执行

      • 线程优先级不是一项稳定的调节手段:1.某些操作系统上不同的优先级实际会变;2.优先级推进器”的功能(Priority Boosting,当然它可以被关掉)
3.状态转换

深入理解Java虚拟机(全)_第49张图片

12.5 Java与协程

内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本

第十三章 线程安全与锁优化

13.2 线程安全

1. Java语言中的线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的

我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提。因为如果根本不存在多线程,又或者一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是没有什么区别的。

Java语言中各种操作共享的数据分为以下五类:(线程安全的“安全程度”由强至弱来排序)

  • 不可变(Immutable)
    • final : 如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的
    • String类
  • 绝对线程安全
  • 相对线程安全
    • 我们通常意义上所讲的线程安全
    • 在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。
  • 线程兼容
    • 是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
    • Vector和HashTable相对应的集合类ArrayList和HashMap等。
  • 线程对立
    • 指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码
    • 很少出现

2. 线程安全的实现方法

(1)互斥同步
  • (Mutual Exclusion & Synchronization)
  • 同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用
  • 而互斥是实现同步的一种手段,**临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)**都是常见的互斥实现方式。
  • 互斥是因,同步是果;互斥是方法,同步是目的。

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。

synchronized关键字经过Javac编译之后,会在同步块的前后分别形成 monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象

  • 如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference
  • 如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
  • 在执行monitorenter指令时,首先要去尝试获取对象的锁
    • 如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一
  • 在执行monitorexit指令时会将锁计数器的值减一
    • 一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
  1. 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。

  2. 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。在第10章中我们知道了在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。


JDK 5起(实现了JSR 166),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的 java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步

重入锁(ReentrantLock)是Lock接口最常见的一种实现

高级功能,主要有以下三项:

  • 等待可中断: 指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁
    • synchronized中的锁是非公平的
    • ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。(但性能会急剧下降,影响吞吐量)
  • 锁可以绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象(多次调用 newCondition()方法即可)

深入理解Java虚拟机(全)_第50张图片

(2)非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。

基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数 据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。

比较并交换(Compare-and-Swap,下文称CAS)

image-20210428103759128

(3)无同步方案
  • 可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。

可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。

  • 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字将它声明为“易变的”;

一个变量只要被某个线程独享,通过java.lang.ThreadLocal类来实现线程本地存储的功能。。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

13.3 锁优化

1. 自旋锁与自适应自旋

共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。

如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁

自旋锁在JDK 6中就已经改为默认开启。

因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。

在 JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。(如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。)

2. 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除

image-20210428123142745

深入理解Java虚拟机(全)_第51张图片

  • 在JDK 5及以后的版本中,字符串加法会转化为StringBuilder对象的连续append()操作。
  • 也就是sb的所有引用都永远不会逃逸到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。

深入理解Java虚拟机(全)_第52张图片

3. 锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

  • 扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了(上面的程序例子)

4. 轻量级锁

HotSpot虚拟机的对象头(Object Header)

  • 第一部分用于存储对象自身的运行时数据-----Mark Word
    • 如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。
    • Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间
  • 另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度

深入理解Java虚拟机(全)_第53张图片

加锁工作过程

  1. 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)
  2. 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针
    • 更新成功—>代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态
    • 更新失败—>就意味着至少存在一条线程与当前线程竞争获取该对象的锁
      • 虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧
        1. 如果,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了
        2. 否则就说明这个锁对象已经被其他线程抢占了
    • 如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

解锁过程也同样是通过CAS操作来进行的

如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

5. 偏向锁

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

  • 减少锁的持有时间
  • 减小锁的粒度
  • 锁分离
  • 锁粗化
    对象的连续append()操作。
  • 也就是sb的所有引用都永远不会逃逸到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。

[外链图片转存中…(img-l1F8DMl2-1628430706015)]

3. 锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

  • 扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了(上面的程序例子)

4. 轻量级锁

HotSpot虚拟机的对象头(Object Header)

  • 第一部分用于存储对象自身的运行时数据-----Mark Word
    • 如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。
    • Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间
  • 另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度

[外链图片转存中…(img-Q3yLjSLC-1628430706017)]

加锁工作过程

  1. 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)
  2. 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针
    • 更新成功—>代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态
    • 更新失败—>就意味着至少存在一条线程与当前线程竞争获取该对象的锁
      • 虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧
        1. 如果,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了
        2. 否则就说明这个锁对象已经被其他线程抢占了
    • 如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

解锁过程也同样是通过CAS操作来进行的

如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

5. 偏向锁

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

  • 减少锁的持有时间
  • 减小锁的粒度
  • 锁分离
  • 锁粗化
  • 锁消除

你可能感兴趣的:(面试之旅,Java,java,java虚拟机)