当前位置:   article > 正文

深入理解Java虚拟机(理论篇)_jvm元空间内存回收机制

jvm元空间内存回收机制

深入理解Java虚拟机

1.JVM概述

  • JDK:软件开发工具包 。
    Java程序设计语言 + Java虚拟机 + JavaAPI类库,是支持Java程序开发的最小环境
  • JRE:Java 运行时环境
    Java API + JVM ,是支持Java程序运行的标准环境
Java程序的执行流程

Java源码 —— 编译器 —— JVM可执行的Java字节码(class文件) —— JVM —— JVM解释器 ——机器可执行的二进制文件 —— 程序运行

  • 编译型语言:通过编译器,将源代码编译成机器码后才能执行的语言。
    优点:编译只做一次,执行效率高
    缺点:根据不同的操作系统需要生成不同的可执行文件

  • 解释型语言:不需要编译,在运行程序时才逐行进行翻译
    优点:平台兼容性好
    缺点:每次运行时都要解释一遍,性能低

  • Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。 由于字节码并不专对一种特点的机器,因此Java程序无须重新编译便可在多种不同的机器上运行。 可以做到“一次编写,到处运行”。

2.内存模型

运行时数据区域

线程私有的区域:

  • 程序计数器:相当于一个程序执行过程中的行号指示器,指向当前执行的虚拟机字节码地址。如果执行的是Java方法,计数器就记录者正在执行的虚拟机字节码指令的地址。如果是native 方法,计数器为空
  • 虚拟机栈:虚拟机栈就是java方法的内存模型,每一个线程在执行时会有自己的一个虚拟机栈,在运行过程中把所调用方法封装为一个栈帧,然后将栈帧存放在栈里面。栈帧包含了一个方法执行时的相关信息,包括方法用到的局部变量,操作数,动态链接等。 每个方法在调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈到过程。
  • 本地方法栈:类似于虚拟机栈,只不过他存放的是Native方法。

线程共享的区域

  • :用来存放所有线程创建的类的对象实例。方法调用中如果创建了对象,会把这个对象实例存放在堆,然后将对于这个对象的引用存放在栈中。 垃圾收集器管理的主要区域。
    Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。
  • 方法区:存放虚拟机加载的类的信息(类的版本,字段,方法,接口)和一些常量、静态变量,即时编译器编译后的代码等,这些内容一般是不可变的。
    方法区有时被称为持久代(PermGen)

其他区域

  • 直接内存:不直接在Java堆中分配内存,而是直接分配堆外内存,然后通过一个存储在Java 堆中的对象作为这块内存的引用进行操作。
OOM和StackOverFlow
  • StackOverFlow: 虚拟机栈会把每次调用的方法作封装为一个栈帧存起来。这些栈帧肯定是要占内存的,而栈的内存也是有限的。如果栈帧很多一直没有释放,这时候又来了一个栈帧,这个栈帧已经没有空间可以容纳了,有两种情况。如果这种虚拟机栈不支持动态扩展,那么将会抛出StackOverFlow异常。如果支持动态扩展,那么这个栈会请求再扩展部分空间。当然内存不是无穷的,如果频繁的扩展内存,以至于无法再继续扩展了,这时候会抛出OutOfMemory异常。
  • OutOfMemory
    以下几种情况会产生OOM异常:
  1. Java堆溢出:不断创建对象且没被回收
  2. 虚拟机栈和本地方法栈溢出:线程不断迭代
    由于线程过多导致的内存溢出,只能减少最大堆和减少栈容量来换取更多的线程。
  3. 方法区和运行时常量池溢出:一直加载新的类(类的回收条件很苛刻),如经常动态生成大量Class的应用,需要特别注意类的回收状况。
对象的创建
  1. 虚拟机发现new指令后,会先看看new 后面跟着的那个参数能否在常量池中定位到一个类的符号引用,并且检查那个类是否已经被加载过。如果没有,则进行一次类的加载工作。
  2. 加载完成后,虚拟机会为新的对象在堆中分配一块内存,具体分配多少,在类加载完之后其实就已经定了。(指针碰撞,空闲列表)

HotSpot 使用了称之为 Thread-Local Allocation Buffers (TLABs) 的技术,该技术能改善多线程空间分配的吞吐量。首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区中进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了它的 TLAB,它才需要使用同步来获取一个新的缓冲区。HotSpot 使用了多项技术来降低 TLAB 对于内存的浪费。比如,TLAB 的平均大小被限制在 Eden 区大小的 1% 之内。TLABs 和使用指针碰撞的线性分配结合,使得内存分配非常简单高效,只需要大概 10 条机器指令就可以完成。

  1. 分配完内存,之后会将这个对象的实例字段初始化为零值。
  2. 对对象进行一些设置,比如设置哈希码,分代年龄信息,这个对象属于哪个类之类的。并把这些信息存放在对象头中。
  3. 执行方法,按程序员意愿进行初始化
