2.4 JVM

本文主要源自 JavaGuide 地址:https://github.com/Snailclimb/JavaGuide 作者:SnailClimb
仅供个人复习使用

2.4.1 介绍下 Java 内存区域(运行时数据区域)

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。 JDK1.8 和 JDK1.8 之前的版本略有不同。主要区别是:JDK1.7 后,方法区移至 Metaspace,字符串常量池单独抽出来移至 Heap 中的一块区域。

JDK1.8 之前:
在这里插入图片描述
JDK1.8 :
在这里插入图片描述
线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存(非运行时数据区的一部分)

1. 程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码执行,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,即线程私有的,能够记录当前线程执行的位置。

tips:在汇编语言中,程序计数器保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

如果线程正在执行的是一个 Java 方法,计数器记录的就是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,计数器的值应为空(Undefined)。

tips:简单地讲,一个Native Method就是一个java调用非java代码的接口。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。


2. Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的线程内存模型, 每次方法调用的数据都是通过栈传递的。

Java 内存可以粗糙的分为堆内存(Heap)和栈内存(Stack),其中栈就是虚拟机栈,或者说是虚拟机栈中的局部变量表部分。(实际上,Java 虚拟机栈是由一个个栈帧组成,每个栈帧中有:局部变量表、操作数栈、动态链接、方法返回地址,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。)
在这里插入图片描述
局部变量表主要存放了基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,如一个指向对象起始地址的引用指针)。

Java 虚拟机栈或出现两种异常:StackOverFLowError 和 OutOfMemoryError。

  • StackOverFLowError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFLowError 异常。
  • OutOfMemoryError:若 Java 虚拟机栈的内存大小允许动态扩展,那么当栈扩展时无法申请到足够的内存就会抛出 OutOfMemoryError 异常。

3. 本地方法栈

本地方法栈和虚拟机栈很相似,区别是:虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中,虚拟机栈和本地方法栈合二为一。

本地方法被执行时,在本地方法栈创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、方法返回地址。

方法执行完毕后,相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFLowError 和 OutOfMemoryError 两种异常。


4. 堆

堆是 Java 虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在的垃圾收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代,再细致一点有:Eden 空间、 From Survivor 空间、To Survivor 空间等。进一步划分的目的是更好地回收内存。

