深入理解JVM(内存、GC、类加载器)

深入理解JVM

第一章 走进java

1.5.2新一代即时编译器

自JDK10起,HotSpot中加入了一个全新的即时编译器:Graal编译器

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

2.2.1 程序计数器PC

  • Java多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。
  • “线程私有的内存”:每条线程都有一个PC,各条线程PC互不影响,独立存储。
    • 执行java方法:PC记录虚拟机字节码指令的地址
    • 执行本地方法:PC为

2.2.2 Java虚拟机栈

  • Java虚拟机栈和PC一样也是线程私有,生命周期与线程相同
  • 栈:虚拟机栈,更多情况下指局部变量表部分
    • 局部变量表内容
      • 基本数据类型
      • 对象引用(reference类型,可能是一个指向对象起始地址的引用指针)
      • returnAddress
    • 局部变量槽(Slot):局部变量表中的存储空间
      • 64位的long和double会占用个变量槽,其余数据类型占一个。
      • 编译期间完成分配,一个方法分配多少局部变量空间完全确定(槽的数量)
      • 异常对比:
        • StackOverflowError:线程请求栈深度大于虚拟机所允许的深度
        • OutOfMemoryError:如果虚拟机栈容量可以动态扩展,扩展时申请不到足够的内存。

2.2.3 本地方法栈

  • 与虚拟机栈类似。

2.2.4 Java堆

  • 虚拟机所管理内存最大的一块,被所有线程共享,存储对象的实例。
  • 被GC(垃圾收集器管理),也称GC堆。
  • java堆在主流java虚拟机都可以扩展(-Xmx和-Xms),但也可能会抛出OOM异常。

2.2.5 方法区

  • 各个线程共享,存储已被虚拟机加载的类型信息、常量、静态变量、代码缓存登数据
  • JDK8完全废弃了永久带的概念,垃圾回收行为出现得比较少。

2.2.6 运行时常量池

  • 是方法区的一部分,用于存放编译器生成的各种字面量与符号引用
  • 相比于Class文件常量池具备动态性,运行期间也可以将新的常量放入池中,会有OOM异常。

2.2.7 直接内存

  • 本机内存,非数据区的一部分,但被频繁使用

2.3.1 对象的创建

遇到new指令时

  1. 检查指令参数是否能在常量池中定位到一个==类的符号引用==,并检查==该类是否被加载、解析和初始化过==
    1. 未加载过,先执行类加载过程
    2. 已加载过:
  2. 如果类已经被加载,加下来虚拟机为新生的对象==分配内存==,内存大小在==类加载完成==后可以==完全确定==
    1. 假设堆中内存规整,采用==指针碰撞==,即将指针向空闲区域方向移动与对象大小相等的距离来分配对象内存。
    2. 如果堆中内存不规整,采用==空闲列表==,即虚拟机维护一个记录哪些内存可用的列表,然后找到一块==足够大==的空间划分给对象实例,并更新记录表。
    3. 采用==垃圾收集器==是否带有==空间压缩存储==->Java堆是否规整->采用哪种分配方式。
  3. 内存分配完毕后,虚拟机必须将分配到内存空间(不包括对象头header)==都初始化为零值==,如果有TLAB,也可以在TLAB分配时顺便进行。
    1. 保证了对象避免出现未初始化就被访问。
  4. 对==对象头Header==进行设置。主要内容包括对象是哪个类的实例、类的元数据信息、对象哈希码、GC分代年龄信息、是否启用偏向锁等。
  5. 执行构造函数等。

如何保证分配对象线程安全不被其他并发情况干扰?

  1. 对分配内存空间的动作进行==同步处理==——实际上虚拟机是采用==CAS==配上==失败重试==的方式保证更新操作的原子性。

  2. 把内存分配的动作按照线程划分在不同空间进行。

    哪个线程需要分配内存,就在线程本地缓冲区中分配,只有本地缓冲区==用完了==,分配新的缓冲区才需要同步锁定。是否使用TLAB,可以通过-XX:+/-UseTLAB设定。

2.3.2 对象的内存布局

对象在堆内存存储布局

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

对象头Header

HotSpt虚拟机对象头包含两部分信息

  • 存储对象自身运行时数据(Mark Word)
    • 如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
    • 32位HotSpot中,25bit存储对象哈希码,4bit存储对象分代年龄、2bit存储锁标志位、1bit固定为0.
  • ==类型指针==(不是所有虚拟机都必须保留类型指针),即对象指向它的类型元数据的指针,java虚拟机通过这个指针来确定对象是哪个类的实例。

实例数据

存储对象真实有效的数据。存储顺序会受到==虚拟机分配策略参数==(-XX:FieldsAllocationStyle)和==字段在java源码中定义顺序==的影响。

对齐填充

==并不是必然存在的!==,仅仅起着占位符的作用。

由于HotSpot的自动内存管理系统要求==对象起始地址==必须是8字节的整数倍,所以任何==对象的大小==都必须是8字节的整数倍!。对象头部分正好是8字节的倍数。

2.3.3 对象的访问定位

主流方式

  • 句柄
  • 直接指针

句柄

Java堆中会划分处一块内存作为==句柄池==,==reference==中存储的的就是对象的句柄地址,句柄中包含==对象实例数据==与类型数据各自具体的==地址信息==。

image-20200715100749428

  • 好处
    • reference中存储的是==稳定句柄地址==,在对象移动(GC移动对象)时只会改变句柄中的地址,而reference本身不需要被改变

直接指针访问

==reference中直接存储对象地址。==

image-20200715101011017

  • 好处
    • 速度更快,节省了一次指针定位的时间开销。==HotSpot采用这种方式。==

2.4实战OOM异常

目的

  • 通过代码验证《Java虚拟机规范》中描述各个运行时区域存储的内容
  • 根据异常提示判断哪个区域的内存溢出,知道哪些代码会导致溢出,以及如何处理。

设置虚拟机参数

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

2.4.1 Java堆溢出

  • 虚拟机参数

-Xms20m -Xmx20M -XX:+HeapDumpOnOutOfMemoryError:限制java堆大小为20M,不可扩展。

Xms:堆的==最小值== Xmx:堆的==最大值==。 -XX:+HeapDumpOnOutOfMemoryError:内存溢出时dump出==当前内存堆转储快照==便于事后分析。

  • 代码
