侧边栏壁纸
博主头像
憨憨大头个人博客博主等级

心存希冀,目有繁星

  • 累计撰写 110 篇文章
  • 累计创建 13 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

Java虚拟机(JVM)面试题大集合

Administrator
2024-09-03 / 0 评论 / 0 点赞 / 7 阅读 / 42736 字

说一下 JVM 的主要组成部分及其作用?

图片

JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。

  • Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。

  • Execution engine(执行引擎):执行classes中的指令。

  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。

  • Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。

作用 :首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

下面是Java程序运行机制详细说明

Java程序运行机制步骤

  • 首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java;

  • 再利用编译器(javac命令)将源代码编译成字节码文件,字节码文件的后缀名为.class;

  • 运行字节码的工作是由解释器(java命令)来完成的。

图片

从上图可以看,java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。
其实可以一句话来解释:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。

说一下 JVM 运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:

图片

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分:

  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成;

  • Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方法出口等信息;

  • 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;

  • Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存;

  • 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

深拷贝和浅拷贝

浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,

深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存,

使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的错误。

浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。

深复制:在计算机中开辟一块新的内存地址用于存放复制的对象。

说一下堆栈的区别?

物理地址

堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)

栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

内存分别

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。

栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。

存放的内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储

栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行。

PS:

  1. 静态变量放在方法区

  2. 静态的对象还是放在堆。

程序的可见度

堆对于整个应用程序都是共享、可见的。

栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

队列和栈是什么?有什么区别?

队列和栈都是被用来预存储数据的。

  • 操作的名称不同。队列的插入称为入队,队列的删除称为出队。栈的插入称为进栈,栈的删除称为出栈。

  • 可操作的方式不同。队列是在队尾入队,队头出队,即两边都可操作。而栈的进栈和出栈都是在栈顶进行的,无法对栈底直接进行操作。

  • 操作的方法不同。队列是先进先出(FIFO),即队列的修改是依先进先出的原则进行的。新来的成员总是加入队尾(不能从中间插入),每次离开的成员总是队列头上(不允许中途离队)。而栈为后进先出(LIFO),即每次删除(出栈)的总是当前栈中最新的元素,即最后插入(进栈)的元素,而最先插入的被放在栈的底部,要到最后才能删除。


HotSpot虚拟机对象探秘

对象是怎么创建的?

说到对象的创建,首先让我们看看 Java 中提供的几种对象创建方式:

使用new关键字解释
使用new关键字调用了构造函数
使用Class的newInstance方法调用了构造函数
使用Constructor类的newInstance方法调用了构造函数
使用clone方法没有调用构造函数
使用反序列化没有调用构造函数

下面是对象创建的主要流程:

图片

虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。划分内存时还需要考虑一个问题-并发,也有两种方式: CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。然后内存空间初始化操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行<init>方法。

为对象分配内存

类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:

  • 指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作。

  • 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。

选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

图片

处理并发安全问题

对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:

  • 对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性);

  • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。

图片

对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄直接指针 两种方式。

指针: 指向对象,代表一个对象在内存中的起始地址。

句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

句柄访问

Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据对象类型数据各自的具体地址信息,具体构造如下图所示:

图片

优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中实例数据指针,而引用本身不需要修改。

直接指针

如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

图片

优势:速度更,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。


内存溢出异常

Java会存在内存泄漏吗?请简单描述

内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。

但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。


垃圾收集器

简述Java垃圾回收机制

在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

GC是什么?为什么要GC

GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存

回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动

回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。

垃圾回收的优点和原理。并考虑2种回收机制

java语言最显著的特点就是引入了垃圾回收机制,它使java程序员在编写程序时不再考虑内存管理的问题。

由于有这个垃圾回收机制,java中的对象不再有“作用域”的概念,只有引用的对象才有“作用域”。

垃圾回收机制有效的防止了内存泄露,可以有效的使用可使用的内存。

垃圾回收器通常作为一个单独的低级别的线程运行,在不可预知的情况下对内存堆中已经死亡的或很长时间没有用过的对象进行清除和回收。

