深入理解java虚拟机-读书笔记

  • 内存动态分配与内存回收技术已经相当成熟,看起来进入了自动化的时代,为什么还要去了解垃圾收集和内存分配
  1. 当需要排查各种内存溢出内存泄漏问题时
  2. 垃圾收集成为系统达到更高并发量的瓶颈
  • 我们就必须对内存动态分配与垃圾收集技术实施必要的监控与调节。
    A = B :A引用B,A依赖于B

一. JAVA内存区域与内存溢出异常

1.1 运行时数据区域

深入理解java虚拟机-读书笔记_第1张图片

程序计数器
  • 线程私有的内存区域,记录的是正在执行的虚拟机字节码指令的地址,通过改变程序计数器的值来选取下一条需要执行的字节码指令。
    分支,循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
  • 唯一不会出现 OutOfMemoryError 的内存区域。
Java虚拟机栈
  • 线程私有,生命周期和线程一致

  • 描述的是java方法执行的内存模型,即栈帧;

  • 方法在执行时会创建一个栈帧,存储的是局部变量表、操作数栈、动态链接、方法出口等信息。

  • 方法从执行到结束对应着一个栈帧从虚拟机栈中入栈到处出栈的过程。

  • 局部变量表:存储的是编译器可知的各种基本数据类型、对象引用类型和返回地址类型

可能会出现两种异常:

  1. 线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常
  2. 虚拟机栈可以动态扩展,当无法申请到足够的内存时,抛出OutOfMemoryError异常。
本地方法栈

与虚拟机栈的作用相似。区别在于虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机执行本地方法服务。本地方法指的是不是由java代码编写的方法。也就是用native修饰的方法,比如底层是c或c++实现的方法。

有的虚拟机(如 HotSpot 虚拟机)把本地方法栈和虚拟机栈合二为一。

和虚拟机栈一样,本地方法栈也会出现 StackOverflowError 和 OutOfMemoryError 两种异常。

  • java堆是JVM所管理的内存中的最大的一块内存,存放对象实例。
  • 被线程共享,但是也会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)
  • 垃圾收集器管理的主要区域
  • OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。
方法区
  • 存放的是已加载的类信息、常量、静态变量、编译后的热点代码
  • 被线程共享
  • 会被垃圾收集器回收,但条件较为苛刻
  • 如果方法区无法满足新的内容分配需求时,将抛出OutOfMemoryError 异常

1.2 HotSpot虚拟机对象探秘

对象的创建
  1. 当java虚拟机遇到一条字节码new指令时,检查指令的参数是否能在方法区的常量池中定位到一个类的符号应用,再检查类是否已被加载、解析和初始化过。如果没有,执行类加载过程。
  2. 执行完类加载过程,对象所需的内存大小便已确定,在java堆中划分一块空闲的内存块分配给它。
  3. 将分配的内存空间(不包括对象头)初始化为零值
  4. 填充对象头,设置对象的相关信息。例如:对象属于哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。
  5. 虚拟机的视角对象已经产生,从程序代码的视角看,还需要通过调用字节码文件的init()方法执行构造函数。
    注:分配内存的方式是采用改变指针的所指向的位置,存在两个线程都在修改指针指向的情况。并发情况下非线程安全。
    解决方式:
  • 采用CAS(Compare and Swap)乐观锁配上失败重试来解决
  • 将分配内存的动作按照线程划分在不同的空间之中,给每个线程预先分配内存,成为本地线程内存分配缓冲(TLAB)
对象的内存布局

堆内存中对象的存储布局可划分为三个部分:
1、对象头 2、实例数据 3、对齐填充

  • 对象头包含两部分:
  1. 存储对象自身的运行时数据:如哈希码,GC分代年龄、线程持有的锁、锁状态标志、偏向线程ID、偏向时间戳等
  2. 类型指针,指向对象类型元数据的指针。虚拟机通过这个指针来确定该对象是哪个类的实例。
  • 实例数据:程序代码中各种类型的字段内容(包括父类继承下来的和子类中定义的)
  • 对齐填充:非必要的占位符,在HosSpot虚拟机中,保证对象的大小必须是8字节的整数倍。
对象的访问定位

程序中通过栈上的reference数据来操作堆上的具体对象。具体实现是由虚拟机完成的,主流的访问方式主要有句柄直接指针两种:

  1. 句柄访问
    使用句柄访问,java堆中划分一块内存作为句柄池,reference中存储的就是对象的句柄地址。句柄中包含了对象实例数据的指针和到对象数据类型数据的指针。
    优点:reference中存储的就是对象的句柄地址,在对象被移动(垃圾收集)时,只需要改变句柄中的实例数据指针,而 reference本身不需要修改
    深入理解java虚拟机-读书笔记_第2张图片
  2. 直接指针访问
    Java堆中对象的内存布局必须考虑如何放置访问访问类型数据的相关信息,reference中存储的直接就是对象地址
    优点:由于直接存储对象地址,直接访问对象。节省了实例数据指针定位的时间开销,速度更快。对象访问在java中非常频繁,在HotSpot虚拟机中,主要使用的就是直接指针进行对象访问
    深入理解java虚拟机-读书笔记_第3张图片

实战:OutOfMemoryError异常

在java运行时数据区域中,只有程序计数器不会发生OutOfMemoryError异常。

Java堆溢出

Java堆溢出异常时实际应用中最常见的内存溢出异常情况。
通过程序测试:无限循环新建对象
查看java堆溢出异常信息:通过参数设置让虚拟机在出线内存溢出异常时Dump出当前的内存对转储快照
通过内存映像分析工具对Dump出来的堆转储快照进行分析,一般是有两种情况:内存泄露与非内存泄漏

  1. 内存泄漏:垃圾回收器无法回收,查看泄露对象到GC Roots的引用链
  2. 非内存泄漏:内存中的对象必须存活,检查堆参数的设置,有没有扩大的空间;再从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长、存储结构不合理等情况。
虚拟机栈和本地方法栈溢出

虚拟机栈和本地方法栈能抛出两种异常:

  1. 线程请求的栈深度大于虚拟机所允许的最大深度,简单来说就是虚拟机栈容量不够了,将抛出StackOverflow异常;
    通过程序测试:运行无终止条件的递归函数。在HotSpot虚拟机上会显示栈溢出异常。

  2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,简单来说就是栈帧太大了,将抛出OutOfMemoryError异常。
    通过程序测试:在方法中定义大量的局部变量+递归调用此方法。因为HotSpot虚拟机不支持栈内存动态扩展,所以在HotSpot虚拟机上依旧会抛出内存溢出OutOfMemoryError异常。而支持栈内存动态扩展的Claasic虚拟机就会抛出内存溢出OutOfMemoryError异常。

