备战秋招009(20230714)


前言

提示:这里为每天自己的学习内容心情总结;

Learn By Doing,Now or Never,Writing is organized thinking.

目前的想法是,根据 Java GuideJavaLearning小林coding进行第一轮复习,之后根据 TigerCS-Notes 进行最后的重点复习。

先多,后少。


提示:以下是本篇文章正文内容

一、Java内存区域

JVM的四个组成部分、运行时内存区域划分、对象内存布局。



1、JVM组成部分

类加载器、运行时数据区、执行引擎、本地库接口。



对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不需要进行手动去释放对象所占用的内存,因为Java程序员把控制内存的权利交给了Java虚拟机。

一旦出现内存泄漏和内存溢出的问题,如果不了解Java虚拟机是怎样使用内存的,那么排查错误、修正问题将会成为一项异常艰难的工作。

Java虚拟机主要有四部分组成:

  • 类加载器:加载字节码(class)文件到 JVM 中;
  • 运行时数据区(重点):JMV 的内存区域;
  • 执行引擎:将字节码翻译成机器指令交给操作系统执行;
  • 本地库接口:与其他的语言交互时候使用;
image-20230401134917936

工作原理: 类加载器classLoader)将字节码文件加载到内存中,然后将其放在运行时数据区的方法区内,由于字节码文件只是JVM的一套指令集规范,不能直接交给底层系统去执行,需要使用执行引擎将字节码指令翻译成底层系统指令,交给CPU去执行,在执行的过程中可能需要调用其它语言的本地库接口来实现整个程序的功能。



2、运行时数据区域

程序计数器、虚拟机栈、本地方法栈、堆、方法区,直接内存。



01、基础

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域

  • JDK 1.7 :
Java 运行时数据区域(JDK1.7)
  • JDK 1.8 :
Java 运行时数据区域(JDK1.8 )

JVM的运行时数据区域,主要五部分有:程序计数器虚拟机栈本地方法栈方法区组成,其中程序计数器、虚拟机栈和本地方法栈是线程私有,而堆和方法区是所有线程共享的;

程私有的:

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

线程共享的:

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

02、程序计数器

**程序计数器:**记住当前字节码文件执行中下一条 JMV 指令的执行地址。

  • 字节码解释器工作时,是通过改变程序计数器的值来选取下一条需要执行的字节码指令;
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置。
  • 程序计数器是唯一一个不会出现 OOM 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
  • 在计算机物理上实现程序计数器,是通过寄存器(cpu register)实现的。

03、虚拟机栈

**虚拟机栈:**指的是每个线程运行时所需要的内存空间。

  • 描述的是 Java的线程内存模型 ,每个栈由多个栈帧组成,每个线程都只能有一个活动栈帧,对应着这个线程正在执行的方法。
  • 每个方法执行的时候,Java虚拟机都会同步创建一个栈帧,对应着每次方法调用时所占用的内存;
  • 每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
Java 虚拟机栈

「栈帧」,对应的是方法调用和方法执行背后的数据结构,每个栈帧中都存储了:方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。

每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,无论方法正常完成还是异常完成都算作方法结束

  • 局部变量表local variables table),存放了方法参数和方法内部定义的局部变量。
    • 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
    • 如果方法内的局部变量,没有逃离方法的作用范围,就是线程安全的;
    • 如果方法内的局部变量,逃离了方法的作用范围,就有可能出现线程安全问题;
  • 操作数栈
    • 用于存放方法执行过程中产生的中间计算结果,计算过程中产生的临时变量也会放在操作数栈中。
  • 动态链接
    • 当一个方法需要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用,动态连接的作用就是将符号引用转换为直接引用;
    • 「符号引用」:是指一组符号来描述所引用的目标,只要使用时能够定位到目标即可,目标不一定是加载到 JVM 内存中的内容;
    • 「直接引用」:可以直接指向引用目标,引用目标必须已经在加载到 JVM 内存中;
局部变量表

栈可能发生两种错误(error):

  • StackOverFlowError :函数调用陷入无限循环,导致栈空间过深,当线程请求栈的深度超过当前 JVM 虚拟机栈的最大深度时,就会抛出该错误;
  • OutOfMemoryError :如果 JVM 在动态扩展栈时无法申请到足够的内存空间,就会抛出该错误。

04、本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

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

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。


05、堆

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