程序员不能实时的对某个对象或所有对象调用垃圾回收器进行垃圾回收。

垃圾回收有分代复制垃圾回收、标记垃圾回收、增量垃圾回收。

垃圾回收器的基本原理是什么?垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?

对于GC来说,当程序员创建对象时,GC就开始监控这个对象的地址、大小以及使用情况。

通常,GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当GC确定一些对象为"不可达"时,GC就有责任回收这些内存空间。

可以。程序员可以手动执行System.gc(),通知GC运行,但是Java语言规范并不保证GC一定会执行。

Java 中都有哪些引用类型?

  • 强引用:发生 gc 的时候不会被回收。

  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。

  • 弱引用:有用但不是必须的对象,在下一次GC时会被回收。

  • 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。

怎么判断对象是否可以被回收?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是「存活」的,是不可以被回收的;哪些对象已经「死掉」了,需要被回收。

一般有两种方法来判断:

  • 引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题;

  • 可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。

在Java中,对象什么时候可以被垃圾回收

当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。

JVM中的永久代中会发生垃圾回收吗

垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。请参考下Java8:从永久代到元数据区
(译者注:Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区)

说一下 JVM 有哪些垃圾回收算法?

  • 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。

  • 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。

  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。

  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

标记-清除算法

标记无用对象,然后进行清除回收。

标记-清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段:

  • 标记阶段:标记出可以回收的对象。

  • 清除阶段:回收被标记的对象所占用的空间。

标记-清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。

优点:实现简单,不需要对象进行移动。

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的频率。

标记-清除算法的执行的过程如下图所示

图片

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

复制算法的执行过程如下图所示

图片

标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法(Mark-Compact)算法,与标记-整理算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边。

优点:解决了标记-清理算法存在的内存碎片问题。

缺点:仍需要进行局部对象移动,一定程度上降低了效率。

标记-整理算法的执行过程如下图所示

图片

分代收集算法

当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代老年代永久代,如图所示:

图片

说一下 JVM 有哪些垃圾回收器?

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

图片

  • Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;

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

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

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

  • Parallel Old收集器 (标记-整理算法):老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

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

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

详细介绍一下 CMS 垃圾回收器?

CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上“-XX:+UseConcMarkSweepGC”来指定使用 CMS 垃圾回收器。

CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。

新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?

  • 新生代回收器:Serial、ParNew、Parallel Scavenge

  • 老年代回收器:Serial Old、Parallel Old、CMS

  • 整堆回收器:G1

新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。

简述分代垃圾回收器是怎么工作的?

分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。

新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:

  • 把 Eden + From Survivor 存活的对象放入 To Survivor 区;

  • 清空 Eden 和 From Survivor 分区;

  • From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。

每次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。

老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。


内存分配策略

简述java内存分配与回收策率以及Minor GC和Major GC

所谓自动内存管理,最终要解决的也就是内存分配和内存回收两个问题。前面我们介绍了内存回收,这里我们再来聊聊内存分配。

对象的内存分配通常是在 Java 堆上分配(随着虚拟机优化技术的诞生,某些场景下也会在栈上分配,后面会详细介绍),对象主要分配在新生代的 Eden 区,如果启动了本地线程缓冲,将按照线程优先在 TLAB 上分配。少数情况下也会直接在老年代上分配。总的来说分配规则不是百分百固定的,其细节取决于哪一种垃圾收集器组合以及虚拟机相关参数有关,但是虚拟机对于内存的分配还是会遵循以下几种「普世」规则:

对象优先在 Eden 区分配

多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。

这里我们提到 Minor GC,如果你仔细观察过 GC 日常,通常我们还能从日志中发现 Major GC/Full GC。

  • Minor GC 是指发生在新生代的 GC,因为 Java 对象大多都是朝生夕死,所有 Minor GC 非常频繁,一般回收速度也非常快;

  • Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。

大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的对象,频繁出现大对象是致命的,会导致在内存还有不少空间的情况下提前触发 GC 以获取足够的连续空间来安置新对象。

