您的位置 首页 java

通往高级 Java 开发的必经之路

一、 JVM 内存模型

Java 虚拟机 (Java Virtual Machine=JVM)的内存空间分为五个部分,分别是:

  1. 程序计数器;

  2. Java 虚拟机栈;

  3. 本地方法栈;

  4. 堆;

  5. 方法区。

下面对这五个区域展开深入的介绍。

1.1 程序计数器

1.1.1 什么是程序计数器?

程序计数器是一块较小的内存空间,可以把它看作当前 线程 正在执行的 字节码 的行号指示器。也就是说,程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。

注:如果当前线程正在执行的是一个本地方法,那么此时程序计数器为空。

1.1.2 程序计数器的作用

程序计数器有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理;

  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

1.1.3 程序计数器的特点

  1. 是一块较小的存储空间;

  2. 线程私有。每条线程都有一个程序计数器;

  3. 是唯一一个不会出现 OutOfMemoryError 的内存区域;

  4. 生命周期随着线程的创建而创建,随着线程的结束而死亡。

1.2 Java 虚拟机栈(JVM Stack)

1.2.1 什么是 Java 虚拟机栈?

Java 虚拟机栈是描述 Java 方法运行过程的内存模型。

Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做 “栈帧” 的区域,这块区域用于存储该方法在运行过程中所需要的一些信息,这些信息包括:

  1. 局部变量 表;

    存放基本数据类型变量、引用类型的变量、returnAddress 类型的变量;

  2. 操作数栈;

  3. 动态链接;

  4. 方法出口信息等。

当一个方法即将被运行时,Java 虚拟机栈首先会在 Java 虚拟机栈中为该方法创建一块 “栈帧”,栈帧中包含局部变量表、操作数栈、动态链接、方法出口信息等。

当方法在运行过程中需要创建局部变量时,就将局部变量的值存入栈帧的局部变量表中。

当这个方法执行完毕后,这个方法所对应的栈帧将会出栈,并释放内存空间。

这里的 “堆” 可以这么理解,但这里的 “栈” 只代表了 Java 虚拟机栈中的局部变量表部分。真正的 Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。

1.2.2 Java 虚拟机栈的特点

(1)局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建。

而且,局部变量表的大小在编译时期就确定下来了,在创建的时候只需分配事先规定好的大小即可。此外,在方法运行的过程中局部变量表的大小是不会发生改变的。

(2)Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

a) StackOverFlowError:

若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 异常。

b) OutOfMemoryError:

若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 异常。

(3)Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

StackOverFlowError 和 OutOfMemoryError 的异同?

StackOverFlowError 表示当前线程申请的栈超过了事先定好的栈的最大深度,但内存空间可能还有很多。而 OutOfMemoryError 是指当线程申请栈时发现栈已经满了,而且内存也全都用光了。

1.3 本地方法栈

1.3.1 什么是本地方法栈?

本地方法栈和 Java 虚拟机栈实现的功能类似,只不过本地方法区是本地方法运行的内存模型。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。

1.4 堆

1.4.1 什么是堆?

堆是用来存放对象的内存空间。几乎所有的对象都存储在堆中。

1.4.2 堆的特点

  1. 线程共享;

    整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个的。

  2. 在虚拟机启动时创建;

  3. 垃圾回收的主要场所;

  4. 可以进一步细分为:新生代、老年代;

    新生代又可被分为:Eden、From Survior、To Survior。

    不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收 算法 ,从而更具有针对性,从而更高效。

  5. 堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已满无法再扩展时,就抛出 OutOfMemoryError。

1.5 方法区

1.5.1 什么是方法区?

Java 虚拟机规范中定义方法区是堆的一个逻辑部分。

方法区中存放已经被虚拟机加载的类信息、常量、 静态变量 、即时编译器编译后的代码等。

1.5.2 方法区的特点

  1. 线程共享;

    方法区是堆的一个逻辑部分,因此和堆一样,都是线程共享的。整个虚拟机中只有一个方法区。

  2. 永久代;

    方法区中的信息一般需要长期存在,而且它又是堆的逻辑分区,因此用堆的划分方法,我们把方法区称为老年代。

  3. 内存回收效率低;

    方法区中的信息一般需要长期存在,回收一遍内存之后可能只有少量信息无效。

    对方法区的内存回收的主要目标是:对常量池的回收 和 对类型的卸载。

  4. Java 虚拟机规范对方法区的要求比较宽松。

    和堆一样,允许固定大小,也允许可扩展的大小,还允许不实现垃圾回收。

1.5.3 什么是运行时常量池?

方法区中存放三种数据:类信息、常量、静态变量、即时编译器编译后的代码。其中常量存储在运行时常量池中。

我们一般在一个类中通过 public static final 来声明一个常量。这个类被编译后便生成 Class 文件,这个类的所有信息都存储在这个 class 文件中。

当这个类被 Java 虚拟机加载后,class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。

如:String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。

当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用,那么就需要垃圾收集器回收。

1.6 直接内存

直接内存是除 Java 虚拟机之外的内存,但也有可能被 Java 使用。

在 NIO 中引入了一种基于通道和缓冲的 IO 方式。

它可以通过调用本地方法直接分配 Java 虚拟机之外的内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象直接操作该内存,而无需先将外面内存中的数据复制到堆中再操作,从而提升了数据操作的效率。

直接内存的大小不受 Java 虚拟机控制,但既然是内存,当内存不足时就会抛出 OOM 异常。

1.7 综上所述

Java 虚拟机的内存模型中一共有两个 “栈”,分别是:Java 虚拟机栈和本地方法栈。

两个 “栈” 的功能类似,都是方法运行过程的内存模型。并且两个 “栈” 内部构造相同,都是线程私有。

只不过 Java 虚拟机栈描述的是 Java 方法运行过程的内存模型,而本地方法栈是描述 Java 本地方法运行过程的内存模型。