1
2
3
4
5
6
7
8
9
public class HeapOOM {
static class OOMObject{};
public static void main(String[] args) {
List<OOMObject> list =new ArrayList<OOMObject>();
while (true){
list.add(new OOMObject()); //不断添加对象直到堆溢出
}
}
}

结果

image-20200715103003677

解决方案

  • 通过内存映像分析工具堆Dump出来的堆转储快照进行分析。
  • 确定导致OOM的对象是否是必要的,分清楚到底出现了==内存泄漏==(Memory Leak)还是==内存溢出==(Memory Overflow)
    • 如果是内存泄漏
      • 通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致GC无法回收。
    • 如果不是内存泄漏(内存中对象必须都是==必须存活的==)
      • 检查虚拟机==堆参数-Xms -Xmx的设置==(如本次实验),看是否还可以调大。再从代码上检查是否存在某些对象生命周期过长,持有状态时间过长,存储设计不合理等情况。

补充:内存泄漏与内存溢出的区别

  • 内存泄漏memory leak :是指程序在申请内存后,==无法释放已申请的内存空间==,一次内存泄漏似乎不会有大的影响,但==内存泄漏堆积后的后果就是内存溢出==。
    • 垃圾清理不掉
  • 内存溢出 out of memory :指程序申请内存时,==没有足够的内存==供申请者使用,或者说,给了你一块存储int类型数据的存储空间,但是你却存储long类型的数据,那么结果就是内存不够用,此时就会报错OOM,即所谓的内存溢出。
    • 垃圾堆积过多导致内存不够

内存溢出原因及解决方法

  • 原因

    • 内存中加载的==数据量过于庞大==,如一次从数据库取出过多数据;
    • 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
    • 代码中存在==死循环或循环产生过多重复的对象实体==;
    • 使用的第三方软件中的BUG;
    • 启动参数内存值设定的过小。
  • 解决方案

    • 修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)
    • 检查错误日志,查看“OutOfMemory”错误前是否有其 它异常或错误。
    • 对代码进行走查和分析,找出可能发生内存溢出的位置。

2.4.2 虚拟机栈和本地方法栈溢出

HotSpot不区分虚拟机栈和本地方法栈!

两种异常

  • 如果线程请求的==栈深度大于虚拟机所允许的最大深度==,抛出StackOverflow异常!
  • 如果虚拟机栈内存==允许动态扩展==,栈容量无法申请到足够内存时,抛出OutOfMemory异常!

StackOverflow异常

虚拟机参数:-Xss180k 64位最低180k,32位最低128k

  • 代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class JavaVMStackSOF {
private int stackLength=1;

/**
* 递归访问栈直到溢出
*/
private void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom=new JavaVMStackSOF();
try{
oom.stackLeak();
}catch (Throwable e){
System.out.println("stack length:"+oom.stackLength);
throw e;
}
}
}
  • 结果

image-20200715105912455

3.4.6并发的可达性分析

要解决或者降低用户线程的停顿,要搞清楚为什么必须再一个==能保障一致性的快照上==才能进行对象图的遍历?

(该时刻各个对象之间的引用关系不会随时发生变化)。

引入三色标记。

  • 白色:未被GC访问过。
  • 黑色:已经被GC访问过,且这个对象的==所有引用==都已经扫描过。
    • 黑色是安全存活的。
    • 黑色不可能直接指向某个白色对象。
  • 灰色:被GC访问过,但至少还存在一个引用没被扫描过。

产生原本应是黑色但被误标记未白色的条件(当切仅当同时满足时):

  • 赋值器插入了一条或多条从==黑色对象到白色对象==的新引用
  • 赋值器删除了全部从==灰色对象==到==白色对象==的直接或间接==引用==

解决方案:破坏任意一个条件即可。

方案一:增量更新。(破坏条件一)

黑色对象插入指向白色对象的引用关系时,将新插入的引用==记录下来==,并发扫描结束后,再以这些==黑色对象为根==重新扫描一次。即==黑色对象插入指向白色对象的引用后,就变回了灰色对象==

方案二:原始快照(破坏条件二)

灰色对象删除指向白色对象的引用关系时,就将这个要删除的引用==记录下来==,并发扫描结束后,再将这些记录过的引用关系中==灰色对象为根==重新扫描一次。即==无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照来进行搜索==

小结

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

HotSpot中,两种解决方案都有应用。例如CMS是基于增量更新做并发标记的,G1,Shenandoah是用原始快照实现。

3.5 HotSpot中出现过的经典垃圾回收器

经典收集器之间的关系图:

image-20200811175928674
  • 连线说明可以搭配使用
  • 分为新生代收集器和老年代收集器

3.5.1 Serial收集器

是最基础、历史最悠久的收集器

单线程工作的收集器:收集垃圾时,==必须暂停其他所有工作线程,直到他收集结束,==

仍然是HotSpot运行在==客户端模式==下的默认新生代收集器。

  • 简单而高效
  • 所有收集器里额外内存消耗最小的

3.5.2 ParNew收集器

实质上是Serial收集器的多线程并行版本

是不少运行在==服务端模式==下的HotSpot虚拟机。

  • 除了Serial收集器,目前只有它能==与CMS收集器配合工作。==
  • 是激活CMS收集器后默认的新生代收集器。可以通过-XX:+/--UseParNewGC来强制指定或禁用

出现G1这种面向全堆的收集器后,==自JDK9ParNew合并入了CMS,成为它专门处理新生代组成部分。==,

ParNew是HotSpot第一款退出的垃圾收集器。

并行和并发

并行:多条==垃圾收集器线程之间的关系==,同一时间有多条垃圾收集器在同时工作。用户线程处于等待状态。

并发:垃圾收集器线程与==用户线程==的关系。同一时间垃圾收集器线程与==用户线程==都在运行。

3.5.3 Parallel Scavenge 收集器

也称吞吐量优先收集器。是一款==新生代==收集器。同样是基于==标记-复制==算法实现,能够并行收集的==多线程==收集器。