但是HotSpot虚拟机也不是不会抛出OutOfMemoryError异常,当创建的线程很多时,则会抛出此异常,但此异常和栈空间大小不存在直接关系,取决于操作系统的内存使用状态。
例如:32位的Windows操作系统单个进程最大内存限制位2GB,除去给方法区、堆分配的内存,程序计数器消耗内存很小可忽略,剩下的内存就由虚拟机栈和本地方法栈来分配了。如果为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少。容易把剩下的内存耗尽。
所以在开发多线程应用时,可以通过减少最大堆和栈容量来换取更多的线程,避免内存溢出;当然,还可以将32位更换成64位操作系统。

方法区和运行是常量池溢出

运行时常量池是方法区的一部分。String::intern()是一个本地方法,返回字符串常量池中等于此String对象的字符串应用,如果字符串常量池中已经有了该对象,则直接返回引用;如果没有,则先添加到字符串常量池中再返回引用。
通过程序测试:在方法中创建数值,准换成String类型并调用intern()方法

方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、字段描述和方法描述等。对于这部分区域的测试,基本思路是
通过程序测试:运行时产生大量的类,使用CGLIB直接操作字节码运行时产生大量的动态类

本机直接内存溢出

通过程序测试:调用Unsafe::allocateMemory()方法

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

2.1 概述

  • Java内存运行时区域中的程序计数器、虚拟机栈和本地方法不需要过多考虑内存回收的原因:
    1、这三个区域随线程而生,随线程而灭。
    2、每一个栈帧分配多少内存在类结构确定下来时就已知
    因此,这三个区域的内存分配与回收都具备确定性,不需要过多考虑内存回收的问题。

  • 然而,java堆和方法区的内存分配与回收有着很显著的不确定性:
    1、一个接口的多个实现类需要的内存可能会不一样 ??
    2、一个方法所执行的不同条件分支所需要的内存也可能不一样 ??
    因此,只有处于运行期间才能知道程序会创建哪些对象,创建多少个对象,这部分内存分配与回收是动态的,垃圾收集器所关注的也是此部分该如何管理

2.2 对象已死?

在垃圾收集器对堆进行回收前,需要判断对象中哪些“存活”,哪些“死去”;“死去”即不可能再被任何途径使用的对象

2.2.1 引用计数法

  • 判断原理:在对象中添加一个引用计数器,每当有一个地方引用它,引用计数器的值就加一;引用失效时,极速器就减一。当计数器为零时,该对象就不可能再被使用。
  • 但是,目前主流的java虚拟机里面都没有选用引用计数器来管理内存,因为要配合大量额外处理才能保证正确地工作,例如引用计数器很难解决对象之前相互循环引用的问题:
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

深入理解java虚拟机-读书笔记_第4张图片此时,objA和objB都为空,但是引用计数器的值不为零,无法被回收

2.2.2 可达性分析算法