对象的内存布局
  • 对象头:包括自身运行时数据(如哈希码,GC分代年龄,锁状态标志),类型指针(指向类元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。
  • 实例数据:对象真正存储的有效信息
  • 对齐填充
对象的访问定位
  • 创建好一个对象,还需要一个引用来持有他才能使用。引用是放在虚拟机栈栈帧的本地变量表中的。
  • 引用有两种形式,一种是直接持有对象地址,一种是持有一个句柄,句柄保存在堆中,包含着对象的地址,是间接访问。直接访问速度快,间接访问在对象频繁移动时比较有优势。
从永久代到元空间
  • 由于永久代内存经常不够用或发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen,JDK8废除了永久代,改用元数据。

  • 元空间是方法区的在HotSpot jvm 中的实现,方法区主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。

  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

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

如何确定对象已死(如何判定对象是垃圾对象)
  • 引用计数法:为每一个对象设一个值,用来计算被引用的次数。只要有一个对于对象的引用存在,就让这个数字加一。这样如果一个对象没有任何引用,那么引用计数为零,这个对象就会被标记为“可回收”。 但是这样有一个很严重的bug,那就是如果我有两个对象,已经不再使用,但是他们互相引用,那么他们的引用计数就永远不会为零,那么就不会被回收。
  • 可达性分析算法:将一些特定的对象作为GC Roots,然后从这个节点向下寻找对其他对象的引用。如果一个对象到GC Roots没有引用链,那么就可以被回收了。在Java虚拟机中,被规定作为GC Roots的对象有:
  1. 虚拟机栈中引用的对象 2.方法区中静态属性引用的对象
  2. 方法区中常量引用的对象 4.Native方法引用的对象
  • 日常开发过程中遇到的内存泄漏,很大一部分原因就是本该被回收的对象无意之中被GC Roots引用到了,比如写的static这样的静态字段引用的对象,这样他就不会被回收了
  • 另外,宣告不可达对象到死亡需要经历两次标记过程:
  1. 发现不可达,被第一次标记并筛选(筛选条件:有没有覆盖finalize()方法,finalize()方法有没有被调用过(一个对象到finalize()方法只会调用一次),如果没覆盖或被调用过,那么没必要再执行finalize()了,它已经玩完了)
  2. 如果有必要,对象被放置在F-Queue队列中,并之后被虚拟机触发finalize()(最后一次逃脱过程),并将F-Queue中的对象进行第二次小规模标记;如果两次标记都没有逃脱,那它也玩完了。
四种引用

Java中引用有四种,分别是强、软、弱、虚。这四种引用的区别就在于GC的过程中:

  • 强引用:直接通过类名new一个对象,这样直接创建的对对象的引用叫做强引用。被强引用的对象,一般是不会被回收掉的。
    Object obj = new Object();
  • 软引用:被软引用持有的对象,只有在“不回收就要内存溢出”的时候,才会回收
    SoftReference sf = new SoftReference(obj);
  • 弱引用:被弱引用持有的对象,在每次GC都会被回收
    WeakReference wf = new WeakReference(obj);
  • 虚引用:无任何时机作用,只是一个标记,为了能使对象被回收时做一些系统通知什么的
    PhantomReference pf = new PhantomReference(obj);
垃圾收集算法
  • 标记—清除算法(Mark-Sweep)

分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;

清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header信息),则将其回收。

不足:标记和清除过程效率都不高,会产生大量碎片,内存碎片过多可能导致无法给大对象分配内存。
在这里插入图片描述

  • 复制算法(Copying)

将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和 使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间。

不足:

将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
在这里插入图片描述

  • 标记—整理算法(Mark-Compact)

标记—整理算法和标记—清除算法一样,但是标记—整理算法不是把存活对象复制到另一块内存,而是把存活对象往内存的一端移动,然后直接回收边界以外的内存,因此其不会产生内存碎片。标记—整理算法提高了内存的利用率,并且它适合在收集对象存活时间较长的老年代。

不足:

效率不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
在这里插入图片描述

  • 分代收集算法(Generational Collection)