在这里插入图片描述
上图所示的 eden 区、s0 区、s1 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先再 eden 区分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或 s1,并且对象的年龄还会加1(Eden 区→ Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。


5. 方法区

方法区和 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有个别名叫 Non-Heap (非堆),目的应该是与 Java 堆区分开来。

方法区也被成为永久代。

方法区和永久代的关系:

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同 JVM 上方法区的实现肯定是不同的了。方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

注意:对象类型数据(Class 文件信息)存放在方法区中,对象实例数据(new 出来的实例)存放在 Java 堆中。

常用参数:

JDK1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小:

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

JDK1.8 时,方法区(HotSpot 的永久代)被彻底移除了,取而代之是 MetaSpace (元空间),元空间使用的是直接内存。

下面是一些常用参数:
在这里插入图片描述
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

为什么要将永久代(PermGen)替换为元空间(MetaSpace)呢?

整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,仅受本机可用内存的限制,并且要永远不会出现 OutOfMemoryError 异常。你可以使用 -XX:MaxMetaSpaceSize 标识设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制,可以根据运行时的应用程序需求动态地调整大小。

(这只是其中一个原因,还有很多底层的原因。)


6. 运行时常量池

JDK1.7之前,运行时常量池是方法区的一部分。Class 文件的常量池表中存放了编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到运行时常量池中。

注意:方法区存的是类和方法的具体信息(如类的版本、字段、方法、接口等描述信息),而常量池存的只是符号引用(如 String 类的完全限定名 java.lang.String)。
在这里插入图片描述

运行时常量池一直在方法区,里面包含了每一个.class文件中的常量池中的内容。而字符串池在Java 7之前保存在方法区,在 Java 7 之后被抽了出来,保存在堆上。


7. 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用。而且也可能导致 OutOfMemoryError 异常出现。

JDK1.4 中新加入的 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能显著提高性能,因为能够避免在 Java 堆和 Native 堆之间来回复制数据。


2.4.2 Java对象的创建过程

下图是 Java 对象的创建过程,最好能默写出来,并且掌握每一步在干什么。
在这里插入图片描述

1. 类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数能否在常量池中定位到这个类的符号引用(类加载后,符号引用会存放到运行时常量池中),并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2. 分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有“指针碰撞”和“空闲列表”两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

补充:内存分配的两种方式(需要掌握):
在这里插入图片描述
补充:内存分配并发问题(需要掌握):

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情。如果两个线程先后把自己虚拟机栈中的 reference 指向了堆中的同一个对象,就会出现并发问题。 通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS + 失败重试:CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突就去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB:为每一个线程预先在 Eden 区分配一块内存,即线程本地分配缓存区(TLAB), JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

3. 初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

4. 设置对象头

初始化零值后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5. 执行init方法

在上面的工作都完成后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象的创建才刚开始(构造函数),即 Class文件中的 <init>()方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息也没用按照预定的意图构造好。所以一般来说,执行 new 指令后会接着执行 <init>() 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。


2.4.3 对象的访问定位有哪两种方式?

建立对象就是为了使用对象,Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有: 使用句柄 和 直接指针 两种。

1. 使用句柄

如果使用句柄,那么 Java 堆中将会划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和对象类型数据的地址信息。如果需要访问对象实例,则需要两次定位,先找到句柄,再找到对象实例。
在这里插入图片描述

2. 直接指针

如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问 对象类型数据 的相关信息,reference 中存储的直接就是对象的地址。如果只是访问对象本身的话,就不需要多一次间接访问的开销。
在这里插入图片描述
这两种对象访问方式各有优势。使用句柄的优势是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针的优势是速度快,节省了一次指针定位的时间开销。


2.4.4 说一下堆内存中对象的分配的基本策略

堆空间的基本结构:
在这里插入图片描述
上图所示的 eden 区、s0 区、s1 区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先再 eden 区分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或 s1,并且对象的年龄还会加1(Eden 区→ Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

另外,大对象和长期存活的对象会直接进入老年代。
在这里插入图片描述


2.4.5 Minor GC 、Major GC、Full GC 有什么不同呢?

  • 部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。Minor GC 非常频繁,回收速度一般也比较快。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。Major GC 的速度一般会比 Minor GC 慢10倍以上。
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

2.4.6 如何判断对象是否死亡?(两种方法)

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

1. 引用计数法

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

2. 可达性分析算法

这个算法的基本思想是通过一系列成为“GC Roots”的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链。当一个对象和 GC Roots 之间没有任何引用链相连的话,则说明此对象是不可用的。
在这里插入图片描述


2.4.7 简单介绍一下强引用,软引用,弱引用,虚引用

这四种引用的强度依次减弱。

1. 强引用(Strongly Reference)

指在程序代码中普遍存在的引用赋值,即类似 Object obj = new Object(); 这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 当内存空间不足时,Java 虚拟机宁愿抛出 OutOfMemoryError 异常,也不会回收强引用对象。

2. 软引用(Soft Reference)

软引用指 有用但非必须 的对象。如果内存空间足够,垃圾收集器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾收集器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

    // 强引用
    String strongReference = new String("abc");
    // 软引用
    String str = new String("abc");
    SoftReference<String> softReference = new SoftReference<String>(str);

3. 弱引用(Weak Reference)

弱引用指 有用但非必须 的对象,但它的强度比软引用更弱一些。当垃圾收集器开始工作,一旦发现弱引用对象,无论当前内存是否足够,都会回收它的内存。

4. 虚引用(Phantom Reference)

虚引用是最弱的引用关系。虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。为一个对象设置虚引用的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

特别注意,在程序设计中一般很少使用弱引用和虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。


2.4.8 如何判断一个常量是废弃常量?

运行时常量池主要回收的就是废弃的常量。那么,如何判断一个常量是废弃常量?

假如在常量池中存在字符串 “abc” ,如果当前没有任何 String 对象引用该字符串常量的话,就说明 “abc” 是废弃常量。如果这时发生内存回收,且内存不够的话,“abc” 就会被系统清理出常量池。


2.4.9 如何判断一个类是无用的类?

方法区主要回收的就是无用的类。那么,如何判断一个类是无用的类?

一个类需要同时满足下面3个条件才能算是无用的类:

  • 该类所有的实例都已经被回收,也就是在 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收。注意仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。


2.4.10 垃圾收集有哪些算法,各自的特点?

1. 标记-清除算法

算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。但这种垃圾收集算法会带来两个问题:

  • 效率问题:如果堆中包含大量对象,则需要大量标记和清除的动作,导致效率降低。
  • 空间问题:标记清除后会产生大量不连续的碎片。

在这里插入图片描述

2. 复制算法

为了解决效率问题,出现了复制算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

  • 优点:如果多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑空间碎片的复杂情况,只需要移动栈顶指针,按顺序分配即可。这样实现简单,运行高效。
  • 缺点:可用内存减半,造成空间浪费
    在这里插入图片描述

3. 标记-整理算法

标记过程仍然和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。

在这里插入图片描述

  • 优点:不会产生内存碎片
  • 缺点:移动对象会造成额外负担

4. 分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。

而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。


2.4.11 HotSpot为什么要分为新生代和老年代?

主要是为了提升 GC 的效率。上面提到的分代收集算法已经很好地解释了这个问题。


2.4.12 常见的垃圾收集器有哪些?

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

补充:吞吐量和停顿时间

如果吞吐量越大(即 CPU 效率高),停顿时间越短(即用户体验好),则一个算法越好。

但实际上,高吞吐量和短停顿时间是矛盾的。因为仅仅偶尔运行GC意味着每当GC运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。 单个GC需要花更多时间来完成, 从而导致更高的平均和最大暂停时间。 因此,考虑到低暂停时间,最好频繁地运行GC以便更快速地完成。 这反过来又增加了开销并导致吞吐量下降。算法只能在两者之间权衡。

  • 串行回收算法(Serial):会停止当前应用进程去回收垃圾,吞吐量大,停顿时间长
  • 并行回收算法(Parallel): 是多个线程同时执行串行回收算法(多核),吞吐量大,停顿时间长
  • 并发回收算法(Concurrent):应用和垃圾回收多个线程并发执行,吞吐量相对小,停顿时间短
  • G1 : 并发 + 并行回收 + 标记管理

在这里插入图片描述

  1. Serial 收集器(复制算法)新生代单线程收集器,优点是简单高效;

  2. ParNew收集器 (复制算法)新生代多线程收集器,实际上是 Serial 收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;

  3. Parallel Scavenge收集器(复制算法)新生代多线程收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;

  4. Serial Old收集器(标记-整理算法)老年代单线程收集器,Serial 收集器的老年代版本;

  5. Parallel Old收集器(标记-整理算法)老年代多线程收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

  6. CMS(Concurrent Mark Sweep)收集器(标记-清除算法)老年代多线程收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短 GC 回收停顿时间。

  7. G1(Garbage First)收集器 ( 标记整理 + 复制算法来回收垃圾 ): Java 堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。


2.4.13 类文件结构

根据 Java 虚拟机规范,类文件由单个 ClassFile 结构组成:

在这里插入图片描述

Class 文件字节码结构组织示意图:

在这里插入图片描述

下面按照上图结构按顺序详细介绍 Class 文件结构涉及到的一些组件:

  • 魔数 :确定这个文件是否一个能被虚拟机接收的 Class 文件
  • Class 文件版本 :Class 文件的版本号,保证编译正常执行
  • 常量池 :主要存放两大常量:字面量和符号引用
  • 访问标志 :标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否是 public 或 abstract 类型,如果是类的话是否声明为 final 等
  • 当前类索引,父类索引 :类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于 Java 语言的单继承特性,所有父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此,除了 java.lang.Object 外,所有 Java 类的父类索引都不为0
  • 接口索引集合 :描述这个类实现了哪些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是 extends )后的接口顺序从左到右排列在接口索引集合中
  • 字段表集合 :描述接口或类中声明的变量。字段包括类级变量和实例变量,但不包括在方法内部声明的局部变量
  • 方法表集合 :描述类中的方法
  • 属性表集合 :在 Class 文件、字段表、方法表中都可以携带自己的属性表集合,以描述某些场景专有的信息

2.4.14 类加载过程

一个 Java 文件从编码完成到最终执行,一般主要包括两个过程:编译和运行

编译 :即把我们写好的 Java 文件,通过 javac 命令编译成字节码,也就是我们常说的 .class 文件

运行 :即把编译生成的 .class 文件交给 Java 虚拟机(JVM)执行

而我们所说的类加载过程是指 JVM 虚拟机把 .class 文件中类信息加载进内存,并进行解析生成对应的 class 对象的过程。

举个例子, JVM 在执行某段代码时,遇到了 class A ,然而此时内存中并没有 class A 的相关信息,于是 JVM 就会到相应的 class 文件中去寻找 class A 的类信息,并加载进内存中,这就是我们所说的类加载过程。

由此可见,JVM 不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次


类加载过程:

类加载过程主要是加载 -> 连接 -> 初始化 。连接过程又可分为三步:验证 -> 准备 -> 解析

在这里插入图片描述

1. 加载

简单来说,加载指的是把 class 字节码文件从各个来源通过类加载器装载进内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,这样便可以通过该对象访问方法区中的这些数据。

这里有两个重点:

  • 字节码来源 :一般的加载来源包括从本地路径下编译生成的 .class 文件、从 jar 包中的 .class 文件等等
  • 类加载器 :JVM 中内置了3个重要的 ClassLoader :BootstrapClassLoader(启动类加载器)、ExtensionClassLoader(扩展类加载器)、AppClassLoader(应用程序类加载器),以及用户的自定义类加载器除了 BootstrapClassLoader 是由 c++ 实现,其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader。

为什么会有自定义类加载器?

  • 一方面是由于 Java 代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
  • 另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。

2. 验证

主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。

对于文件格式的验证,比如常量中是否有不被支持的常量?文件中是否有不规范的或者附加的其他信息。

对于元数据的验证,比如该类是否继承了被 final 修饰的类?类中的字段、方法是否与父类冲突?是否出现了不合理的重载?

对于字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。

对于符号引用的验证,比如校验符号引用中通过全限定名是否能找到对应的类?校验符号引用中的访问性(private、public等)是否可被当前类访问?

3. 准备

正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存,并且赋予初值,这些内存都将在方法区中进行分配(逻辑上位于方法区,但在 JDK1.8 后,类变量会随着 Class 对象一起放在堆中)。注意,不包括实例变量。

特别需要注意,初值,不是代码中具体写的初始化的值,而是 Java 虚拟机根据不同变量类型的默认初始值。

比如8种基本类型的初值,默认为0;引用类型的初值,默认为 null ;常量的初值,默认为代码中设置的值,如 final static tmp = 456; ,那么该阶段 tmp 的初值就是456.

4. 解析

将常量池内的符号引用替换为直接引用的过程。

  • 符号引用 :即一个字符串,但是这个字符串给出了一些能够唯一识别一个方法、一个变量、一个类的相关信息。如 java.lang.String。
  • 直接引用 :可以理解为一个内存地址,或者一个偏移量。比如类方法、类变量的直接引用是指向方法区的指针;而实例方法、实例变量的直接引用是从实例的头指针开始算起,到这个实例方法或变量位置的偏移量。

在解析阶段,JVM 会把所有的类名、方法名、字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。

5. 初始化

这个阶段主要是对类中定义的变量初始化,是执行类构造器的过程。

换句话说,只对 static 修饰的变量或语句进行初始化(前面只初始化了默认值的 static 变量将会在这个阶段自定义赋值)。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化父类。


2.4.15 介绍一下双亲委派模型

1. 双亲委派模型介绍

每一个类都有一个对应它的类加载器,系统中的 ClassLoader 在协同工作时会默认使用双亲委派模型。

其工作原理是:当类加载请求到来时,先从缓存中查找该类对象,如果存在直接返回,如果不存在则交给该类加载器的父加载器去加载,倘若没有父加载器(即父类加载器为 null )则直接交给顶级启动类加载器去加载,最后倘若仍没有找到,则使用findClass()方法去加载。即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己想办法去完成,

在这里插入图片描述

2. 双亲委派模型源码

loadClass(String) 方法是 ClassLoader 类自己实现的,该方法中的逻辑就是双亲委派模型的工作原理的实现。

protected Class<?> loadClass(String name, boolean resolve)
      throws ClassNotFoundException
  {
      synchronized (getClassLoadingLock(name)) {
          // 先从缓存查找该class对象,找到就不用重新加载
          Class<?> c = findLoadedClass(name);
          if (c == null) {
              long t0 = System.nanoTime();
              try {
                  if (parent != null) {
                      //如果找不到,则委托给父类加载器去加载
                      c = parent.loadClass(name, false);
                  } else {
                  //如果没有父类,则委托给启动加载器去加载
                      c = findBootstrapClassOrNull(name);
                  }
              } catch (ClassNotFoundException e) {
                  // 如果父类抛出异常,则说明父类加载器无法完成加载请求
              }

              if (c == null) {
                  // 如果都没有找到,则通过自定义实现的findClass去查找并加载
                  c = findClass(name);

                  // this is the defining class loader; record the stats
                  sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                  sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                  sun.misc.PerfCounter.getFindClasses().increment();
              }
          }
          if (resolve) {//是否需要在加载时进行解析
              resolveClass(c);
          }
          return c;
      }
  }

3. 双亲委派模型的优点

  • Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次
  • 考虑到安全因素, Java 核心 API 中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心 Java API 发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的 java.lang.Integer ,而直接返回已加载过的 Integer.class ,这样便可以防止核心API库被随意篡改。

4. 如果我们不想用双亲委派模型怎么办?

如果不想用,我们可以自定义一个类加载器,然后重载 loadClass() 即可。但 JDK1.2 之后不建议用户自己重载,而是直接调用 loadClass() 。

5. 如何自定义类加载器?

除了 BootstrapClassLoader 是由 c++ 实现,其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader 。因此,如果我们要自定义类加载器,需要继承 java.lang.ClassLoader。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值