当前主流的商用程序语言(Java、C#)的内存管理子系统都是通过可达性分析算法来判定对象是否存活。
深入理解java虚拟机-读书笔记_第5张图片

  • 判断原理:通过一系列的“GC Roots”的对象作为起始点,从这些节点开始根据引用关系向下搜索的路径称为引用链,当一个对象不在引用链上(即从GC roots到这个对象不可达),则证明此对象不可能再被使用。
  • 固定可作为GC Roots的对象:
    1、虚拟机栈(栈帧中的本地变量表)中引用的对象。譬如当前正在运行方法所用到的参数、局部变量表、临时变量等
    2、本地方法栈中JNI(即通常所说的Native方法)引用的对象。
    3、方法区中类静态属性引用的对象。譬如Java类的引用类型静态变量。
    4、方法区中常量引用的对象。譬如字符串常量池里的引用。
    5、所有被同步锁(synchronized关键字)持有的对象
    6、Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有类加载器。
    7、反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

2.2.3 再谈引用

前面的两种方式判断存活时都与‘引用’有关,一个对象只有“被引用”和“未被引用”两种状态,但是 JDK 1.2 之后,引用概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用,下面四种引用强度一次逐渐减弱,下面具体介绍。

  • 强引用:通过传统的引用赋值实现,强引用的对象不会被垃圾回收期回收
  • 软引用:通过SoftReference类实现软引用,描述一些还有用,但非必须的对象。在系统将要发生内存溢出异常前,会把这些对象列进回收范围中进行第二次回收
  • 弱引用:通过WeakReference类实现弱引用,也是描述非必须对象,当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用:通过PhanReference类实现虚引用,一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。设置虚引用关联的唯一目的只是为了能在这个对象被收集回收时收到一个系统通知。

2.2.4 生存还是死亡?

即使在可达性分析算法中不可达的对象,也并非是“facebook”的,这时候它们暂时出于“缓刑”阶段,一个对象的真正死亡至少要经历两次标记过程:如果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象竟会放置在一个叫做 F-Queue 的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会出发这个方法,并不承诺或等待他运行结束。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象简历关联即可。

总结:当对象变成(GC Roots)不可达时,GC会判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。否则,若对象未执行过finalize方法,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize方法。执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。

finalize() 方法只会被系统自动调用一次。

finalize() 方法并不等同于C、C++中的析构函数,运行代价高昂,不确定性大,无法保证各个对象的调用顺序,不推荐使用,使用try-finally更好

2.2.5 回收方法区

在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空间,而方法区(HotSpot虚拟机中的元空间或者永久代)的垃圾收集效率远低于此。

永久代垃圾回收主要两部分内容:废弃的常量无用的类

  • 对于常量的回收:与java堆中回收对象非常类似,例如:当前系统没有一个字符串对象的值是“java”,即没有任何字符串对象引用常量池中的“java”常量,那么就会被垃圾收集器回收。

  • 对于类的回收:需要满足以下三个条件
    1、该类的所有实例都已经被回收,也就是java堆中不存在该类及其任何派生子类的实例
    2、加载该类的类加载器已经被回收
    3、该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法

注:满足上述三个条件仅仅是“被允许”,是否要被回收需要根据虚拟机参数的控制来判断。

2.3 垃圾收集算法

关于判定对象消亡,垃圾收集算法可划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类,但是在主流的Java虚拟机中用的都是后者,因此本节讨论的是追踪式垃圾收集算法。

2.3.1 分代收集理论

当前的垃圾收集器(垃圾收集算法)都遵循了“分代收集”理论,名为理论,实则经验法则,建立在两个分代假说之上:
1、弱分代假说:绝大数对象都是朝生夕灭的;因此将java堆划分出新生代,新生代收集:重点关注如何保留少量存活而不是去标记将要回收的对象
2、强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡;因此有老年代,老年代收集:低频率回收

分代的缺点:对象不是孤立的,对象之前存在跨代引用,例如在新生代中扫描GC roots的路径,还得遍历整个老年代的所有对象是否存在跨代引用。因此添加了第三条假说:
3、跨代引用假说:跨代引用相对于同代引用仅占少数;基于此假说,不必为了少量的跨代引用去扫描整个老年代,可以在新生代中建立一个数据结构把老年代分块,标识出哪一块内存会存在跨代引用。这样就不用对整个老年代进行扫描。

2.3.2 标记—清除算法

简介:算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象;或者反过来,标记存活的对象,统一回收未被标记的对象。

存在两个主要缺点:

  • 执行效率不稳定,如果Java堆中大部分对象都需要回收,那么标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,碎片太多会导致以后分配较大对象时没有足够的内存而触发又一次的垃圾回收动作。

2.3.3 标记—复制算法

简介:1969年第一次称为“半区复制”的垃圾收集算法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。

优点:顺序分配内存,移动对顶指针。不会产生内存碎片
主要存在两个缺点:

  • 如果内存中多数对象都是存活的,产生大量的内存复制开销
  • 可用内存缩小了原来的一半,空间浪费

简介:现在的Java虚拟机大多都采用了这种收集算法去回收新生代,新生代中的对象98%熬不过第一轮收集(IBM公司曾做过一项研究),所以不需要按照1:1来划分新生代的内存空间

1989年提出了一个更优化的半区复制分代策略,HotSpot虚拟机按照8:1:1的比例划分Eden:Survivor:Survivor区,内存可分配空间占90%,剩下10%作为复制区,当复制区的内存不足以存储存活下来的对象,需要依赖其他区域存储(例如老年代)进行分配担保。

2.3.4 标记—整理算法

对于标记—复制算法,对象存活率较高则会有较多的复制操作,效率会降低。因此老年代不适合选用这种算法。
简介:标记过程与标记—清除算法一样,后续步骤是让所有存活对象都向内存空间一段移动,然后直接清理掉边界以外的内存。

优点:无需浪费空间
缺点:移动存活对象,需要暂停用户应用程序
优缺点:移动内存回收时更复杂,不移动内存(标记—清除算法)则内存分配时会更复杂。从整个程序的吞吐量来看,移动对象会更划算。
吞吐量的本质是:赋值器与收集器的效率总和,不移动对象会使收集器效率增加,但内存分配和访问比垃圾收集频率要高得多,所以这部分耗时增加,总吞吐量仍然是下降的。

标记—整理算法+内存—清除算法

多数时间使用标记—清除算法,知道产生的内存碎片化程度大到影响对象分配时,再用标记-整理算法收集一次以获得更连续规整的内存空间。

2.4 HotSpot的算法细节实现

2.4.1 根节点枚举

  • 可达性算法通过根节点枚举来找引用链,根节点枚举过程必须暂停用户线程,原因是需要保证 根节点对象的引用关系不再发生变化。
  • 在即时编译过程中,在特定的位置(安全点)用OopMap这种数据结构存放对象引用,这样就不必进行全局扫描

因此,在 HotSpot 中采取了空间换时间的方法,使用 OopMap 来存储栈上的对象引用的信息。
在 GC Roots 枚举时,只需要遍历每个栈桢的 OopMap,通过 OopMap 存储的信息,快捷地找到 GC Roots。

2.4.2 安全点

用OopMap,可能会发生的情况是:引用关系变化导致OopMap存储的内容变化,如果为每一条指令设置OopMap开销则很大
优化:OopMap是只在特定的位置记录信息,将这些位置设成安全点,强制要求程序必须执行到安全点后才能执行暂停(之前暂停是基于垃圾收集时刻,垃圾收集则会暂停,安全点解决的是如何停顿用户线程的问题),安全点的选取是以“能否具有让程序长时间执行的特征”为标准选取的,例如指令序列的复用(方法调用、循环跳转、异常跳转);
那么如何保证垃圾收集发生时让所有线程跑到最近的安全点:
1、抢先式中断:垃圾收集发生时,所有线程中断,如果有用户线程中断的地方不在安全点上,就恢复执行直到跑到安全点上再中断
2、主动式中断:垃圾收集发生时,设置标志位,各线程在执行过程中不断轮训标志,如果发现中断标志为真,那么就在最近的安全点上主动中断挂起

2.4.3 安全区域

用安全点的设计方案,可能会发生的情况是:处于Sleep或者Blocked状态的线程无法走到安全点去中断挂起自己。
优化:将安全点扩展拉伸成安全区域,确保安全区域中引用关系不会发生变化,从任意地方开始垃圾收集都是安全的
程序进入安全区域,标识自己进入安全区域,离开时如果根节点枚举完成后,如果完成,则没事发生;如果没完成,一直等待,直到收到可以离开安全区域的信号为止。

2.4.4 记忆集与卡表

  • 记忆集是记录非收集区域指向收集区域的指针集合的抽象数据结构
  • 而卡表是记忆集的具体实现,定义了记忆集的记录精度、堆内存的映射关系等;最简单的形式是字节数组,每个元素对应堆内存区域一块特定大小的内存块,可能不止包含一个对象,如果对象中存在跨代指针,那就将卡表数组元素的值设为1。
  • 卡表为什么能做到地址映射对应?
    因为字节数组是顺序存储(卡表索引号),卡页(内存块,大小相等)的地址也是顺序存储

2.4.5 写屏障

通过写屏障技术来维护卡表状态,一旦收集器在写屏障中增加了更新卡表操作,虚拟机就会为所有赋值操作生成相应的指令,每次只要对引用进行更新就会更新卡表状态

2.4.6 并发的可达性分析

可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能进行分析,这意味着必须全冻结用户线程的运行;
但如果用户线程和收集器并发工作呢?收集器在对象图上标记颜色,用户线程修改引用关系相当于修改对象图的结构,可能会导致:
1、原本消亡的对象错误标记为存活;可容忍
2、原本存活的对象错误标记为消亡;程序肯定会发生错误
初始阶段,只有GC ROOTS节点是黑色的
深入理解java虚拟机-读书笔记_第6张图片

如何解决?
1、增量更新:当黑色对象插入新的指向白色对象的引用关系时,将插入的引用记录下来,等并发扫描结束之后,将记录引用关系中的黑色对象为根节点,重新扫描一次。可以理解为:黑色对象插入新指向白色对象的引用之后,就变成了了灰色对象
2、原始快照:当灰色对象删除指向白色对象的引用关系时(相当于给黑色对象提供了白色对象),将删除的引用记录下来,等并发扫描之后,将记录引用关系中的灰色对象为根节点,重新扫描一次(这时灰色对象和白色对象的引用关系还在,因为做了原始快找保存)。

2.5 经典垃圾收集器

深入理解java虚拟机-读书笔记_第7张图片
没有最好的垃圾收集器,也不存在万能的收集器,有的只是在具体应用场景下最合适的收集器。衡量收集器的三项最重要指标是:内存占用吞吐量和延迟,三者形成了“不可能三角”,一款收集器不可能在这三个方面都有优秀的表现,通常最多可以同时达成其中的两项。

2.5.1 Serial 收集器

深入理解java虚拟机-读书笔记_第8张图片
特点

  • 单线程收集器;单线程指的是:1、只使用一个处理器或一条收集线程去完成垃圾收集工作;2、单线程更重要的强调是它在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
  • 新生代采用标记-复制算法; 老年代(Serial Old收集器)采用标记-整理算法

优点

  • 简单而高效(与其他收集器的单线程相比);对于内存资源受限的环境,它是收集器里内存消耗(保证垃圾收集能够顺利高效地进行而存储的额外信息)最小的。
  • 单线程收集效率最高;对于单核处理器或处理器核心数较少的环境来说,Serial收集器没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率

缺点

  • 因为是单线程所以在垃圾收集时,必须暂停所有用户线程。无论是新生代还是老年代

适用场景

  • 用户桌面的应用场景以及部分微服务应用中,分配给虚拟机管理的内存不大
  • 对于运行在客户端模式下的虚拟机来说是一个很好的选择

2.5.2 Serial Old收集器

适用场景

  • 供客户端模式下的HotSpot虚拟机使用
  • 在服务端模式下,可能有两种用途:1、在JDK 5及之前的版本中与Parrallel Scavenge收集器搭配使用;2、一种是作为CMS收集器发生失败时的后备预案

2.5.3 ParNew 收集器

深入理解java虚拟机-读书笔记_第9张图片
特点

  • ParNew收集器实质上是Serial收集器的多线程并行版本
  • 缺点与Serial收集器一样,垃圾收集时暂停所有用户线程
  • 在单核心处理器的环境中比不过Serial收集器,因为存在线程交互的开销

适用场景

  • 多核心处理器的场景,ParNew收集器对于垃圾收集时系统资源的高效利用是有好处的
  • 默认开启的收集线程数与处理器核心数量相同,也可以通过修改参数来限制收集器数量

补充
在谈论垃圾收集器的上下文语境中,并行和并发可以理解为:

  • 并行:描述的是多条垃圾收集器线程之间的关系,同一时间有多条这样的线程在协同工作。通常默认此时用户线程处于等待状态。
  • 并发:描述的是垃圾收集器线程与用户线程之间的关系,同一时间用户线程与垃圾收集线程都在运行;但由于垃圾收集器线程占用了一部分系统资源,应用程序的处理的吞吐量将受到一定影响

2.5.4 Parallel Scavenge 收集器

深入理解java虚拟机-读书笔记_第10张图片
特点

  • 新生代收集器、基于标记—复制算法、多线程并行收集;与ParNew收集器非常相似
  • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量;吞吐量是处理器用于运行用户代码的时间与处理器总消耗时间(运用用户代码时间+运行垃圾收集时间)的比值。通过控制两个参数:最大垃圾收集停顿时间的参数和直接设置吞吐量大小的参数
  • Parallel Scavenge收集器支持开启自适应的调节策略:监视当前系统运行情况,动态调整一些参数已提供最合适的停顿时间或者最大的吞吐量

2.5.5 Parallel Old 收集器

深入理解java虚拟机-读书笔记_第11张图片
特点

  • Parallel Old 收集器是Parallel Scavenge收集器的老年代版本,支持多线程并行收集,基于标记-整理算法

适用场景:

  • 注重吞吐量或者处理器资源稀缺(正因为稀缺,所以得充分利用;也有可能相对CMS收集器来说,对处理器的要求比较高)的环境中,用Parallel Old 收集器+Parallel Scavenge收集器

2.5.6 CMS 收集器

CMS收集器与G1收集器相比于前5个收集器更复杂,具体体现在标记过程和回收过程是分开的,分别多了很多操作;
深入理解java虚拟机-读书笔记_第12张图片
特点

  • 老年代收集器、基于标记-清除算法、并行与并发收集;
  • CMS 收集器关注的目标是用户程序的停顿时间尽可能短,以给用户带来良好的交互体验
  • 基于标记—清除算法的过程:
    1、初始标记:暂停用户线程,标记一下GC roots能直接关联到的对象,速度很快
    2、并发标记:并发标记就是从GC roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长不需要停顿用户线程
    3、重复标记:暂停用户线程,修正在并发标记期间,因用户程序而导致标记产生变动的那一部分对象的标记记录(CMS 收集器用的是增量标记),时间比初始标记时间长,比并发标记时间短
    4、并发清除:与用户线程同时运行,清理掉标记为死亡的对象

缺点

  • CMS处理器对系统资源非常敏感,并行收集器的线程数默认开启数量是(处理器核心数 + 3)/4,因此处理器核心数越少,垃圾收集器占用的系统资源的比例就越多。如果处理器的核心数较少,导致用户程序的执行速度大幅度降低。
  • CMS处理器无法处理“浮动垃圾”,因为是并发清除,所以在这一阶段用户线程还在运行产生的垃圾只能等到下一次垃圾收集时再清理掉;由于在垃圾收集阶段用户线程还在运行,所以需要预留内存提供给用户线程使用,不能等到老年代的垃圾被填满再收集。
  • 由于是预留内存,如果实际运行中内存无法满足程序分配新对象的需要,就会出现一次“并发失败”,这时会启动后备预案,暂停用户线程使用Serial Old收集器来进行重新收集,但是用户线程的停顿时间边长
  • 基于标记—清除算法,会产生碎片,会导致无法找到足够大的连续空间分配给当前对象,而不得不触发一次Full GC(收集java堆和方法区的垃圾),为了解决该问题,可设置两个参数:1、CompactAtFullCollection开关,当CMS不得不进行Full GC时开启内存碎片的合并整理过程。2、CMSFullGCsBeforeCompaction参数,这个参数的作用是要求执行若干次不整理空间的GC之后,下一次进入Full GC前会先进行碎片整理。

适用场景

  • java 应用集中在互联网网站或者基于浏览器的B/S系统的服务端上

2.5.7 Garbage First收集器

深入理解java虚拟机-读书笔记_第13张图片

特点

  • 跨代收集器、Mixed GC模式、面向局部收集的设计思路和基于Region的内存布局形式;
    1、面向局部收集思路:在G1收集器之前,垃圾收集的目标范围要么是整个新生代、整个老年代或者整个Java堆,而G1收集器是面向堆内存任何部分进行回收,衡量标准不再是它属于哪个分代,而是那块内存中存放的垃圾最多,回收收益最大,这属于Mixed GC模式
    2、基于Region的堆内存布局:
    (1)G1不再坚持固定大小固定数量的分代区域划分,把连续的堆内存划分为多个大小相等的独立区域,每一个Region根据需要可扮演为Eden空间、Survivor空间或者老年代空间。收集器能够根据不同的角色的Region采用不同策略(内存分配与回收策略,可通过设置一些参数来实现)去处理。
    (2)Region中还有一类特殊的Humongous区域,专门用来存储大对象。大对象的判定标准是对象大小超过了一个Region容量的一半。G1大多数行为把Humongous Region看做是老年代
    (3)G1收集器跟踪每个Region里面的垃圾堆积的”价值“大小,所谓价值指的是回收所获的耳空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,再根据用户指定的收集停顿时间优先处理回收价值收益最大的Region。这也就是Garbage First 名字的由来。
  • 关注的目标是收集器能够建立“时间停顿模型”,能够指定在一个时间长度范围M毫秒内,消耗在垃圾收集器上的时间大概率不超过N毫秒的目标;具体怎么实现见特点1;

怎么保证时间停顿模型可靠?通过获得“衰减平均值”:统计可测量的数据,得到预测值。多次预测取平均,统计状态越新平均值约新,预测的停顿时间越准确

  • 收集过程分为以下4个步骤:
    1、初始标记:暂停用户线程,修改TAMS指针的值,让下一阶段用户线程并发运行时能正确地在地方分配新对象,耗时很短
    2、并发标记:并发标记就是从GC roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长不需要停顿用户线程,耗时较长
    3、最终标记:暂停用户线程,修正在并发标记期间,因用户程序而导致标记产生变动的那一部分对象的标记记录(CMS 收集器用的是原始快照)
    4、筛选回收:暂停用户线程,更新Region的统计数据,对每个Region的回收价值和成本进行预测并排序,根据用户所期望的停顿时间来制定回收计划(选择任意多个Region构成回收集),把存活的对象复制到空的Region中,在清理掉旧Region的全部空间。从整体来看,该过程使用的是标记-整理算法,而从局部(两个Region之间)来看,该过程属于是标记—复制算法

优点

  • 用户能够指定期望的停顿时间
  • 使用Region划分内存空间、以及根据优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率,获得尽可能高的吞吐量
  • 不会产生内存空间碎片

缺点

  • 运行G1垃圾收集器占用更多的内存(包括垃圾收集产生的内存占用和用户程序运行时(写屏障维护卡表,相比CMS只有写后屏障,G1写前写后都有屏障)的额外执行负载);Region存在跨Region引用,每个Region都维护自己的记忆集(卡表),记忆集记录的是别的Region指向自己的指针,也记录着自己指向别的Region的指针;用的是哈希表,key是别的Region地址,Value是卡表的索引号。
    ps:CMS卡表就一份,只需要处理老年代到新生代的引用,反过来则不需要,这样做是因为新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,维护开销很大。但是也会有不好的影响就是在Old GC(所有收集器只有CMS有针对老年代的Old GC)时,需要把整个新生代作为GC roots来进行扫描
  • 由于垃圾收集阶段会与用户线程并发执行,与CMS的问题一样,用户运行需要占用内存,当垃圾收集的速度赶不上内存分配的速度导致用户程序内存不足以分配给新的对象时,则会停顿掉用户线程,导致长时间的Full GC

适用场景

  • 面向服务端应用的垃圾收集器
  • 在小内存应用上CMS的表现大概率优于G1,大内存应用G1能发挥更大优势。

2.6 低延迟垃圾收集器

Shenandoah 收集器和 ZGC 收集器;在任意可管理的堆容量下,实现垃圾收集的停顿不超过10毫秒。但是这两款目前仍处于实验状态。

2.6.1 Shenandoah 收集器

特点

  • 基于Region的内存布局,与G1相似;
  • 用Humongous Region存储大对象,与G1相似;
  • 默认的回收策略根据Region的优先级,与G1相似;
  • 支持并发的整理算法,而G1只支持多线程并行清理,但不能与用户线程并发;
  • 为了追求更低的停顿时间,并发地进行对象清理后的动作;
  • 不是由Oracle公司团队开发的HotSpot收集器,由RedHat公司独立开发,在OpenJDK12中才会包含。
    更多的特点之后再述。

2.6.2 ZGC 收集器

特点

  • OpenJDK11是具有实验性质,OpenJDK12中支持。
  • 也是基于Region内存布局,但是容量大小是动态的,有大中小三类Region;小中型Region容量固定、大型Region的容量动态变化。
  • 支持并发的整理算法,提出了染色指针技术,把标记信息存在对象的指针上,不用写屏障来维护块表中的引用信息。
    更多的特点之后再述。

2.7 内存分配与回收策略

Java技术体系的内存管理,根本上解决的是两个问题:1、自动给对象分配内存; 2、自动回收分配给对象的内存
前面垃圾收集器体系以及运作原理已经介绍了自动内存回收原则,下面通过Serial + Serial Old收集器重点讲解若干最基本的内存分配原则

  • 对象优先在Eden分配;当Eden区没有足够空间进行分配时,虚拟机将进行一次Minor GC
  • 大对象直接进入老年代;
  • 长期存活的对象将进入老年代;虚拟机给每个对象定义了年龄计数器,每次Minor GC后如果存活,年龄+1,当年龄增加到一定程度(默认为15),就会进入到老年代;
  • 动态对象年龄判定;Survivor空间中低于或等于某年龄的所有对象大小的综合大于Survivor空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代
  • 空间分配担保;
    在Minor GC之前,
    1、先检查老年代最大可用连续空间是否大于新生代所有对象总大小:
    ——1.1 如果满足这个条件成立则Minor GC可以确保安全;
    ——1.2 如果不满足,再检查参数设置是否允许空间担保失败。
    ————1.2.1 如果允许,再检查老年代最大可用的连续空间是否大于历代晋升到老年代对象的平均大小;
    ——————1.2.1.1 如果大于,将尝试进行Minor GC;
    ——————1.2.1.2 如果小于,则不允许毛线,这时要改为进行一次Full GC。
    ————1.2.2 如果不允许,则直接Full GC
    将允许空间担保失败的参数打开,这样做的好处是减少Full GC的频率,因为Full GC的停顿时间很长。

三 类文件结构

3.1 无关性的基石

字节码文件和Java虚拟机既是构成平台无关性的基石,也是语言无关性的基石。

  • 平台无关性
    深入理解java虚拟机-读书笔记_第14张图片
     Write once, run everywhere(一次编写,到处运行)这是Java语言诞生之处就宣传的一个口号。Java语言之所以能够跨平台运行,其实就是因为Java虚拟机对各个平台的适配,在不同的系统下安装不同的Java虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,我们程序当然能够在不同的系统上运行。
  • 语言无关性
    深入理解java虚拟机-读书笔记_第15张图片
    Java虚拟机不与任何程序语言绑定,除了JAVA语言,还可以运行其他语言,例如Kotlin、Groovy、Jruby、JPython。
    Java虚拟机只认识字节码文件,这些语言通过对应的编译器将源程序代码编译成字节码文件就能被Java虚拟机载入和执行。

3.2 Class 类文件结构

  • class文件本质是一串二进制字节流(JVM指令);虚拟机执行引擎解释执行字节码,将其翻译成cpu可以识别的指令
  • 从二进制流——>字节码指令:通过javap -v class文件名 > 输出文件名 命令:将class文件,翻译成可读懂的字节码指令文件,里面包含了各种各样的指令(注:这里的指令不是汇编语言)
  • jvm虚拟机在解释字节码文件的过程中需要先将其翻译成指令吗?
  • 通过classfile定义,类似c语言结构体,记录了诸如当前类、父类、接口、字段属性、方法等信息。
  • 任何一个class文件都对应着唯一的一个类或接口的定义信息,但是类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入到类加载器中)
