跳至主要內容

JVM 常见面试题


什么是字节码?采用字节码的好处是什么?

在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。

Java 程序从源代码到运行的过程如下图所示

我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(Just in Time Compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言

对象头了解吗?

Java 内存中的对象由以下三部分组成:对象头、实例数据和对齐填充字节。

而对象头由以下三部分组成:mark word、指向类信息的指针和数组长度(数组才有)。

mark word 包含:对象的哈希码、分代年龄和锁标志位。

对象的实例数据就是 Java 对象的属性和值。

对齐填充字节:因为JVM要求对象占的内存大小是 8bit 的倍数,因此后面有几个字节用于把对象的大小补齐至 8bit 的倍数。

内存对齐的主要作用是:

  1. 平台原因:不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  2. 性能原因:经过内存对齐后,CPU的内存访问速度大大提升。

讲一下JVM内存结构?

详细内容可以查看:JVM内存结构

JVM内存结构分为5大区域,程序计数器、虚拟机栈、本地方法栈、堆、方法区。

程序计数器

特点:

  • 线程私有
  • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
  • 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • 不存在内存溢出

程序计数器的作用?

线程私有的,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。

  1. 当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

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

程序计数器会出现OOM吗?

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

虚拟机栈

定义:每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。

作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

特点:

  • 每个线程运行需要的内存空间,称为虚拟机栈。是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡

  • Java 虚拟机栈是由一个个栈帧组成,对应着每次调用方法时所占用的内存。每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出。两种返回函数的方式,不管用哪种方式,都会导致栈帧被弹出

    • 正常的函数返回,使用 return 指令

    • 抛出异常

  • 每个线程只能有一个活动栈帧,栈顶存放当前当前正在执行的方法

虚拟机栈里有什么?

每个栈帧中都存储着:

  • 局部变量表(Local Variables)

  • 操作数栈(Operand Stack)(或称为表达式栈)

  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用

  • 方法返回地址(Return Address):方法正常退出或异常退出的地址

虚拟机栈会发生stackOverflowError吗?

stackOverflowError发生原因

  • 虚拟机栈中,栈帧过多(无限递归)

  • 每个栈帧所占用内存过大

虚拟机栈会发生OutOfMemoryError吗?

OutOfMemoryError发生原因:

  • 在单线程程序中,无法出现OOM异常;但是通过循环创建线程(线程体调用方法),可以产生OOM异常。此时OOM异常产生的原因与栈空间是否足够大无关。

  • 线程动态扩展,没有足够的内存供申请时会产生OOM

垃圾回收是否涉及栈内存?

不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。

栈内存的分配越大越好吗?

不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

方法内的局部变量是否是线程安全的?

如果方法内局部变量没有逃离方法的作用范围,则是线程安全的;如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题

本地方法栈

也是线程私有的

虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Native 方法一般是用其它语言(C、C++等)编写的。

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

为什么需要本地方法?

一些带有native关键字的方法就是需要JAVA去调用C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法

Native Method Stack:它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies

Native Interface本地接口:本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C程序, Java在诞生的时候是C/C横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了块区域处理标记为native的代码,它的具体做法是在Native Method Stack 中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。  目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等

在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一

通过new关键字创建的对象都会被放在堆内存

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题

  • 有垃圾回收机制

  • 堆中的区域:新生代( Eden 空间、 From Survivor 、 To Survivor 空间)和老年代。

说一下堆栈的区别?

  1. 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
  2. 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
  3. 堆是线程共享的;栈是线程私有的。

如何设置堆内存大小

Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定

  • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize

  • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。

为什么通常会将 -Xmx 和 -Xms 两个参数配置为相同的值?

目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。

如果 -Xms-Xmx 设置为不同的值,JVM 在运行时可能会根据内存使用情况不断调整堆的大小。这种动态调整需要进行内存分配和垃圾收集,可能会增加系统的开销和延迟。而将这两个参数设置为相同的值,JVM 在启动时就分配好固定量的堆内存,从而避免了内存重新分配的开销。

了解TLAB吗?

TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间(包含在 Eden 空间内),只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

为什么要有TLAB?

  • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据

  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的

  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

当然了,不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存

对象一定分配在堆中吗?

不一定的,JVM通过「逃逸分析」,那些逃不出方法的对象就会在栈上分配。

栈上分配的条件?

  • 作用域不会逃逸出方法的对象

  • 小对象(一般几十个byte);大对象无法在栈上分配

  • 标量替换:若逃逸分析证明一个对象不会逃逸出方法,不会被外部访问,并且这个对象是可以被分解的,那程序在真正执行的时候可能不创建这个对象,而是直接创建这个对象分解后的标量来代替。这样就无需在对对象分配空间了,只在栈上为分解出的变量分配内存即可。

什么是逃逸分析?

逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。

  • 一个对象在方法中被定义后,对象如果只在方法内部使用,则认为没有发生逃逸;(没有发生逃逸的对象,会在栈上分配)

  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生了逃逸。

如何快速的判断是否发生了逃逸分析?

看new的对象实体是否有可能在方法外被调用。注意是看new 出来的实体,而不是那个引用变量。

通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。

逃逸分析的好处

  • 栈上分配,可以降低垃圾收集器运行的频率。

  • 同步消除,如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作不需要同步。

  • 标量替换,把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有

    • 减少内存使用,因为不用生成对象头。

    • 程序内存回收效率高,并且GC频率也会减少。

逃逸分析一定好吗?

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

方法区

结构

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载

方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。

  • 永久代

方法区是 JVM 的规范,而永久代 PermGen 是方法区的一种实现方式,并且只有 HotSpot 有永久代。对于其他类型的虚拟机,如 JRockit 没有永久代。由于方法区主要存储类的相关信息,所以对于动态生成类的场景比较容易出现永久代的内存溢出。

永久区是常驻内存的,是用来存放JDK自身携带的Class对象和interface元数据。这样这些数据就不会占用空间。用于存储java运行时环境。

  1. 在JDK1.7前,字符串存放在方法区之中
  2. 在JDK1.7后字符串被放在了堆
  3. 在Java8,取消了方法区,改用了直接使用直接内存的的元空间。即元空间逻辑上属于堆,但在物理内存上,元空间的内存并不由堆空间内存分配
  • 元空间

JDK 1.8 的时候, HotSpot 的永久代被彻底移除了,使用元空间替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。两者最大的区别在于:元空间并不在虚拟机中,而是使用直接内存。

为什么要将永久代替换为元空间呢?

永久代内存受限于 JVM 可用内存,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是相比永久代内存溢出的概率更小。

运行时常量池

运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引号放到运行时常量池。在运行期间动态生成的常量,如 String 类的 intern()方法,也会被放入运行时常量池。

直接内存

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

NIO的Buffer提供了DirectBuffer ,可以直接访问系统物理内存,避免堆内内存到堆外内存的数据拷贝操作,提高效率。DirectBuffer 直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制,不受最大堆内存的限制。

直接内存的读写操作比堆内存快,可以提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到直接内存。

Java对象的定位方式?

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

  • 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
  • 直接指针。reference 中存储的直接就是对象的地址。对象包含到对象类型数据的指针,通过这个指针可以访问对象类型数据。使用直接指针访问方式最大的好处就是访问对象速度快,它节省了一次指针定位的时间开销,虚拟机hotspot主要是使用直接指针来访问对象。

类加载机制

详细内容可以查看:类加载机制

Java 创建对象的生命周期,JVM 的变化,说说你的理解

‌Java对象在JVM中的生命周期可以分为以下几个阶段:创建阶段、应用阶段、不可见阶段、不可达阶段、收集阶段和终结阶段。‌

  1. 创建阶段‌:当虚拟机遇到一条new指令时,首先检查指令的参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。然后,虚拟机为新生对象分配内存,并将分配到的空间初始化为默认值。接下来,虚拟机对对象进行必要的设置,如对象的类信息、哈希码、GC分代年龄等,并执行<init>方法进行实例初始化‌。
  2. 应用阶段‌:对象创建后,只要这个对象被引用变量引用,它就会一直驻留在内存中,执行其功能‌。
  3. 不可见阶段‌:当对象对其引用变量不再可见时,对象进入不可见阶段‌。
  4. 不可达阶段‌:如果一个对象没有任何引用指向它,那么这个对象就进入了不可达阶段。此时,对象虽然还存在,但是已经无法通过正常的途径访问‌。
  5. ‌收集阶段‌:垃圾回收器会定期检查内存中的对象,如果发现不可达的对象,就会进行回收,释放其占用的内存‌。
  6. 终结阶段‌:在收集阶段之后,如果对象还被引用但不再需要,就会进入终结阶段,此时对象会被彻底删除‌。

在创建对象的过程中,JVM会经历以下变化:

  • 内存分配‌:为对象分配内存空间‌。
  • 初始化‌:将分配的内存初始化为默认值‌。
  • 设置对象信息‌:包括对象的类信息、哈希码、GC分代年龄等‌。
  • 执行构造方法‌:执行对象的构造方法进行初始化‌

什么是类加载?类加载的过程?

虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。类加载过程包括了加载、验证、准备、解析、初始化五个阶段

加载

查找并加载类的二进制数据

在加载阶段,虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取其定义的二进制字节流。

  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  • 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

这个阶段既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载

验证

确保Class文件的字节流中包含的信息符合JVM规范,保证在运行后不会危害虚拟机自身的安全。即安全性检查,主要包括四种验证:

  • 文件格式验证: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。

  • 元数据验证:: 对字节码描述的信息进行语义分析(注意: 对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object之外。

  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

  • 符号引用验证:确保解析动作能正确执行

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

准备

准备阶段是正式为static 变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成。也就是说这里给类变量设置初始值,设置的是数据类型默认的零值(如0、0L、null、false等)

  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成

  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成

解析

将常量池内的符号引用替换为直接引用的过程。符号引用用于描述目标,直接引用直接指向目标的地址。

  • 未解析时,常量池中的看到的对象仅是符号,未真正的存在于内存中

  • 解析以后,会将常量池中的符号引用解析为直接引用

初始化

初始化阶段会执行cinit方法来为 类变量static变量 赋上定义的值并执行类中的静态代码块

在Java中对类变量进行初始值设定有两种方式:

  • 声明类变量是指定初始值

  • 使用静态代码块为类变量指定初始值

何时进行类加载?

  • 定义了main的类,启动main方法时该类会被加载

  • 创建类的实例,即new对象的时候

  • 访问类的静态方法

  • 访问类的静态变量

  • 反射 Class.forName()

JVM初始化步骤?

  • 假如这个类还没有被加载和连接,则程序先加载并连接该类

  • 假如该类的直接父类还没有被初始化,则先初始化其直接父类

  • 假如类中有初始化语句,则系统依次执行这些初始化语句

初始化发生的时机?

概括得说,类初始化是【懒惰的】,只有当对类的主动使用的时候才会导致类的初始化

  • main 方法所在的类,总会被首先初始化

  • 首次访问这个类的静态变量或静态方法时

  • 子类初始化,如果父类还没初始化,会引发父类初始化

  • 子类访问父类的静态变量,只会触发父类的初始化

  • Class.forName new 会导致初始化

不会导致类初始化的情况?

  • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化

  • 类对象.class 不会触发初始化

  • 创建该类的数组不会触发初始化

  • 类加载器的 loadClass 方法

  • Class.forName 的参数 2 为 false 时

cinit方法如果执行失败了怎么办,这个类还能用吗?

  • 在Java类加载的过程中,cinit 方法实际上指的是类的静态初始化方法,也就是类的静态代码块或者静态变量的初始化代码。如果类的静态初始化方法执行失败,通常会导致类的初始化失败,这意味着这个类不能被正常使用。会抛出异常,如 ExceptionInInitializerError

  • 在Java中,类的静态初始化方法只会执行一次,无论类被加载多少次,静态初始化方法只会在首次加载类的时候执行。因此,cinit 方法不会多次执行。一旦类的静态初始化方法执行过,后续对同一个类的加载都不会再次触发静态初始化方法的执行。这种机制确保了类的静态初始化只会在需要的时候执行一次,避免了不必要的开销和重复操作。

分配内存

在类加载后,接下来虚拟机将为新⽣对象分配内存。

分配在哪?

主要就是根据JVM的分配机制:对象优先分配Eden

  1. 先TLAB分配
  2. 再通过CAS在Eden区分配
  3. 大对象直接分配到老年代

TLAB:线程本地分配缓冲区,为每⼀个线程预先在 Eden 区分配⼀块⼉私有的缓存区域,JVM 在给线程中的对象分配内存时,⾸先在 TLAB 分配,当对象⼤于 TLAB 中的剩余内存或 TLAB 的内存已⽤尽时(或者未开启TLAB),再采⽤上述的 CAS 进⾏内存分配。默认情况TLAB仅占每个Eden区域的1%。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。

为什么要CAS分配内存?

多个并发执行的线程需要创建对象、申请分配内存的时候,有可能在 Java 堆的同一个位置申请,这时就需要对拟分配的内存区域进行加锁或者采用 CAS 等操作,保证这个区域只能分配给一个线程。

何时进行类卸载?

类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。

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

  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。

Java虚拟机将结束生命周期的几种情况?

  • 执行了System.exit()方法

  • 程序正常执行结束

  • 程序在执行过程中遇到了异常或错误而异常终止

  • 由于操作系统出现错误而导致Java虚拟机进程终止

什么是类加载器,类加载器有哪些?

实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。

主要有一下四种类加载器:

  • 启动类加载器:用来加载 Java 核心类库,无法被 Java 程序直接引用。
  • 扩展类加载器:它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  • 系统类加载器:它根据应用的类路径来加载 Java 类。可通过ClassLoader.getSystemClassLoader() 获取它。
  • 自定义类加载器:通过继承java.lang.ClassLoader 类的方式实现。

JVM类加载机制?

  • 全盘负责:当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入

  • 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效

  • 双亲委派机制:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

什么是双亲委派机制?

一个类加载器收到一个类的加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

双亲委派模型的具体实现代码在 java.lang.ClassLoader 中,此类的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出ClassNotFoundException ,此时尝试自己去加载。

双亲委派模型目的?

可以防止内存中出现多份同样的字节码。如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的 Object 类,那么类之间的比较结果及类的唯一性将无法保证。

什么时候需要打破双亲委派模型?

比如类A已经有一个classA,恰好类B也有一个clasA 但是两者内容不一致,如果不打破双亲委派模型,那么类A只会加载一次

只要在加载类的时候,不按照UserCLASSlOADER->Application ClassLoader->Extension ClassLoader->Bootstrap ClassLoader的顺序来加载就算打破打破双亲委派模型了。比如自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。

打破双亲委派模型的方式?

有两种方式:

  1. 自定义一个类加载器的类,并覆盖抽象类java.lang.ClassL oader中loadClass..)方法,不再优先委派“父”加载器进行类加载。(比如Tomcat)

  2. 主动违背类加载器的依赖传递原则

    • 例如在一个BootstrapClassLoader加载的类中,又通过APPClassLoader来加载所依赖的其它类,这就打破了“双亲委派模型”中的层次结构,逆转了类之间的可见性。

    • 典型的是Java SPI机制,它在类ServiceLoader中,会使用线程上下文类加载器来逆向加载classpath中的第三方厂商提供的Service Provider类。(比如JDBC)

什么是依赖传递原则?

如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。

Tomcat是如何打破双亲委派模型的?

在Tomcat部署项目时,是把war包放到tomcat的webapp下,这就意味着一个tomcat可以运行多个Web应用程序。

假设现在有两个Web应用程序,它们都有一个类,叫User,并且它们的类全限定名都一样,比如都是com.yyy.User,但是他们的具体实现是不一样的。那么Tomcat如何保证它们不会冲突呢?

Tomcat给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找,这样就做到了Web应用层级的隔离。

但是并不是Web应用程序的所有依赖都需要隔离的,比如要用到Redis的话,Redis就可以再Web应用程序之间贡献,没必要每个Web应用程序每个都独自加载一份。因此Tomcat就在WebAppClassLoader上加个父加载器ShareClassLoader,如果WebAppClassLoader没有加载到这个类,就委托给ShareClassLoader去加载。(意思就类似于将需要共享的类放到一个共享目录下)

Web应用程序有类,但是Tomcat本身也有自己的类,为了隔绝这两个类,就用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类

Tomcat与Web应用程序还有类需要共享,那就再用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器,来加载他们之间的共享类

Tomcat加载结构图如下:

JDBC 是如何打破双亲委派模型的?

实际上JDBC定义了接口,具体的实现类是由各个厂商进行实现的(比如MySQL)

类加载有个规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。

而在用JDBC的时候,是使用DriverManager获取Connection的,DriverManager是在java.sql包下的,显然是由BootStrap类加载器进行装载的。当使用DriverManager.getConnection ()时,需要得到的一定是对应厂商(如Mysql)实现的类。这里在去获取Connection的时候,是使用「线程上下文加载器」去加载Connection的,线程上下文加载器会直接指定对应的加载器去加载。也就是说,在BootStrap类加载器利用「线程上下文加载器」指定了对应的类的加载器去加载

什么线程上下文加载器?

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC 。

这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器来加载的;SPI的实现类是由系统类加载器来加载的。启动类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能委派给系统类加载器,因为它是系统类加载器的祖先类加载器。

线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到。

线程上下文加载器的一般使用模式(获取 - 使用 - 还原)

ClassLoader calssLoader = Thread.currentThread().getContextClassLoader();
 
try {
    //设置线程上下文类加载器为自定义的加载器
    Thread.currentThread.setContextClassLoader(targetTccl);
    myMethod(); //执行自定义的方法
} finally {
    //还原线程上下文类加载器
    Thread.currentThread().setContextClassLoader(classLoader);
}

能自定义类加载器加载 java.lang.String吗?

详细内容可以查看:类加载机制

GC

如何判断一个对象是否存活?

引用计数法

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

优点:可即刻回收垃圾,当对象计数为0时,会立刻回收;

弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。JVM不用这种算法

可达性分析算法

通过 GC Root 对象为起点,从这些节点向下搜索,搜索所走过的路径叫引用链,当一个对象到 GC Root没有任何的引用链相连时,说明这个对象是不可用的。

  • JVM中的垃圾回收器通过可达性分析来探索所有存活的对象

  • 扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收

GC Root的对象有哪些?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法栈用到的参数、局部变量或者临时变量等。

  • 方法区中类静态属性引用的对象或者说Java类中的引用类型的静态变量。

  • 方法区中常量引用的对象或者运行时常量池中的引用类型变量。

  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

  • JVM内部的内存数据结构的一些引用、同步的监控对象(被修饰同步锁)。

finalize()方法的作用?

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。(Java 9中已弃用)

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。

Java种有哪些引用类型?有什么特点?

  • 强引用:gc时不会回收

  • 软引用:只有在内存不够用时,gc才会回收

  • 弱引用:只要gc就会回收

  • 虚引用:是否回收都找不到引用的对象,仅用于管理直接内存

GC是什么?为什么要GC?

GC( Garbage Collection ),垃圾回收,是Java与C++的主要区别之一。作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码。这是因为在Java虚拟机中,存在自动内存管理和垃圾清理机制。对JVM中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,保证JVM中的内存空间,防止出现内存泄露和溢出问题。

Minor GC 和 Full GC的区别?

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC:回收老年代和新生代,老年代的对象存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

对象在堆中的生命周期?

  1. 在 JVM 内存模型的堆中,堆被划分为新生代和老年代

    • 新生代又被进一步划分为 Eden区Survivor区From SurvivorTo Survivor
  2. 当创建一个对象时,对象会被优先分配到新生代的 Eden 区

    • 此时 JVM 会给对象定义一个对象年轻计数器 -XX:MaxTenuringThreshold
  3. 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)

    • JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1

    • 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1

  4. 如果分配的对象超过了 -XX:PetenureSizeThreshold 直接被分配到老年代

