JVM那些事 (含经典面试题)
我们平时所说 Java 具有 " 可移植性 " / " 跨平台性 ",说的其实不是 Java 本身,而是 JVM 能够事先跨平台。我们平时写的 Java 程序 不是直接在电脑上运行的,而是在 JVM 上进行的,每个系统平台都是有自己的 JVM 虚拟机。所以,只要 JVM 能够正常运作,我们写的代码就能够在任何地方运行。
🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!欢迎志同道合的朋友一起加油喔🤺🤺🤺
目录
1. JVM:Java 虚拟机(Java Virtual Machine)
前言:
JDK(Java Development Kit)、JRE(Java Runtime Environment)和JVM(Java Virtual Machine)是Java开发和运行环境的三个核心组件,它们之间有一些重要的区别。
- JDK(Java Development Kit):JDK是Java开发工具包,它包含JRE以及开发Java程序所需的编译器(javac)、工具(如JavaDoc、JavaP等)、库和其他文件。如果你需要编写Java代码,那么你需要JDK。
- JRE(Java Runtime Environment):JRE是运行Java程序的环境,它包含JVM以及Java类库(Java Class Libraries),使得Java程序可以在其上执行。如果你只需要运行Java程序,而不需要进行Java开发,那么你只需要JRE。
- JVM(Java Virtual Machine):JVM是Java虚拟机,它是运行Java字节码的平台。Java源代码首先被编译成字节码,然后JVM在运行时将字节码转换为特定机器的机器码。JVM是JRE的一部分,它使得Java程序可以跨平台运行,实现“一次编写,到处运行”。
总结一下,JDK用于开发Java应用,包含了JRE和其他开发工具。JRE是运行Java应用的环境,包含了JVM和其他Java类库。JVM是运行Java字节码的虚拟机,它是实现Java跨平台运行的关键。
1. JVM:Java 虚拟机(Java Virtual Machine)
虽然 JVM 说是一个虚拟机,但本质上,更像是一个解 释器。
VMware、Virtual Box 算得上是真正的虚拟机,它们 100% 地模拟出了真实的硬件,而 JVM 只是对硬件设备进行了简单抽象的封装,从而能够达到跨平台效果。
而我们平时所说 Java 具有 " 可移植性 " / " 跨平台性 ",说的其实不是 Java 本身,而是 JVM 能够事先跨平台。我们平时写的 Java 程序 不是直接在电脑上运行的,而是在 JVM 上进行的,每个系统平台都是有自己的 JVM 虚拟机。所以,只要 JVM 能够正常运作,我们写的代码就能够在任何地方运行。
2. JVM 执行流程
如下图所示,便是 JVM 的整个执行流程.
3. JVM 内存区域划分
JVM 其实本质上是一个 Java 进程,用来管理硬件资源(例如内存), JVM 启动后就会从操作系统这里申请到一大块内存区域.
可以这样比喻, 一个操作系统就类似于一个中学, 而一个进程呢就相当于这所中学里面的初一年级, 初一年级从这所中学申请了一块区域,例如蕴美楼这块区域就是初一年级所申请的区域,这就相当于 JVM 从系统中申请到了内存, 然后初一年级的老师又将蕴美楼分为多个班级,这就相当于 Java 进程对内存空间划分了多个区域, 每个区域都有着不同的功能.
3.1 内存区域中的各个概念解释
这一块主要的考点,就是给你一段代码,问你某个变量是在哪个区域???
- 堆: 里面放的便是 new 的对象和普通成员变量;
- 方法区 (JDK8改名为元数据区): 里面放的是 常量池(JDK8之前在方法区,JDK8之后在堆里面)和 类对象(class对象), 类的 static 成员变量作为类的属性, 同样也是在类对象中, 也就是在方法区里 ,
那么类对象里面都有什么呢?
1. 包含了这个类的各种属性的名字,类型以及访问权限;
2. 包含了这个类的各种方法的名字,参数类型,返回值类型,访问权限以及方法实现的二进制代码;
3. 包含了这个类的 static 成员对于方法区而言: 一个进程里只有一块,多个线程共用这一块!
- 程序计数器: 是内存区域中最小的一部分, 里面只是放了一个内存地址,这个地址的含义便是接下来要执行的指令地址;例如 .class 文件(二进制字节码)中就是一些指令,在内存中每个指令都有自己的地址,CPU 执行指令就需要从内存中取地址,然后再在 CPU 上执行.
- 栈: 其里面放的是局部变量 (代码块里的都是局部变量);
本地方法栈和虚拟机栈(Java 栈)的区别: 虚拟机栈是给 JVM 使用的, 而本地方法栈则是给本地方法使用的.
3.2 内存区域中的相关异常
内存区域中的异常主要有两种,一种是堆溢出,另一种是栈溢出.简单来说:
- 堆溢出: 堆空间耗尽,出现这样的情况大多是一直 new 对象但不去释放对象;
- 栈溢出: 典型的场景就是无限递归, 栈里面除了要放局部变量之外,还要放方法的调用关系.
堆和栈搞多大的空间是可以通过 JVM 来显示配置的,例如可以设置 JVM 参数
- -Xms: 设置堆最小值;
- -XMX: 设置堆最大值.
- 内存泄漏: 泄漏对象无法被 GC(垃圾回收);
- 内存溢出: 内存对象确实还应该存活, 此时要根据 JVM 堆参数与物理内存相比较检查是否还应该把 JVM 堆内存调大,或者是检查对象的生命周期是否过长.
3.3 Java 引用类型的理解
在Java编程语言中,"引用"是一个非常重要的概念。它是指向一个对象在内存中的地址的变量。换句话说,引用就是对象的别名,或者说是指向对象的指针。在Java中,所有的对象都是通过引用来访问的。Java中所有的复杂数据类型(比如类、接口和数组)的变量都是引用类型。
4. JVM 类加载
Java 中的类加载是 JVM 中的一个非常核心的流程,其做的事情就是将 .class 文件转换成 JVM 中的对象, 例如我们发明了一个编程语言,那么肯定是想让这个编程语言跑起来, 那么这就需要把源代码编译成可执行程序,然后再去执行代码逻辑. 要想完成类加载,必须要明确的知道 .class 文件中都有啥,按照规则来进行解析,因此编译器和 JVM 类加载器必须要商量好 .class 文件的格式, 这个 .class 文件的格式是 Java 虚拟机规范文档里面约定的,其实就是一种"协议".关于 Java 虚拟机规范文档地址为: Java虚拟机规范文档.
类加载的基本流程:
- 加载(Loading):这是类加载过程的第一步,负责找到数据(即.class文件)并从文件系统或网络等地方加载它。这个步骤由类加载器(ClassLoader)完成。加载完成后,JVM会在内存中生成一个代表这个类的java.lang.Class对象。
- 验证(Verification):加载完成后,验证器会检查加载的字节码文件是否符合Java虚拟机规范,例如是否有越界的指令等。如果验证失败,JVM将抛出VerifyError。
- 准备(Preparation):在准备阶段,JVM为类变量(也称为静态变量)分配内存,并设置默认值。例如,整型变量会被设置为0,引用类型变量会被设置为null。
- 解析(Resolution):在解析阶段,JVM将类、接口、字段和方法中的符号引用转换为直接引用。符号引用就是一些字符串,而直接引用就是指向目标的指针或者偏移量。这个阶段可能会引发类的加载。(针对字符串常量进行初始化)
- 初始化(Initialization):在初始化阶段,JVM会执行类构造器(<clinit>方法)。这个方法由编译器自动收集类中的所有静态变量的赋值动作和静态代码块合并产生的。这个阶段是类加载过程中的最后一步,也是唯一一个明确被Java虚拟机规范规定必须是单线程执行的部分。(真正针对类对象里面的内容进行初始化.加载父类,执行静态代码块的代码…..)
在类的初始化阶段,静态代码块和静态变量的初始化代码会被执行。静态代码块和静态变量的初始化是按照它们在代码中出现的顺序来执行的,且只执行一次。
只有当主类以及其父类都被加载和初始化之后,
main
方法才会被执行。
对第四步的解释:针对字符串常量进行初始化
在.class文件中的常量池中,数据项(例如字面量和符号引用)并没有实际的内存地址。相反,它们是以索引值或偏移量的形式存储的。这是因为在编译阶段,还不能确定运行时的内存地址。
当我们在Java源代码中使用一个字符串常量,例如String s = "Hello, world!";,这个字符串常量会被存储在编译后的.class文件的常量池(Constant Pool)中。常量池是.class文件的一部分,用来存储编译时产生的字面量(如字符串、数字)和符号引用(如类名、方法名、字段名)。
在编译阶段,这个字符串常量并不会被存储为一个直接的内存地址,因为编译器在这个时候并不知道它在运行时的内存地址会是什么。相反,它会被存储为一个在常量池中的索引值或者偏移量。这个索引值或偏移量可以被看作是一个"占位符",在类加载的时候,JVM会用它来查找并加载字符串常量。
当类被加载到JVM中时,常量池中的数据会被读取并处理。对于字符串常量,JVM会把它加载到运行时常量池,并分配一个真正的内存地址。这个过程被称为常量池的解析。
因此,可以理解为在类加载之前,字符串常量在.class文件中是以偏移量的形式存在的,而在类加载后,字符串常量在JVM中则是以真正的内存地址存在的。
经典面试题 1
分析下面题,我们自己试着写出结果:
class A{
public A(){
System.out.println("这是A的构造方法");
}
{
System.out.println("这是A的代码块");
}
static{
System.out.println("这是A的静态代码块");
}
}
class B extends A{
public B(){
System.out.println("这是B的构造方法");
}
{
System.out.println("这是B的代码块");
}
static{
System.out.println("这是B的静态代码块");
}
}
public class test extends B{
public static void main(String[] args) {
new test();
new test();
}
}
结果如下:
- 总结来说,就是类加载的时候是第一步,就会执行对应其父类的静态代码块,并且静态代码块只会执行一次,而构造方法和构造代码块每次实例化都会执行,并且代码块的优先级是高于构造方法的。
经典面试题2: 类加载的时机
在Java中,类的加载时机主要发生在以下几种情况:
- 新建实例:当用new关键字创建类的新实例时,如new MyClass(),如果MyClass还没有被加载,那么JVM就会加载它。
- 调用静态方法:当调用类的静态方法时,如MyClass.staticMethod(),如果MyClass还没有被加载,那么JVM就会加载它。
- 访问静态字段:当访问类的静态字段时,如MyClass.staticField,如果MyClass还没有被加载,那么JVM就会加载它。
- 子类的初始化:当初始化一个类的子类时,如果父类还没有被初始化,那么父类就会被加载和初始化。
- 加载类的时候:如果代码中显示地使用Class.forName("MyClass"),JVM就会加载MyClass。
5. 双亲委派模型
双亲委派模型:这是Java类加载机制的核心。当一个类加载器收到类加载请求时,它首先不会自己去加载,而是把这个请求委托给父类加载器,这个过程会递归,即父类加载器再委托它的父类加载器,直到达到顶层的启动类加载器;只有当父类加载器无法完成这个加载请求(例如因为找不到所需的.class文件)时,子类加载器才会尝试自己去加载。这个模型确保了Java核心库的类型安全(所有的Java应用都至少引用java.lang.Object类,因此在运行期,java.lang.Object类不能被替换),并且能够保证Java核心库的类由启动类加载器来加载。
默认的类加载器:
- 启动类加载器(Bootstrap ClassLoader):这是最顶层的类加载器,负责加载JVM的核心类库(位于<JAVA_HOME>/jre/lib/rt.jar或者<JAVA_HOME>/jre/classes),如java.lang.*, java.util.*等。它是用C++实现的,并不继承自java.lang.ClassLoader。
- 扩展类加载器(Extension ClassLoader):这是启动类加载器的子加载器,负责加载JVM的扩展类库(位于<JAVA_HOME>/jre/lib/ext或者由系统变量java.ext.dirs指定的目录)。它是用Java实现的,继承自java.lang.ClassLoader。
- 应用类加载器(Application ClassLoader):这是扩展类加载器的子加载器,也是Java应用的默认类加载器,负责加载classpath指定的类库,也就是用户自定义的类。它也是用Java实现的,继承自java.lang.ClassLoader。
根据双亲委派模型,当一个类需要被加载时,会执行以下步骤:
- 启动类加载器会首先尝试加载这个类。如果在其负责的加载路径中找到了对应的.class文件,就会完成加载,然后返回。如果没找到,加载任务就会退回到扩展类加载器。
- 扩展类加载器会接着尝试在其负责的加载路径中寻找并加载类。如果找到了,就完成加载,否则,任务退回到应用类加载器。
- 应用类加载器在其父类加载器都无法完成加载任务的情况下,会在其负责的加载路径中尝试加载类。如果还是找不到对应的.class文件,那么将会抛出ClassNotFoundException。
总的来说,这个过程是从下到上(从应用类加载器到启动类加载器)的委托,然后再从上到下(从启动类加载器到应用类加载器)的加载。这就是所谓的双亲委派模型。
6. 垃圾回收(GC)
6.1 什么是垃圾回收(GC)
举个例子来说明: 例如积攒了一天的各种各样的垃圾我放到了一个袋子里面, 这个时候我把这些垃圾分类好放到对应的垃圾桶里面, 这就是相当于 C 语言的手动回收内存; 但是呢有时候我比较懒, 我只是把所有垃圾直接扔到楼下的垃圾桶旁边, 当我走后, 保洁员在收拾小区卫生的时候就会对垃圾桶旁边的这些垃圾进行归类, 分类扔进不同的垃圾桶里面, 这就相当于垃圾回收机制, 也就是说谁扔垃圾都可以, 但是呢会有个统一负责的人来进行归类回收. 对于 Java 来说, 代码中的任何地方都可以申请内存, 然后由 JVM 统一进行释放, 具体来说 JVM 内部的一组专门负责垃圾回收的线程来进行这样的工作.简言之,垃圾回收就是回收内存的过程, 我们都知道其实 JVM 就是个 Java 进程, 但是一个进程会持有很多的硬件资源, 如 CPU, 内存, 硬盘以及带宽资源; 但是系统的内存总量是一定的, 并且内存还要同时给很多的进程来使用, 例如我们电脑是16G内存,它是一定的,程序在使用内存的时候必须先申请才能使用, 用完之后还要记得释放掉.
6.2 为什么要进行垃圾回收
从代码编写的角度来看, 内存的申请时机是非常明确的, 但是内存的释放时机有些时候却是不太明确的, 这个时候就给内存的释放带来了一些困难, 典型的问题就是: 这个内存我是否还要继续使用? 关于内存泄露这件事一旦出现是比较难调试的, 我们很难找到是哪个地方出现了内存泄露; 当出现在生产环境中时, 如果不能第一时间暴露出问题, 而是逐渐积累达到一定程度后, 这时候爆发出来将会造成毁灭性的问题, 因此有效的垃圾回收是非常有必要的!
6.3 垃圾回收的优点/缺点是什么
优点: 可以更大程度的保证不出现内存泄露的情况, 但是需要注意的是这里不是 100% 保证, 当程序猿作死的时候 JVM 是救不了的!!!
缺点:
1) 需要消耗额外的系统资源;
2) 内存的释放可能存在延迟, 也就是说某个内存不用了可能不会第一时间去释放掉内存, 而是稍后释放;
3) 可能会出现 STW 问题.
6.4 垃圾回收要回收的内存有哪些
- 堆: 垃圾回收要释放的内容主要是在堆区;
- 方法区: 方法区里面大部分存的是"类对象", 通过类加载生成的, 对方法区进行垃圾回收就相当于是"类卸载";
- 栈 / 程序计数器: 内存都是和具体的线程绑定在一起的, 这两部分的内容都是自动释放的, 当代码块结束/线程结束的时候, 内存就自动释放了. 我们日常所说的垃圾回收主要是指堆上内存的回收,因为堆所占据的内存空间是最大的, 本身就占据了一个程序绝大部分的内存.
那么关于回收堆上的内容, 具体回收的是什么呢?
由上图可以看出, 针对堆上的对象主要分为三种情况:
(1) 完全使用的; 这里的肯定不是我们回收的对象.
(2) 完全不使用的; 这才是我们要回收的内存.
(3) 一半要使用, 一半不使用的: 这种情况下不能进行回收, 因为如果回收起来的话成本会比较大, Java 中的垃圾回收是以"对象"为基本单位, 一个对象要么被回收, 要么不被回收, 不会出现回收一般的情况.
6.5 垃圾回收到底是怎么回收的
垃圾回收的基本思想: 先找出垃圾, 然后再回收垃圾; 应遵循宁可放过, 也不能错杀. 上面我们也说过了回收的是再也不会被使用的对象, 如果正在使用的对象也回收了的话, 那程序猿还怎么"玩". 例如去看牙医, 医生说有两颗蛀牙, 但是呢有一颗蛀牙不是特别的确定要不要拔, 那么这时候肯定是先把确定的那颗蛀牙给拔了, 不确定的先留着. 相比于回收少了这样的问题而言, 回收多了显然是更严重的问题.
6.6 垃圾对象的判断算法
对于 GC, 判断垃圾主要有下面这两种典型方案.
6.6.1 引用计数算法
==算法基本思想: 给对象增加一个引用计数器, 每当有一个地方引用它时, 计数器就 + 1; 当引用时效时, 计数器就 - 1; 任何时刻计数器为 0 的对象就是不能再被使用的对象.==举例如下:
引用计数的优缺点:
优点: 规则简单, 实现方便, 程序运行效率高效;
缺点:
- 空间利用率比较低,如果一个对象很大, 在程序中对象数目也不多,此时引用计数完全可以; 如果一个很大的对象在里面加多个计数器也没有什么负担; 但是如果一个对象很小, 在程序中对象数据也很多, 此时引用计数就会带来不可忽视的空间开销.
- 存在循环引用的问题, 在某些特殊的代码下, 循环引用会导致代码的引用计数判断出现问题, 从而无法回收.
下面我们举个例子来解释一下这个致命的缺点是怎么回事:
由于以上错误, 我们在 Java 中并没有使用引用计数这种方式来判定垃圾, 而是使用了下面的可达性分析.
6.6.2 可达性分析 (Java采用的方案)
可达性分析指的是通过额外的线程,定期的针对整个内存空间的对象进行扫描,有一些起始位置,我们把它称为GCRoots,会类似于深度优先遍历一样,把可以访问到的对象都标记一遍(此时,带有标记的对象就是可达的对象),没有被标记的对象就是不可达的,换言之,就是垃圾。
而这里的GCRoots的遍历位置,往往是以下三种:
a.栈上的局部变量
b.常量池中的引用指向的对象
c.方法区中的静态成员指向的对象
这个给大家举个例子,便于大家更好的理解可达与不可达。
基于上述过程, 便可以完成垃圾对象的标记, 和引用计数相比较, 可达性分析确实是复杂了很多, 同时实现可达性分析的遍历过程的开销也是比较大的; 但是带来的好处就是解决了引用计数的两个缺点: 内存上不需要消耗额外的空间, 也没有产生循环引用的问题.
不管是引用计数还是可达性分析, 其判定规则都是看当前对象是否有引用来指向, 也就是说都是通过引用来进行判定对象的生死的; 引用的诞生之前只是为了用来访问对象, 但是随着时代的发展, 引用也是可以用来判定对象的生死的, 但是也有的不可以判定, 主要有如下四种引用.
- 强引用: 这是我们日常使用的引用, 既能够访问对象, 也能决定对象的生死;只要强引用还存在, 垃圾回收器永远不会回收掉引用的对象实例.
- 软引用: 能够访问对象, 但是只能一定程度的决定对象的生死, 也就是说 JVM 会根据内存是否富裕来自行决定; 对于软引用关联着的对象, 在系统将要发生内存溢出之前, 会把这些对象列入回收范围之中进行第二次回收, 如果这次回收还是没有足够的内存, 才会抛出内存溢出异常.
- 弱引用: 能够访问对象, 用来描述非必须对象的, 但是其强度要弱于软引用, 被弱引用关联的对象只能生存到下一次垃圾回收发生之前, 当垃圾回收器开始进行工作的时候, 无论当前内容是否够用, 都会回收掉只被弱引用关联的对象.
- 虚引用: 既不能找到对象, 也不能决定对象的生死, 只能在对象临被回收前进行一些善后的工作; 其也可以称为幽灵引用或者幻影引用, 也是最弱的一种引用关系, 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例, 为一个对象设置虚引用的目的就是能在这个对象被收集器回收时收到一个系统通知.
6.7 垃圾回收算法, 具体是怎么回收的
明确了谁是垃圾之后,接下来就要谈论回收垃圾了。回收垃圾实质上就是释放内存
6.7.1标记 - 清除
①什么是标记 - 清除?
标注就是可达性分析的过程,而清除,就是直接释放内存。我们用一个数组来表示这个过程,垃圾我们用绿色来表示。
这个时候,我们就需要清除他们;
②优缺点:
优点:方便,可以直接清除。
缺点:当我们清除这个垃圾后,就变成了一个个离散的空间。这个时候就造成了内存碎片,可能过多,可能过少,利用率就大大降低了。
内存碎片就是:比如空闲的内存有很多,假设一共是 1G,如果要申请 500M 内存,也是可能申请失败的,因为要申请 500M 的内存 必须是连续的,每次申请,都是申请的连续的内存空间,而这里的 1G 可能是多个 碎片加在一起 才 1G,可用的并不多
6.7.2复制算法
①什么是复制算法?
复制算法就是为了解决上述这种离散内存碎片的问题。它的原理是用一半,丢一半。就是将原来的空间划成两份。把是垃圾的放在一起,不是垃圾的放在一起,最后释放掉是垃圾的部分。(这种放在一起就是利用的拷贝的原理),那么最后就可以得到连续的内存空间。
如下图:把一块内存,分成两半,左边一半有很多对象,打钩的标记为垃圾,右边的是:把左边没有打勾的可用的对象拷贝过来的
然后直接把原来这个空间整体全部都释放掉
②优缺点:
优点:
解决了内存碎片的问题
缺点:
内存空间利用率低,当要保留的对象较多,释放的对象较少时,此时复制的开销就很大了。
6.7.3标记 - 整理
①什么是标记 - 整理:
类似于删除顺序表中间的元素,然后将后面有空间的移位至于前面。实质上就是一个搬运的工作。
确认垃圾后使用标记整理算法如下图
②优缺点:
优点:
针对复制算法解决了问题,空间利用率最高
缺点:
但是没有解决复制搬运过程的开销大的问题。
6.7.4分代回收
①什么是分代回收?
分代回收实质上就是结合了上述多种方案来完成的操作:
原理是针对对象进行分类(根据对象的“年龄”来进行分类):一个对象,每经过一次GC扫描,就相当于“长了一岁”。接着我们根据不同年龄的对象,来采取不同的方案。
我们以下面这张图来进行讲解:
a.伊甸区:
我们把刚创建出来的对象放在伊甸区
b.幸存区:
如果在伊甸区经过一轮GC扫描,就会被拷贝到幸存区A(这里就是指第一个幸存区)
在后续的几轮GC中,幸存区的对象就在两个幸存区之间来回拷贝,这里用的是复制的算法,每轮都会淘汰一批幸存者
c.老年代:
在持续若干轮后,对象终于可以进入老年代,老年代里的对象都是年龄较大的,我们默认一个对象越老,继续存活的可能性就越大,那么老年代GC的扫描概率大大低于新生代,老年代中就会使用标记整理的方式来进行回收
②举个例子:
其实这个阶段就很像我们考研的过程。最开始有很多人决定考研,这个时候大家都在伊甸区,一段时间后,一些人放弃了,剩下的就进入了幸存区,经过时间反复沉淀,就会有很多放弃的,最后一批人进了复试,进入了老年代,但是复试中仍然会有表现不好,而被刷的。
③注意:
分代回收中,还有一个特殊情况,有一类对象可以直接进入老年代,(占有较多内存的对象),但是大对象拷贝开销比较大,不适合使用复制算法
上面说的找垃圾,和释放垃圾,说的都是算法思想,不是具体落地实现,在JVM里,真正实现上述算法的模块称为“垃圾回收器”。
由于这些垃圾回收器都还是在不断发展不断进化,所以我们要重点掌握的是垃圾回收算法(引用计数+可达性分析+标记清除+标记整理+复制算法+分代回收),这些垃圾收集器,简单了解即可❗
更多推荐
所有评论(0)