可关注微信公众号“假装正经的程序员”查看更多面试题,回复“多线程”可获取详细答案
本篇为多线程基础内容,部分问题相对比较“刁钻”,却又是中高级开发工程师必须掌握的知识点,因此本篇内容需要全部熟知
问题
- 听说过哪些虚拟机
- 什么是类加载器
- 类是如何被加载的
- 什么是双亲委派机制
- Java内存模型是什么
- 说说Java内存布局
- 元空间和方法区是什么关系
- 为什么要使用元空间替代永久代
- 如何判断对象是否存活
- GC Roots有哪些
- 常见的垃圾回收算法有哪些
- 常见的垃圾回收器有哪些
回答
- 听说过哪些虚拟机
HotSpot JVM:这是Oracle JDK中默认的JVM实现,也是最常用的JVM。它具有优秀的性能和即时编译(JIT)功能。
OpenJ9:由IBM开发的JVM实现,专注于内存效率和快速启动。它在某些方面优于HotSpot,尤其适用于云环境。
GraalVM:由Oracle开发,支持多种语言(包括Java、JavaScript、Python等)。它具有即时编译、AOT编译和嵌入式模式。
Zing JVM:由Azul Systems开发,专注于大规模、高吞吐量的应用程序。它具有低延迟和高度可预测的性能。
JRockit:曾由BEA Systems开发,后被Oracle收购。它专注于服务器端应用程序,但已不再活跃维护。
- 什么是类加载器
类加载器(Class Loader)是 Java 虚拟机(JVM)的重要组成部分,负责将字节码文件加载到内存中并转换为可执行的类。
类加载总共分为以下四种:
1.启动类加载器(Bootstrap Class Loader):它是 JVM 的内部组件,负责加载 Java 核心类库(如java.lang)和其他被系统类加载器所需要的类。启动类加载器是由 JVM 实现提供的,通常使用本地代码来实现。
2.扩展类加载器(Extension Class Loader):它是 sun.misc.Launcher$ExtClassLoader 类的实例,负责加载 Java 的扩展类库(如 java.util、java.net)等。扩展类加载器通常从 java.ext.dirs 系统属性所指定的目录或 JDK 的扩展目录中加载类。
3.系统类加载器(System Class Loader):也称为应用类加载器(Application Class Loader),它是sun.misc.Launcher$AppClassLoader 类的实例,负责加载应用程序的类。系统类加载器通常从 CLASSPATH 环境变量所指定的目录或 JVM 的类路径中加载类。
4.用户自定义类加载器(User-defined Class Loader):这是开发人员根据需要自己实现的类加载器。用户自定义类加载器可以根据特定的加载策略和需求来加载类,例如从特定的网络位置、数据库或其他非传统来源加载类。
- 类是如何被加载的
1.加载:查找并加载类的二进制数据。
2.连接:将 Java 类的二进制数据合并到 JVM 运行状态之中。
a.验证:验证加载的类是否符合 Java 虚拟机规范。
b.准备:为类的静态变量分配内存,并设置默认初始值。
c.解析:将类中的符号引用转换为直接引用。
3.初始化:执行类的初始化代码,包括静态变量赋值和静态代码块的执行
- 什么是双亲委派机制
双亲委派模型是 Java 类加载器的一种工作机制。
它是指当一个类加载器需要加载一个类时,它首先不会自己去尝试加载这个类,
而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,
因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父
加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)
时,子加载器才会尝试自己去完成加载。
5. Java内存模型是什么
Java Memory Model,简称 JMM,是用来定义 Java 线程和内存之间的操作规范的,目的是解决多线程正确执行的问题。
Java 内存模型规范的定义确保了多线程程序的可见性、有序性和原子性,从而保证了线程之间正确的交互和数据一致性。
Java 内存模型主要包括以下内容:
1.主内存(Main Memory):所有线程共享的内存区域,包含了对象的字段、方法和运行时常量池等数据。
2.工作内存(Working Memory):每个线程拥有自己的工作内存,用于存储主内存中的数据的副本。线程只能直接操作工作内存中的数据。
3.内存间交互操作:线程通过读取和写入操作与主内存进行交互。读操作将数据从主内存复制到工作内存,写操作将修改后的数据刷新到主内存。
4.原子性(Atomicity):JMM 保证基本数据类型(如 int、long)的读写操作具有原子性,即不会被其他线程干扰,保证操作的完整性。
5.可见性(Visibility):JMM 确保一个线程对共享变量的修改对其他线程可见。这意味着一个线程在工作内存中修改了数据后,必须将最新的数据刷新到主内存,以便其他线程可以读取到更新后的数据。
6.有序性(Ordering):JMM 保证程序的执行顺序按照一定的规则进行,不会出现随机的重排序现象。这包括了编译器重排序、处理器重排序和内存重排序等。
Java 内存模型通过以上规则和语义,提供了一种统一的内存访问方式,使得多线程程序的行为可预测、可理解,并帮助开发者编写正确和高效的多线程代码。
开发者可以利用 JMM 提供的同步机制(如关键字 volatile、synchronized、Lock 等)来实现线程之间的同步和通信,以确保线程安全和数据一致性。
6.说说Java内存布局
通常所说的 JVM 内存布局,一般指的是 JVM 运行时数据区(Runtime Data Area),也就是当字节码被类加载器加载之后的执行区域划分。
《Java虚拟机规范》中将 JVM 运行时数据区域划分为以下 5 部分:
1.程序计数器(Program Counter Register):用于记录当前线程执行的字节码指令地址,是线程私有的,线程切换不会影响程序计数器的值。
2.Java 虚拟机栈(Java Virtual Machine Stacks):用于存储方法执行时的局部变量表、操作数栈、动态链接、方法出口等信息,也是线程私有的。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈等信息。
3.本地方法栈(Native Method Stack):与 Java 虚拟机栈类似,用于存储本地方法的信息。
4.Java 堆(Java Heap):用于存储对象实例和数组,是 JVM 中最大的一块内存区域,它是所有线程共享的。堆通常被划分为年轻代和老年代,以支持垃圾回收机制。
- 年轻代(Young Generation):用于存放新创建的对象。年轻代又分为 Eden 区和两个 Survivor 区(通常是一个 From 区和一个 To 区),对象首先被分配在 Eden 区,经过垃圾回收后存活的对象会被移到 Survivor 区,经过多次回收后仍然存活的对象会晋升到老年代。
- 老年代(Old Generation):用于存放存活时间较长的对象。老年代主要存放长时间存活的对象或从年轻代晋升过来的对象。
5.方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区也是所有线程共享的。
7.元空间和方法区是什么关系
方法区是《Java虚拟机规范》中定义的内存区域,用于存储类的结构信息(如类的字节码、常量池、字段和方法信息等),
而 Java 默认虚拟机 HotSpot 中,在 JDK 1.8 之前的版本中,是通过永久代来实现方法区的,
但 JDK 1.8 之后,永久代被元空间(Metaspace)取代。
所以,方法区和永久代的区别在于,方法区是规范,而元空间(和永久代)是具体实现。
8.为什么要使用元空间替代永久代
1.提高稳定性,降低 OOM:当使用永久代实现方法区时,永久代的最大容量受制于 PermSize 和 MaxPermSize 参数设置的大小,而这两个参数的大小又很难确定,因为在程序运行时需要加载多少类是很难估算的,如果这两个参数设置的过小就会频繁的触发 FullGC 和导致 OOM(Out of Memory,内存溢出)。但是,当使用元空间替代了永久代之后,出现 OOM 的几率就被大大降低了,因为元空间使用的是本地内存,这样元空间的大小就只和本地内存的大小有关了,从而大大降低了 OOM 的问题。
2.降低运维成本:元空间使用的是本地内存,这样就无需运维人员再去专门设置和调整元空间的大小了。
9.如何判断对象是否存活
1.引用计数法
引用计数器算法的实现思路是,给对象增加一个引用计数器,每当有一个地方引用它时,计数器就 +1;当引用失效时,计数器就 -1;任何时刻计数器为 0 的对象就是不能再被使用的,即对象已"死"。
优点:实现简单,判定效率也比较高。
缺点:是引用计数法无法解决对象的循环引用问题。
2.可达性分析法
可达性分析算法是通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,
搜索走过的路径称之为"引用链",当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots
到这个对象不可达)时,证明此对象是不可用的。
如下图,对象 Object5-Object7 之间虽然彼此还有关联,但是它们到 GC Roots 是不可达的,因此它们会被判定为可回收对象。
目前主流的 Java 虚拟机使用的都是可达性分析算法来判断对象是否存活的。
10.GC Roots有哪些
1.Java 虚拟机栈(栈帧中的本地变量表)中引用的对象;
2.方法区中类静态属性引用的对象;
3.方法区中常量引用的对象;
4.本地方法栈中 JNI(Native方法)引用的对象。
11.常见的垃圾回收算法有哪些
1.标记-清除算法:标记-清除(Mark-Sweep)算法属于早期的垃圾回收算法,它是由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。而标记的判断方法就是前面讲的引用计数算法和可达性分析算法
2.复制算法:复制算法是将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。这样就不会产生内存碎片的问题了
3.标记-整理算法:标记-整理(Mark-Compat)算法是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除
标记-清除:
复制:
标记-整理:
12.常见的垃圾回收器有哪些
JVM中常用的垃圾回收器有以下几种:
1.Serial收集器:这是最基本的、发展历史最悠久的收集器,它是一个单线程的收集器,采用复制算法,适用于新生代,它会暂停所有用户线程进行垃圾回收,也称为“Stop The World”。
优点:简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。
缺点:会在用户不知道的情况下停止所有工作线程,导致系统停顿时间较长,不适合对响应时间敏感的场景。
2.ParNew收集器:这是Serial收集器的多线程版本,除了使用多线程之外,其余行为和Serial收集器基本一样,它也采用复制算法,适用于新生代,它是目前唯一能和老年代的CMS收集器配合工作的新生代收集器。
优点:多线程版本的Serial,可以更加有效的利用系统资源,减少系统停顿时间,适用于多核CPU的环境。
缺点:同Serial,会在用户不知道的情况下停止所有工作线程,而且由于使用了多线程,会消耗更多的CPU资源。
3.Parallel Scavenge收集器:这是一个以达到可控制的吞吐量为目标的收集器,它也是多线程的,采用复制算法,适用于新生代,它可以设置垃圾收集时间占总时间的比率,以及最大垃圾收集停顿时间,从而提高系统的运行效率。
优点:追求高吞吐量,高效利用CPU,是吞吐量优先,且能进行精确控制,适用于后台运算为主的场景。
缺点:应该说是特点,追求高吞吐量必然要牺牲一些其他方面的优势,不能做到既,又。ParNew收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间,原本10s收集一次, 每次停顿100ms, 设置完参数之后可能变成5s收集一次, 每次停顿70ms. 停顿时间变短, 但收集次数变多。
4.Serial Old收集器:这是Serial收集器的老年代版本,它是一个单线程的收集器,采用标记-整理算法,适用于老年代,它也会暂停所有用户线程进行垃圾回收,它一般作为CMS收集器的后备方案,当CMS收集器失败时,会启动Serial Old收集器进行垃圾回收。
优点:简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个cpu来说没有了上下文之间的的切换,效率比较高。
缺点:会在用户不知道的情况下停止所有工作线程,导致系统停顿时间较长,不适合对响应时间敏感的场景。
5.Parallel Old收集器:这是Parallel Scavenge收集器的老年代版本,它是多线程的,采用标记-整理算法,适用于老年代,它和Parallel Scavenge收集器一起,可以达到一个可控制的吞吐量,适用于后台运算为主的场景。
优点:多线程版本的Serial Old,可以更加有效的利用系统资源,减少系统停顿时间,适用于多核CPU的环境,追求高吞吐量。
缺点:同Serial Old,会在用户不知道的情况下停止所有工作线程,而且由于使用了多线程,会消耗更多的CPU资源。
6.CMS收集器:这是一种以获取最短回收停顿时间为目标的收集器,它是多线程的,采用标记-清除算法,适用于老年代,它的特点是采用并发收集,尽量减少用户线程的停顿,但是会产生内存碎片,以及对CPU资源非常敏感,适用于用户交互较多的场景。
优点:停顿时间短,吞吐量大,并发收集,适用于对响应时间敏感的场景,如WEB、B/S系统。
缺点:对CPU资源非常敏感,无法收集浮动垃圾,容易产生大量内存碎片,可能导致频繁的Full GC。
7.G1收集器:这是一种面向服务端应用的收集器,它是多线程的,采用标记-整理算法,适用于整个堆内存,它的特点是把堆内存划分为多个大小相等的区域,然后根据垃圾最多的区域优先进行回收,从而实现高效的垃圾回收,同时也能控制停顿时间,避免内存碎片,适用于大内存的场景²。
优点:并行与并发,分代收集,空间整合,可预测的停顿,适用于大内存,多核CPU的场景。
缺点:还不够成熟,可能存在一些bug,需要调整更多的参数,对内存占用较高。