ClassFile {
    u4             magic; //Class 文件的标志
    u2             minor_version;//Class 的小版本号
    u2             major_version;//Class 的大版本号
    u2             constant_pool_count;//常量池的数量
    cp_info        constant_pool[constant_pool_count-1];//常量池
    u2             access_flags;//Class 的访问标记
    u2             this_class;//当前类
    u2             super_class;//父类
    u2             interfaces_count;//接口
    u2             interfaces[interfaces_count];//一个类可以实现多个接口
    u2             fields_count;//Class 文件的字段属性
    field_info     fields[fields_count];//一个类会可以有多个字段
    u2             methods_count;//Class 文件的方法数量
    method_info    methods[methods_count];//一个类可以有个多个方法
    u2             attributes_count;//此类的属性表中的属性数
    attribute_info attributes[attributes_count];//属性表集合
}
  • 常量池中主要存放两大类常量: 字面量(Literal)符号引用(Symbolic References)
  • 字面量比较接近于Java语言层面的常量概念, 如文本字符串、 被声明为final的常量值等。——>常量的静态对照物
  • 符号引用则属于编译原理方面的概念, 主要包括下面几类常量:
    1、被模块导出或者开放的包(Package)
    2、类和接口的全限定名(Fully Qualified Name)——>类型信息的静态对照物
    3、字段的名称和描述符(Descriptor)——>静态变量的静态对照物
    4、方法的名称和描述符
    5、方法句柄和方法类型(Method Handle、 Method Type、 Invoke Dynamic)
    6、动态调用点和动态常量(Dynamically-Computed Call Site、 Dynamically-Computed Constant)
  • 虚拟机在加载Class文件时才会进行动态链接,也就是说,Class文件中不会保存各个方法、 字段最终在内存中的布局信息, 这些字段、 方法的符号引用不通过虚拟机在运行期转换的话是没法获得真正的内存入口地址, 也就没法直接被虚拟机使用的,当虚拟机作类加载时, 将会从常量池得到对应的符号引用, 再在类建立时或运行时解析、 翻译到具体的内存地址之中常量池中每一项常量都是一个表,截至JDK13, 常量表中分别有17种不一样类型的常量。这17类表都有一个共同的特色, 表结构起始的第一位是个u1类型的标志位,表明着当前常量属于哪一种常量类型。 17种常量类型所表明的具体含义以下图所示。