前面我们介绍过新生代使用的复制算法来处理垃圾回收的,如果大对象直接在新生代分配就会导致 Eden 区和两个 Survivor 区之间发生大量的内存复制。因此对于大对象都会直接在老年代进行分配。

长期存活对象将进入老年代

虚拟机采用分代收集的思想来管理内存,那么内存回收时就必须判断哪些对象应该放在新生代,哪些对象应该放在老年代。因此虚拟机给每个对象定义了一个对象年龄的计数器,如果对象在 Eden 区出生,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,这时设置对象年龄为 1。对象在 Survivor 区中每「熬过」一次 Minor GC 年龄就加 1,当年龄达到一定程度(默认 15) 就会被晋升到老年代。


虚拟机类加载机制

简述java类加载机制?

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的java类型。

描述一下JVM加载Class文件的原理机制

Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。

类装载方式,有两种 :

1.隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中,

2.显式装载, 通过class.forname()等方法,显式加载需要的类

Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。

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

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

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

  1. 启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用。

  2. 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。

  3. 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。

  4. 用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现。

说一下类装载的执行过程?

类装载分为以下 5 个步骤:

  • 加载:根据查找路径找到相应的 class 文件然后导入;

  • 验证:检查加载的 class 文件的正确性;

  • 准备:给类中的静态变量分配内存空间;

  • 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;

  • 初始化:对静态变量和静态代码块执行初始化工作。

什么是双亲委派模型?

在介绍双亲委派模型之前先说下类加载器。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。

图片

类加载器分类:

  • 启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;

  • 其他类加载器:

  • 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库;

  • 应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。

双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。

当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。


young gc、old gc、full gc、mixed gc 傻傻分不清?

这个问题的前置条件是你得知道 GC 分代,为什么分代。这个在之前文章提了,不清楚的可以去看看。

现在我们来回答一下这个问题。

其实 GC 分为两大类,分别是 Partial GC 和 Full GC。

Partial GC 即部分收集,分为 young gc、old gc、mixed gc。

  • young gc:指的是单单收集年轻代的 GC。

  • old gc:指的是单单收集老年代的 GC。

  • mixed gc:这个是 G1 收集器特有的,指的是收集整个年轻代和部分老年代的 GC。

Full GC 即整堆回收,指的是收取整个堆,包括年轻代、老年代,如果有永久代的话还包括永久代。

其实还有 Major GC 这个名词,在《深入理解Java虚拟机》中这个名词指代的是单单老年代的 GC,也就是和 old gc 等价的,不过也有很多资料认为其是和 full gc 等价的。

还有 Minor GC,其指的就是年轻代的 gc。

young gc 触发条件是什么?

大致上可以认为在年轻代的 eden 快要被占满的时候会触发 young gc。

为什么要说大致上呢?因为有一些收集器的回收实现是在 full gc 前会让先执行以下 young gc。

比如 Parallel Scavenge,不过有参数可以调整让其不进行 young gc。

可能还有别的实现也有这种操作,不过正常情况下就当做 eden 区快满了即可。

eden 快满的触发因素有两个,一个是为对象分配内存不够,一个是为 TLAB 分配内存不够。

full gc 触发条件有哪些?

这个触发条件稍微有点多,我们来看下。

  • 在要进行 young gc 的时候,根据之前统计数据发现年轻代平均晋升大小比现在老年代剩余空间要大,那就会触发 full gc。

  • 有永久代的话如果永久代满了也会触发 full gc。

  • 老年代空间不足,大对象直接在老年代申请分配,如果此时老年代空间不足则会触发 full gc。

  • 担保失败即 promotion failure,新生代的 to 区放不下从 eden 和 from 拷贝过来对象,或者新生代对象 gc 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 full gc。

  • 执行 System.gc()、jmap -dump 等命令会触发 full gc。

知道 TLAB 吗?来说说看

这个得从内存申请说起。

一般而言生成对象需要向堆中的新生代申请内存空间,而堆又是全局共享的,像新生代内存又是规整的,是通过一个指针来划分的。

图片

内存是紧凑的,新对象创建指针就右移对象大小 size 即可,这叫指针加法(bump [up] the pointer)。