Java 虚拟机的内存模型中一共有两个 “堆”,一个是原本的堆,一个是方法区。方法区本质上是属于堆的一个逻辑部分。

堆中存放对象,方法区中存放类信息、常量、静态变量、即时编译器编译的代码。堆是 Java 虚拟机中最大的一块内存区域,也是垃圾收集器主要的工作区域。

程序计数器、Java 虚拟机栈、本地方法栈是线程私有的,即每个线程都拥有各自的程序计数器、Java 虚拟机栈、本地方法区。并且他们的生命周期和所属的线程一样。

而堆、方法区是线程共享的,在 Java 虚拟机中只有一个堆、一个方法栈。并在 JVM 启动的时候就创建,JVM 停止才销毁。

第二章、揭开 Java 对象创建的奥秘

2.1 对象的创建过程

当虚拟机遇到一条含有 new 的指令时,会进行一系列对象创建的操作:

1)检查常量池中是否有即将要创建的这个对象所属的类的符号引用;

  • 若常量池中没有这个类的符号引用,说明这个类还没有被定义!抛出 ClassNotFoundException;

  • 若常量池中有这个类的符号引用,则进行下一步工作;

2)进而检查这个符号引用所代表的类是否已经被 JVM 加载;

  • 若该类还没有被加载,就找该类的 class 文件,并加载进方法区;

  • 若该类已经被 JVM 加载,则准备为对象分配内存;

3)根据方法区中该类的信息确定该类所需的内存大小;

一个对象所需的内存大小是在这个对象所属类被定义完就能确定的!且一个类所生产的所有对象的内存大小是一样的!

JVM 在一个类被加载进方法区的时候就知道该类生产的每一个对象所需要的内存大小。

4)从堆中划分一块对应大小的内存空间给新的对象;分配堆中内存有两种方式:

  • 指针碰撞:如果 JVM 的垃圾收集器采用复制算法或标记-整理算法,那么堆中空闲内存是完整的区域,并且空闲内存和已使用内存之间由一个指针标记。

    那么当为一个对象分配内存时,只需移动指针即可。因此,这种在完整空闲区域上通过移动指针来分配内存的方式就叫做 “指针碰撞”。

  • 空闲列表:如果 JVM 的垃圾收集器采用标记-清除算法,那么堆中空闲区域和已使用区域交错,因此需要用一张 “空闲列表” 来记录堆中哪些区域是空闲区域,从而在创建对象的时候根据这张 “空闲列表” 找到空闲区域,并分配内存。

综上所述:JVM 究竟采用哪种内存分配方法,取决于它使用了何种垃圾收集器。

5)为对象中的成员变量赋上初始值 (默认初始化);

6)设置对象头中的信息;

7)调用对象的构造函数进行初始化;

此时,整个对象的创建过程就完成了。

2.2 对象的内存模型

一个对象从逻辑角度看,它由成员变量和成员函数构成,从物理角度来看,对象是存储在堆中的一串二进制数,这串二进制数的组织结构如下。

对象在内存中分为三个部分:

  1. 对象头;

  2. 实例数据;

  3. 对齐补充。

2.2.1 对象头

对象头中记录了对象在运行过程中所需要使用的一些数据:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

此外,对象头中可能还包含类型指针。通过该指针能确定这个对象所属哪个类。此外,如果对象是一个数组,那么对象头中还要包含数组长度。

2.2.2 实例数据

实力数据部分就是成员变量的值,其中包含父类的成员变量和本类的成员变量。

2.2.3 对齐补充

用于确保对象的总长度为 8 字节的整数倍。

HotSpot 要求对象的总长度必须是 8 字节的整数倍。由于对象头一定是 8 字节的整数倍,但实例数据部分的长度是任意的,因此需要对齐补充字段确保整个对象的总长度为 8 的整数倍。

2.3 访问对象的过程

我们知道,引用类型的变量中存放的是一个地址,那么根据地址类型的不同,对象有不同的访问方式:

  1. 句柄访问方式;

    堆中需要有一块叫做 “句柄池” 的内存空间,用于存放所有对象的地址和所有对象所属类的类信息。

    引用类型的变量存放的是该对象在句柄池中的地址。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址再访问对象。

  2. 直接指针访问方式;

    引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。

    但对象所在的内存空间中需要额外的策略存储对象所属的类信息的地址。

2.4 比较

HotSpot 采用直接指针方式访问对象,因为它只需一次寻址操作,从而性能比句柄访问方式快一倍。但它需要额外的策略存储对象在方法区中类信息的地址。

三、揭开 Java 对象内存分配的秘密

Java 所承诺的自动内存管理主要是针对对象内存的回收和对象内存的分配。

在 Java 虚拟机的五块内存空间中,程序计数器、Java 虚拟机栈、本地方法栈内存的分配和回收都具有确定性,一半都在编译阶段就能确定下来需要分配的内存大小,并且由于都是线程私有。

因此它们的内存空间都随着线程的创建而创建,线程的结束而回收。也就是这三个区域的内存分配和回收都具有确定性。

而 Java 虚拟机中的方法区因为是用来存储类信息、常量静态变量,这些数据的变动性较小,因此不是 Java 内存管理重点需要关注的区域。

而对于堆,所有线程共享,所有的对象都需要在堆中创建和回收。虽然每个对象的大小在类加载的时候就能确定。

但对象的数量只有在程序运行期间才能确定,因此堆中内存的分配具有较大的不确定性。

此外,对象的生命周期长短不一,因此需要针对不同生命周期的对象采用不同的内存回收算法,增加了内存回收的复杂性。

综上所述:Java 自动内存管理最核心的功能是堆内存中对象的分配与回收。

3.1 对象优先在 Eden 区中分配

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代。

在新生代中为了防止内存碎片问题,因此垃圾收集器一般都选用 “复制” 算法。因此,堆内存的新生代被进一步分为:Eden 区+Survior1 区+Survior2 区。