符号引用:符号引用以一组符号来描述所引用的目标,符号能够是任何形式的字面量,只要使用时能无歧义地定位到目标便可。符号引用与虚拟机实现的内存布局无关,引用的目标并不必定已经加载到了内存中。
直接引用:直接引用能够是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不一样虚拟机实例上翻译出来的直接引用通常不会相同。若是有了直接引用,那说明引用的目标一定已经存在于内存之中了

Class文件的结构时,会有这样几个概念,字面量、全限定名(Fully Qualified Name)、简单名称(Simple Name)和描述符(Descriptor)和符号引用

  • 字面量:字面量表示源代码中一个固定值的表示法,比如数字1就是字面量,字符串也是字面量;Java中有整数字面量,浮点数字面量,布尔型字面量,字符字面量,字符串字面量。
  • 符号引用:符号引用用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。下面几个概念都属于符号引用
  • 简单名称:没有类型和参数修饰的方法或字段名称,如下面代码,inc是方法的简单名称,m是字段的简单名称。
public class TestClass {
    private int m;
    public int inc() {
        return m+1;
    }
}
  • 全限定名:全限定名是将类全名的 “.” 全部替换为 “/”;例如 “com/enjoy/learn/core/oop/method/TestClass”
  • 描述符:1)描述字段的数据类型;2)描述方法的参数列表和返回值

