秋春招总结之JVM系列全解

文章目录

JVM

Java内存区域

基础认知 :JVM 是可运行 Java 代码的假想计算机 ,并不是真实存在的。它包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收,堆 和 一个存储方法域。JVM 是运行在操作系统之上的,它与硬件并没有直接的交互。

主要的内存区域(见下图)

JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区域【JAVA 堆、方法区】、直接内存。

线程私有区域

对于生命周期的考量要理解清楚。

线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在 Hotspot VM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死对应)。

线程公有区域

线程共享区域随虚拟机的启动/关闭而创建/销毁。

程序计数器(线程私有)

是一块比较小的内存单元。``是当前线程所执行的字节码的行号指示器`,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。

虚拟机栈(线程私有)

是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。**栈帧( Frame)**是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁

本地方法栈(线程私有)

本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务。

堆(heap-线程共享)运行时数据区

是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。

由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区From Survivor 区To Survivor 区)和老年代。

方法区

即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息常量静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java

态变量即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器

JVM 运行时内存

因为我们在进行Java的GC的时候都是在堆上进行回收,同时从GC的角度细分来看,可以分为新生代(Eden 区, from Survivor 区,和To Survivor区)和老年代

新生代

是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区ServivorFrom、ServivorTo 三个区。

Eden 区

Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。

Servivor from

上一次GC的幸存者,作为这一次的GC的被扫描者。

Servivor To

保留了一次MinorGC过程中的幸存者。

MinorGC的过程

采用到复制算法。

  1. eden,servicorFrom 复制到ServicorTo,年龄+1

    首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果有对象的年龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果 ServicorTo 不够位置了就放到老年区);

  2. 清空eden,ServivorFrom

    然后,清空 Eden 和 ServicorFrom 中的对象;

  3. 进行Servicor 和 ServicotFrom 相互交换

​ 最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom区。

老年代

主要存放应用程序中生命周期长的内存对象。

老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。

当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。 MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。

MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。

永久代(元数据)

指的时内存永久保存的区域,主要存放Class 和Meta(元数据)的信息,Class在被加载的时候被放入到永久区域,和存放示例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。

在Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

垃圾回收与算法:

垃圾回收器的基本原理是什么

对于GC来说 ,当程序员完成了对这个对象的创建之后,GC就会开始监控这个对象的地址,大小,以及使用的情况。通常情况下,GC采用有向图的方式记录和管理堆中的所有对象,通过这种方式确定哪些对象是可达,哪些是不可达的,在确定完成之后,就会开始进行回收处理,可以手动使用System.gc() 来通知GC来执行,但是不确保一定会执行。

哪些需要进行确定是垃圾

在完成垃圾回收之前,我们需要判断哪些的对象是需要进行回收的–对于那些已经死去的对象我们需要进行回收。但是如何判断对象是都已经死去,有两种的方法。

引用计数算法(Reference Counting)

给对象添加一个引用计数器,当有一个地方引用它的时候,计数器加一。当引用失效的时候,计数器减一,任何时刻计数器为0的对象就是不可能再被引用的。此时就可以对其进行回收处理。此方法却不能解决对象循环引用的问题:
循环引用的栗子
开始有一个方法A和一个方法B,开始时候 有对象对A进行引用,也有一些栈方法什么的对B进行引用,且两则之间有相互引用的关系。后来其余的引用不再工作,这两个就互相引用,此时引用计数器的值也不是0,但是对于外部来说,这两个方法以及不具有任何的价值,但是就是不能够被回收掉,就是循环引用的问题。

根搜索算法。

在java中 使用根搜索算法判断是否存活。
基本思路: 就是通过一系列的称为“GC Roots”的点作为起始进行向下搜索,当一个对象到GC Roots 没有任何引用链相连,就证明此对象是不可用的。
但是问题来了什么是 GC Roots:

GC Roots

以下的引用我们可以称作其为:

  • 在VM (帧中的本地变量)中的引用
  • 方法区中的静态引用。
  • JNI(即一般说的Native方法)中的引用

在讲述完判断是否是垃圾的方法以后,下面我们开始要做的就是 对其进行回收。

垃圾回收算法(四种)

1. 标记清除

两个阶段: 标记- 清除
首先 标记所有需要回收的对象(就是在前面进行过判断的“已死的对象”),然后回收所有需要回收的对象。
缺点

  • 效率都不太高
  • 会产生大量的不连续内存碎片,空间碎片太多,会导致后续使用中由于无法找到足够的连续内存而提前触发另一次的垃圾搜集动作。
    GC的次数越多 碎片化情况就会越加严重。

从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

2. 复制算法

为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:

这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。

3. 标记整理算法

结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:

4. 分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

新生代与复制算法

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少(这也是在面试中常常问道的为什么新生代就要用复制),但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

老年代与标记整理算法

而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。

  1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。

  2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。

  3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。

  4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。

  5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。

  6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中

Java中的四种引用类型

强引用

在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

软引用

软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。

弱引用

弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。

虚引用

虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。

垃圾收集器

Serial 垃圾收集器(单线程,复制算法)

Serial 是最基本的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。

Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。

ParNew 垃圾收集器(Serial + 多线程)

ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样。ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过

-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。

【Parallel:平行的】ParNew虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器

Parallel Scavenge 收集器(多线程复制算法,高效)

Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与ParNew收集器的一个重要区别

Serial Old 收集器(单线程标记整理算法)

Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在 Client 默认的 java 虚拟机默认的年老代垃圾收集器。

在 Server 模式下,主要有两个用途:

  1. 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。

  2. 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

Parallel Old 收集器(多线程标记整理算法)

Parallel Old 收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在 JDK1.6才开始提供。

在 JDK1.6 之前,新生代使用 ParallelScavenge收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。

CMS 垃圾回收器

(Concurrent Mark Sweep)CMS垃圾回收器顾名思义是一种以获取最短回收停顿时间为目标的收集器。就是说 想要停顿的时间更少,也是因为很大一部分的Java应用集中在互联网或者B/S系统的服务器上,这样的服务器很需要响应速度,给用户较好的体验,其有四个步骤:

初始标记:

仅仅是标记与GC Roots能够直接关联到的对象,速度很快

并发标记

就是进行Gc RootsTracing的过程

重新标记

是为了修正在并发标记期间因用户程序的继续运作而导致标记参数变动的哪一步分对象的标记记录,也比并发标记的时间要短。

并发清除

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从整体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

缺点:

  1. 对CPU资源非常敏感,实际上面向并发设计的程序都对CPU资源比较敏感。在 并发执行的阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程,从而导致应用程序变慢,总吞吐量会降低。
  2. 无法处理浮动垃圾。因为CMS并发清理阶段用户的线程还在运行,就会导致有新的垃圾不断产生,CMS无法在当次处理掉,只好等待以一次的GC,这一部分的垃圾叫做浮动垃圾。
  3. 是基于标记-清楚,算法。所以就会产生大量的空间碎片,就会导致虽然老年代还有很大的空间,但是无法找到足够大的连续空间来分配当前对象,就会导致不得不 提前触发一次 FUll GC。

前面介绍到的几种垃圾回收器,或多或少的都是有延续之前的思想,复制,标记清除,整理,或是eden /to survivor/ from survivor 等,但是对于 G1 垃圾收集器来说 。对之前的禁锢都有了一定的推翻,使用了全新的模式,也是在未来的时间里,将要推到最前端的垃圾回收器。

G1 垃圾回收器

G1算法将堆划分为若干个区域( Region后面会具体讲到),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压宿),这样也就不会有CMS内存碎片问题的存在

运行模式:会在Young GC 和Mixed GC 之间不断进行切换的处理,同时定期地做全局并发标记,若是在进行垃圾回收地速度赶不上创建速度地时候下会使用到Full GC(Serial GC)

注意: 当在进行Mixed GC地时候赶不上对象产生地速度时候就退化为Full GC(这也是我们需要进行调优地地方)

出现的初衷: 在未来可以替换掉CMS收集器
在进行G1的讲解之前,首先来了解G1与其相关的概念:

基础概念:

吞吐量:在一个指定的时间内,最大化一个应用的工作量
例如一个使用下面的方式来衡量一个系统吞吐量的好坏:

  • 在一个小时内同一个事务(或者说是任务)完成的次数(tps)
  • 数据库一个小时可以完成多少次查询。
    对于关注吞吐量的系统,对于我们来说卡顿在日常是可以接受的,因为对于我们来说,关注一个系统关注的是长时间的大量任务的执行能力,单词的响应并不值得我们去追求和考虑、

响应能力: 一个程序或者是系统能否及时响应,多久完成响应。比如:在解决到一个请求时候需要多久能够完成这个请求。

简介:设计目标

是一个面向服务端的垃圾收集器,适用于多核处理器,大内存容量的服务端系统。能够满足短时间GC停顿的同时达到一个较高的吞吐量。

  • 与应用线程同时工作,几乎不需要stop the world (与 CMS类似)
  • 整理剩余空间时候,不会产生内存碎片(前面我们讲到了是对于 CMS来说 只能再 Full GC 时候,用 stop the world)但是对于 G1来说,一是没有内存碎片,但是也不用等到 Full gc。对于CMS的full gs 时候,无论是新生代,还是老年代,都是会进行全部的垃圾回收,此时就需要线程进行等待,这个也是会抢占cpu的地方。(在所有的垃圾收集器的设计中,都会避免出现 full GC 的情况。)
  • 停顿时间更加可控 说的是对于 g1来说,我们可以在启动的时候 设置一个停顿的时间,例如和cms进行比较的时候,cms在进行full gc 时停顿的时间是不能够控制的,有时候就算是停顿很长的时间,也是没有办法的,但是对于g1 来说 我们设置一个时间,就算是有很多的垃圾需要回收时候,g1也会先进性一个评估,评估大概需要多久,最后 回收的也只是在对应时间差不多的空间大小,等到下一次的再度回收,可以控制。与堆的设计相关,看来也是比较重要的一部分知识啊。
  • 不牺牲系统吞吐量: 指的是说 前面我们也有讲到了 cms的不好的地方,就还是说 在并发执行阶段,虽然说 不会使用户线程停止但是也是会占用一部分的线程,会使得应用程序变慢。
  • gc 不要求额外的内存空间 (CMS需要预留空间 存储 浮动垃圾)这里说明什么是浮动垃圾,就是说 对于cms来说 ,在执行的时候,用户的线程还是在执行的,开始任务不是垃圾,不会进行回收,但是后来变成了垃圾,需要回收时候,此时的cms没有能够认为是垃圾。也是比较好的地方对于 cms来说

G1与CMS的优势。

  • 在压缩空间有优势:
  • 内存分区region 不再固定。
  • 各个代不需要指定大小
  • 控制时间 控制垃圾收集时间,避免雪崩
  • 在回收内存以后,会立马进行合并内存的操作,但是cms要进行stop the word
  • G1 可以在yong 但是cms只能老年代。
  • 一个是复制,一个是 标记整理,不会有内碎片产生。
  • 同比较 parallel Scavenge和 parallel Old 比较时候 parallel 会对整个区域做整理,此时的时间停顿比较长
  • 前面讲到了会根据用户设定的停顿时间,会智能评估,回收哪几个的时候可以满足用户的设定

前言:本篇博文结合了《深入理解java虚拟机》(第二版),以及张龙的 “深入理解JVM虚拟机”(B站视频版)以及本人所看的各种其他书籍,及一些java面试题目之中介绍到的类加载机制部分,从底层全面讲起来,真正的能够理解这些过程,当然写出来也是对学习情况的一种输出的过程。

内存泄露

所谓的内存泄露就是说一个已经不会再被我们程序所使用的对象或变量但是却还是会一知占据内存空间。
常见的情况就是长生命周期的对象持有短生命周期对象的引用,就很有可能会发生内存泄露问题,尽管对于一个短生命周期的对象来说已经不会再被使用到,但是由于长生命周期的对象持有对其的引用,所以不能够进行回收。

虚拟机的类加载机制

首先既然讲到了虚拟机的类加载机制,我们当然就是想知道的第一点就是——什么是类加载?

什么是?

什么是虚拟机的类加载机制: 把描述类的数据从Class文件加载到内存,并对数据进行校检转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。 就是被称为 虚拟机的类加载机制。

怎么做到?

过程

分为五个大的步骤:加载 连接 初始化 使用 卸载
但是其中 连接又分为三个步骤: 验证 准备 解析

如何具体实现?

加载

加载是Java虚拟机中类加载中的第一个过程:
注意: 加载是类加载五个大步骤中的第一个小的步骤,不要弄混淆
关于加载 虚拟机会做以下的三件事情

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

注意: 但是关于第一点的获取二进制字节流就有多种的方法:

  1. 在本地系统中直接加载
  2. 过网络下载 .class 文件
  3. 通过 压缩 zip jar,进行加载

加载完成之后: 虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区之中的数据存储格式由虚拟机自行定义。

连接

连接分为三个小的步骤来分步执行,这里一一进行讲解

验证(连接阶段的第一步)

这一步所做的工作很少:目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机自身的安全
分为一下的四个步骤:

  • 类文件的结构检查
  • 语义检查 (例如一个类不能是abstract 和final)
  • 字节码验证
  • 二进制兼容性的验证

准备(连接阶段的第二步)

这个阶段相对之下还是比较重要的:

正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的的内存都将在方法区中进行分配
但是这个时候 要注意的是 这个时候进行内存分配仅仅包括类变量(被static修饰的变量,而不包括实例变量)赋初始值: 并不是我们认为赋予的初始值,是根据类型所指定的初始零值。这个时候分配的都是给定变量的零值。
下图是一些类型的变量所给定的零值。

举个栗子

public static int value=123;

在准备阶段 会为其赋予的是 0,而不是123 ,想要获取到123 我们认为给定的值,必须是在程序被编译以后才会有,存放在类构造器中才会执行。

解析(连接阶段的第三步)

解析阶段就是虚拟机将常量池内的符号引用替换成直接引用的过程
此时我们不禁会有疑问,什么是符号引用,什么又是直接引用呢。

  • 符号引用
    符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中
  • 直接引用
    直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

初始化

初始化算是类加载的最后一步,(为什么是类加载的最后一步:因为对于后面的使用就是我们的调用的各种过程,已经不需要再做过多的介绍内容
在前面的各个步骤中除了加载阶段用户可以自定义的使用自己编写的类加载器,其余的阶段都是自发进行性的。到了初始化阶段 才开始执行用户所定义的java代码。

前面讲到了在准备阶段中,系统会为变量赋予了最开始的零值,在初始化阶段就会根据程序员通过程序制定的主观计划去初始化类变量和其他资源。其实初始化阶段就是执行类构造器的阶段<clinit>()
<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作,和静态语句块(static{}})中的语句合并产生的。

什么时候初始化?

我们在上面已经完成了对基础信息的理解与掌握。下面开始学习什么时候一个类会初始化成功?这里就要提及到主动使用被动使用
并且所有的虚拟机的实现 必须是每个类或接口 被java程序 首次主动使用 时候才是会初始化。
主动使用 :

  • 创建类的实例 new
  • 访问某个类或接口的静态变量,或者对该静态变量赋值。
  • 调用类的静态方法
  • 反射
  • 初始化一个类的子类 在初始化一个类的子类 表示对父类的主动使用
  • 被标记为启动类 (包含main方法。)
被动使用

注意的是除了以上的情况下其他的方法,都被列为被动使用 都不会初始化 ,即使已经执行完了初始化之前的步骤但是也不会初始化。

初始化步骤
  • 假如说这个类还没有被加载和连接,那就先进行加载和连接。因为任何类的三个步骤 都是 加载 连接 初始化。
  • 假如说这个类还有一个父类 那就先对这个类的父类进行初始化 (对于接口的类型 不进行初始化处理)
  • 假如说类存在初始化语句,那就依次执行这个初始化语句。
  • 一个父接口 不会因为他的子接口 或者实现类的初始化而初始化 只用当程序首次使用特定接口的静态变量时,才会导致接口的初始化。
  • **如下图表示了类与接口的不同,在下面也会进行一一讲解。**

既然已经讲到了初始化步骤时候,这里就要讲到final关键字:

final

表示常量在编译的阶段 这个常量就会被存入到 调用这个常量的方法所在的类的常量池中 所以就相当于会被放入到一个类的常量池中
可能这句话听起来有点拗口,在下面的栗子中我们可以得知的是对于parent_3的 str1来说,其就会被存放到main方法的常量池中。
但是在本质上 调用类并没有直接引用到定义常量的类 就是 说 在一个类中 引用另外一个类中的 final 时候 并不会对其类中的静态代码块进行对应的初始化 (这个时候 两则之前就没有任何的关系 所以 就可以将class文件就算删除 也是可以的)
栗子

  public static void main(String[] args) {
// 1        System.out.println(Parent_3.str1);
//  2       System.out.println(Parent_3.str);
    }
}
class  Parent_3{

    public static final String  str1="dd";
    public static  final  String  str=UUID.randomUUID().toString();
    static {
        System.out.println("parent static code ");
    }

此时对于上面的输出:
在使用第一条打印语句的时候 只会打印出: dd。
在使用第二条打印语句的时候,会出现:
parent static code e39877fd-0bce-472c-a70d-320c9707f8bf
以上例子说明的是 在我们调用(因为被final修饰时候 被称为 常量的) str 与 str1时候 由于 str1是一个编译器就可以知道的常量,所以在调用时候 ,编译期就知道其值,就会把它放到调用类的常量池中,这个时候 就不会对类Parent_3进行初始化。此时静态代码块也就不会执行。但是在调用 str时候 由于 在编译期间无法知道其值,是一个运行期常量,所以要对被调用类进行初始化才能够知道其值,所以可以对静态代码块进行打印输出。

关于接口的基本特点

在前面讲到的:一个父接口 不会因为他的子接口 或者实现类的初始化而初始化 只用当程序首次使用特定接口的静态变量时,才会导致接口的初始化
在调用一个接口的时候,若是一个接口继承自一个父类的接口。此时,若是删除父类的接口,并不会产生问题,说明在接口类型中,调用子类的时候并不会对父类进行一个初始化。
这是为什么呢:
是因为:对于一个接口来说时候 其中的值都是 public static final 类型的常量。前面我们有对final类型进行一个讲解,就是说,会存放到调用类的常量池中。所以此时并不会执行初始化,也是原由。
此时若是把子类中的类型改成UUID类型的时候,删除class 文件就会出现问题.
这说明了,静态类型的时候 会在main函数中进行调用时候 加载到 常量池中。若是 UUID类型时候 就需要在运行期才能够 知道其值,运行期时候就需要有其原class文件 所以 在使用到UUID,并且删除掉class 时候 就会出现编译的异常。但是在只有真正的使用到父类的时候 (例如应用父类中的常量时候) 才会真正的初始化。


前面讲解了final关键字和接口的相关问题,下面举一个栗子来真实类在加载和初始化时候的特性。
栗子

public class Test4 {
    public static void main(String[] args) {
      Single single= Single.getSingle();
        System.out.println("A"+Single.a);
        System.out.println(Single.b);
    }
}
class  Single{
  public   static  int a=1;

    private static  Single single=new Single();
  private  Single(){
      a++;
      b++;
      System.out.println(a);
      System.out.println(b);
    }
    public   static  int b=0;
  public  static  Single getSingle(){
      return  single;
  }
}

最后打印的结果是
2 1 A2 0
解释:由于前面提到的 第一个步骤是:连接+ 加载 + 初始化
在加载里面还是会有 三个步骤其中有一个就是准备的过程 其目的
为类的静态变量赋初值,分配空间等
所以在开始时候 的准备中 会先进行一轮的初始化 int类型会变成0, string类型是 null 。所以第一轮的时候 a是系统初始化 0 new类型是null b也是 0 这个是准备阶段。在执行阶段时候 a赋值 1 调用过程中会调用到构造函数 此时 会对 a++ 和b++ 。执行到此时候 并没有 我们人为的对b 进行赋值 所以 此时的打印是 2,1 然后 执行到下面 时候 我们人为赋值重新赋初值时候 ,又重新变成了 0 所以最后的打印是 0。
这个过程就深刻演示了在准备阶段和初始化是什么样的过程。

初始化时机

类的卸载

栗子
例如说 一个类被加载 ,连接 初始化后,她的生命周期就开始了。当代码这个类的Class文件不再被引用,即不可以触及时候,Class对象就会结束生命周期,这个类在方法区内的数据也会被卸载,从而结束这个类的生命周期,就是说一个类什么时候结束生命周期,取决于它所代表的Class对象什么时候结束生命周期。
由java虚拟机自带的类加载器所加载的类在虚拟机的生命周期中,始终不会被卸载。在后面我们介绍的: 根类加载器,扩展类加载器,和系统类加载器。java虚拟机本身会始终引用这个类加载器,而这些类加载器则会始终引用他们所加载的类的class对象,因此这些class对象始终是可触及的。用户自己定义的类加载器是可以卸载的


在完成了上面的讲解以后,我们对类的记载的过程有了基础的认知,关于 类和接口的问题是重总之中,两者之前的不同也是在面试时候常常问到的问题。下面我们开始更加细致的讲解。

类加载器

什么是类加载器:前面讲到加载时候的第一件事:“通过一个类的全限定名来获取描述此类的二进制字节流”但是这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何取获取所需要的类。这个动作代码模块称为类加载器

类加载器:并不需要等到某一个类首次主动使用时候才会加载它
(此时就想到了一个例子: 是说一个子类和一个父类,在其中都有静态代码块 但是对于静态代码块而言 只用对类进行初始化使用到时候 才会被使用到 main函数中 使用子类调用父类的变量(这里因为 子类是继承自父类的 所以可以使用其中的变量)但是打印出来的是 父类的静态代码块 此时 对子类并没有初始化 但是不代表没有进行加载 关于类的加载而是 是会被加载的)所以有了以下的定义:
对于 jvm来说 运行类加载器在预料某个类将要被使用之前就会先对其进行一个预先加载,但是如果遇到了什么错误,此时也并不一定会报错,必须在程序首次使用该类的时候 才会报错误。
对于类加载器而言:
从虚拟机的角度来说有两种类加载器:

  1. 启动类加载器是虚拟机的自身一部分;
  2. 所有其他的类加载器(都有java语言进行编写)。
    除了根类加载器之外,其余的类加载器都有且只有一个父加载器。
    但是在开发角度来说 就会有以下的三种类加载器:
  • 启动类加载器:
  • 扩展类加载器
  • 应用程序类加载器
  • 自己定义的类加载器(属于用户自定义的加载器)
类加载器各个用途

类加载器的种类

类与类加载器之间的关系

前面讲到了类加载器,但是类加载器并不只是对类加载这个功能,还有更多的功能。对于每一个类,都需要由加载她的类加载器和这个类本身来一起确定其在java虚拟机中的唯一性。对于每一个类加载器 都拥有自己独立的命名空间。这个时候结合给的例子说,只有两个类是由同一个类加载的前提下才能说其是否相等,进行比较。否则尽管说这两个类来源于同一个class文件,但是也必定不相等。


在讲述完类加载器以后,我们可能还需要了解一下命名空间的作用:在自定义类记载器时候会出现的一个问题,也是在面试时候比较容易考到的地方。

命名空间

什么是?

  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成
  • 在同一个命名空间中,不会出现类的完整名字(包括类的包名) 相同的像个类。
  • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名) 相同的两个类
    理解
    前面已经介绍过了关于命名空间的问题,若是使用父类的加载器进行加载 会从类路径的class文件中加载,此时若是new了两个对象 但是对于使用了父类加载器的时候,前一次加载以后 后一次的就会直接调用 就属于在同一个命名空间之下。
    但是若是使用自己所定义的类加载器 由于new出来了两个对象,就会产生两个不同的命名空间,也会产生不同的类。 这但是若是新的对象指定了前一个对象作为其父类加载器时候,产生的就是相同的hashcode,因为父类加载器在前面加载过,在后面就不会重复加载,而是在其的基础上再进行一遍调用。

以上是对类加载器有了基础的认知,其实类加载器之中的知识要在具体的实战中才会得以显示,下面我们来介绍一下一个重要的机制,来具体也更加深刻认知类加载器。

双亲委派机制

什么是?

什么是双亲委派机制:如果一个类加载器收到了类加载的请求,她首先不会自己尝试去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层序的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只用当父加载器反馈自己无法完成这个加载请求(在搜索范围汇总没有找到所需要的类)时候,子加载器才会尝试着自己去加载。(这里的类加载器之间的父子关系一般不会以继承的关系,而是使用组合关系来复用父加载器的代码)
为什么要使用?
使用这个模式来组织类加载器之间的关系,有一个显而易见的好处就是java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如我们的java.lang.Object,存在方 rt.jar中无论是哪个类加载器要加载这个类,最后实现的结果是都会委托给模型最顶端的启动类加载器进行加载,因此Object类在程序的各个类记载器环境中都是同一个类,否则自行加载时候 最基础的体系结构就不能够得到保证。
好处

  • 可以确保java核心库的类型安全
    前面提到了会引用 java.lang.Object 时候,也是就说 在运行期若是使用自己所定义的类进行加载,就会存在多个版本的java.lang.Object,而且是不兼容,不可见的。
  • 提供了java核心类库不会被自定义的类所替代
  • 不同的类加载器可以为相同(binary name) 的类创建额外的命名空间,相同名称的类可以并存在java虚拟机中,只需要不同的类加载器来加载他们即可。不同类加载器所加载的类之间是不兼容的。这就相当于在java虚拟机内部创建了一个又一个相互隔离的Java类空间。
    如何实现双亲委派模型的呢
    实现在 java.lang.ClassLoader中的loaderclass()方法中:首先检查是否已经被加载,若没有加载则调用父加载器的loadClass() 方法,若父加载器为空,则默认使用启动类加载器作为父加载器。如果父类加载失败,,抛出 ClassNotFoundException异常后,再调用自己的findClass() 方法进行加载。
    优点(面试)
    能够提高软件系统的安全性。因为在此机制下,用户之定义的类加载器不可能加载应该由父加载器加载的可靠类。从而防止不可靠甚至恶意的diamante代替由父加载器加载的可靠代码。例如java.lang.Object 类总是由根类加载器加载,其他任何用户定义的类加载器都不可能加载含有恶意代码的java.lang.Object类。

前面我们介绍到了双亲委派机制的好处所在,但是其不是一个强制性的约束模型而是说java设计者推荐给我们使用的类加载的实现方式,在Java的世界中大部分的类加载器都遵循这个模型。但是有时候也会出现不适用的情况,这个时候推出了线程上下文加载器。

双亲委派机制(不适用情况)

在我们的双亲委托模型中,类加载器是由下至上的,即下层的类加载器会委托上层加载器进行加载,但是对于 SPI来说,有些接口却是Java核心库所提供的的,而Java核心库是由启动类记载器进行加载的,而这些接口的实现却是由不容的jar(包,厂商提供的),Java的启动了加载器是不会加载来源于其他的jar包中的信息,这样双亲委托模型就无法满足SPI的要求。

为了解决的是在有得时候 不适用的情况下:线程上下文加载器(ThreadContestClassLoader)这个类加载器可以通过java.lang.Thread类的setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程汇总继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

为什么会出现线程上下文加载器

是因为举个例子“对于jDBC我们都有学习和了解,JDBC是一个标准,对于很多的数据库厂商,例如MySQLDB2等都会根据这个标准来进行自己的实现,既然是一个标准,那么这些原生的标准和类库都是存在于JDK中的,很多的接口都是有厂商来实现”这个时候,这些接口都肯定是由我们根类加载器进行加载实现的。
我们要是想来直接使用厂商提供给我们的实现,就需要把厂商给的实现放置在类的应用的ClassPath下面,此时就不会由启动类来进行加载,这也是加载器的加载范围限制,这个时候 这些实现,就需要用到系统类加载器(关于这一点为什么会使用到系统类加载器可以去看类加载器的各个不同的使用场景),或是应用类加载器来进行加载,因为这些加载器才回去扫描当前的classPath
。这个时候 就会出现一个问题“例如有一个Connection 接口 这个接口由启动类进行加载,但是具体实现由系统或是应用加载。父加载加载的接口看不到子加载器实现的类或是接口(关于这一点是需要记忆的),这个时候 例如一个接口想要去调用自己的实现,但是由于加载自己(接口)的是父亲加载器,加载实现的是儿子加载器,所以根本就不可能读到相关的信息。 这个时候 对于就算是将实现放入到ClassPath下也不能够应用”所以因运而生了(不得以为止)产生了线程上下文加载器

但是对于线程上下文加载器:父ClassLoader可以使用当前线程Thread.currentthread().getContextClassLoader()所指定的ClassLoader加载的类

猜你喜欢

转载自blog.csdn.net/weixin_44015043/article/details/107980748