分代回收算法实际上是把复制算法和标记整理法的结合,并不是真正一个新的算法,一般分为:老年代(Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。

新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。

老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对象需要回收,故采用标记清除或者标记整理算法

GC触发条件
  • GC的类型:
  1. Minor GC: 发生在新生代的GC,频繁,回收速度块
  2. Full GC: 发生在老年代的GC,速度慢10倍以上

每次分配内存,如果对象比较大的话直接进入老年代。否则,先进入Eden区和一个Survivor区,同时会为每一个对象设一个年龄值。之后会周期性的在某个安全点(Savapoint)。 检查一下,当Eden区满时,对于新生代的对象,将可回收的对象回收掉,将剩余的对象复制到另一个Survivor区,这一过程中会对年龄值加一。(空间分配担保:如果此时Survivor放不下了,则直接进入老年代),这一过程叫做Minor GC,是属于新生代的GC。

当某些对象年龄值比较大时,会将他们移动到老年代去(此外还有动态年龄判定:当Survior空间中相同年所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。)当然在这之前会先查看一下老年代剩余空间是否满足移动。如果不能满足,就会对老年代进行一次GC,这一过程叫做Full GC。而这个检查对象是否可GC得时机,也就是GC的时机,一般是确定的被称作“安全点”。在这一时机进行检查,是不会影响程序正常运行的。

除此之外,其他情况也会发生Full GC:

(1)调用System.gc时,系统建议执行Full GC,但是不必然执行

(2)老年代空间不足

(3)方法去空间不足

(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

关于安全点和安全区域

在这之前,需要提到一个概念:Stop the world

Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。

上面讲到,线程需要在“安全点”进行检查是否可GC,如果可以则进行GC,因此到了安全点时,需要Stop-The-World,具体方式为主动式中断(设置一个标志,和安全点重合,各个线程主动轮询这个标志,发现中断标志为真就挂起自己。)

安全点的选定原则:“是否具有让程序长时间执行”,即执行序列可以复用,一般选在方法调用,循环跳转,抛出异常。

安全区域:线程阻塞时无法到安全点挂起,故采用Sava Region
安全区域指在一段代码片段中,引用关系不会发生变化,在该区域任何地方发生GC都是安全的,当代码执行到安全区域时,标识自己已进入安全区域,如果在这段时间里JVM发起GC,就不用管在安全区域里的线程了;在线程离开安全区时,会检查系统是否进行GC,如果是,则GC完成后再离开安全区域。

OopMap:快速找到GC Roots
垃圾收集时,需要对全局性引用和执行上下文进行检查,如果要逐个检查这里面的引用,消耗太大。因此通过OopMap来记录哪些地方存放着对象引用,哪些地方没有存放对象引用,消耗太大。
OopMap的更新也在安全点完成。

垃圾收集器详解
  • 新生代收集器:
  1. Serial收集器:单线程收集器
  2. ParNew收集器:Serial收集器的多线程方法(可以和CMS配合工作)
  3. Parallel收集器:可控的吞吐量(运行用户代码时间/(运行时间+GC时间))CPU利用率高,适合尽快完成运算任务,不需要太多交互的任务
  • 老年代收集器
  1. Serial Old 收集器:单线程收集器,CMS的后备方案
  2. ParNew Old 收集器:ParNew收集器的老年代版本 多线程+标记整理
  3. CMS 收集器:以获取最短回收停顿时间为目标的收集器(适用于应用程序要求低停顿,同时能接受在垃圾收集阶段和垃圾收集线程一起共享 CPU 资源的场景,典型的就是 web 应用),基于标记清除

CMS 收集过程首先是一段小停顿 stop-the-world,叫做 初始标记阶段(initial mark),用于确定 GC Roots。然后是 并发标记阶段(concurrent mark),标记 GC Roots 可达的所有存活对象,由于这个阶段应用程序同时也在运行,所以并发标记阶段结束后,并不能标记出所有的存活对象。为了解决这个问题,需要再次停顿应用程序,称为 再次标记阶段(remark),遍历在并发标记阶段应用程序修改的对象(标记出应用程序在这个期间的活对象),由于这次停顿比初始标记要长得多,所以会使用多线程并行执行来增加效率。

再次标记阶段结束后,能保证所有存活对象都被标记完成,所以接下来的 并发清理阶段(concurrent sweep) 将就地回收垃圾对象所占空间。

如果老年代空间不足以容纳从新生代垃圾回收晋升上来的对象,那么就会发生 concurrent mode failure,此时会退化到发生 Full GC,清除老年代中的所有无效对象,这个过程是单线程的,比较耗时

另外,即使在晋升的时候判断出老年代有足够的空间,但是由于老年代的碎片化问题,其实最终没法容纳晋升上来的对象,那么此时也会发生 Full
GC,这次的耗时将更加严重,因为需要对整个堆进行压缩,压缩后年轻代彻底就空了。

并发标记和并发清理两个耗时最长的阶段不需要STW,可以和用户线程并发执行,因此缩小了响应时间;但初始标记阶段和再次标记阶段还是需要STW的。

缺点:CPU资源敏感,浮动垃圾

G1 垃圾收集器

G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,这一点非常重要。

G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间

如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。

首先是内存划分上,之前介绍的分代收集器将整个堆分为年轻代、老年代和永久代,每个代的空间是确定的。

而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。
在这里插入图片描述
执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间,这也是为什么 G1 被取名为 Garbage-First 的原因。

在 G1 中,目标停顿时间非常非常重要,用 -XX:MaxGCPauseMillis=200 指定期望的停顿时间。

G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收

我们要知道 G1 不是一个实时收集器,它会尽力满足我们的停顿时间要求,但也不是绝对的,它基于之前垃圾收集的数据统计,估计出在用户指定的停顿时间内能收集多少个区块。

注意:G1 有和应用程序一起运行的并发阶段,也有 stop-the-world 的并行阶段。但是,Full GC 的时候还是单线程运行的,所以我们应该尽量避免发生 Full GC,后面我们也会介绍什么时候会触发 Full GC。

G1工作流程:
1.初始标记 2.并发标记(只有该阶段是并发的
3.最终标记 4.筛选回收

  • G1 参数配置和最佳实践
    https://blog.csdn.net/a724888/article/details/78764006

4.类文件结构

  • Java之所以能够实现跨平台,便在于其编译阶段不是将代码直接编译为平台相关的机器语言,而是先编译成二进制形式的java字节码,放在Class文件之中,虚拟机再加载Class文件,解析出程序运行所需的内容。每个类都会被编译成一个单独的class文件,内部类也会作为一个独立的类,生成自己的class。
Class类文件结构

Class文件是一组以8位字节为基础单位的二进制流,非常紧凑的排列在一起,很严格的规定了第几位到第几位是什么

ClassFile {
       u4 magic;  #魔数
       u2 minor_version;  #版本号
       u2 major_version;
       u2 constant_pool_count;
       cp_info constant_pool[constant_pool_count-1];
       u2 access_flags;
       u2 this_class;
       u2 super_class;
       u2 interfaces_count;
       u2 interfaces[interfaces_count];
       u2 fields_count;
       field_info fields[fields_count];
       u2 methods_count;
      method_info methods[methods_count];
       u2 attributes_count;
       attribute_info attributes[attributes_count];
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 常量池:存放常量的资源池,这里的常量指的是字面量符号引用。字面量指的是一些字符串资源,声明为final的常量等;而符号引用分为三类:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。(包括名称和描述符)。通过将资源放在常量池中,其他项就可以直接定义成常量池中的索引了,避免了空间的浪费

他们的作用就是在虚拟机运行时,通过常量池入口,在常量池中找到对应的符号引用,从而找到引用的类或者方法等。

常量的格式:

cp_info {
    u1 tag;
    u1 info[]; 
}
  • 1
  • 2
  • 3
  • 4

常量的类型有14种,对应不同的tag
在这里插入图片描述
CONSTANT_Utf8:存放UTF-8编码的字符串;
CONSTANT_Class:存储类或者接口的符号引用:

CONSTANT_Class_info {
    u1 tag;
    u2 name_index;
}
  • 1
  • 2
  • 3
  • 4

这里的name_index并不是直接的字符串,而是指向常量池中cpInfo数组的name_index项,且cpInfo[name_index]一定是CONSTANT_Utf8格式。

常量池解析完毕后,就可以供后面的数据使用了,比方说ClassFile中的this_class指向的就是常量池中格式为CONSTANT_Class的某一项,那么我们就可以读取出类名:

int classIndex = U2.read(inputStream);
ConstantClass clazz = (ConstantClass) constantPool.cpInfo[classIndex];
ConstantUtf8 className = (ConstantUtf8) constantPool.cpInfo[clazz.nameIndex];
classFile.className = className.value;
System.out.print("classname:" + classFile.className + "\n");
  • 1
  • 2
  • 3
  • 4
  • 5
  • 方法表和属性表
    method_info的格式:
method_info {
    u2 access_flags;  #访问标志
    u2 name_index;   #方法名索引
    u2 descriptor_index;  #方法描述符索引
    u2 attributes_count;  
    attribute_info attributes[attributes_count];#属性数组
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

这里要强调的是属性数组,因为字节码指令就存储在这个属性数组里。属性有很多种,比如说异常表就是一个属性,而存储字节码指令的属性为CODE属性。属性的通用格式为:

attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}
  • 1
  • 2
  • 3
  • 4
  • 5

根据attribute_name_index可以从常量池中拿到属性名,再根据属性名就可以判断属性种类了。

Code属性的具体格式为:

Code_attribute {
    u2 attribute_name_index; u4 attribute_length;
    u2 max_stack;
    u2 max_locals;	#局部变量表存储空间
    u4 code_length;
    u1 code[code_length];  #存放字节码
    u2 exception_table_length; 
    {
        u2 start_pc;
        u2 end_pc;
        u2 handler_pc;
        u2 catch_type;
    } exception_table[exception_table_length];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

其中code数组里存储就是字节码指令,那么如何解析呢?每条指令在code[]中都是一个字节,我们平时javap命令反编译看到的指令其实是助记符,只是方便阅读字节码使用的,jvm有一张字节码与助记符的对照表,根据对照表,就可以将指令翻译为可读的助记符了。
在这里插入图片描述

5.虚拟机类加载机制

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

类从被加载到内存中开始,到卸载出内存,经历了加载、连接、初始化、使用四个阶段,其中连接又包含了验证、准备、解析三个步骤。但是Java语言本身支持运行时绑定,所以解析阶段也可以是在初始化之后进行的。以上顺序都只是说开始的顺序,实际过程中是交叉进行的,加载过程中可能就已经开始验证了。

类加载的时机
  • 虚拟机并没有规定类的加载过程什么时候开始,只是明确了类加载的生命周期是固定的。但是比较特别的是“初始化”。我们需要用到一个类的时候,就一定要“初始化”,而其他在他之前的步骤,自然也就必须要调用了。因此可以这样概括为:加载、验证、准备、解析,这个过程是不确定的,由不同虚拟机自己控制,可能不知道哪个时候就进行了。但是当我们需要用到一个类时,就必须要立刻从加载开始执行到初始化结束,之后才能使用。

初始化的时机有以下几种:

  • new一个对象,或者调用一个类的静态字段或者静态方法
  • 反射调用一个类
  • 子类加载前要先加载父类
  • 虚拟机刚启动时执行主类

这些情况,属于对类的主动引用

除了这5种场景,所有引用类的方式都不会触发初始化,称为被动引用
如通过子类引用父类的静态方法,通过数组定义来引用类,引用常量池中的常量时,都不会触发初始化。

类加载的过程
  • 加载
  1. 通过一个类的全限定名来获取定义此类的二进制字节流(不一定是从Class文件中,可以通过自定义的类加载器去控制字节流的获取方式)
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的class对象,作为方法区这个类的各种数据访问入口(存放在方法区中)

因为加载这个过程没有限制具体的来源,所以衍生出了很多新东西,比如Jar包的读取,从网络中加载类等。

这是对于简单类而言的。对于数组,不会通过类加载器加载,而是由虚拟机直接创建,之后才会递归的加载数组中的引用类。

  • 验证:验证是链接过程的第一步,目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,且不会危害虚拟机本身的安全。
    验证主要有四类:
  1. 文件格式验证:字节流是否符合Class文件格式规范
  2. 元数据验证:语义分析,符合语言规范
  3. 字节码验证:分析数据流,确定语义是合法的,符合逻辑的。
  4. 符号引用验证:验证符号引用合法性

验证阶段非常重要,但不一定必要,如果所有代码极影被反复使用和验证过,那么可以通过虚拟机参数-Xverify: none来关闭验证,加速类加载时间。

  • 准备:为类或者接口的静态字段分配空间,并且默认初始化这些字段。

这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显示的初始化这些字段,所以准备阶段不会做这些事情。假设有:

public static int value = 123;
  • 1

value在准备阶段的初始值为0而不是123,只有到了初始化阶段,value才会为0。

  • 解析:将常量池内的符号引用(符号引用就是Class文件中的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量)替换为直接引用。符号引用只是以简单的通过名称等信息指出引用的方法或类。在这里才会真正的将符号引用转换为直接引用,即对于方法区类的引用。直接引用类似于指针,所以这一过程可以理解为从名称到地址的转化。

1.类或接口的解析:
设Java虚拟机在类D的方法体中引用了类N或者接口C,那么会执行下面步骤:
如果C不是数组类型,D的定义类加载器被用来创建类N或者接口C。加载过程中出现任何异常,可以被认为是类和接口解析失败。
如果C是数组类型,并且它的元素类型是引用类型。那么表示元素类型的类或接口的符号引用会通过递归调用来解析。
检查C的访问权限,如果D对C没有访问权限,则会抛出java.lang.IllegalAccessError异常。
2. 字段解析:
要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析
如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。否则先去找父接口,找不到再去找父类,最后进行权限验证。
在实际的实现中,要求可能更严格,如果同一字段名在C的父类和接口中同时出现,编译器可能拒绝编译。
3.类方法解析,接口方法解析…

  • 初始化:到了初始化阶段,才开始真正执行用户编写的java代码

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

Clinit<>()方法与类的构造函数init<>()方法不同,它不需要显示地调用父类构造器,虚拟机会在子类的Clinit<>()方法执行之前,父类的<clinit()已经执行完毕
但是接口中的<clinit()不需要先执行父类的,只有当父类中定义的变量使用时,父接口才会初始化。除此之外,接口的实现类在初始化时也不会执行接口的<clinit()方法。
虚拟机会保证一个类的**<clinit()方法在多线程环境中能被正确的枷锁、同步。如果多个线程初始化一个类,那么只有一个线程会去执行<clinit()方法,其它线程都需要等待。**

加载的工具——类加载器
前面说过,第一步“加载”过程,要通过一个类的全限定名来获取这个类的二进制字节流。这个过程,是要借助于一股虚拟机外部的工具来进行的,这一工具就是类加载器。每一个类,都有一个针对他的类加载器。两个类是否相同,不但要比较他本身,还要比较他们的类加载器。

类加载器:
  • 启动类加载器:由C++编写,属于虚拟机的一部分,是属于很基础的加载器,回加载Java目录下lib中的类
  • 扩展类加载器:负责加载Java\lib\ext目录中的类库
  • 应用类加载器:也叫做系统类加载器,加载用户类路径上自己指定的类,我们平时使用也基本是使用这个

而具体的加载逻辑,被称为“双亲委派模型”,即首先有一个根部的加载器“启动类加载器”,其下有一个儿子叫“扩展类加载器”,其下是“应用程序类加载器”,最后是“自定义类加载器”。具体流程:

一个类收到了加载的请求,首先会把请求委托给父类加载,每一个加载器都是如此。这样最终会把请求交给根节点的“启动类加载器”。之后如果父加载器可以加载,就会直接加载。否则,会将请求再传下来。
这样可以避免程序员自己随意串改系统级的类。

双亲委派模型是可以被打破的:

线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器(Application ClassLoader)。

有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI服务使用这个线程上下文类加载器去加载所需要的 SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器 ,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI 、JDBC、JCE、 JAXB 和JBI等。

自定义类加载器

https://blog.csdn.net/a724888/article/details/78396462

Tomcat:正统类加载架构

Java Web服务器需要满足:

  1. 部署在同一个Web容器上 的两个Web应用程序所使用的Java类库可以实现相互隔离。
  2. 部署在同一个Web容器上 的两个Web应用程序所使用的Java类库可以互相共享 。
  3. Web容器需要尽可能地保证自身的安全不受部署的Web应用程序影响。容器所使用的类库应该与应用程序的类库互相独立。
  4. 支持JSP生成类的热替换

由于存在上述问题,在部署Web应用时,单独的一个Class Path就无法满足需求了,所以各种 Web容都“不约而同”地提供了好几个Class Path路径供用户存放第三方类库,这些路径一般都以“lib”或“classes ”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库

在Tomcat目录结构中,有3组目录(“/common/”、“/server/”和“/shared/”)可以存放Java类库,另外还可以加上Web 应用程序自身的目录“/WEB-INF/” ,一共4组,把Java类库放置在这些目录中的含义分别如下:
①放置在/common目录中:类库可被Tomcat和所有的 Web应用程序共同使用。
②放置在/server目录中:类库可被Tomcat使用,对所有的Web应用程序都不可见。
③放置在/shared目录中:类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
④放置在/WebApp/WEB-INF目录中:类库仅仅可以被此Web应用程序使用,对 Tomcat和其他Web应用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,
CommonClassLoader,Catalina ClassLoader,SharedClassLoader,WebAppClassLoader

如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的,那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢?

答案是使用线程上下文类加载器来实现的,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。看spring源码发现,spring加载类所用的Classloader是通过Thread.currentThread().getContextClassLoader()来获取的,而当线程创建时会默认setContextClassLoader(AppClassLoader),即线程上下文类加载器被设置为 AppClassLoader,spring中始终可以获取到这个AppClassLoader( 在 Tomcat里就是WebAppClassLoader)子类加载器来加载bean ,以后任何一个线程都可以通过 getContextClassLoader()获取到WebAppClassLoader来getbean 了 。

6.虚拟机字节码执行引擎

JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。通俗点来说,也就是 JVM 解析字节码指令,输出运行结果的一个过程。

运行时栈帧结构

在 Java 中,一个栈帧对应一个方法调用,方法中需涉及到的局部变量、操作数,返回地址等都存放在栈帧中的。每个方法对应的栈帧大小在编译后基本已经确定了,方法中需要多大的局部变量表,多深的操作数栈等信息早以被写入方法的 Code 属性中了。所以运行期,方法的栈帧大小早已固定,直接计算并分配内存即可。

  • 局部变量表:局部变量表用来存放方法运行时用到的各种变量,以及方法参数。虚拟机规范中指明,局部变量表的容量用变量槽(slot)为最小单位,却没有指明一个 slot 的实际空间大小,只是说,每个 slot 应当能够存放任意一个 boolean,byte,char,short,int,float,reference 等。

局部变量表第一项是名为 this 的一个类引用,它指向堆中当前对象的引用。接着就是我们的方法参数

  • 操作数栈:操作数栈也称作操作栈,它不像局部变量表采用的索引机制访问其中元素,而是标准的栈操作,入栈出栈,先入后出。操作数栈在方法执行之初为空,随着方法的一步一步运行,操作数栈中将不停的发生入栈出栈操作,直至方法执行结束。

  • 返回地址:一个方法在调用另一个方法结束之后,需要返回调用处继续执行后续的方法体。那么调用其他方法的位置点就叫做「返回地址」,我们需要通过一定的手段保证,CPU 执行其他方法之后还能返回原来调用处,进而继续调用者的方法体。这个返回地址往往会被提前压入调用者的栈帧中,当方法调用结束时,取出栈顶元素即可得到后续方法体执行入口

方法调用

因为往往一条虚拟机指令要求调用某个方法,但是该方法可能会有重载,重写等问题,那么虚拟机又该如何确定调用哪个方法呢

首先我们要谈谈这个解析过程,从上篇文章中可以知道,当一个类初次加载的时候,会在解析阶段完成常量池中符号引用到直接引用的替换。这其中就包括方法的符号引用翻译到直接引用的过程,但这只针对部分方法,有些方法只有在运行时才能确定的,就不会被解析。我们称在类加载阶段的解析过程为「静态解析」。

那么哪些方法是被静态解析了,哪些方法需要动态解析呢?

Object obj = new String("hello");
obj.equals("world");
  • 1
  • 2

Object 类中有一个 equals 方法,String 类中也有一个 equals 方法,上述程序显然调用的是 String 的 equals 方法。那么如果我们加载 Object 类的时候将 equals 符号引用直接指向了本身的 equals 方法的直接引用,那么上述的 obj 永远调用的都是 Object 的 equals 方法。那我们的多态就永远实现不了。

只有那些,「编译期可知,运行时不变」的方法才可以在类加载的时候将其进行静态解析,这些方法主要有:private 修饰的私有方法,类静态方法,类实例构造器,父类方法。

其余的所有方法统称为「虚方法」,类加载的解析阶段不会被解析。这些方法的调用不存在问题,虚拟机直接根据直接引用即可找到方法的入口,但是「非虚方法」就不同了,虚拟机需要用一定的策略才能定位到实际的方法,下面我们一起来看看。

  • 静态分派
public class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
public class Hello {
    public void sayHello(Father father){
        System.out.println("hello , i am the father");
    }
    public void sayHello(Daughter daughter){
        System.out.println("hello i am the daughter");
    }
    public void sayHello(Son son){
        System.out.println("hello i am the son");
    }
}
public static void main(String[] args){
    Father son = new Son();
    Father daughter = new Daughter();
    Hello hello = new Hello();
    hello.sayHello(son);
    hello.sayHello(daughter);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

输出结果

hello , i am the father

hello , i am the father
  • 1
  • 2
  • 3

首先需要介绍两个概念,「静态类型」和「实际类型」。静态类型指的是包装在一个变量最外层的类型,例如上述 Father 就是所谓的静态类型,而 Son 或是 Daughter 则是实际类型。
我们的编译器在生成字节码指令的时候会根据变量的静态类型选择调用合适的方法。就我们上述的例子而言:
在这里插入图片描述
这两个方法就是我们 main 函数中调用的两次 sayHello 方法,但是你会发现传入的参数类型是相同的,Father,也就是调用的方法是相同的,都是这个方法:

所有依赖静态类型来定位方法执行版本的分派动作称作「静态分派」,而方法重载是静态分派的一个典型体现。但需要注意的是,静态分派不管你实际类型是什么,它只根据你的静态类型进行方法调用

  • 动态分派
public class Father {
    public void sayHello(){
        System.out.println("hello world ---- father");
    }
}
public class Son extends Father {
    @Override
    public void sayHello(){
        System.out.println("hello world ---- son");
    }
}
public static void main(String[] args){
    Father son = new Son();
    son.sayHello();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

输出结果:

hello world ---- son
  • 1

显然,最终调用了子类的 sayHello 方法,我们看生成的字节码指令调用情况:在这里插入图片描述
在这里插入图片描述
看到没?编译器为我们生成的方法调用指令,选择调用的是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?

当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual 指令需要完成以下几个步骤才能实现对一个方法的调用:

  • 弹出操作数栈顶部元素,判断其实际类型,记做 C
  • 在类型 C 中查找需要调用方法的简单名称和描述符相同的方法,如果有则返回该方法的直接引用
  • 否则,向 C 的父类再做搜索,有即返回方法的直接引用
  • 否则,抛出异常 java.lang.AbstractMethodError 异常

所以,我们此处的示例调用的是子类 Son 的 sayHello 方法就不言而喻了。

至于虚拟机为什么能这么准确高效的搜索某个类中的指定方法,各个虚拟机的实现各有不同,但最常见的是使用「虚方法表」,这个概念也比较简单,就是为每个类型都维护一张方法表,该表中记录了当前类型的所有方法的描述信息。于是虚拟机检索方法的时候,只需要从方法表中进行搜索即可,当前类型的方法表中没有就去父类的方法表中进行搜索。

静态分派属于多分派(根据多个宗量对目标方法进行选择),动态分派属于单分派(根据一个宗量对目标方法进行选择)

动态类型语言

动态类型语言的一个关键特征就是,类型检查发生在运行时。也就是说,编译期间编译器是不会管你这个变量是什么类型,调用的方法是否存在的。

静态语言会在编译期检查变量类型,并提供严格的检查,而动态语言在运行期检查变量实际类型,给了程序更大的灵活性。各有优劣,静态语言的优势在于安全,缺点在于缺乏灵活性,动态语言则是相反的。

JDK1.7 提供了两种方式来支持 Java 的动态特性,invokedynamic 指令和 java.lang.invoke 包。

基于操作数栈

总结一下,HotSpot 虚拟机基于操作数栈进行方法的解释执行,所有运算的中间结果以及方法参数等等,基本都伴随着出入栈的操作取出或存储。这种机制最大的优势在于,可移植性强。不同于基于寄存器的方法执行机制,对底层硬件依赖过度,无法很轻易的跨平台,但是劣势也很明显,就是同样的操作需要相对更多的指令才能完成。

作者:YangAM
链接:https://juejin.im/post/5abc97ff518825556a727e66
来源:掘金

7.Java的编译期优化与运行期优化

  • Java的编译期,是一个极不确定的过程。因为Java的编译期很多,有前端编译期,有后端编译器,还有静态提前编译器。前端编译期负责将.java转化为简单的.class,后端编译器负责将字节码转换为机器码,如JIT。静态提前编译器会将.java直接翻译为本地机器码,如AOT。因此,编译期并不能很精准的分类,因此只能大概分为“早期”和“晚期”。
早期(编译期)优化

早期阶段,可以概括的看做前端编译器将.java转化为.class的过程。这一阶段的优化又可以称作编译期优化。

这一阶段其实和其他语言的编译期优化类似,无非就是词法、语法分析,语义分析,然后做一些语言层面的优化。比如,语法糖、注解的处理,还有字符串拼接。Java语法糖不多,但是挺实用的,诸如类型擦除啊,自动拆箱、装箱啊。注解是在编译时进行优化,具体在运行时才会体现出作用。

Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换成原来的原生类型了,并且在相应的地方插入了强制转型代码。伪泛型

自动装箱、拆箱在编译之后会被转化成对应的包装和还原方法,如Integer.valueOf()与Integer.intValue(),而遍历循环则把代码还原成了迭代器的实现,变长参数会变成数组类型的参数。
然而包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们的equals()方法不处理数据转型的关系

Java语言也可以进行条件编译,方法就是使用条件为常量的if语句,它在编译阶段就会被“运行”:只能是条件为常量的if语句,这也是Java语言的语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除掉

晚期(运行期)优化

Java程序最初是通过解释器进行解释执行的,当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译时间,立即执行;当程序运行后,随着时间的推移,编译期逐渐发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率解释执行节约内存,编译执行提升效率

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler(C1编译器)和Server Compiler(C2编译器),默认采用解释器与其中一个编译器直接配合的方式工作

在运行过程中会被即时编译器编译的“热点代码”有两类:
1.被多次调用的方法:由方法调用触发的编译,属于JIT编译方式
2.被多次执行的循环体:也以整个方法作为编译对象,因为编译发生在方法执行过程中,因此成为栈上替换(OSR编译)

热点探测判定方式有两种:
1.基于采样的热点探测:虚拟机周期性的检查各个线程的栈顶,如果某个方法经常出现在栈顶,则判定为“热点方法”。(简单高效,可以获取方法的调用关系,但容易受线程阻塞或别的外界因素影响扰乱热点探测)
2.基于计数的热点探测:虚拟机为每个方法建立一个计数器,统计方法的执行次数,超过一定阈值就是“热点方法”。(需要为每个方法维护计数器,不能直接获取方法的调用关系,但是统计结果精确严谨)

编译优化技术:
公共子表达式消除,数组边界消除,方法内联等等。。。。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/2023面试高手/article/detail/186859
推荐阅读
相关标签
  

闽ICP备14008679号