四 虚拟机类加载机制

4.1 概述

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

4.2 类加载的时机

深入理解java虚拟机-读书笔记_第16张图片

  • 加载、验证、准备、初始化和卸载这5个阶段顺序是确定,类型的加载过程必须按照这种顺序按部就班地开始(注意不是进行或完成,因为这些阶段通常都是交叉混合地进行,在一个阶段的执行过程中调用、激活另一个阶段)。解析阶段也可以是在初始化阶段之后进行。
  • 类加载的第一个阶段“加载”的开始时机没有强制约束,但是《Java虚拟机规范》则是严格规定了有且只有六种情况必须对类进行初始化
    1、遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要触发初始化阶段。能够生成四条指令的典型Java代码场景时:
    (1)使用new关键字实例化对象的时候
    (2)读取或设置一个类型的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候;字段是实例变量或者类变量(静态变量)
    (3)调用一个类型的静态方法的时候
    2、使用Java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行初始化则需要触发初始化
    3、当初始化类的时候,如果父类还没有进行初始化,则需要先触发其父类的初始化
    4、当虚拟机启动时、用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
    5、当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。
    6、当一个接口中定义了JDK 8新加入的默认方法(被defalut关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化

这六种场景中的行为成为对一个类型进行主动引用,出此之外所有引用类型的方式都不会触发初始化,称为被动引用,例如以下场景:

  • 通过子类引用父类的静态字段,不会导致子类初始化,只会触发父类的初始化
  • 通过数组来引用类,不会触发此类的初始化;例如:A[] arrayObj = new A[10];
  • 常量(final修饰)在编译阶段会存入调用类的常量池中,本质上没有引用定义常量的类,因此不会触发定义常量的类的初始化

4.3 类加载的过程

4.3.1 加载

  • 在加载阶段,Java虚拟机需要完成以下三件事情:
    1、通过一个类的全限定名来获取定义此类的二进制字节流
    2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    3、在内存中生成一个代表这个类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口

  • 在1中,该规则没有指明从哪里获取、如何获取class文件,因此,开发人员可以在这个空隙上大做文章,许多举足轻重的Java技术都建立在这一基础之上,例如:
    1、从ZIP压缩包中读取,最终成为日后JAR、EAR、WAR格式的基础;
    2、从网络中获取,这种场景最典型的就是Web Applet;
    3、运行时计算生成,这种场景使用得最多的就是动态代理技术;
    4、由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件
    5、…

  • 相对于类加载过程的其他阶段:
    1、 非数组类型的加载阶段(准确地说,是加载阶段中获取二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用java虚拟机内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过自定义的类加载器去控制获取字节流的获取方式(重写类加载器的findClass()或loadClass()方法)
    2、 数组类的加载阶段,情况有所不同,数组类本身不通过加载器创建,它是由Java虚拟机直接在内存中动态构造出来,但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程遵循以下规则:
    (1)如果数组的组件类型是引用类型,那就递归采用类加载加载。
    (2)如果数组的组件类型不是引用类型(例如int[] 数组),Java 虚拟机会把数组标记为引导类加载器关联。
    (3)数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。

  • 加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区中了。

4.3.2 验证

验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息当做代码不会危害虚拟机自身的安全。验证阶段大致上会完成下面四个阶段的校验动作:

  • 文件格式验证
    例如:
    1、是否以魔数0×CAFEBABE开头;
    2、主、次版本号是否在当前Java虚拟机接受范围之内;
    3、常量池中的常量中是否有不被支持的常量类型 等等
    该阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合一个Java类型信息的要求。通过该阶段之后,字节流才被允许进入Java虚拟机内存的方法区中进行存储,因此这部分也是要在加载阶段之前完成;后面的三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

  • 元数据验证
    例如:
    1、这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);
    2、这个类的父类是否继承了不允许被继承的类(被final修饰的类);
    3、如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法;
    4、类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)
    5、 …
    该阶段的主要目的是对类的元数据信息进行语义校验,保证符合《Java虚拟机规范》

  • 字节码验证
    第三阶段是验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。该阶段主要是对类的方法体进行校验(Class文件中的Code属性)分析,确保被校验类的方法在运行时不会做出危害虚拟机的行为,例如:
    1、保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作;(例如:不会出现类似于“在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况)
    2、保证任何跳转指令都不会挑战转到方法体意外的字节码指令上
    3、保证方法中类型转换总是有效的,(例如:可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫不相干的一个数据类型,则是危险和不合法的)
    4、 …

  • 符号引用验证
    发生的时机是在虚拟机将符号引用转化为直接引用的时候,这个转化动作是发生在连接的第三阶段——解析阶段,符号引用验证可以看作是对类自身以外(常量池中的各类符号引用)的各类信息进行匹配性校验,通俗来说就是该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源,例如:
    1、符号引用中通过字符串描述的全限定名是否能找到对应的类
    2、在指定类中是否符合方法的字段描述符简单名称所描述的方法和字段
    3、符号引用中的类、字段、方法的可访问性(private、protected、public、)