可想而知如果多个线程都在分配对象,那么这个指针就会成为热点资源,需要互斥那分配的效率就低了。

于是搞了个 TLAB(Thread Local Allocation Buffer),为一个线程分配的内存申请区域。

这个区域只允许这一个线程申请分配对象,允许所有线程访问这块内存区域。

TLAB 的思想其实很简单,就是划一块区域给一个线程,这样每个线程只需要在自己的那亩地申请对象内存,不需要争抢热点指针。

当这块内存用完了之后再去申请即可。

这种思想其实很常见,比如分布式发号器,每次不会一个一个号的取,会取一批号,用完之后再去申请一批。

图片

可以看到每个线程有自己的一块内存分配区域,短一点的箭头代表 TLAB 内部的分配指针。

如果这块区域用完了再去申请即可。

不过每次申请的大小不固定,会根据该线程启动到现在的历史信息来调整,比如这个线程一直在分配内存那么 TLAB 就大一些,如果这个线程基本上不会申请分配内存那 TLAB 就小一些。

还有 TLAB 会浪费空间,我们来看下这个图。

图片

可以看到 TLAB 内部只剩一格大小,申请的对象需要两格,这时候需要再申请一块 TLAB ,之前的那一格就浪费了。

在 HotSpot 中会生成一个填充对象来填满这一块,因为堆需要线性遍历,遍历的流程是通过对象头得知对象的大小,然后跳过这个大小就能找到下一个对象,所以不能有空洞。

当然也可以通过空闲链表等外部记录方式来实现遍历。

还有 TLAB 只能分配小对象,大的对象还是需要在共享的 eden 区分配。

所以总的来说 TLAB 是为了避免对象分配时的竞争而设计的。

那 PLAB 知道吗?

可以看到和 TLAB 很像,PLAB 即 Promotion Local Allocation Buffers。

用在年轻代对象晋升到老年代时。

在多线程并行执行 YGC 时,可能有很多对象需要晋升到老年代,此时老年代的指针就“热”起来了,于是搞了个 PLAB。

先从老年代 freelist(空闲链表) 申请一块空间,然后在这一块空间中就可以通过指针加法(bump the pointer)来分配内存,这样对 freelist 竞争也少了,分配空间也快了。

图片

大致就是上图这么个思想,每个线程先申请一块作为 PLAB ,然后在这一块内存里面分配晋升的对象。

这和 TLAB 的思想相似。

产生 concurrent mode failure 真正的原因