不同点:

  • 其他收集器关注点是==尽可能缩短垃圾收集时用户线程的停顿时间==。Parallel Scavenge的目标是达到一个可控制的吞吐量。
    • 吞吐量:处理器用于==运行用户代码的时间==与==处理器总消耗时间(运行代码时间+运行垃圾收集时间)==的比值。
  • 有关参数:
    • 最大垃圾收集停顿时间:-XX:MaxGCPauseMillis
      • 值为大于0的毫秒数
      • 收集器将尽力保证内存回收花费时间不超过设定值,但不能太低,因为是==牺牲吞吐量和新生代空间==为代价换取的。
      • 如10s收集一次,停顿100ms->5秒收集一次,停顿70ms。停顿时间下降了,但吞吐量也降低了。
    • 直接设置吞吐量大小: -XX:GCTimeRatio
      • 值为大于0小于100的整数,即垃圾收集时间占总时间的==比率==,吞吐量的倒数。
      • 如设置为19,则最大收集时间占总时间的5%(1/(1+19))
    • 开关参数:-XX:+UseAdaptiveSizePolicy
      • 激活后就==不需要==人工指定新生代大小-Xmn,虚拟机回根据当前系统的情况收集性能监控信息,动态调整参数。----垃圾收集的自适应的调节策略。
      • 只需要把基本的内存数据设置好(-Xmx),然后使用-XX:MaxGCPauseMillis==或==-XX:GCTimeRatio设立一个优化目标,虚拟机就会自动调节参数。

3.5.4 Serial Old 收集器

是Serial收集器的老年代版本。使用==标记-整理==算法。主要供==客户端==hotSpot使用。

如果出现在==服务端模式==下:

  • JDK5以及之前与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器发生失败时的==后备预案==,在并发收集发生Concurrent Mode Failure使用。

3.5.5 Parallel Old 收集器

JDK6才开始提供。解决了之前新生代如果选择Parallel Scavenge,老年代除了Serial Old外别无选择的尴尬处境。而Serial Old在服务端应用性能上的“拖累”,未必能获得吞吐量最大化的效果。

在==注重吞吐量==或者处理器==资源较为稀缺==的场合,都可以优先考虑Parallel Scavenge+Parallel Old组合。

3.5.6 CMS 收集器

CMS(Concurrent Mark Sweep)是一种以获取==最短回收停顿时间==为目标的收集器,基于==标记-清除==算法。

整个过程分为四个步骤:

  • 初始标记(CMS initial mark)
    • 会停顿用户线程
    • 仅仅标记一下GCRoots==直接关联到的对象==
  • 并发标记(CMS concurrent mark)
    • 从上一步标记的对象中开始遍历整个对象图,进行标记。
    • 过程比较耗时但==不需要==停顿用户线程。
  • 重新标记(CMS remark)
    • 为了修正并发标记期间,因用户继续操作导致的变动标记状态。即==增量更新法==。
    • 会停顿用户线程,停顿时间比第一阶段长,但远比第二阶段短。
  • 并发清除(CMS concurrent sweep)
    • 清理删除掉标记阶段判断已经死亡的对象,由于==不需要移动存活对象==,可以与用户线程同时并发。
    • 过程比较耗时但==不需要==停顿用户线程。

早期CMS缺点

  • 对处理器资源非常敏感(占用的处理器资源大小)。CMS默认启动回收线程数是==(处理器核心数量+3)/4==,如果核心在4个及以上,并发回收时只占用不超过25%,并且==随着核心数增加而下降==。
    • 但如果不足4核,CMS对用户程序的影响就会变得很大,会占用很多处理器资源。
    • 因此虚拟机提供了==增量式并发收集器==来解决问题:并发标记、清理的时候和用户线程交替进行,不会一直独占资源。但==效果很一般,JDK9后i-CMS就被完全废弃==
  • CMS收集器无法处理==浮动垃圾==(2,4阶段用户线程产生的垃圾)。
    • 必须预留一部分空间供并发收集时的用户程序运作。JDK5老年代使用68%后就会被激活CMS,==比较偏保守==。JDK6时,启动阈值提升到了92%,但又会出现==预留内存无法满足新对象产生==而并发失败冻结用户线程,临时启动Serial Old进行重新收集老年代的垃圾,这样停顿时间就很长了。
    • 可以通过-XX:CMSInitiatingOccupancyFraction设置阈值。但具体数值需要根据实际情况进行权衡。
  • 标记-清除后会产生大量的碎片空间,会出现==老年代还有很多空间,但就是无法找到足够大的连续空间来分配对象==。
    • -XX:+UseCMSCompactAtFullCollection 开关参数触发FullGC开启内存碎片的合并整理。==JDK9已废弃==
    • -XX:CMSFullGCsBefore-Compaction CMS执行若干次未整理后,下一次进入FullGC前会先进行碎片整理==JDK9已废弃==

3.5.7 Gabage First(G1)收集器

里程碑式成果。主要面向==服务端==的GC。 JDK9发布后,Parallel Scavenge+Parallel Old组合被G1取代。CMS被声明为不推荐使用。

G1的Mixed GC模式:

  • 目标范围不再是整个新生代或老年代,而是堆内存任何部分来组成==回收集==进行回收。

原理

G1基于==Region==的堆内存布局。G1==不再坚持固定大小及固定数量的分代区域划分==,而是把==连续的java堆==划分为多个大小相等的==独立区域(Region)==,每个Region扮演新生代、老年代等角色。

==Region是单次回收的最小单元==

Region中还有特殊的==Humongous==区域,专门存储大对象(大小超过Region的一半)。

  • 调整Region大小:-XX:G1HeapRegionSize,取值范围为1MB~32MB,且应为2的n次幂。
  • 特大对象会被存放在n个连续的Humongous Region中。
  • G1大多数情况将Humongous Region当作==老年代的一部分==看待。

G1细节问题解决方案

将Java堆划分成多个独立的Region后,Region里的跨Region引用对象如何解决?

  • 使用记忆集避免全堆作为GC Roots扫描。
  • G1记忆集本质是一种哈希表,key是Region的==起始地址==,value是一个集合,==存储卡表的索引号==,双向卡表结构,G1比其他GC有着更高的内存负担。

并发阶段如何保证收集线程与用户线程互不干扰地运行?

  • CMS通过增量更新实现,G1通过==原始快照==实现。
  • G1为每个Region分配了==两个TAMS指针==,用于新对象分配,默认存活不纳入回收范围。

怎么建立可靠的停顿预测模型?