每次创建对象时,首先会在 Eden 区中分配。

若 Eden 区已满,则在 Survior1 区中分配。若 Eden 区+Survior1 区剩余内存太少,导致对象无法放入该区域时,就会启用 “分配担保”,将当前 Eden 区+Survior1 区中的对象转移到老年代中,然后再将新对象存入 Eden 区。

3.2 大对象直接进入老年代

所谓 “大对象” 就是指一个占用大量连续存储空间的对象,如数组。

当发现一个大对象在 Eden 区+Survior1 区中存不下的时候就需要分配担保机制把当前 Eden 区+Survior1 区的所有对象都复制到老年代中去。

我们知道,一个大对象能够存入 Eden 区+Survior1 区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。

因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。

那么,什么样的对象才是 “大对象” 呢?

通过-XX:PretrnureSizeThreshold 参数设置大对象,该参数用于设置大小超过该参数的对象被认为是 “大对象”,直接进入老年代。

注意:该参数只对 Serial 和 ParNew 收集器有效。

3.3 生命周期较长的对象进入老年代

老年代用于存储生命周期较长的对象,那么我们如何判断一个对象的年龄呢?

新生代中的每个对象都有一个年龄计数器,当新生代发生一次 MinorGC 后,存活下来的对象的年龄就加一,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。

使用-XXMaxTenuringThreshold 设置新生代的最大年龄,设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去。

3.4 相同年龄的对象内存超过 Survior 内存一半的对象进入老年代

如果当前新生代的 Survior 中,年龄相同的对象的内存空间总和超过了 Survior 内存空间的一半,那么所有年龄相同的对象和超过该年龄的对象都被转移到老年代中去。

无需等到对象的年龄超过 MaxTenuringThreshold 才被转移到老年代中去。

3.5 “分配担保” 策略详解

当垃圾收集器准备要在新生代发起一次 MinorGC 时,首先会检查 “老年代中最大的连续空闲区域的大小 是否大于 新生代中所有对象的大小?”,也就是老年代中目前能够将新生代中所有对象全部装下?

若老年代能够装下新生代中所有的对象,那么此时进行 MinorGC 没有任何风险,然后就进行 MinorGC。

若老年代无法装下新生代中所有的对象,那么此时进行 MinorGC 是有风险的,垃圾收集器会进行一次预测:根据以往 MinorGC 过后存活对象的平均数来预测这次 MinorGC 后存活对象的平均数。

如果以往存活对象的平均数小于当前老年代最大的连续空闲空间,那么就进行 MinorGC,虽然此次 MinorGC 是有风险的。

如果以往存活对象的平均数大于当前老年代最大的连续空闲空间,那么就对老年代进行一次 Full GC,通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。

这个过程就是 分配担保。

注意:

  1. 分配担保是老年代为新生代作担保;

  2. 新生代中使用 “复制” 算法实现垃圾回收,老年代中使用 “标记-清除” 或 “标记-整理” 算法实现垃圾回收,只有使用 “复制” 算法的区域才需要分配担保,因此新生代需要分配担保,而老年代不需要分配担保。

四、了解 Java 虚拟机的垃圾回收算法

Java 虚拟机的内存模型分为五个部分,分别是:程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区。

这五个区域既然是存储空间,那么为了避免 Java 虚拟机在运行期间内存存满的情况,就必须得有一个垃圾收集者的角色,不定期地回收一些无效内存,以保障 Java 虚拟机能够健康地持续运行。

这个垃圾收集者就是平常我们所说的 “垃圾收集器”,那么垃圾收集器在何时清扫内存?清扫哪些数据?这就是接下来我们要解决的问题。

程序计数器、Java 虚拟机栈、本地方法栈都是线程私有的,也就是每条线程都拥有这三块区域,而且会随着线程的创建而创建,线程的结束而销毁。

那么,垃圾收集器在何时清扫这三块区域的问题就解决了。

此外,Java 虚拟机栈、本地方法栈中的栈帧会随着方法的开始而入栈,方法的结束而出栈,并且每个栈帧中的本地变量表都是在类被加载的时候就确定的。

因此以上三个区域的垃圾收集工作具有确定性,垃圾收集器能够清楚地知道何时清扫这三块区域中的哪些数据。

然而,堆和方法区中的内存清理工作就没那么容易了。

堆和方法区所有线程共享,并且都在 JVM 启动时创建,一直得运行到 JVM 停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。

堆中存放 JVM 运行期间的所有对象,虽然每个对象的内存大小在加载该对象所属类的时候就确定了,但究竟创建多少个对象只有在程序运行期间才能确定。

方法区中存放类信息、静态成员变量、常量。类的加载是在程序运行过程中,当需要创建这个类的对象时才会加载这个类。因此,JVM 究竟要加载多少个类也需要在程序运行期间确定。

因此,堆和方法区的内存回收具有不确定性,因此垃圾收集器在回收堆和方法区内存的时候花了一些心思。

4.1 堆内存的回收

4.1.1 如何判定哪些对象需要回收?

在对堆进行对象回收之前,首先要判断哪些是无效对象。我们知道,一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收。一般有两种判别方式:

引用计数法:每个对象都有一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加一;若该引用失效则计数器减一。当计数器为 0 时,就认为该对象是无效对象。

可达性分析法:所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。

GC Roots 是指:

  1. Java 虚拟机栈所引用的对象 (栈帧中局部变量表中引用类型的变量所引用的对象);

  2. 方法区中静态属性引用的对象;

  3. 方法区中常量所引用的对象;

  4. 本地方法栈所引用的对象。

两者对比:

引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。

因此,目前主流语言均使用可达性分析方法来判断对象是否有效。

4.1.2 回收无效对象的过程

当 JVM 筛选出失效的对象之后,并不是立即清除,而是再给对象一次重生的机会,具体过程如下:

判断该对象是否覆盖了 finalize() 方法;

  • 若已覆盖该方法,并该对象的 finalize() 方法还没有被执行过,那么就会将 finalize() 扔到 F-Queue 队列中;

  • 若未覆盖该方法,则直接释放对象内存。

执行 F-Queue 队列中的 finalize() 方法;

  • 虚拟机会以较低的优先级执行这些 finalize() 方法们,也不会确保所有的 finalize() 方法都会执行结束。

    如果 finalize() 方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。

  • 对象重生或死亡;

    如果在执行 finalize() 方法时,将 this 赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。

注意:强烈不建议使用 finalize() 函数进行任何操作!如果需要释放资源,请使用 try-finally。因为 finalize() 不确定性大,开销大,无法保证顺利执行。

4.2 方法区的内存回收

我们知道,如果使用复制算法实现堆的内存回收,堆就会被分为新生代和老年代,新生代中的对象 “朝生夕死”,每次垃圾回收都会清除掉大量的对象;而老年代中的对象生命较长,每次垃圾回收只有少量的对象被清除掉。

由于方法区中存放生命周期较长的类信息、常量、静态变量,因此方法区就像是堆的老年代,每次垃圾收集的只有少量的垃圾被清除掉。

方法区中主要清除两种垃圾:

  1. 废弃常量;

  2. 废弃的类。

4.2.1 如何判定废弃常量?

清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。

4.2.2 如何废弃废弃的类?

清除废弃类的条件较为苛刻:

  1. 该类的所有对象都已被清除;

  2. 该类的 java.lang.Class 对象没有被任何对象或变量引用;只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除;

  3. 加载该类的 ClassLoader 已经被回收。

4.3 垃圾收集算法

现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来介绍如何清除这些数据。

4.3.1 标记-清除算法

首先利用刚才介绍的方法判断需要清除哪些数据,并给它们做上标记;然后清除被标记的数据。

分析:这种算法标记和清除过程效率都很低,而且清除完后存在大量碎片空间,导致无法存储大对象,降低了空间利用率。

4.3.2 复制算法

将内存分成两份,只将数据存储在其中一块上。当需要回收垃圾时,也是首先标记出废弃的数据,然后将有用的数据复制到另一块内存上,最后将第一块内存全部清除。

分析:这种算法避免了碎片空间,但内存被缩小了一半。而且每次都需要将有用的数据全部复制到另一片内存上去,效率不高。

解决空间利用率:在新生代中,由于大量的对象都是 “朝生夕死”,也就是一次垃圾收集后只有少量对象存活,因此我们可以将内存划分成三块:Eden、Survior1、Survior2,内存大小分别是 8:1:1。

分配内存时,只使用 Eden 和一块 Survior1。当发现 Eden+Survior1 的内存即将满时,JVM 会发起一次 MinorGC,清除掉废弃的对象,并将所有存活下来的对象复制到另一块 Survior2 中。那么,接下来就使用 Survior2+Eden 进行内存分配。

通过这种方式,只需要浪费 10% 的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题。

但是,当一个对象要申请内存空间时,发现 Eden+Survior 中剩下的空间无法放置该对象,此时需要进行 Minor GC,如果 MinorGC 过后空闲出来的内存空间仍然无法放置该对象,那么此时就需要将对象转移到老年代中,这种方式叫做 “分配担保”。

什么是分配担保?

当 JVM 准备为一个对象分配内存空间时,发现此时 Eden+Survior 中空闲的区域无法装下该对象,那么就会触发 MinorGC,对该区域的废弃对象进行回收。

但如果 MinorGC 过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将 Eden+Survior 中的所有对象都转移到老年代中,然后再将新对象存入 Eden 区。这个过程就是 “分配担保”。

4.3.3 标记-整理算法

在回收垃圾前,首先将所有废弃的对象做上标记,然后将所有未被标记的对象移到一边,最后清空另一边区域即可。

分析:它是一种老年代的垃圾收集算法。

老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用 “复制” 算法,每次需要复制大量存活的对象,会导致效率很低。

而且,在新生代中使用 “复制” 算法,当 Eden+Survior 中都装不下某个对象时,可以使用老年代的内存进行 “分配担保”,而如果在老年代使用该算法,那么在老年代中如果出现 Eden+Survior 装不下某个对象时,没有其他区域给他作分配担保。

因此,老年代中一般使用 “标记-整理” 算法。

4.3.4 分代收集算法

将内存划分为老年代和新生代。老年代中存放寿命较长的对象,新生代中存放 “朝生夕死” 的对象。然后在不同的区域使用不同的垃圾收集算法。

4.4 Java 中引用的种类

Java 中根据生命周期的长短,将引用分为 4 类。

4.4.1 强引用

我们平时所使用的引用就是强引用。

A a = new A(); 也就是通过关键字 new 创建的对象所关联的引用就是强引用。

只要强引用存在,该对象永远也不会被回收。

4.4.2 软引用

只有当堆即将发生 OOM 异常时,JVM 才会回收软引用所指向的对象。

软引用通过 SoftReference 类实现。软引用的生命周期比强引用短一些。

4.4.3 弱引用

只要垃圾收集器运行,软引用所指向的对象就会被回收。

弱引用通过 WeakReference 类实现。弱引用的生命周期比软引用短。

4.4.4 虚引用

虚引用也叫幽灵引用,它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数。

一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知。虚引用通过 PhantomReference 类来实现。

五、class 文件结构详解

5.1 什么是 JVM 的 “无关性”?

Java 具有平台无关性,也就是任何操作系统都能运行 Java 代码。之所以能实现这一点,是因为 Java 运行在虚拟机之上,不同的操作系统都拥有各自的 Java 虚拟机,因此 Java 能实现 “一次编写,处处运行”。

而 JVM 不仅具有平台无关性,还具有语言无关性。平台无关性是指不同操作系统都有各自的 JVM,而语言无关性是指 Java 虚拟机能运行除 Java 以外的代码!