为什么说是几乎所有对象实例都存在于堆中呢?

这是因为 HotSpot 虚拟机引入了 JIT 编译器优化之后,会对对象进行逃逸分析,如果发现方法中的对象引用没有被返回或者被外部使用(未逃逸),那么对象可以直接在栈上分配内存


06、方法区

「方法区」属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域,在不同的虚拟机实现上,方法区的实现是不同的。

当虚拟机要使用一个类时,需要加载字节码(class)文件获取类相关信息,将类相关信息存储到方法区中。

方法区存储已经被 JVM 加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

在 HotSpot 虚拟机中,方法区的具体实现和 JDK 的版本息息相关:

  • JDK 1.6 : 通过永久代实现,永久代在堆中,运行时常量区在永久代中,字符串常量池在运行时常量池中;
  • JDK 1.7 : 通过永久代实现,永久代在堆中,运行时常量池在永久代中,字符串常量池在堆中;
  • JDK 1.8 : 通过元空间实现,元空间在本地内存中,运行时常量池在元空间中,字符串常量池在堆中;

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

因为永久代会受限于 JVM 本身设置的空间大小,有上限而无法调整,而元空间使用的是本地内存,虽然受本机可用内存的限制,但是溢出概率更小,可以加载的类更多了。

常量池表会在类加载后存放到方法区中的**「运行时常量池」**中,常量池表主要存放两个信息:

  • 编译生成的字面量:是指源代码中的固定值的表示法,即通过字面就能知道其值的含义,包括整数、浮点数和字符串字面量;
  • 符号引用:是指一组符号来描述所引用的目标,只要使用时能够定位到目标即可,目标不一定是加载到 JVM 内存中的内容。

当运行时常量池无法再申请到内存时,会抛出 OutOfMemoryError 错误。

**「字符串常量池」**是 JVM 为了提升性能和减少内存消耗,针对字符串(String)专门开辟的一块区域,目的是避免字符串的重复创建

Q:JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。


07、直接内存

**「直接内存」**是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

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

「直接内存」通过DirectByteBuffer 对象的 allocateDirect() 方法进行分配内存,基于通道(Channel) 与 缓存区(Buffer)的 I/O 方式,直接操作本地内存,无需将数据拷贝到 JVM 堆内存中,从而避免频繁的内存拷贝,提高程序的性能。

注意事项:

  • 首先,由于直接内存是在堆外分配的,因此它的分配和释放需要手动管理,需要使用 ByteBuffer 对象的 clean() 或者 free() 方法进行释放。
  • 其次,直接内存虽然不受 JVM 堆内存大小的限制,但由于操作系统对于进程能够分配的内存大小也有限制,因此过多地使用直接内存可能会导致内存不足或者性能下降的问题。因此,在使用直接内存时需要谨慎考虑,根据具体的场景进行选择。
image-20230401170442139

3、HotSpot虚拟机对象

对象的创建流程(五步)、内存分配方式和线程安全实现、对象访问方式。



01、对象的创建

Java 对象(仅限于普通 Java 对象,不包括数组和Class对象等)的创建过程;

  • 类加载检查:
    • 当虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  • 分配内存:
    • 在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需的内存大小在类加载完成后便可以确定,从 Java 堆中划分内存空间;
  • 初始化零值:
    • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头);
    • 这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  • 设置对象头:
    • 内存空间初始化零值后,虚拟机需要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息;
  • 执行init方法:
    • 上述操作完成后,从虚拟机的角度看,一个新的对象已经产生了。但从Java程序的角度看,对象创建才刚刚开始,还会接着执行<init>方法,把对象按照程序员的意愿进行初始化(赋值),这样一个真正可用的对象才算完全产生出来

img


02、内存分配

类加载完成后,对新建对象在 Java 堆中分配内存空间,分配方式有两种,区别是 Java 堆是否规整,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

  • 指针碰撞;
  • 空闲列表;

「指针碰撞」,是假设 Java 堆中的内存是一个绝对规整的空间,所有被使用过的内存被放在一边,空闲的内存被放在另外一边,在使用过的内存和空闲内存之间放着一个指针,作为已使用和未使用空间的分界点

在进行内存分配时,只需将指针朝着未使用的方向移动一段和对象大小相等的距离即可。

使用该分配方式的 GC 收集器:Serial, ParNew。