用户可以通过-XX:MaxGCPauseMillis指定停顿时间的==期望值(默认200ms,几十到100甚至200都正常,但不能太小==,G1会怎么做来尽量靠近这个期望值?

  • G1以==衰减均值==为理论基础,记录每个Region的==回收耗时、脏卡数量、各个步骤的成本等==,分析出平均值、标准偏差、置信度等。
  • Region的==统计状态越新==,==越能决定其回收的价值==。G1再判断哪些Region组成回收集能在==不超过期望停顿时间==下获得最高的收益(回收更多的垃圾)。

G1运作步骤

  • 初始标记
    • 仅仅标记一下与GC Root直接关联的对象
    • 新:修改TAMS指针的值,以能正确分配新对象。
    • 会停顿用户线程(短)
  • 并发标记
    • 从 GC Root开始对堆对象进行==可达性分析==,找到回收的对象,耗时较长
    • 新:由于不会停顿用户线程,所以扫描完后还要重新处理SATB记录下的==在并发时引用有变动的对象==
  • 最终标记
    • 处理上一步扫描出的变动的少量SATB记录。
    • 会停顿用户线程(短)
  • 筛选回收
    • 更新Region统计数据,对每个Region==回收价值和成本进行排序==,制定回收计划。
    • 可以自由选择==多个任意的Region==构成回收集,将存货对象复制到空的Region上,再清理掉整个==旧的Region==
    • 会停顿用户线程(短)

从G1开始,后续的GC设计导向由==一次把整个Java堆清理干净==转变为==能够应付应用的内存分配速率(防止出现Full GC)==。

G1与CMS对比

CMS G1
算法理论 标记-清除 整体看基于标记-整理,局部看基于标记-复制
内存占用 随内核数量变化 都比CMS高,卡表更复杂
执行负载 较低 较高

G1从整体看是基于==标记-整理==算法,但从局部(两个Region)上看又是==标记-复制==算法。

  • 执行负载:
    • 都使用==写后屏障==。CMS用来更新维护卡表
    • G1也维护卡表(更复杂),但为了实现原始快照,还需要使用==写前屏障==跟踪并发时指针变化情况。G1不得不实现类似于==消息队列==的结构,把写前屏障和写后屏障的事情放入队列里然后==异步处理。==

3.6 低延迟垃圾收集器

衡量GC的三项最重要的指标:

  • 内存占用(Footprint)
  • 吞吐量(Throughput)
  • 延迟(Latency)

三者构成了一个“不可能三角”。即不可能三者同时达到完美,最多可以同时达成其中两项。

image-20200813104255074

3.6.1 Shenandoah收集器

Shenandoah不由Oracle开发,于是受到排挤,JDK12中明确拒绝支持Shenandoah。但OpenJDK中存在。

目标:实现在任何堆内存大小大小下,GC停顿时间都可以限制在10ms

特点

  • 支持并发的整理算法(G1回收可以多线程,但并不支持并发)
  • 默认不使用分代收集。抛弃了G1中耗费大量内存去维护的记忆集。改为连接矩阵来记录跨Region的引用关系。
    • 连接矩阵:可以理解为一张二维表格,如果Region M 有对象指向Region N,则在 M行N列打一个标记。
image-20200813104330958

运行步骤(9个阶段)

  • 初始标记
  • 并发标记
  • 最终标记
  • 并发清理
    • 清理整个区域内连一个存活对象都没有找到的Region
  • 并发回收(核心差异)
    • 与用户线程并发。
    • 回收集中存活的对象复制一份到未被使用过的Region
    • 使用读屏障Brooks Pointers转发指针 来解决复制对象的同时与用户线程并发
  • 初始引用更新
    • 将所有指向旧对象的引用修正到复制后的新地址
    • 时间很短,会产生一个非常短暂的停顿
  • 并发引用更新
    • 真正开始进行引用更新。与用户线程并发
    • 时间长短取决于内存中涉及的引用数量多少
  • 最终引用更新
    • 解决堆中的引用更新后,还要修正存在于GC Roots中的引用。
    • 是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量有关
  • 并发清理
    • 经过并发回收和引用更新后,整个回收集中已经不存在存活对象,于是清理掉这些Region。

总结:

  • 前三阶段与G1一样。
  • 三个最重要的并发阶段:
    • 并发标记、并发回收、并发引用更新
  • 工作过程示意图
image-20200813104726933

转发指针-Brooks Pointers

Shenandoah收集器的并发回收的核心是--转发指针。

转发指针的核心内容就是,在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己

image-20200813105136130

好处:

转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码仍然可用,都会被自动转发到新对象上继续工作。

image-20200813105536262

​ (旧对象转发到新对象上)

性能测试

image-20200813110146927

停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在十毫秒以内的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。

3.6.2 ZGC收集器

专业术语描述:ZGC是一款基于Region内存布局的,(暂时)不设置分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发标记-整理算法的,以低延迟为首要目标的一款垃圾收集器

内存布局

ZGC的Region具有动态性--动态创建和销毁、动态的区域容量大小。分为小、中、大型。

  • 小型Region:固定为2MB,放置小于256KB的小对象。
  • 中型Region:固定为32MB,放置大于等于256KB,小于4MB的对象。
  • 大型Region:容量不固定,但必须为2MB的整数倍,放置4MB以上的大对象。每个Region只会存放一个大对象,不会被重分配。

并发整理算法的实现

Shenandoah使用转发指针和读屏障。

ZGC也使用到了读屏障,但是多了染色指针(Colored Pointer)。

染色指针直接把标记信息记在了引用对象的指针上。

为什么指针可以存储信息?

尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如图3-20所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)

image-20200814201116753

染色指针的优势

  • 一旦某个Region的存活对象被移走以后,这个Region立即就能够被释放和重用掉(染色指针标记信息发生变化,而不需要等待引用更新)。
  • 大幅减少在垃圾收集过程中内存屏障的使用数量
    • 设置内存屏障目的:记录对象引用的变化。
    • ZGC未使用任何写屏障,只使用了读屏障,所以ZGC对吞吐量的影响也比较低
  • 可以作为一种可扩展的存储结构来记录更多与对象标记、重定位有关的数据。

带来问题:随意定义指针其中几位,操作系统干不干?

X86-64平台上因为不支持虚拟地址掩码,所以需要用到虚拟内存映射技术