这听起来非常惊人,但 JVM 对能运行的语言是有严格要求的。首先来了解下 Java 代码的运行过程。

Java 源代码首先需要使用 Javac 编译器编译成 class 文件,然后启动 JVM 执行 class 文件,从而程序开始运行。

也就是 JVM 只认识 class 文件,它并不管何种语言生成了 class 文件,只要 class 文件符合 JVM 的规范就能运行。

因此目前已经有 Scala、JRuby、Jython 等语言能够在 JVM 上运行。它们有各自的语法规则,不过它们的编译器都能将各自的源码编译成符合 JVM 规范的 class 文件,从而能够借助 JVM 运行它们。

5.2 纵观 Class 文件结构

class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全是连续的 0/1。class 文件中的所有内容被分为两种类型:无符号数 和 表。

  • 无符号数;

    它表示 class 文件中的值,这些值没有任何类型,但有不同的长度。根据这些值长度的不同分为:u1、u2、u4、u8,分别代表 1 字节的无符号数、2 字节的无符号数、4 字节的无符号数、8 字节的无符号数。

  • 表;

    class 文件中所有数据 (即无符号数) 要么单独存在,要么由多个无符号数组成二维表。即 class 文件中的数据要么是单个值,要么是二维表。

5.2.1 class 文件的组织结构

  1. 魔数;

  2. 本文件的版本信息;

  3. 常量池;

  4. 访问标志;

  5. 索引

  6. 父类索引;

  7. 接口索引集合;

  8. 字段表集合;

  9. 方法表集合。

5.3 Class 文件的构成 1:魔数

class 文件的头 4 个字节称为魔数,用来表示这个 class 文件的类型。

魔数的作用就相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在 class 文件中标示文件类型比较合适。

class 文件的魔数是用 16 进制表示的 “CAFEBABE”,非常具有浪漫主义色彩,谁说程序员的情商都很低!

5.4 Class 文件的构成 2:版本信息

紧接着魔数的 4 个字节是版本号。它表示本 class 中使用的是哪个版本的 JDK。

在高版本的 JVM 上能够运行低版本的 class 文件,但在低版本的 JVM 上无法运行高版本的 class 文件,即使该 class 文件中没有用到任何高版本 JDK 的特性也无法运行!

5.5 Class 文件的构成 3:常量池

5.5.1 什么是常量池?

紧接着版本号之后的就是常量池。常量池中存放两种类型的常量:

1)字面值常量,即我们在程序中定义的字符串、被 final 修饰的值。

2)符号引用,就是我们定义的各种名字:

  • 类和接口的全限定名;

  • 字段的名字 和 描述符;

  • 方法的名字 和 描述符。

5.5.2 常量池的特点

  • 常量池长度不固定

    常量池的大小是不固定的,因此常量池开头放置一个 u2 类型的无符号数,用来存储当前常量池的容量。JVM 根据这个值就知道常量池的头尾来。

注:这个值是从 1 开始的,若为 5 表示池中有 4 个常量。

  • 常量池中的常量由而为表来表示;

    常量池开头有个常量池容量计数器,接下来就全是一个个常量了,只不过常量都是由一张张二维表构成,除了记录常量的值以外,还记录当前常量的相关信息。

  • 常量池是 class 文件的资源仓库;

  • 常量池是与本 class 中其它部分关联最多的部分;

  • 常量池是 class 文件中空间占用最大的部分之一。

5.5.3 常量池中常量的类型

刚才介绍了,常量池中的常量大体上分为:字面值常量 和 符号引用。在此基础上,根据常量的数据类型不同,又可以被细分为 14 种常量类型。

这 14 种常量类型都有各自的二维表示结构。每种常量类型的头 1 个字节都是 tag,用于表示当前常量属于 14 种类型中的哪一个。

以 CONSTANT_Class_info 常量为例,它的二维表示结构如下:

CONSTANT_Class_info 表

name_index 表示这个类或接口全限定名的位置。它的值表示指向常量池的第几个常量。它会指向一个 CONSTANT_Utf8_info 类型的常量。

它的二维表结构如下:tag 表示当前常量的类型 (当前常量为 CONSTANT_Class_info,因此 tag 的值应为 7,表示一个类或接口的全限定名);

CONSTANT_Utf8_info 表

为什么 Java 中定义的类、变量名字必须小于 64K?

  • CONSTANT_Utf8_info 表示字符串常量;

  • tag 表示当前常量的类型,这里应该是 1;

  • length 表示这个字符串的长度;

  • bytes 为这个字符串的内容 (采用缩略的 UTF8 编码)。

类、接口、变量等名字都属于符号引用,它们都存储在常量池中。而不管哪种符号引用,它们的名字都由 CONSTANT_Utf8_info 类型的常量表示,这种类型的常量使用 u2 存储字符串的长度。

由于 2 字节最多能表示 65535 个数,因此这些名字的最大长度最多只能是 64K。

什么是 UTF-8 编码?什么是缩略 UTF-8 编码?

前者每个字符使用 3 个字节表示,而后者把 128 个 ASKII 码用 1 字节表示,某些字符用 2 字节表示,某些字符用 3 字节表示。

5.6 Class 文件的构成 4:访问标志

在常量池之后是 2 字节的访问标志。访问标志是用来表示这个 class 文件是类还是接口、是否被 public 修饰、是否被 abstract 修饰、是否被 final 修饰等。

由于这些标志都由是 / 否表示,因此可以用 0/1 表示。访问标志为 2 字节,可以表示 16 位标志,但 JVM 目前只定义了 8 种,未定义的直接写 0.

5.7 Class 文件的构成 5:类索引、父类索引、接口索引集合

类索引、父类索引、接口索引集合是用来表示当前 class 文件所表示类的名字、父类名字、接口们的名字。

它们按照顺序依次排列,类索引和父类索引各自使用一个 u2 类型的无符号常量,这个常量指向 CONSTANT_Class_info 类型的常量,该常量的 bytes 字段记录了本类、父类的全限定名。