总结:验证阶段非常重要但不是非必要的,如果程序运行的全部代码都已经被反复使用和执行过,那么就可以设置参数来关闭大部分类的验证措施,以缩短虚拟机类加载的时间

4.3.3 准备

准备阶段是正式为静态变量分配内存并设置变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中分配内存。并且这里所说的初始值“通常情况下”是数据类型的零值

int 0
boolean false
long 0L
short (short)0
float 0.0f
double 0.0d
reference null
byte 0
char '\u0000'

public static int value = 1127;
这句代码在初始值设置之后为 0,因为这时候尚未开始执行任何 Java 方法。而把 value 赋值为 1127 的 putstatic 指令是程序被编译后,存放于 clinit() 方法中,所以初始化阶段才会对 value 进行赋值。

public static final int value = 1127;
编译时Javac将会为value生成 ConstantValue属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 1127。

4.3.4 解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程;简单来说,在解析阶段,Java虚拟机会把符号引用替换为一个指针,该指针指向目标在方法区的内存位置

  • 符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量;符号引用与虚拟机实现的内存布局无关,引用的目标不一定是已经加载到内存当中的内容;
  • 直接引用:直接引用是:
    (1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
    (2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
    (3)一个能间接定位到目标的句柄
    直接引用与虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。

1. 类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,(相当于嵌套解析,但是解析的目标是类型是D还是C?)那虚拟机完成解析过程包括以下3个步骤:
1、如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出线了任何异常,解析过程就将宣告失败
2、如果C是一个数组类型,并且数组类型为对象,那将会按照第一个点的规则加载元素类型,接着由虚拟机生成一个代表该数组维度和元素的数组类型
3、如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为了一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限,必须满足以下3条规则中的一个:
1)被访问类C是public,并且与访问类D处于同一个模块
2)被访问类C是public,不与访问类D处于同一个模块,但是被访问类C的模块允许访问类D的模块进行访问
3)被访问类C不是public,但是它与访问类D处于同一个包中

2. 字段解析

Java中字段包括类级变量和实例级变量,不包括局部变量;字段可以包括的修饰符有:访问权限修饰符,类变量还是实例变量(static)、可变性(final)、并发可见性(volatile,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本数据类型、对象、数组)、字段名称

要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用进行解析,如果解析完成,那么把这个字段所属的类或接口用C表示,按照以下步骤对C进行后续字段的搜索:
1、如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
2、否则,如果在C中实现了接口,将会按照继承关系向上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
3、否则,如果在C不是java.lang.Object的话,将会按照继承关系向上递归搜索其父类,如果父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
4、否则,查找失败,抛出java.lang.NosuchFieldError异常
最后,对字段进行权限验证,如果不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常

3. 方法解析

要解析一个未被解析过的方法符号引用,首先会对方法表内class_index项中索引的方法所属的类或接口的符号引用进行解析,如果解析完成,那么把这个方法所属的类或接口用C表示,按照以下步骤对C进行后续方法的搜索:
1、由于Class文件格式中的类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常
2、如果通过了第一步,在类C中查找是否有简单名称和描述符斗鱼目标相匹配的方法,如果有,则返回这个方法的引用,查找结束。
3、否则,如果在C不是java.lang.Object的话,将会按照继承关系向上递归搜索其父类,如果父类中包含了简单名称和字段描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
4、否则,在类C实现的接口列表及它们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常
5、否则,查找失败,抛出java.lang.NosuchMethodError异常
最后,对方法进行权限验证,如果不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常

4. 接口方法解析

要解析一个未被解析过的方法符号引用,首先会对接口方法表class_index项中索引的方法所属的类或接口的符号引用进行解析,如果解析完成,那么把这个方法所属的类或接口用C表示,按照以下步骤对C进行后续方法的搜索:
1、由于Class文件格式中的类的方法和接口的方法符号引用的常量类型定义是分开的,如果在接口方法表中发现class_index中索引的C是个类而不是接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常;
2、否则,在接口C中查找是否有简单名称和描述符斗鱼目标相匹配的方法,如果有,则返回这个方法的引用,查找结束。
3、否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找也会包括Object类中的方法)为止,是否有简单名称和描述符都与目标相匹配的方法,如果有,则返回这个方法的引用,查找结束。
4、对于规则3,由于java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法返回其中一个并查找结束
5、否则,宣告方法查找失败,抛出java.lang.NosuchMethodError异常
在JDK9之前,Java接口中的所有方法都默认是public,也没有模块化约束,所以不存在访问权限的问题;但在JDK9中增加了接口的静态私有方法,也有了模块化访问的约束,所以从JDK9起,接口方法的访问也可能存在因访问权限控制而出现java.lang.IllegalAccessError异常