Linux/x86-64平台上的ZGC使用了多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了

image-20200814202249925

ZGC运作过程

  • 并发标记

    • ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
  • 并发预备重分配

    • 根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)(非回收集)。
    • ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的 维护成本。
  • 并发重分配(核心阶段)

    • 重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系

    • 由于有染色指针,ZGC可以仅从引用上判断是否在重分配集中。

    • 用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(SelfHealing)能力。

      对比Shenandoah转发指针每次都需要转发,ZGC只需要第一次转发

  • 并发重映射

    • 修正整个堆中指向重分配集中旧对象的所有引用(旧引用也可自愈)
    • 由于需要遍历对象,ZGC巧妙地将此阶段合并到了下一次并发标记完成,省掉了一次遍历。

ZGC的劣势:由于未分代,当新对象的分配速率大于回收速率时,这些新对象很难进入本次收集的标记范围,就会被全部当作活对象处理,会产生大量浮动垃圾。

ZGC与其他GC性能比较

image-20200814204438047
image-20200814204501855

3.7选择合适的垃圾收集器

3.7.3 虚拟机及垃圾收集器日志

JDK9时,HostSpot统一了日志处理框架。所有日志都收归到了-Xlog参数上。

  • -Xlog[:[selector][:[output][:[decorators][:output-options]]]]
    • 最关键的参数是选择器(Selector),由标签(Tag/功能模块名字)和日志级别(Level)共同组成. 垃圾收集器标签为gc。

日志级别由低到高共有Trace,Debug,Info,Warning,Error,Off六种级别。默认级别为Info,HotSpot的日志规则与Log4j、SLF4j这类Java日志框架大体上是一致的。

使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容:

  • time:当前日期和时间。
  • uptime:虚拟机启动到现在经过的时间,以秒为单位。
  • timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输·uptimemillis:虚拟机启动到现在经过的毫秒数。
  • timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
  • uptimenanos:虚拟机启动到现在经过的纳秒数。
  • pid:进程ID。
  • tid:线程ID。
  • level:日志级别。
  • tags:日志输出的标签集

如果不指定,默认值是uptime、level、tags这三个,此时日志输出类似于以下形式:

[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s

查看GC:-Xlog:gc GCTest

其他参数

  • 查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之 后使用-Xlog:gc+heap=debug:
  • 查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-XX +PrintGCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime,JDK 9之后使用-Xlog:safepoint:
  • 查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收 集器开始支持)自动调节的相关信息。在JDK 9之前使用-XX:+PrintAdaptive-SizePolicy,JDK 9之后使用-Xlog:gc+ergo*=trace:
  • 查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用-XX:+PrintTenuring-Distribution, JDK 9之后使用-Xlog:gc+age=trace:

3.8 实战:内存分配与回收策略

3.8.1 对象优先在Eden(新生代)分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起 一次Minor GC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package JVM;