由于一个类的接口可能有好多个,因此需要用一个集合来表示接口索引,它在类索引和父类索引之后。这个集合头两个字节表示接口索引集合的长度,接下来就是接口的名字索引。

5.8 Class 文件的构成 6:字段表的集合

5.8.1 什么是字段表集合?

接下来是字段表的集合。字段表集合用于存储本类所涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。

每一个字段表只表示一个成员变量,本类中所有的成员变量构成了字段表集合。

5.8.2 字段表结构的定义

5.8.3 什么是描述符?

  • access_flags

    字段的访问标志。在 Java 中,每个成员变量都有一系列的修饰符,和上述 class 文件的访问标志的作用一样,只不过成员变量的访问标志与类的访问标志稍有区别。

  • name_index

    本字段名字的索引。指向一个 CONSTANT_Class_info 类型的常量,这里面存储了本字段的名字等信息。

  • descriptor_index

    描述符。用于描述本字段在 Java 中的数据类型等信息 (下面详细介绍)

  • attributes_count

    属性表集合的长度。

  • attributes

    属性表集合。到 descriptor_index 为止是字段表的固定信息,光有上述信息可能无法完整地描述一个字段,因此用属性表集合来存放额外的信息,比如一个字段的值。(下面会详细介绍)

成员变量 (包括静态成员变量和实例变量) 和 方法都有各自的描述符。

对于字段而言,描述符用于描述字段的数据类型;对于方法而言,描述符用于描述字段的数据类型、参数列表、返回值。