「空闲列表」,虚拟机维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并且更新列表上的记录。

使用该分配方式的 GC 收集器:CMS。

在实际开发过程中,创建对象是很频繁的事情,在进行内存分配时,需要保证线程安全,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试:
    • 每次不加锁去完成操作,如果有冲突导致失败了,就重试直到成功为止;
  • 线程本地分配缓冲:
    • TLAB,thread local allocation buffer,预先让每个线程在 Java 堆中分配一小块内存,当创建对象的时候首先在当前线程的本地缓冲区中进行分配,当本地缓冲区内存耗尽时,再使用CAS+失败重试;

03、内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:

  • 对象头
    • 对象头包括两部分信息,mark wordclass pointer
    • mark word : 用于存储对象自身的运行时数据,比如hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;
    • class pointer :这个对象实例指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例;
  • 实例数据
    • 是对象真正存储有效信息,也是代码中所定义的各种类型的字段内容;
  • 对齐填充
    • 是占位作用,确保对象是8字节的整数倍;

图片


04、访问定位

Java 程序通过操作栈上栈帧中的 reference 数据来操作堆上的具体对象reference 是指向对象的引用 ,目前主流的访问方式有:句柄直接指针

HotSpot 虚拟机主要使用的是直接指针来进行对象访问。

使用句柄是在 Java 堆中划分出一块内存作为句柄池reference 中存储的句柄池中的句柄地址,句柄包含了对象实例数据和对象类型数据的具体地址信息:

使用句柄的优点是,在发生对象移动时只会修改句柄池中句柄对实例数据的引用地址,reference中存储的地址不会修改;

对象的访问定位-使用句柄

使用直接指针是在堆中直接找到对象的内存地址,通过对象可以访问到对象类型数据的信息:

使用直接引用的优点是,访问速度快,节省了一次指针定位的时间开销;

对象的访问定位-直接指针



二、垃圾回收

对象引用算法、垃圾回收算法、垃圾收集器。



如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

常见面试题:

  • 如何判断对象是否死亡(两种方法)。
  • 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
  • 如何判断一个常量是废弃常量
  • 如何判断一个类是无用的类
  • 垃圾收集有哪些算法,各自的特点?
  • HotSpot 为什么要分为新生代和老年代?
  • 常见的垃圾回收器有哪些?
  • 介绍一下 CMS,G1 收集器。
  • Minor Gc 和 Full GC 有什么不同呢?


1、堆空间



01、空间结构

Java 的**「自动内存管理」**主要是针对 Java 堆中的对象内存的分配、回收, Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  • 新生代内存(Young Generation):eden
  • 老生代(Old Generation):tenured
  • 永久代(Permanent Generation):metaspace

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

堆内存结构

新生代:老年代 = 12 .

新生代中的,EdenFrom-Survivor 1To-Survivor 1 = 811 .

Java堆


02、GC 分类

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

  • 部分收集 (Partial GC):

    • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;

    • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;

    • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

  • 整堆收集 (Full GC):收集整个 Java 堆和方法区。


03、内存分配和回收原则

当对象在 Java 堆中进行内存分配时,遵循的规则如下:

  • 对象优先在 Eden 区分配:
    • 大多数情况下,对象在新生代中 Eden 区分配;
    • 当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC,也可能通过 分配担保机制 将新生代的对象提前转移到老年代中;
  • 大对象直接进入老年代:
    • 大对象就是需要大量连续内存空间的对象(比如:字符串、数组);
    • 大对象直接进入老年代,目的是避免在 eden 和两个 survivor 区域之间来回复制,产生大量的内存复制操作。
  • 长期存活的对象将进入老年代:
    • 对象头中有对象年龄计数器(age),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。
    • 大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1。

存活的对象年龄必须达到阈值才能晋升到老年代中吗?

不是,如果 survivor 区域空间中,小于或等于某个年龄的所有对象总和的内存大小超过了 survivor 内存空间的一半,那么年龄大于或等于这个年龄的对象可以直接进入老年代中。

空间分配担保是什么?为什么需要有空间分配担保机制呢?

在进行 Minor GC 之前,JVM 首先会检查老年代中的最大可用连续空间是否大于新生代中所有对象的内存空间之和

  • 如果这个条件成立,那么可以确保这次 Minor GC 是安全的;
  • 如果条件不成立,则会检查是否允许担保失败。
    • 如果允许担保,就检查老年代中连续可用的最大内存空间是否大于历次晋升到老年代中的对象平均大小,如果大于,可以尝试进行一次 Minor GC
    • 如果小于或者是不允许担保,就会执行 Full GC