4.3.5 初始化

  • 初始化阶段是类加载过程的最后一个步骤,前几个步骤都是由虚拟机来主导,而此阶段主导权交给应用程序;在初始化阶段,根据程序编码指定的主观计划去初始化类变量和其他资源。也可以从另一种更直接的形式来表达:初始化阶段就是执行类构造器()方法的过程。
  • ()方法不是程序代码中直接编写的方法,而是由Javac编译器自动的生成物,下面来具体来介绍该方法的特点:
    深入理解java虚拟机-读书笔记_第17张图片1、clinit()方法具体是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,静态语句块只能访问定义在静态访问块之前的变量,或定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
public class Test {
 static {
 i = 0; // 给变量复制可以正常编译通过
 System.out.print(i); // 这句编译器会提示“非法向前引用”
 }
 static int i = 1;
}

2、clinit()方法与类的构造函数不同,它不需要显示地调用父类构造器,Java虚拟机保证在子类的()方法执行前,父类的()方法已经执行完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object
3、由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下面代码所示,字段B的值将会是2而不是1

static class Parent {
 public static int A = 1;
 static {
 A = 2;
 }
}
static class Sub extends Parent {
 public static int B = A;
}
public static void main(String[] args) {
 System.out.println(Sub.B);
}

4、()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成()方法
5、接口中不能使用静态语句块,但任然有变量初始化的复制操作,因此接口与类一样都会生成()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法,因为只有当父类接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法
6、Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他下线程都需要阻塞等待,直到活动线程执行完毕()方法。

需要注意,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒后则不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会被初始化一次。

4.4 类加载器

在类加载阶段中,类加载器可以通过一个类的全限定名来获取描述该类的二进制字节流,这个动作在虚拟机外部实现,以便让程序自己去决定如何获取所需的类

4.4.1 类与类加载器

  • 对于任何一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。比如,判断两个类是否“相等”,只有在两个类都是由同一类加载器加载的前提下才有意义。否则,即时这两个类源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
  • “相等”代表类的Class对象的equals()、isAssignableFrom()方法、isInstance()方法的返回结果相同,也包括了使用istanceof关键字做对象所属关系判定等情况

4.4.2 双亲委派模型

从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
  • 所有其它类的加载器,使用 Java 实现,独立于虚拟机,并且全部继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • 启动类加载器(Bootstrap ClassLoader):前面已经大致介绍过了,这个类加载器负责将存放在 \lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可

  • 扩展类加载器(Extension ClassLoader):这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将 /lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader):这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般也称为“系统类加载器”。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由上述这三种类加载器互相配合从而实现类加载,如果有必要,还可以加入自己定义的类加载器:来扩展获取Class文件的方式

  • 双亲委派模型:指的是类加载器之间的层次关系;双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系不是以继承关系来实现的,而是通常使用组合关系来复用父加载器的代码
  • 双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(在搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
    在这里插入图片描述
    优点
  • 解决各个类加载器协作时基础类型一致性的问题,保证Java的程序的稳定运行;Java中的类随着它的加载器一起具备了一种带有优先级的层次关系,例如类java.lang.Object,它存放在rt.jr中,无论是哪一个类加载器要加载这个类,最终都是会委托到模型最顶端的启动加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类。
    如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。在这种情况下,可以正常编译,但是无法被加载运行。
    ps:什么是基础类型? 因为它们总是作为被用户代码继承、调用的API存在;

简单总结两个优点
1、因为双亲委派是向上委托加载的,所以它可以确保类只被加载一次,避免重复加载
2、Java的核心API都是通过引导类加载器进行加载的,如果别人通过定义同样路径的类比如java.lang.Integer,类加载器通过向上委托,两个Integer,那么最终被加载的应该是jdk的Integer类,而并非我们自定义的,这样就避免了我们恶意篡改核心包的风险

作者:一个程序员的成长
链接:https://www.zhihu.com/question/315563427/answer/1807406721
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

4.4.3 破坏双亲委派模型

未详述

上文提到过双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的“远 古”时代。
  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。

4.5 Java模块化系统

Java9 模块化系统

未详述

JDK9 后的类加载器委派关系

深入理解java虚拟机-读书笔记_第18张图片

  • JDK9中虽然维持着三层模型,不但扩展类加载起被平台类加载器取代,并且类加载的委派关系也发生了变动:当平台类和应用程序类加载器收到类加载请求,在委派给父加载器前,要先判断这个类是否能归属到某个系统模块中,如果可以找到这样的归属关系,则优先委派给负责那个模块的加载器加载,这算是对双亲委派的第四次破坏。
  • 在Java模块化系统中明确规定了三个类加载器负责各自加载的模块,即前面所说的归属关系

五. JAVA内存模型与线程

并发处理的广泛应用是Amadahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。
Amadal定律:通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力;
摩尔定律:用于描述处理器晶体管数量与运行效率之间的发展关系
这两个定律的更替代表了近年来硬件发展从追求处理器频率到追求多核心并行处理的发展过程

5.1 概述

  • 为什么需要并发处理 ?
    1、并发处理能够充分压榨计算机运算能力,该方案的可行性体现在:计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间花费在磁盘I/O、网络通信或者数据库访问上,如果不希望处理器在大部分时间里都处于等待其他资源的空闲手段,就必须使用一些手段去把处理器的远算能力“压榨”出来。最容易想到的是让计算机同时处理几项任务
    2、一个服务端同时对多个客户端提供服务,则是一个更具体的并发应用场景;

5.2 硬件的效率与一致性

  • 当前绝大部分计算任务不可能只靠计算机“计算”就能完成,处理器需要与内存交互,如读取运算数据、存储运算结果等;
  • 但是,计算机的存储设备与处理器的运算速度有着几个数量级的差距;所以,现代计算机都加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能够快速进行,当运算结果结束后从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。高速缓存解决了处理器与内存速度之间的矛盾,缩短I/O操作的时间,进一步充分利用处理器的运算单元;
  • 但是,带来了缓存一致性的问题,如下图所示:每个处理器都有自己的高速缓存,共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,当不同的缓存数据同步到主内存时该以哪个缓存数据为准呢?
  • 内存模型,可以理解为在特定的内存或高速缓存进行读写访问的过程抽象。内存访问操作及硬件的缓存访问操作具有高度的可类比性深入理解java虚拟机-读书笔记_第19张图片
  • 除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序优化。

5.3 Java内存模型

5.3.1 简介

  • Java虚拟机的内存模型,对特定的内存进行读写访问过程的抽象;Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。直至JDK5发布后,Java内存模型才终于成熟、完善起来了。

注:此处的变量指的是实例字段、静态字段和构成数组的对象的元素,不包括局部变量和方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

  • 作用(特点):屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果;(注:C或C++等程序语言可以直接使用物理硬件和操作系统的内存模型,不同平台上的内存模型的差异可能会导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错)

5.3.2 主内存与工作内存

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

这里的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本没什么关系;
如果一定要对应起来,那么主内存主要对应于Java堆中的实例数据部分,而工作内存则对应与虚拟机占中的部分区域。
从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存

5.3.3 内存间交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8中原子操作来完成:

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

你可能感兴趣的:(java基础,java虚拟机)