在描述符中,基本数据类型用大写字母表示,对象类型用 “L 对象类型的全限定名” 表示,数组用 “[数组类型的全限定名” 表示。

描述方法时,将参数根据上述规则放在 () 中,() 右侧按照上述方法放置返回值。而且,参数之间无需任何符号。

5.8.4 字段表集合的注意点

  1. 一个 class 文件的字段表集合中不能出现从父类 / 接口继承而来字段;

  2. 一个 class 文件的字段表集合中可能会出现程序猿没有定义的字段;

    如编译器会自动地在内部类的 class 文件的字段表集合中添加外部类对象的成员变量,供内部类访问外部类。

  3. Java 中只要两个字段名字相同就无法通过编译。但在 JVM 规范中,允许两个字段的名字相同但描述符不同的情况,并且认为它们是两个不同的字段。

5.9 Class 文件的构成 7:方法表的集合

在 class 文件中,所有的方法以二维表的形式存储,每张表来表示一个函数,一个类中的所有方法构成方法表的集合。

方法表的结构和字段表的结构一致,只不过访问标志和属性表集合的可选项有所不同。

5.9.1 方法表集合的注意点

方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译过后的字节码指令。

如果本 class 没有重写父类的方法,那么本 class 文件的方法表集合中是不会出现父类 / 父接口的方法表;

本 class 的方法表集合可能出现程序猿没有定义的方法,编译器在编译时会在 class 文件的方法表集合中加入类构造器和实例构造器;

重载一个方法需要有相同的简单名称和不同的特征签名。JVM 的特征签名和 Java 的特征签名有所不同:

  • Java 特征签名:方法参数在常量池中的字段符号引用的集合;

  • JVM 特征签名:方法参数+返回值。

六、详解 Java 类的加载过程

6.1 类的生命周期

一个类从加载进内存到卸载出内存为止,一共经历 7 个阶段:

加载——> 验证——> 准备——> 解析——> 初始化——> 使用——> 卸载

其中,类加载包括 5 个阶段:

加载——> 验证——> 准备——> 解析——> 初始化

在类加载的过程中,以下 3 个过程称为连接:

验证——> 准备——> 解析

因此,JVM 的类加载过程也可以概括为 3 个过程:

加载——> 连接——> 初始化

C/C++ 在运行前需要完成预处理、编译、汇编、链接;而在 Java 中,类加载 (加载、连接、初始化) 是在程序运行期间完成的。

在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处——提高程序的灵活性。

Java 语言的灵活性体现在它可以在运行期间动态扩展,所谓动态扩展就是在运行期间动态加载和动态连接。

6.2 类加载的时机

6.2.1 类加载过程中每个步骤的顺序

我们已经知道,类加载的过程包括:加载、连接、初始化,连接又分为:验证、准备、解析,所以说类加载一共分为 5 步:加载、验证、准备、解析、初始化。

其中加载、验证、准备、初始化的开始顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。而解析过程会发生在初始化过程中。

6.2.2 类加载过程中 “初始化” 开始的时机

JVM 规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始 (解析除外),这些过程具体在何时开始,JVM 规范并没有定义,不同的虚拟机可以根据具体的需求自定义。

初始化开始的时机:

在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic。

这四个指令对应的 Java 代码场景是:

  • 通过 new 创建对象;

  • 读取、设置一个类的静态成员变量 (不包括 final 修饰的静态变量);

  • 调用一个类的静态成员函数。

使用 java.lang.reflect 进行反射调用的时候,如果类没有初始化,那就需要初始化。

当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类;当虚拟机启动时,虚拟机会首先初始化带有 main 方法的类,即主类。

6.2.3 主动引用 与 被动引用

JVM 规范中要求在程序运行过程中,“当且仅当” 出现上述 4 个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。

其中,直接满足上述初始化条件的情况叫做主动引用;间接满足上述初始化过程的情况叫做被动引用。

那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。

6.2.4 被动引用的场景示例

示例一

public class Fu{ public static String name = " 柴毛毛 "; static{
 System.out.println(" 父类被初始化!");
 }
}public class Zi{ static{
 System.out.println(" 子类被初始化!");
 }
}public static void main(String[] args){
 System.out.println(Zi.name);
} 

输出结果:父类被初始化!柴毛毛

原因分析:

本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。

但由于这个静态成员变量属于 Fu 类,Zi 类只是间接调用 Fu 类中的静态成员变量,因此 Zi 类调用 name 属性属于间接引用,而 Fu 类调用 name 属性属于直接引用,由于 JVM 只初始化直接引用的类,因此只有 Fu 类被初始化。

示例二

public class A{ public static void main(String[] args){
 Fu[] arr = new Fu[10];
 }
} 

输出结果:并没有输出 “父类被初始化!”

原因分析:

这个过程看似满足初始化时机的第一条:遇到 new 创建对象时若类没被初始化,则初始化该类。

但现在通过 new 要创建的是一个数组对象,而非 Fu 类对象,因此也属于间接引用,不会初始化 Fu 类。

示例三

public class Fu{ public static final String name = " 柴毛毛 "; static{
 System.out.println(" 父类被初始化!");
 }
}public class A{ public static void main(String[] args){
 System.out.println(Fu.name);
 }
} 

输出结果:柴毛毛。

原因分析:

本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。

但是,Fu 类的静态成员变量被 final 修饰,它已经是一个常量。被 final 修饰的常量在 Java 代码编译的过程中就会被放入它被引用的 class 文件的常量池中 (这里是 A 的常量池)。

所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。

6.2.5 接口的初始化

接口和类都需要初始化,接口和类的初始化过程基本一样。

不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己;

但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到当父接口中的东西时才初始化父接口。

6.3 类加载的过程

通过之前的介绍可知,类加载过程共有 5 个步骤,分别是:加载、验证、准备、解析、初始化。其中,验证、准备、解析称为连接。

下面详细介绍这 5 个过程 JVM 所做的工作。

6.3.1 加载

注意:“加载” 是 “类加载” 过程的第一步,千万不要混淆。

加载的过程

在加载过程中,JVM 主要做 3 件事情:

  1. 通过一个类的全限定名来获取这个类的二进制字节流,即 class 文件:

    在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化时机的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程。

  2. 将二进制字节流的存储结构转化为特定的数据结构,存储在方法区中;

  3. 在内存中创建一个 java.lang.Class 类型的对象。

    接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个 Class 类型的类对象是提供给外界访问该类的接口。

从哪里加载?

JVM 规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地 class 文件中读取,此外还可以从以下地方读取:

  • 从压缩包中读取,如 Jar、War、Ear 等。

  • 从其它文件中动态生成,如:从 JSP 文件中生成 Class 类。

  • 从数据库中读取,将二进制字节流存储至数据库中,然后在加载时从数据库中读取。有些中间件会这么做,用来实现代码在集群间分发。

  • 从网络中获取,从网络中获取二进制字节流。典型就是 Applet。

类和数组加载过程的区别?

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

String[] str = new String[10]; 

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

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

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

加载过程的注意点

  1. JVM 规范并未给出类在方法区中存放的数据结构;

    类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,JVM 规范并没有指定。

  2. JVM 规范并没有指定 Class 对象存放的位置;

    在二进制字节流以特定格式存储在方法区后,JVM 会创建一个 java.lang.Class 类型的对象,作为本类的外部接口。

    既然是对象就应该存放在堆内存中,不过 JVM 规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象。HotSpot 将 Class 对象存放在方法区。

  3. 加载阶段和连接阶段是交叉的;

    通过之前的介绍可知,类加载过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。也就是说,类加载过程中,必须按照如下顺序开始:

    加载、连接、初始化,但结束顺序无所谓,因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉。

6.3.2 验证

验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none 参数关闭,以缩短类加载时间。

验证的目的是什么?

验证是为了保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。

为什么需要验证?

虽然 Java 语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行,如果出现这些情况,编译无法通过。

也就是说,Java 语言的安全性是通过编译器来保证的。

但是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的。

当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。

通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证!

验证的过程

  1. 文件格式验证;

    这个阶段主要验证输入的二进制字节流是否符合 class 文件结构的规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。

    本验证阶段是基于二进制字节流的,而后面的三个验证阶段都是在方法区中进行,并基于类特定的数据结构的。

    通过上文可知,加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区。而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。

    也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建 Class 对象等操作。

    这个过程印证了:加载和验证是交叉进行的。

  2. 元数据验证;

    本阶段对方法区中的字节码描述信息进行语义分析,确保其符合 Java 语法规范。

  3. 字节码验证;

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

    本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。

  4. 符号引用验证;

    本阶段验证发生在解析阶段,确保解析能正常执行。

6.3.3 准备

准备阶段完成两件事情:

  1. 为已经在方法区中的类中的静态成员变量分配内存,类的静态成员变量也存储在方法区中;

  2. 为静态成员变量设置初始值,初始值为 0、false、null 等。

示例 1:

public static String name = " 柴毛毛 "; 

在准备阶段,JVM 会在方法区中为 name 分配内存空间,并赋上初始值 null。

给 name 赋上 “ 柴毛毛 “ 是在初始化阶段完成的。

示例 2:

public static final String name = " 柴毛毛 "; 

被 final 修饰的常量如果有初始值,那么在编译阶段就会将初始值存入 constantValue 属性中,在准备阶段就将 constantValue 的值赋给该字段。

6.3.3 解析

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

6.3.4 初始化

初始化阶段就是执行类构造器 clinit() 的过程。

clinit() 方法由编译器自动产生,收集类中 static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。

在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit() 方法对静态成员变量进行显示初始化。

初始化过程的注意点:

  1. clinit() 方法中静态成员变量的赋值顺序是根据 Java 代码中成员变量的出现的顺序决定的;

  2. 静态代码块能访问出现在静态代码块之前的静态成员变量,无法访问出现在静态代码块之后的成员变量;

  3. 静态代码块能给出现在静态代码块之后的静态成员变量赋值;

  4. 构造函数 init() 需要显示调用父类构造函数,而类的构造函数 clinit() 不需要调用父类的类构造函数,因为虚拟机会确保子类的 clinit() 方法执行前已经执行了父类的 clinit() 方法;

  5. 如果一个类 / 接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会生成 clinit() 方法;

  6. 接口也需要通过 clinit() 方法为接口中定义的静态成员变量显示初始化;

  7. 接口中不能使用静态代码块;

  8. 接口在执行 clinit() 方法前,虚拟机不会确保其父接口的 clinit() 方法被执行,只有当父接口中的静态成员变量被使用到时才会执行父接口的 clinit() 方法;

  9. 虚拟机会给 clinit() 方法加锁,因此当多条线程同时执行某一个类的 clinit() 方法时,只有一个方法会被执行,其它的方法都被阻塞。并且,只要有一个 clinit() 方法执行完,其它的 clinit() 方法就不会再被执行。因此,在同一个类加载器下,同一个类只会被初始化一次。

6.4 类加载器

6.4.1 类与类加载器

  • 类加载器的作用:将 Class 文件加载进 JVM 的方法区,并在方法区中创建一个 java.lang.Class 对象作为外界访问这个类的接口;

  • 类与类加载器的关系:比较两个类是否相等,只有当这两个类由同一个加载器加载才有意义;否则,即使同一个 class 文件被不同的类加载器加载,那这两个类必定不同,即通过类的 Class 对象的 equals 执行的结果必为 false。

6.4.2 类加载器种类

JVM 提供如下三种类加载器:

  • 启动类加载器:负责加载 Java_Home\lib 中的 class 文件;

  • 扩展类加载器:负责加载 Java_Home\lib\ext 目录下的 class 文件;

  • 应用程序类加载器:负责加载用户 classpath 下的 class 文件。

6.4.3 双亲委派模型

工作过程:如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。

作用:像 java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的 Object 类都是同一个。

原理:双亲委派模型的代码在 java.lang.ClassLoader 类中的 loadClass 函数中实现,其逻辑如下:

  • 首先检查类是否被加载;

  • 若未加载,则调用父类加载器的 loadClass 方法;

  • 若该方法抛出 ClassNotFoundException 异常,则表示父类加载器无法加载,则当前类加载器调用 findClass 加载类;

  • 若父类加载器可以加载,则直接返回 Class 对象。

七、Java 虚拟机的锁优化策略

7.1 自旋锁

背景:互斥同步对性能最大的影响是阻塞,挂起和恢复线程都需要转入内核态中完成;并且通常情况下,共享数据的锁定状态只持续很短的一段时间,为了这很短的一段时间进行上下文切换并不值得;

原理:当一条线程需要请求一把已经被占用的锁时,并不会进入阻塞状态,而是继续持有 CPU 执行权等待一段时间,该过程称为『自旋』;

优点:由于自旋等待锁的过程线程并不会引起上下文切换,因此比较高效;

缺点:自旋等待过程线程一直占用 CPU 执行权但不处理任何任务,因此若该过程过长,那就会造成 CPU 资源的浪费;

自适应自旋:自适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。

7.2 锁清除

编译器会清除一些使用了同步,但同步块中没有涉及共享数据的锁,从而减少多余的同步。

7.3 锁粗化

若有一系列操作,反复地对同一把锁进行上锁和解锁操作,编译器会扩大这部分代码的同步块的边界,从而只使用一次上锁和解锁操作。

7.4 轻量级锁

本质:使用 CAS 取代互斥同步。

背景:『轻量级锁』是相对于『重量级锁』而言的,而重量级锁就是传统的锁。

轻量级锁与重量级锁的比较:

  • 重量级锁是一种悲观锁,它认为总是有多条线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用互斥同步来保证线程的安全;

  • 而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是使用 CAS 操作来获得锁,这样能减少互斥同步所使用的『互斥量』带来的性能开销。

实现原理:

  • 对象头称为『Mark Word』,虚拟机为了节约对象的存储空间,对象处于不同的状态下,Mark Word 中存储的信息也所有不同;

  • Mark Word 中有个标志位用来表示当前对象所处的状态;

  • 当线程请求锁时,若该锁对象的 Mark Word 中标志位为 01(未锁定状态),则在该线程的栈帧中创建一块名为『锁记录』的空间,然后将锁对象的 Mark Word 拷贝至该空间;最后通过 CAS 操作将锁对象的 Mark Word 指向该锁记录;

  • 若 CAS 操作成功,则轻量级锁的上锁过程成功;

  • 若 CAS 操作失败,再判断当前线程是否已经持有了该轻量级锁;若已经持有,则直接进入同步块;若尚未持有,则表示该锁已经被其他线程占用,此时轻量级锁就要膨胀成重量级锁;

前提:轻量级锁比重量级锁性能更高的前提是,在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争。

若在该过程中一旦有其他线程竞争,那么就会膨胀成重量级锁,从而除了使用互斥量以外,还额外发生了 CAS 操作,因此更慢!

7.5 偏向锁

作用:偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。

与轻量级锁的区别:轻量级锁是在无竞争的情况下使用 CAS 操作来代替互斥量的使用,从而实现同步;而偏向锁是在无竞争的情况下完全取消同步。

与轻量级锁的相同点:它们都是乐观锁,都认为同步期间不会有其他线程竞争锁。

原理:当线程请求到锁对象后,将锁对象的状态标志位改为 01,即偏向模式。

然后使用 CAS 操作将线程的 ID 记录在锁对象的 Mark Word 中。以后该线程可以直接进入同步块,连 CAS 操作都不需要。

但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。

优点:偏向锁可以提高有同步但没有竞争的程序性能。但是如果锁对象时常被多条线程竞争,那偏向锁就是多余的。偏向锁可以通过虚拟机的参数来控制它是否开启。

文章来源:智云一二三科技

文章标题:通往高级 Java 开发的必经之路

文章地址:https://www.zhihuclub.com/194201.shtml

关于作者: 智云科技

热门文章

发表回复

您的电子邮箱地址不会被公开。

网站地图