什么时候会触发 YGC 和 FGC?对象什么时候会进入老年代?

img

2、对象死亡判断

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



01、引用计数法

在对象头中添加一个引用计数器:

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

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。

对象之间循环引用

所谓「对象之间的相互引用」问题,是除了对象 obj1obj2 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。


02、可达性分析算法

「可达性分析算法」是指,以 GC ROOTS 对象作为起点,根据引用关系向下搜索,搜索过程走过的路径称之为「引用链」。当一个对象和 GC ROOTS 之间没有引用链相连时,则证明此对象是可被回收的。

哪些对象可以作为 GC Roots 呢?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 本地方法栈(Native 方法)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 所有被同步锁持有的对象;

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中被判定为不可达的对象,也不一定会被回收。

一个对象真正被回收之前,至少需要经历两次标记过程:

  • 在可达性分析中被判定为不可达,这是第一次标记:
    • 对不可达对象进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法,当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被 JVM 调用过,就不会回收这个对象;
    • finalize() 方法是对象逃脱会后的最后一次机会。
  • 被判定需要进行回收的对象,会被放在一个队列中,进行第二次标记,除非这个对象与 GC RROTS 对象建立关联,否则就会被回收。

为什么不推荐使用 finalize() 方法呢?

任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,对象的 finalize() 方法就不会被再执行。并且,它的运行代价高昂、不确定性大,无法保证各个对象的调用顺序


03、引用关系

判定对象的存活都与**「引用」**有关,引用分为:强引用、软引用、弱引用、虚引用,四种(引用强度逐渐减弱、强软弱虚)。

Java 引用类型总结

  • 强引用(StrongReference):
    • 无论任何情况下,只要被强引用着的对象,垃圾收集器就永远不会回收掉
    • 当内存空间不足,Java 虚拟机会抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
  • 软引用(SoftReference):
    • 被软引用着的对象,只有当内存不够时,发生垃圾回收才会将其回收掉;
    • 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用的对象被垃圾回收,JVM 就会把这个软引用加入到与之关联的引用队列中。
  • 弱引用(WeakReference):
    • 被弱引用着的对象,无论当前内存是否充足,只要发生垃圾回收都会被回收掉;
    • 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用的对象被垃圾回收,JVM 就会把这个弱引用加入到与之关联的引用队列中。
  • 虚引用(PhantomReference):
    • 根据虚引用是无法得到一个对象实例的,唯一目的是,让这个对象在被垃圾回收掉时有一个系统通知
    • 如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

04、方法区

方法区中的垃圾回收成果很低,主要回收两部分内容:废弃的常量、废弃的类型。

  • 废弃常量:
    • 没有任何对象引用这个常量,就可被回收;
  • 废弃类型:
    • 该类所有实例,被回收;
    • 加载该类的类加载器,被回收;
    • 该类对象的 Class 对象没有在任何地方被引用、无法在任何地方通过反射访问该类;
    • 满足以上三个条件,才 允许 被回收,也不一定会被回收;


3、垃圾收集算法



01、标记-清除

**「标记-清除」**算法(Mark and Sweep),分为两个阶段:标记(mark)、清除(sweep)。

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收没有被标记的对象,标记就是判断对象是否属于垃圾的判定过程。

会有两个明显的问题:

  • 效率:
    • 如果堆中存在大量的对象,而且其中大部分是需要被回收的,这时必须要进行大量的标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低。
  • 内存空间碎片化:
    • 标记清除会产生大量不连续的内存碎片,空间碎片化太多可能会导致在程序运行过程时,如果需要分配较大内存的对象无法找到足够的连续的内存空间,而不得不去触发另外一次垃圾收集动作。
标记-清除算法
02、标记-复制

为了解决「标记-清除」算法的效率和内存碎片问题,提出了**「标记-复制」**算法。

**「标记-复制」**算法(Mark and Copy),是指将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把这次使用的空间一次清理掉,每次的内存回收都是对内存区间的一半进行回收。

仍然有两个问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。

复制算法


03、标记-整理

「标记-复制」算法在对象存活率较高时,就需要进行较多的复制操作,效率会很低,提出了「标记-整理」算法。