内存的分配策略?

  • 对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC

  • 大对象直接进入老年代: 当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代,最典型的大对象有长字符串和大数组。可以设置JVM参数 -XX:PretenureSizeThreshold ,大于此值的对象直接在老年代分配。

  • 长期存活的对象进入老年代: 通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在 Survivor 区每经过一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。

  • 动态对象年龄判定: 并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。

  • 空间分配担保: 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次Minor GC是有风险的;(也就是说,会把原先新生代的对象挪到老年代中) ;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC 。

空间分配担保时的 “冒险”是冒了什么风险?

新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。但前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

Full GC 的触发条件?

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:•

  • 用 System.gc(): 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

  • 老年代空间不足: 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组、注意编码规范避免内存泄露。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

  • 空间分配担保失败: 当程序创建一个大对象时,Eden区域放不下大对象,使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。

  • JDK 1.7 及以前的永久代空间不足: 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError 。(JDK 8以后元空间不足

  • Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

垃圾回收算法有哪些?

垃圾回收算法有四种,分别是标记清除法、标记整理法、复制算法、分代收集算法。

  • 标记清除算法:首先利用可达性去遍历内存,把存活对象和垃圾对象进行标记。标记结束后统一将所有标记的对象回收掉。这种垃圾回收算法效率较低,并且会产生大量不连续的空间碎片。
  • 复制清除算法:半区复制,用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。
    特点:实现简单,运行高效,但可用内存缩小为了原来的一半,浪费空间。
  • 标记整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
  • 分类收集算法:根据各个年代的特点采用最适当的收集算法。

一般将堆分为新生代和老年代。新生代使用复制算法,老年代使用标记清除算法或者标记整理算法。

在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用标记-清理或者标记-整理算法进行垃圾回收。

有哪些垃圾回收器?

详细查看:垃圾收集器

G1收集器可以查看:G1收集器详解

G1 收集器的最大特点

  • G1 最大的特点是引入分区的思路,弱化了分代的概念。

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,不会产生空间碎片;从局部上来看是基于“标记-复制”算法实现的。

  • 可预测的停顿:G1垃圾回收器设定了用户可控的停顿时间目标,开发者可以通过设置参数来指定允许的最大垃圾回收停顿时间。G1会根据这个目标来动态调整回收策略,尽可能地减少长时间的垃圾回收停顿。

如何完成可预测的停顿?

G1根据历史数据来预测本次回收需要的堆分区数量,也就是选择回收哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去10次一共收集了10GB的内存,花费了1s。那么在200ms的时间下,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。

CMS 和 G1 的区别

  • CMS 中,堆被分为 PermGen,YoungGen,OldGen ;而 YoungGen 又分了两个 survivo 区域。在 G1 中,堆被平均分成几个区域 (region) ,在每个区域中,虽然也保留了新老代的概念,但是收集器是以整个区域为单位收集的。

  • G1 在回收内存后,会立即同时做合并空闲内存的工作;而 CMS ,则默认是在 STW(stop the world)的时候做。

  • G1 会在 Young GC 中使用;而 CMS 只能在 Old 区使用

G1如何解决漏标问题吗?为什么G1采用SATB而不用incremental update

**SATB算法:**是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到satb_mark_queue中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。

因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。

也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成。

G1一定不会产生内存碎片吗?

堆内存的动态变化、分配模式以及回收行为等因素影响下,仍然可能出现一些碎片问题。当某些Region中存在多个不连续的小块空闲内存,无法完全满足某些大对象的内存需求时,仍然可以称之为碎片问题。

  1. 分配模式不规律: 如果应用程序的内存分配模式不规律,频繁地分配和释放不同大小的对象,可能会导致一些小的空闲内存碎片在堆中产生。
  2. 大对象分配: G1回收器的区域被划分为不同大小的Region,当一个大对象无法在单个Region中分配时,G1可能会在多个Region中分配这个大对象,这可能导致跨多个Region的碎片。
  3. 并发情况下的内存变化: G1回收器会在后台进行并发的垃圾回收,如果在回收过程中发生了内存变化,如某个区域中的对象被回收,留下一些零散的空闲空间,也有可能会导致内存碎片。
  4. 频繁的Full GC: 尽管G1垃圾回收器的设计可以减少Full GC(全局垃圾回收)的频率,但如果频繁发生Full GC,可能会导致内存布局的重组,产生一些碎片。