《深入理解Java虚拟机》:由于CMS收集器无法处理“浮动垃圾”(FloatingGarbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。

这段话的意思是因为抛这个错而导致一次 Full GC。

而实际上是 Full GC 导致抛这个错,我们来看一下源码,版本是 openjdk-8。

首先搜一下这个错。

图片

再找找看 report_concurrent_mode_interruption 被谁调用。

查到是在 void CMSCollector::acquire_control_and_collect(...) 这个方法中被调用的。

图片

再来看看 first_state :CollectorState first_state = _collectorState;

图片

看枚举已经很清楚了,就是在 cms gc 还没结束的时候。

acquire_control_and_collect 这个方法是 cms 执行 foreground gc 的。

cms 分为 foreground gc 和 background gc。

foreground 其实就是 Full gc。

因此是 full gc 的时候 cms gc 还在进行中导致抛这个错。

究其原因是因为分配速率太快导致堆不够用,回收不过来因此产生 full gc。

也有可能是发起 cms gc 设置的堆的阈值太高。

CMS GC 发生 concurrent mode failure 时的 full GC 为什么是单线程的?

以下的回答来自 R 大。

因为没足够开发资源,偷懒了。就这么简单。没有任何技术上的问题。大公司都自己内部做了优化。

所以最初怎么会偷这个懒的呢?多灾多难的CMS GC经历了多次动荡。它最初是作为Sun Labs的Exact VM的低延迟GC而设计实现的。

但 Exact VM在与 HotSpot VM争抢 Sun 的正牌 JVM 的内部斗争中失利,CMS GC 后来就作为 Exact VM 的技术遗产被移植到了 HotSpot VM上。

就在这个移植还在进行中的时候,Sun 已经开始略显疲态;到 CMS GC 完全移植到 HotSpot VM 的时候,Sun 已经处于快要不行的阶段了。

开发资源减少,开发人员流失,当时的 HotSpot VM 开发组能够做的事情并不多,只能挑重要的来做。而这个时候 Sun Labs 的另一个 GC 实现,Garbage-First GC(G1 GC)已经面世。

相比可能在长时间运行后受碎片化影响的 CMS,G1 会增量式的整理/压缩堆里的数据,避免受碎片化影响,因而被认为更具潜力。

于是当时本来就不多的开发资源,一部分还投给了把G1 GC产品化的项目上——结果也是进展缓慢。

毕竟只有一两个人在做。所以当时就没能有足够开发资源去打磨 CMS GC 的各种配套设施的细节,配套的备份 full GC 的并行化也就耽搁了下来。

但肯定会有同学抱有疑问:HotSpot VM不是已经有并行GC了么?而且还有好几个?

让我们来看看:

  • ParNew:并行的young gen GC,不负责收集old gen。

  • Parallel GC(ParallelScavenge):并行的young gen GC,与ParNew相似但不兼容;同样不负责收集old gen。

  • ParallelOld GC(PSCompact):并行的full GC,但与ParNew / CMS不兼容。

所以…就是这么一回事。

HotSpot VM 确实是已经有并行 GC 了,但两个是只负责在 young GC 时收集 young gen 的,这俩之中还只有 ParNew 能跟 CMS 搭配使用;

而并行 full GC 虽然有一个 ParallelOld,但却与 CMS GC 不兼容所以无法作为它的备份 full GC使用。

为什么有些新老年代的收集器不能组合使用比如 ParNew 和 Parallel Old?

图片

这张图是 2008 年 HostSpot 一位 GC 组成员画的,那时候 G1 还没问世,在研发中,所以画了个问号在上面。

里面的回答是 :

"ParNew" is written in a style... "Parallel Old" is not written in the "ParNew" style

HotSpot VM 自身的分代收集器实现有一套框架,只有在框架内的实现才能互相搭配使用。

而有个开发他不想按照这个框架实现,自己写了个,测试的成绩还不错后来被 HotSpot VM 给吸收了,这就导致了不兼容。

我之前看到一个回答解释的很形象:就像动车组车头带不了绿皮车厢一样,电气,挂钩啥的都不匹配。

新生代的 GC 如何避免全堆扫描?

在常见的分代 GC 中就是利用记忆集来实现的,记录可能存在的老年代中有新生代的引用的对象地址,来避免全堆扫描。

图片

上图有个对象精度的,一个是卡精度的,卡精度的叫卡表。

把堆中分为很多块,每块 512 字节(卡页),用字节数组来中的一个元素来表示某一块,1表示脏块,里面存在跨代引用。

图片

在 Hotspot 中的实现是卡表,是通过写后屏障维护的,伪代码如下。

图片

cms 中需要记录老年代指向年轻代的引用,但是写屏障的实现并没有做任何条件的过滤。

即不判断当前对象是老年代对象且引用的是新生代对象才会标记对应的卡表为脏。

只要是引用赋值都会把对象的卡标记为脏,当然YGC扫描的时候只会扫老年代的卡表。

这样做是减少写屏障带来的消耗,毕竟引用的赋值非常的频繁。

那 cms 的记忆集和 G1 的记忆集有什么不一样?

cms 的记忆集的实现是卡表即 card table。

通常实现的记忆集是 points-out 的,我们知道记忆集是用来记录非收集区域指向收集区域的跨代引用,它的主语其实是非收集区域,所以是 points-out 的。

在 cms 中只有老年代指向年轻代的卡表,用于年轻代 gc。

而 G1 是基于 region 的,所以在 points-out 的卡表之上还加了个 points-into 的结构。

因为一个 region 需要知道有哪些别的 region 有指向自己的指针,然后还需要知道这些指针在哪些 card 中。

其实 G1 的记忆集就是个 hash table,key 就是别的 region 的起始地址,然后 value 是一个集合,里面存储这 card table 的 index。

我们来看下这个图就很清晰了。

图片

像每次引用字段的赋值都需要维护记忆集开销很大,所以 G1 的实现利用了 logging write barrier(下文会介绍)。

也是异步思想,会先将修改记录到队列中,当队列超过一定阈值由后台线程取出遍历来更新记忆集。

为什么 G1 不维护年轻代到老年代的记忆集?

G1 分了 young GC 和 mixed gc。

young gc 会选取所有年轻代的 region 进行收集。

midex gc 会选取所有年轻代的 region 和一些收集收益高的老年代 region 进行收集。

所以年轻代的 region 都在收集范围内,所以不需要额外记录年轻代到老年代的跨代引用。

cms 和 G1 为了维持并发的正确性分别用了什么手段?

之前文章分析到了并发执行漏标的两个充分必要条件是:

  1. 将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用。

  2. 删除了灰色对象到白色对象的引用。

cms 和 g1 分别通过增量更新和 SATB 来打破这两个充分必要条件,维持了 GC 线程与应用线程并发的正确性。

cms 用了增量更新(Incremental update),打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在 remark 阶段再扫描,防止漏标情况。

G1 用了 SATB(snapshot-at-the-beginning),打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系再扫描过。

这个从英文名词来看就已经很清晰了。讲白了就是在 GC 开始时候如果对象是存活的就认为其存活,等于拍了个快照。

而且 gc 过程中新分配的对象也都认为是活的。每个 region 会维持 TAMS (top at mark start)指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置。

Top 指针就是 region 中最新分配对象的位置,所以 nextTAMS 和 Top 之间区域的对象都是新分配的对象都认为其是存活的即可。

图片

而利用增量更新的 cms 在 remark 阶段需要重新所有线程栈和整个年轻代,因为等于之前的根有新增,所以需要重新扫描过,如果年轻代的对象很多的话会比较耗时。

要注意这阶段是 STW 的,很关键,所以 CMS 也提供了一个 CMSScavengeBeforeRemark 参数,来强制 remark 阶段之前来一次 YGC。

而 g1 通过 SATB 的话在最终标记阶段只需要扫描 SATB 记录的旧引用即可,从这方面来说会比 cms 快,但是也因为这样浮动垃圾会比 cms 多。

什么是 logging write barrier ?

写屏障其实耗的是应用程序的性能,是在引用赋值的时候执行的逻辑,这个操作非常的频繁,因此就搞了个 logging write barrier。

把写屏障要执行的一些逻辑搬运到后台线程执行,来减轻对应用程序的影响。

在写屏障里只需要记录一个 log 信息到一个队列中,然后别的后台线程会从队列中取出信息来完成后续的操作,其实就是异步思想。

像 SATB write barrier ,每个 Java 线程有一个独立的、定长的 SATBMarkQueue,在写屏障里只把旧引用压入该队列中。满了之后会加到全局 SATBMarkQueueSet。

图片

后台线程会扫描,如果超过一定阈值就会处理,开始 tracing。

在维护记忆集的写屏障也用了 logging write barrier 。

简单说下 G1 回收流程

G1 从大局上看分为两大阶段,分别是并发标记和对象拷贝。

并发标记是基于 STAB 的,可以分为四大阶段:

1、初始标记(initial marking),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象即可。在G1中标记对象是利用外部的bitmap来记录,而不是对象头。

2、并发阶段(concurrent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。STAB 也会在这个阶段记录着变更的引用。

3、最终标记(final marking), 这个阶段是 STW 的,处理 STAB 中的引用。

4、清理阶段(clenaup),这个阶段是 STW 的,根据标记的 bitmap 统计每个 region 存活对象的多少,如果有完全没存活的 region 则整体回收。

对象拷贝阶段(evacuation),这个阶段是 STW 的。

根据标记结果选择合适的 reigon 组成收集集合(collection set 即 CSet),然后将 CSet 存活对象拷贝到新 region 中。

G1 的瓶颈在于对象拷贝阶段,需要花较多的瓶颈来转移对象。

简单说下 cms 回收流程

其实从之前问题的 CollectorState 枚举可以得知几个流程了。

1、初始标记(initial mark),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象即可。

2、并发标记(Concurrent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。

3、并发预清理(Concurrent precleaning),这个阶段和应用线程并发,就是想帮重新标记阶段先做点工作,扫描一下卡表脏的区域和新晋升到老年代的对象等,因为重新标记是 STW 的,所以分担一点。

4、可中断的预清理阶段(AbortablePreclean),这个和上一个阶段基本上一致,就是为了分担重新标记标记的工作。

5、重新标记(remark),这个阶段是 STW 的,因为并发阶段引用关系会发生变化,所以要重新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记。

6、并发清理(Concurrent sweeping),这个阶段和应用线程并发,用于清理垃圾。

7、并发重置(Concurrent reset),这个阶段和应用线程并发,重置 cms 内部状态。

cms 的瓶颈就在于重新标记阶段,需要较长花费时间来进行重新扫描。

cms 写屏障又是维护卡表,又得维护增量更新?

卡表其实只有一份,又得用来支持 YGC 又得支持 CMS 并发时的增量更新肯定是不够的。

每次 YGC 都会扫描重置卡表,这样增量更新的记录就被清理了。

所以还搞了个 mod-union table,在并发标记时,如果发生 YGC 需要重置卡表的记录时,就会更新 mod-union table 对应的位置。

这样 cms 重新标记阶段就能结合当时的卡表和 mod-union table 来处理增量更新,防止漏标对象了。

GC 调优的两大目标是啥?

分别是最短暂停时间和吞吐量。

最短暂停时间:因为 GC 会 STW 暂停所有应用线程,这时候对于用户而言就等于卡顿了,因此对于时延敏感的应用来说减少 STW 的时间是关键。

吞吐量:对于一些对时延不敏感的应用比如一些后台计算应用来说,吞吐量是关注的重点,它们不关注每次 GC 停顿的时间,只关注总的停顿时间少,吞吐量高。

举个例子:

方案一:每次 GC 停顿 100 ms,每秒停顿 5 次。

方案二:每次 GC 停顿 200 ms,每秒停顿 2 次。

两个方案相对而言第一个时延低,第二个吞吐高,基本上两者不可兼得。

所以调优时候需要明确应用的目标。

GC 如何调优

这个问题在面试中很容易问到,抓住核心回答。

现在都是分代 GC,调优的思路就是尽量让对象在新生代就被回收,防止过多的对象晋升到老年代,减少大对象的分配。

需要平衡分代的大小、垃圾回收的次数和停顿时间。

需要对 GC 进行完整的监控,监控各年代占用大小、YGC 触发频率、Full GC 触发频率,对象分配速率等等。

然后根据实际情况进行调优。

比如进行了莫名其妙的 Full GC,有可能是某个第三方库调了 System.gc。

Full GC 频繁可能是 CMS GC 触发内存阈值过低,导致对象分配不过来。

还有对象年龄晋升的阈值、survivor 过小等等,具体情况还是得具体分析,反正核心是不变的。

最后

有关 GC 的问题在面试中还是很常见的,其实来来回回就那么几样东西,记得我提到的抓住核心即可。

当然如果你有实际调优经历那更可,所以要抓住工作中的机会,如果发生异常情况请积极参与,然后勤加思考,这可都是实打实的实战经历。

当然如果你想知道更多的 GC 细节那就看源码吧,源码之中无秘密。

JVM调优

说一下 JVM 调优的工具?

JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。

  • jconsole:用于对 JVM 中的内存、线程和类等进行监控;

  • jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。

常用的 JVM 调优的参数都有哪些?

  • -Xms2g:初始化推大小为 2g;

  • -Xmx2g:堆最大内存为 2g;

  • -XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;

  • -XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;

  • –XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;

  • -XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;

  • -XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;

  • -XX:+PrintGC:开启打印 gc 信息;

  • -XX:+PrintGCDetails:打印 gc 详细信息。

0

评论区