**「标记-整理」**算法(Mark and Compact),是根据老年代的存活特点。首先,标记完存活的对象后,不直接对可回收的对象进行清理,而是将所有存活的对象都向一端移动,然后直接清理掉其他地方的内存。

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。

标记-整理算法



4、分代收集



01、基础

「分代收集」将 Java 堆分为新生代、老年代,可以根据各个年代的特点选择合适的垃圾收集算法。

  • 新生代中,每次收集都会有大量对象死去,存活下来的对象较少,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成垃圾收集。
  • 老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,且需要移动的对象较少,因此在老年代使用标记-清除标记-整理算法进行垃圾收集比较合适。

02、问题

为什么需要分代收集算法?

分代的垃圾回收策略,是基于不同对象的生命周期是不一样的,对于不同生命周期的对象可以采取不同的收集方式,以提高回收效率。

分代收集下的新生代和老年代应该采用什么样的垃圾回收算法?

**「新生代」**的内存按照 8:1:1 的比例分为一个 Eden 区和两个 survivor(survivor0、 survivor1)区。

大部分对象在 Eden 区中生成,回收时先将 Eden 区存活对象复制到一个 survivor0 区,然后清空 Eden 区,当这个 survivor0 区也存放满的时候,则将 Eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 Eden 区 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。

**「老年代」**中存放的都是一些生命周期较长的对象,老年代的存储空间也比新生代也大很多(大概比例是2 : 1),当老年代内存满时触发 Major GC,Major GC 发生频率比较低,因为老年代对象存活时间比较长,存活率标记高。



5、知识铺垫



01、根节点枚举

所有的垃圾收集器在进行**「根节点枚举」时,都必须暂停用户线程( STW ),并且,根节点枚举是在一个能保障一致性的快照**中进行,这个快照保证在执行枚举根节点过程中,根节点集合的对象引用关系不会出现不断变化的情况。

JVM 是通过一组称为「OopMap」的数据结构,来快速完成根节点枚举操作。

OopMap (Object Pointer Map),用于记录方法中栈帧的对象引用和堆中的对象之间的映射关系,通常是由编译期完成的,在编译阶段就能确定一个方法中包含哪些对象引用。在进行垃圾回收时,垃圾回收器会使用 OopMap 来快速判断对象是否可以被回收,提高垃圾回收的效率和准确性。


02、安全点

OopMap 的协助下,可以快速完成根节点枚举,但是如果为每一条指令都生成对应的 OopMap ,需要大量额外的存储空间,所以引入了「安全点」。

只有在**「安全点」**才会生成 OopMap ,保证了在用户程序执行时,并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是只有当指令到达安全点后才能暂停用户程序。


03、安全区域

使用安全点的设计,似乎完美解决了停顿用户线程。但是,可能出现刚过安全点,CPU 时间片用完,导致无法继续响应 JVM 的请求,于是引入「安全区域」。

当进入到安全点后,用户线程停顿后,「安全区域」证停顿时间内引用关系不会发生变化。在这个安全区域中,任意地方开始垃圾收集都是安全的,可以将安全区域理解成拉伸了的安全点。


04、记忆集和卡表

为了解决对象的跨域(跨代)引用所带来的问题,设计了一个名为**「记忆集」**(Remembered Set)的数据结构,用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

「卡表」card table),就是记忆集中的一种具体实现。

在发生垃圾收集时,只要筛选出卡表变脏的元素,就能轻易得到哪些卡页内存块中包含跨代指针,把它们加入 GC ROOTS 中一并扫描。



6、垃圾收集器

垃圾收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。



垃圾收集器


01、Serial