/**
* 测试新生代Minor GC
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public class testAllocation {
private static final int _1MB=1024*1024;
public static void main(String[] args) {

byte[] allocation1,allocation2,allocation3,allocation4;
allocation1 = new byte[2 *_1MB];
allocation2 = new byte[2 *_1MB];
allocation3 = new byte[2 *_1MB];
allocation4 = new byte[2 *_1MB]; //出现一次minorGC
}

}

运行结果及分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[0.009s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
[0.115s][info ][gc,heap] Heap region size: 1M
[0.118s][info ][gc ] Using G1
[0.118s][info ][gc,heap,coops] Heap address: 0x00000000fec00000, size: 20 MB, Compressed Oops mode: 32-bit
[0.170s][info ][gc ] Periodic GC disabled
[0.500s][info ][gc,start ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[0.501s][info ][gc,task ] GC(0) Using 2 workers of 4 for evacuation
[0.504s][info ][gc,phases ] GC(0) Pre Evacuate Collection Set: 0.0ms
[0.504s][info ][gc,phases ] GC(0) Evacuate Collection Set: 2.7ms
[0.504s][info ][gc,phases ] GC(0) Post Evacuate Collection Set: 0.1ms
[0.504s][info ][gc,phases ] GC(0) Other: 0.3ms
[0.504s][info ][gc,heap ] GC(0) Eden regions: 3->0(8) //3个新生代对象被转移。
[0.504s][info ][gc,heap ] GC(0) Survivor regions: 0->2(2)
[0.504s][info ][gc,heap ] GC(0) Old regions: 0->0
[0.504s][info ][gc,heap ] GC(0) Archive regions: 0->0
[0.504s][info ][gc,heap ] GC(0) Humongous regions: 9->9
[0.515s][info ][gc,metaspace ] GC(0) Metaspace: 736K->736K(1056768K)
[0.515s][info ][gc ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 11M->10M(20M) 14.442ms
[0.515s][info ][gc,cpu ] GC(0) User=0.00s Sys=0.00s Real=0.01s
[0.515s][info ][gc ] GC(1) Concurrent Cycle
[0.515s][info ][gc,marking ] GC(1) Concurrent Clear Claimed Marks
[0.515s][info ][gc,marking ] GC(1) Concurrent Clear Claimed Marks 0.008ms
[0.515s][info ][gc,marking ] GC(1) Concurrent Scan Root Regions
[0.516s][info ][gc,marking ] GC(1) Concurrent Scan Root Regions 0.894ms
[0.516s][info ][gc,marking ] GC(1) Concurrent Mark (0.516s)
[0.516s][info ][gc,marking ] GC(1) Concurrent Mark From Roots
[0.516s][info ][gc,task ] GC(1) Using 1 workers of 1 for marking
[0.516s][info ][gc,marking ] GC(1) Concurrent Mark From Roots 0.088ms
[0.516s][info ][gc,marking ] GC(1) Concurrent Preclean
[0.516s][info ][gc,marking ] GC(1) Concurrent Preclean 0.073ms
[0.516s][info ][gc,marking ] GC(1) Concurrent Mark (0.516s, 0.516s) 0.187ms
[0.517s][info ][gc,start ] GC(1) Pause Remark
[0.517s][info ][gc,stringtable] GC(1) Cleaned string table, strings: 2909 processed, 0 removed
[0.517s][info ][gc ] GC(1) Pause Remark 13M->13M(20M) 0.354ms
[0.517s][info ][gc,cpu ] GC(1) User=0.00s Sys=0.00s Real=0.00s
[0.517s][info ][gc,marking ] GC(1) Concurrent Rebuild Remembered Sets
[0.517s][info ][gc,marking ] GC(1) Concurrent Rebuild Remembered Sets 0.039ms
[0.517s][info ][gc,start ] GC(1) Pause Cleanup
[0.517s][info ][gc ] GC(1) Pause Cleanup 13M->13M(20M) 0.022ms
[0.517s][info ][gc,cpu ] GC(1) User=0.00s Sys=0.00s Real=0.00s
[0.517s][info ][gc,marking ] GC(1) Concurrent Cleanup for Next Mark
[0.517s][info ][gc,marking ] GC(1) Concurrent Cleanup for Next Mark 0.211ms
[0.517s][info ][gc ] GC(1) Concurrent Cycle 2.435ms
[0.518s][info ][gc,heap,exit ] Heap
[0.518s][info ][gc,heap,exit ] garbage-first heap total 20480K, used 13464K [0x00000000fec00000, 0x0000000100000000)
[0.518s][info ][gc,heap,exit ] region size 1024K, 3 young (3072K), 2 survivors (2048K) //
[0.518s][info ][gc,heap,exit ] Metaspace used 757K, capacity 4531K, committed 4864K, reserved 1056768K
[0.518s][info ][gc,heap,exit ] class space used 66K, capacity 402K, committed 512K, reserved 1048576K

Process finished with exit code 0
image-20200815181753819

类文件结构

Java虚拟机提供的语言无关性,java语言能够跨平台的原因

image-20210328202730918

Java技术能够一直保持好的向后兼容性,得益于Class文件结构的稳定

Class文件是一组以8个字节为基础单位的二进制流,中间没有任何的分隔符。

Class文件中只有两种数据类型:无符号数和表。

  • 无符号数:u1,u2,u4,u8等,可以用来描述数字、索引引用、UTF-8编码构成字符串等。
  • 表:由多个无符号数或其他表作为数据项构成的复合数据类型。所有表都以_info结尾.

魔数:每个Class文件头4个字节,作用是确定这个文件是否为一个可被虚拟机接受的Class文件。

Java的Class文件魔数:0xCAFEBABE

第5个和第6个字节为次版本号,7和8字节为主版本号。

Java版本号从45开始。 JDK8->52

image-20210328204243398

可以看到16进制的38对应十进制是56,即JDK12.

常量池

常量池是Class文件中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一。

常量池中常量数量不固定,所以需要在常量池入口放一个u2类型的数据,代表常量池容量计数值(constant_pool_count);索引从1开始计算,如图0x001d十进制为29,即表示常量池中有28项常量。

image-20210328204727727

常量池存放:字面量以及符号引用

  • 字面量:文本字符串、final的常量值等。
  • 符号引用:
    • 被模块导出或开放的包。
    • 类和接口的全限定名
    • 字段的名称和描述符(private?static?)
    • 方法的名称和描述符
    • 方法句柄和方法类型
    • 动态调用点和动态常量

Java进行Javac编译时不会像C++一样有“链接”步骤。因为JVM在加载Class文件的时候进行动态链接

常量池中每一项常量都是一个表

image-20210328205354625
  • CONSTANT_Utf8_info型常量的结构

    image-20210328205454248

length值说明了这个UTF-8编码的字符串长度是多少字节

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

由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名 称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度

即u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB 英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译

具体的常量池信息可以使用javap -verbose [Class]查看

image-20210328210041345

访问标志

常量池结束后,后面的两个字节表示访问标志(access_flags),表示是否为public、static、abstract、final等。

image-20210328210230925
image-20210328210515256

可以看到标志位为0x0021,表示为这个类为ACC_PUBLIC以及ACC_SUPER为真,0x0001|0x0020=0x0021。

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

Class文件确定继承关系的方式:如下图

image-20210328210758303
  • 类索引(this_class)用来确定这个类的全限定名
  • 父类索引(super_class)确定父类的全限定名

因为Java不允许多重继承,所以父类索引只有一个

除了java.lang.Object类外,所有Java都有父类,所以除此外的所有类的父类索引都不为0;

虚拟机类加载机制

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

C、C++链接过程在程序运行前进行,Java在程序运行期间完成,为Java应用提供了极高的扩展性和灵活性。

类加载的时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期会经过加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

image-20210328212141057

加载、验证、准备、初始化和卸载五个阶段顺序是确定的。解析则不一定。

需立即对类进行初始化的条件:(注意区分类初始化与类加载)

  • new一个对象的时候 (数组不会触发
    • 读取或设置一个static字段时。 (被final修饰的、已放入常量池的除外
    • 调用静态方法时。
  • 使用java.lang.reflect包进行反射调用时。如果类未初始化,需要先对其进行初始化。
  • 需要执行main方法时,先初始化main方法所在的主类
  • 接口中定义了JDK8新加入的被default关键字修饰的接口方法时,如果接口实现类发生初始化,需要先初始化接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class NoInitialization {
public static void main (String[] args) throws java.lang.Exception
{
System.out.println(SubClass.value);
}
}
class SuperClass
{
static{
System.out.println("SuperClass init!");
}
public static int value=123;

}
class SubClass extends SuperClass{
static{
System.out.println("SubClass init!");
}
}

运行后只会输出“SuperClass init!”。

image-20210329121551773

对于静态字段,只有直接定义这个字段的类才会被初始化。

  • 由于static字段在父类,直接调用该字段只会初始化父类不会初始化子类。
  • 可通过-XX:+TraceClassLoading观察是否会导致子类加载

案例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NoInitialization {
public static void main (String[] args) throws java.lang.Exception
{
SuperClass[] sca=new SuperClass[10];
}
}
class SuperClass
{
static{
System.out.println("SuperClass init!");
}
public static int value=123;

}

创建对象数组不会触发初始化阶段。但触发了了 另一个名为[Lorg.fenixsoft.classloading.SuperClass的类的初始化阶段。该类是由虚拟机自动生成的,代表数组类

Java对数组的访问比C/C++更安全的原因很大程度上就因为这个类包装了数组元素的访问。避免了直接移动指针,越界会抛出异常而不会造成非法内存访问。

案例3

1
2
3
4
5
6
7
8
9
10
11
12
public class NoInitialization {
public static void main (String[] args) throws java.lang.Exception
{
System.out.println(ConstClass.HELLOWORLD);
}
}
class ConstClass{
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD="hello world";
}

结果:

image-20210329122128786

结果也没有触发类的初始化。

因为常量在编译阶段会存入调用类(非所在类!)的常量池,本质上没有直接引用到定义常量的类。

即引用常量不会触发初始化,在编译时会被存入主类的常量池中。(常量传播优化)

如果去掉final,那么HELLOWORLD就属于该类的静态成员变量,则会触发该类的初始化。

类加载的过程

加载(Loading)

区分与类加载(Class Loading),加载属于类加载的一个过程。

该阶段JVM需要完成的事情:

  • 通过类的全限定名获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 内存中生成一个代表这个类的Class对象,作为方法区这个类各种数据的访问入口

这个阶段的要求不是很具体,所以灵活性很大。所以基于此阶段完成的产品有:

  • ZIP压缩包读取
  • 网络中读取
  • 运行时计算生成(动态代理)
    • java.lang.reflect.Proxy中用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流
  • JSP等等
  • 非数组类型的加载阶段(获取类的二进制流)是可控性最强的阶段,重写类加载器findClass()以及loadClass()方法来指定获取方式都是可行的。
  • 数组类本身不通过类加载器创建

数组类创建遵循以下规则:

  • 如果数组组件类型(User[]->user)引用类型,则递归使用本加载过程加载。数组会被标识在加载该组件类型的类加载器的类名称空间上
  • 如果数组组件类型非引用(int[]->int)类型,,数组会被标记为与引导类加载器关联,且数组类可访问性默认为public。

加载阶段和连接阶段部分动作是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但仍保持固定的先后顺序。

验证(Verification)

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

目的:确保Class文件字节流包含的信息符合《Java虚拟机规范》全部约束条件,保证运行后不会危害虚拟机自身安全。

验证阶段非常重要,验证阶段的工作量在虚拟机类加载过程中占了相当大的比重

如果字节流不符合Class文件格式规范,会抛出java.lang.VerfuError异常。

验证阶段会从四个阶段进行检验:

  • 文件格式检验
  • 元数据验证
  • 字节码验证
  • 符号引用验证

文件格式验证

主要验证字节流是否符合Class文件格式规范。主要包括:

  • 是否以魔数0xCAFEBABE开头。
  • 主、次版本号是否在当前Java虚拟机接受范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
  • ……

验证的内容实际上还有很多,目的是保证输入的字节流能正确解析并存储于方法区中

只有该阶段是基于二进制字节流进行的,后面三个阶段全部是基于方法区的存储结构进行,不会再读取、操作字节流了。

元数据验证

该阶段主要对字节码描述的信息进行语义分析。主要包括:

  • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方 法重载,例如方法参数都一致,但返回值类型却不同等)。
  • ……

字节码验证

该阶段是验证过程中最复杂的一个阶段。

目的是确定程序语义是合法、符合逻辑的,元数据验证是对类的属性进行验证,该阶段就要对类的方法体进行验证。主要包括:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作 栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全 的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个 数据类型,则是危险和不合法的。
  • ……

需要注意的是虽然通过了字节码验证,也不能保证代码一定是安全的。涉及了离散数学很著名的“停机问题”----不能通过程序准确检查出程序是否能在有限时间内结束运行。即不能通过程序判断程序是否有bug。

符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

即检查该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源。主要包括:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当 前类访问。
  • ……

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机 将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如: java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

准备

该阶段即正式为类中定义的变量(static变量)分配内存并设置初始值(零值)

注意!此时仅包括类变量,而不包括实例变量,实例变量会再对象实例化时随对象一起分配再java堆中。

想想一个类变量

public static int value=1111;

在准备阶段后的值是多少?

答案是0;而不是1111;,因为此时未开始执行任何java方法

1111的赋值会在类初始化阶段,在类构造器<clinit>()中才会执行。

但是如果不是类变量,而是常量

public static final int value=1111;

编译时javac会为value生成ConstantValue属性,准备阶段就会根据其值赋值为1111.

解析

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

  • 符号引用:以一组符号来描述所引用的目标,可以是任意的字面量。
  • 直接引用:可以指向目标的指针相对偏移量或者能间接定位到目标的句柄

对方法或字段的访问也会对它们的可访问性检查(public、private)。

如果对同一个符号进行多次引用,jvm会对第一次解析的结果进行缓存,并把常量标识为已解析状态,避免解析动作重复进行。

类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接 引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:

1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个 类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例 如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。

2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类 似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所 假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元 素的数组对象。

3)如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了, 但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限, 将抛出java.lang.IllegalAccessError异常。 针对上面第3点访问权限验证,在JDK 9引入了模块化以后,一个public类型也不再意味着程序任 何位置都有它的访问权限,我们还必须检查模块间的访问权限。 如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:

  • 被访问类C是public的,并且与访问类D处于同一个模块。

  • 被访问类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的 模块进行访问。

  • 被访问类C不是public的,但是它与访问类D处于同一个包中。

  • 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那 么就直接抛出java.lang.IncompatibleClassChangeError异常。

  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

  • 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括 Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。

  • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。

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

字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index 项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个 类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完 成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段 的搜索:

  • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引 用,查找结束。

  • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找 结束。

  • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父 类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

  • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。 如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权 限,将抛出java.lang.IllegalAccessError异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FieldResolution {
interface Interface0{
int A=0;
}
interface Interface1 extends Interface0 {
int A = 1;
}
interface Interface2 {
int A = 2;
}
static class Parent implements Interface1{
public static int A=3;
}
static class Sub extends Parent implements Interface2{
public static int A=4;
}

public static void main(String[] args) {
System.out.println(Sub.A);
}
}

结果是4

image-20210329210751121

如果注释掉Sub中的A字段,javac编译器则会报错拒绝编译

image-20210329210904193

因为需要保证解析规则能够确保Java虚拟机获得字段唯一的解析结果。

方法解析

方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index 项中索引的方 法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按 照如下步骤进行后续的方法搜索:

  • 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的 方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError 异常。

  • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则 返回这个方法的直接引用,查找结束。

  • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返 回这个方法的直接引用,查找结束。

  • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标 相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError异常。

  • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。 最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此 方法的访问权限,将抛出java.lang.IllegalAccessError异常。

接口方法解析

接口方法也是需要先解析出接口方法表的class_index 项中索引的方法所属的类或接口的符号引 用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

  • 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那 么就直接抛出java.lang.IncompatibleClassChangeError异常。

  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。

  • 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括 Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。

  • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并 没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。

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

初始化

类的初始化阶段是类加载过程的最后一个步骤。这个阶段JVM才真正开始执行类中编写的Java代码。

在这之前变量已经在准备阶段赋值了零值,这个阶段则会进行预期的初始化。初始化阶段就是执行类构造器<clinit>()方法的过程。()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物.

<clinit>()方法由编译器自动收集所有类变量的赋值动作和静态语句块(static{}),这个过程是有顺序的,后收集的变量可以访问已经被收集过的变量。

例如

1
2
3
4
5
6
static int i = 1;
static {
i = 0;
System.out.print(i); // 此时可以正常编译,因为在之前声明过i。
}

1
2
3
4
5
static {
i = 0; // 但是可以进行赋值
System.out.print(i);// 此时不可以正常编译,会提示非法向前引用
}
static int i = 1;
image-20210329211726219

区分<clinit>()与类构造函数

<clinit>()方法与类的构造函数不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

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

如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
static class Parent {
public static int A = 1; // 这里静态变量和静态代码块的执行顺序,经过测试会按照语句的先后顺序执行,也正符合了clinit的收集操作。
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}

结果为2

image-20210329212243804

<clinit>()方法对于类或接口来说并不是必须的,如果类没有静态语句块或变量的赋值操作,则可以不生成<clinit>()方法

接口中不能使用静态语句块,但是可以进行变量赋值。但是执行接口的<clinit>()方法时不需要先执行父接口的<clinit>()方法。因为只有当父接口中定义的变量被使用时,父接口才会被初始化。且接口的实现类不会执行接口的<clinit>()方法。

下面代码演示了多线程创建实例时<clinit>()只会执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DeadLoopClass {
static {
// 如果不加上这个if语句,编译器将提示“Initializer does not complete normally”并拒绝编译
if (true) {
System.out.println(Thread.currentThread().getName()+"--init DeadLoopClass");
while (true){
}
}

}

public static void main(String[] args) {
Runnable r= () -> {
System.out.println(Thread.currentThread().getName()+"--start");
DeadLoopClass deadLoopClass = new DeadLoopClass();
System.out.println(Thread.currentThread().getName()+"--over");
};
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
t1.start();
t2.start();
}
}
image-20210329214012171

这里main线程执行了初始化方法,其他线程只能一直等待。说明<clinit>()的执行是有同步锁的。

如果将死循环去掉,可以发现<clinit>()的确只会执行一次。即同一个类加载器下,一个类型只会被初始化一次。

image-20210329214142603

类加载器

什么是类加载器

类加载阶段中“通过一个类的全限定类名来获取描述该类的二进制字节流”,实现该动作的代码称为类加载器。

类加载器的主要作用即获取类的二进制字节流。

类与类加载器

对于任意一个类、都必须由加载它的类加载器和这个类的本身一起共同确立其在java虚拟机中的唯一性

理解:一个类的比较不能脱离加载他的类加载器。如果两个类源于同一个class文件,被同一个JVM加载、但是加载他们的类加载器不同,那么两个类就不相等。

相等:包括equas(),isAssignableFrom()--判断是否为某类父类、isInstance()、instanceof等等方法的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ClassLoaderTest {
public static void main(String[] args) throws Exception{
ClassLoader classLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1)+".class";
// 获取该类的资源文件输入流
InputStream inputStream = getClass().getResourceAsStream(fileName);
// 如果输入流为空则虚拟机自行创建类加载器进行加载
if (inputStream == null) {
return super.loadClass(name);
}
byte[] b = new byte[inputStream.available()];
inputStream.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};

Object o = classLoader.loadClass("JVM.ClassLoaderTest").getDeclaredConstructor().newInstance();
System.out.println(o.getClass());
System.out.println(o instanceof JVM.ClassLoaderTest);
}
}

结果:

image-20210411161246928

结果分析:

​ 第一行结果是我们自行通过类加载器进行加载的类实例对象。第二行进行instanceof输出为false

因为JVM中同时存在了两个ClassLoaderTest类,一个是由虚拟机应用程序类加载器加载,另外一个是我们自定义的类加载器加载。虽然都来自同一个Class文件,但是仍然属于两个互相独立的类,所以结果为false;

双亲委派机制

站在JVM角度看,只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),另外一种是其他所有类加载器,都由java代码实现,独立存在于虚拟机外部,全部继承抽象类java.lang.ClassLoader

从java开发人员看,存在多种类加载器。包括三层类加载器、双亲委派的类加载架构。

启动类加载器(Bootstrap Class Loader)

负责加载存放在<JAVA_HOME\lib>目录的类到虚拟机内存中。如rt.jar、tools.jar(按文件名识别,不对的文件即使放进去也不会被加载)

扩展类加载器(Extension Class Loader)

负责加载存放在<JAVA_HOME\lib\ext>目录的类库到虚拟机内存中

应用程序类加载器 (Application Class Loader)

也称为“系统类加载器”。负责加载用户类路径(ClassPath)下所有的类库,如Maven导入的依赖等就在这个阶段被加载进JVM内存中。如果用户没有自定义过自己的类加载器,即为默认类加载器。

类加载器双亲委派机制

上图中展示的即为类加载器的双亲委派模型。

双亲委派要求除了顶层的启动类加载器外,其余的类加载器都由自己的父类加载器。但一般不通过继承而通过组合进行复用代码。

双亲委派的工作过程

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

即我拿到了我先不动,先送给老二,老二送给老大,老大动不了给老二、老二动不了再给我。

好处:

  • 能够保证基础的类不管在哪一台虚拟机上运行都由同一个类加载器加载,不会出现多个由同一个class文件加载的多个实例对象。如Object类都会由启动类加载器Bootstrap ClassLoader进行加载,从而保证只会存在一个类加载器的Object对象。
  • 防止替换系统级别的类(如String.class),防止植入危险代码

双亲委派对于维护JVM稳定性非常重要,但是实现比较简单,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}return c;
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!