Serial(串行)收集器,是一个单线程的垃圾收集器,只会使用一个线程(GC 线程)去完成垃圾回收的工作,并且在进行垃圾收集时必须暂停其它所有的工作线程(Stop the World!

  • 新生代:标记-复制 算法;
  • 老年代: 标记-整理 算法;
  • 优点:单线程简单高效;
  • 缺点:Stop The World! 带来不良的用户体验;
  • 适用:运行在 Client 模式下的虚拟机来说是个不错的选择。

Serial 收集器


02、ParNew

「ParNew」Serial多线程版本,除了在进行垃圾收集时候可以使用多线程并行进行垃圾回收,其余和 Serial 完全一样;

  • 新生代:标记-复制 算法;
  • 老年代: 标记-整理 算法;
  • 适用:是许多运行在 Server 模式下的虚拟机的首要选择;

ParNew 收集器


03、ParallelScavenge

「ParallelScavenge」也是一个多线程的收集器,注重吞吐量优先的垃圾收集器。

「吞吐量」ThroughPut)是指,处理器用于运行用户代码的时间与处理器总消耗时间的比值,高吞吐量可以最高效率的利用处理器资源,尽快的完成程序的运算任务,主要适用于在后台运算而不需要太多和用户交互的场景。

  • 新生代:标记-复制 算法;
  • 老年代: 标记-整理 算法;
  • 是 JDK1.8 默认收集器;

Parallel Old收集器运行示意图


04、SerialOld

**「Serial Old」**是 Serial 的老年代版本,也是一个单线程垃圾收集器。


05、Parallel Old

**「Parallel Old」**是 Parallel Scavenge 的老年代版本,支持多线程并行垃圾收集。

在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器


06、CMS

「CMS」(Concurrent Mark Sweep)收集器是基于标记-清除 算法 ,一种以获取最短回收停顿时间为目标的收集器,非常符合在注重用户体验的应用上使用。

是 HotSpot 虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。CMS 的工作流程分为四步:

  • **初始标记:**暂停其他的线程,只标记和 GC ROOTS 直接关联的对象,速度很快;
  • **并发标记:**用户线程接着运行,是整个回收过程中耗时最长的,不需要停顿;
  • **重新标记:**为了修正并发标记期间,需要STW,因为用户程序继续运作而导致标记发生变动的那一部分,需要停顿;
  • **并发清除:**清除死亡对象,不需要停顿;

CMS 收集器

初始标记和重新标记两个阶段需要 STW,耗时最长的并发标记和并发清除,都不需要停顿,可以和用户线程一起工作。

  • 吞吐量低,虽然不会导致用户线程的停顿,但因为占用了线程,就会导致程序速度变慢,降低总的吞吐量;
  • 无法处理浮动垃圾,在并发标记和并发清理过程中,由于已经结束完这次清除的标记流程,在清理垃圾过程中,用户线程是继续执行的,在用户执行的过程中还会产生大量的垃圾,但是这次垃圾回收无法清理,需要等到下一次垃圾回收再去清理;
  • 需要预留一部分空间以供并发操作时的程序运行,因为在垃圾收集阶段,用户线程还需要持续运行,就需要预留足够的内存空间以供用户线程使用。
  • 会产生大量的内存碎片,由于CMS是基于标记-清除算法的,就会产生大量的空间碎片;

07、G1

「G1」Garbage-First) 是,一款面向服务器的垃圾收集器,能满足回收停顿时间的要求同时还能具备高吞吐量。

G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。

G1 收集器的运作大致分为以下几个步骤:初始标记、并发标记、最终标记、筛选回收。

G1 收集器

「G1」面向局部收集的设计思路和基于 Region 的内存布局形式,G1 垃圾收集器不再区分新生代和老年代,是面向堆内存中的所有区域进行垃圾回收。

G1 将 Java 堆分为多个大小相等的独立区域(Region,每一个 Region 都可以根据需要,扮演新生代(EdenSurvivor)或者老年代空间。以 Region 作为每次垃圾回收的最小单元,跟踪每个 Region 区域里所能释放的空间以及回收时间,在后台维护一个优先级列表,优先处理回收价值最大的 Region 区域。

从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。





三、类加载



1、类加载过程

加载、连接(验证、准备、解析)、初始化。



01、基础

字节码文件加载到 JVM 中,需要三个步骤:

  • 加载;
  • 连接;
    • 验证;
    • 准备;
    • 解析
  • 初始化;

类加载过程


02、加载

「加载」是类加载过程的第一步,主要完成以下三件事:

  • 通过全类名获取定义此类的二进制字节流
    • 没有指明必须从哪里获取、如何获取;
  • 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表该类的 Class 对象,作为方法区这个类的各种数据的访问入口。

为什么 JVM 允许还没有进行验证、准备和解析的类信息放入方法区呢?

是因为加载阶段和连接阶段的部分动作(比如一部分字节码文件格式验证动作)是交叉进行的,也就是说加载阶段还没完成,链接阶段可能已经开始了。但这些夹杂在加载阶段的动作(验证文件格式等)仍然属于连接操作。


03、连接

「连接」(Linking)阶段,又可细分为三个:验证、准备、解析。

「验证」是指,确保当前字节码文件中的各种数据信息符合虚拟机的规范,不会危害虚拟机自身的安全。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

验证阶段大致会完成 4 个阶段的检验动作:

验证阶段示意图

**「准备」**是指,为类的静态变量(被 static 修饰的变量)分配内存,并将其初始化为默认值,这些内存都将在方法区中分配。

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中;
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如00Lnullfalse等),而不是被在 Java 代码中被显式地赋予的值;
  • 只有被final修饰的,并且只有是基本数据类型或者是字符串常量,才会被初始化为设定的值;
  • 如果 static 变量是被 final 修饰的,但是属于引用类型,赋值动作也会在初始化阶段完成。

「解析」是指,JVM 将常量池内的符号引用替换为直接引用的过程。


04、初始化

**「初始化」**是指,执行 <cinit> 方法的过程,也是真正开始执行类中定义的 Java 程序代码。

<cinit()>方法 是编译期将这个类的所有静态变量和静态代码块合并到一起并产生的,编译期收集的顺序是由程序代码在文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在静态代码块后的变量,静态代码块只能对其赋值,不能访问。

<cinit()>,也不是必须的,如果一个类中没有静态代码块和静态变量,编译期就可以不为这个类生成<clinit()>方法。

在多个线程同时去对一个类执行<clinit()>方法的时候,只有一个线程能够去执行,其余的线程都会被阻塞,确保一个类加载只会执行一次。



2、类加载器

启动类加载器(BootstrapClassLoader)、扩展类加载器(ExtentionClassLoader)、应用程序类加载器(ApplicationClassLoader)。



01、基础

「类加载器」是负责加载 Java 类到 JVM 中并执行,每一个 Java 类都有一个指针指向加载它的类加载器(ClassLoader)。

  • 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步;
  • 每个 Java 类都有一个引用指向加载它的 ClassLoader
  • 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。

「类加载器」的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)

其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。


02、加载规则

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

对于已经加载的类会被放在 ClassLoader 中。

在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载,对于一个类加载器来说,相同二进制名称的类只会被加载一次。


03、分类

在 JVM 中内置了三个重要的 ClassLoader

名称加载哪儿的类说明
BootstrapClassLoader%JAVA_HOME%/lib启动类加载器,最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库以及被 -Xbootclasspath参数指定的路径下的所有类。
ExtensionClassLoader%JRE_HOME%/lib/ext扩展类加载器,主要负责加载 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。
AppClassLoaderclasspath应用程序类加载器,面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。

类加载器层次关系图

除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader 抽象类,这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

每个 ClassLoader 可以通过 getParent() 方法获取其父类加载器,如果获取父类加载器为 null 的话,那么该类是通过 BootstrapClassLoader 加载的。

public abstract class ClassLoader {
  ...
  // 父加载器
  private final ClassLoader parent;
  @CallerSensitive
  public final ClassLoader getParent() {
     //...
  }
  ...
}

为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢?

这是因为 BootstrapClassLoader 是由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。

如果需要自定义实现自己的类加载器,只需要继承 ClassLoader 抽象类,重写里面的方法。



3、双亲委派模型



01、基础

「双亲委派模型」是指 JVM 中各种类加载器之间的层次关系

  • ClassLoader 类使用委托模型来搜索类和资源;
  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器;
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。

双亲委派模型,保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。


02、执行流程

双亲委派模型的执行流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过。
  • 已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类),这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 1、首先,检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            // 2、如果 c 为 null,则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 3、当父类的加载器不为空,则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    // 4、当父类的加载器为空,则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                //非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                //当父类加载器无法加载时,则调用findClass方法来加载该类
                //用户可通过覆写该方法,来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                //用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            //对类进行link操作
            resolveClass(c);
        }
        return c;
    }
}

JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。

只有两者都相同的情况,才认为两个类是相同的,即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。


03、打破双亲委派机制

如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。

如果想打破双亲委派模型则需要重写 loadClass() 方法。

为什么是重写 loadClass() 方法打破双亲委派模型呢?

因为类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 *loadClass()*方法来加载类)。

Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。






总结

提示:这里对文章进行总结:

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值