CrabNote螃蟹笔记

Java基础常见面试题总结(中)

[![《SpringAI 智能面试平台+RAG 知识库》](/content/images/javaguide/xingqiu/interview-guide-banner.png)](https://

面向对象基础 ⭐️ 面向对象和面向过程的区别 面向过程编程(Procedural Oriented Programming,POP)和面向对象编程(Object Oriented Programming,OOP)是两种常见的编程范式,两者的主要区别在于解决问题的方式不同: 面向过程编程(POP) :面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题。 面向对象编程(OOP) :面向对象会先抽象出对象,然后用对象执行方法的方式解决问题。 相比较于 POP,OOP 开发的程序一般具有下面这些优点: 易维护 :由于良好的结构和封装性,OOP 程序通常更容易维护。 易复用 :通过继承和多态,OOP 设计使得代码更具复用性,方便扩展功能。 易扩展 :模块化设计使得系统扩展变得更加容易和灵活。 POP 的编程方式通常更为简单和直接,适合处理一些较简单的任务。 POP 和 OOP 的性能差异主要取决于它们的运行机制,而不仅仅是编程范式本身。因此,简单地比较两者的性能是一个常见的误区(相关 issue : 面向过程:面向过程性能比面向对象高??)。 在选择编程范式时,性能并不是唯一的考虑因素。代码的可维护性、可扩展性和开发效率同样重要。 现代编程语言基本都支持多种编程范式,既可以用来进行面向过程编程,也可以进行面向对象编程。 下面是一个求圆的面积和周长的示例,简单分别展示了面向对象和面向过程两种不同的解决方案。 面向对象 : 我们定义了一个 类来表示圆,该类包含了圆的半径属性和计算面积、周长的方法。 面向过程 : 我们直接定义了圆的半径,并使用该半径直接计算出圆的面积和周长。 创建一个对象用什么运算符?对象实例与对象引用有何不同? new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球); 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。 ⭐️ 对象的相等和引用相等的区别 对象的相等一般比较的是内存中存放的内容是否相等。 引用相等一般比较的是他们指向的内存地址是否相等。 这里举一个例子: 输出结果: 从上面的代码输出结果可以看出: 和 不相等,而 和 相等。这是因为 运算符比较的是字符串的引用是否相等。 、 、 三者的内容都相等。这是因为 方法比较的是字符串的内容,即使这些字符串的对象引用不同,只要它们的内容相等,就认为它们是相等的。 如果一个类没有声明构造方法,该程序能正确执行吗? 构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。 如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。 我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。 构造方法有哪些特点?是否可被 override? 构造方法具有以下特点: 名称与类名相同 :构造方法的名称必须与类名完全一致。 没有返回值 :构造方法没有返回类型,且不能使用 声明。 自动执行 :在生成类的对象时,构造方法会自动执行,无需显式调用。 构造方法 不能被重写(override) ,但 可以被重载(overload) 。因此,一个类中可以有多个构造方法,这些构造方法可以具有不同的参数列表,以提供不同的对象初始化方式。 ⭐️ 面向对象三大特征 封装 封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了(当然现在还有很多其他方法,这里只是为了举例子)。 继承 不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间,提高我们的开发效率。 关于继承如下 3 点请记住: 1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问, 只是拥有 。 2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。 3. 子类可以用自己的方式实现父类的方法。(以后介绍)。 多态 多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。 多态的特点: 对象类型和引用类型之间具有继承(类)/实现(接口)的关系; 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定; 多态不能调用“只在子类存在但在父类不存在”的方法; 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。 图表加载中 ⭐️ 接口和抽象类有什么共同点和区别? 接口和抽象类的共同点 实例化 :接口和抽象类都不能直接实例化,只能被实现(接口)或继承(抽象类)后才能创建具体的对象。 抽象方法 :接口和抽象类都可以包含抽象方法。抽象方法没有方法体,必须在子类或实现类中实现。 接口和抽象类的区别 设计目的 :接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。 继承和实现 :一个类只能继承一个类(包括抽象类),因为 Java 不支持多继承。但一个类可以实现多个接口,一个接口也可以继承多个其他接口。 成员变量 :接口中的成员变量只能是 类型的,不能被修改且必须有初始值。抽象类的成员变量可以有任何修饰符( , , ),可以在子类中被重新定义或赋值。 方法 : Java 8 之前,接口中的方法默认是 ,也就是只能有方法声明。自 Java 8 起,可以在接口中定义 (默认) 方法和 (静态)方法。 自 Java 9 起,接口可以包含 方法。 抽象类可以包含抽象方法和非抽象方法。抽象方法没有方法体,必须在子类中实现。非抽象方法有具体实现,可以直接在抽象类中使用或在子类中重写。 在 Java 8 及以上版本中,接口引入了新的方法类型: 方法、 方法和 方法。这些方法让接口的使用更加灵活。 Java 8 引入的 方法用于提供接口方法的默认实现,可以在实现类中被覆盖。这样就可以在不修改实现类的情况下向现有接口添加新功能,从而增强接口的扩展性和向后兼容性。 Java 8 引入的 方法无法在实现类中被覆盖,只能通过接口名直接调用( ),类似于类中的静态方法。 方法通常用于定义一些通用的、与接口相关的工具方法,一般很少用。 Java 9 允许在接口中使用 方法。 方法可以用于在接口内部共享代码,不对外暴露。 深拷贝和浅拷贝区别了解吗?什么是引用拷贝? 图表加载中 关于深拷贝和浅拷贝区别,我这里先给结论: 浅拷贝 :浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。 深拷贝 :深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。 上面的结论没有完全理解的话也没关系,我们来看一个具体的案例! 浅拷贝 浅拷贝的示例代码如下,我们这里实现了 接口,并重写了 方法。 方法的实现很简单,直接调用的是父类 的 方法。 测试: 从输出结构就可以看出, 的克隆对象和 使用的仍然是同一个 对象。 深拷贝 这里我们简单对 类的 方法进行修改,连带着要把 对象内部的 对象一起复制。 测试: 从输出结构就可以看出,显然 的克隆对象和 包含的 对象已经是不同的了。 那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。 我专门画了一张图来描述浅拷贝、深拷贝和引用拷贝: ⭐️ Object Object 类的常见方法有哪些? Object 类是一个特殊的类,是所有类的父类,主要提供了以下 11 个方法: == 和 equals() 的区别 对于基本类型和引用类型的作用效果是不同的: 对于基本数据类型来说, 比较的是值。 对于引用数据类型来说, 比较的是对象的内存地址。 因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。 方法存在于 类中,而 类是所有类的直接或间接父类,因此所有的类都有 方法。 类 方法: 方法存在两种使用情况: 类没有重写 方法 :通过 比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 类 方法。 类重写了 方法 :一般我们都重写 方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。 举个例子(这里只是为了举例。实际上,你按照下面这种写法的话,像 IDEA 这种比较智能的 IDE 都会提示你将 换成 ): 中的 方法是被重写过的,因为 的 方法是比较的对象的内存地址,而 的 方法比较的是对象的值。 当使用字符串字面量创建 类型的对象(如 )时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用;如果没有,就在常量池中创建一个 对象并赋给当前引用。但当使用 关键字创建对象(如 )时,虚拟机总是会在堆内存中 创建一个新的对象 并使用常量池中的值(如果没有,会先在字符串常量池中创建字符串对象 "ab")进行初始化,然后赋给当前引用。 类 方法: hashCode() 有什么用? 的作用是获取哈希码( 整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。 定义在 JDK 的 类中,这就意味着 Java 中的任何类都包含有 函数。另外需要注意的是: 的 方法是本地方法,也就是用 C 语言或 C++ 实现的。 ⚠️ 注意:该方法在 Oracle OpenJDK8 中默认是 “使用线程局部状态来实现 Marsaglia's xor shift 随机数生成”, 并不是 “地址” 或者 “地址转换而来”, 不同 JDK/VM 可能不同。在 Oracle OpenJDK8 中有六种生成方式(其中第五种是返回地址), 通过添加 VM 参数: XX:hashCode=4 启用第五种。参考源码: <https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/globals.hpp (1127 行) <https://hg.openjdk.org/jdk8u/jdk8u/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp (537 行开始) 散列表存储的是键值对(key value),它的特点是: 能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象) 为什么要有 hashCode? 我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode? 当我们把对象加入 HashSet 时,HashSet 会先调用对象的 方法,得到一个“哈希值”,并通过内部散列函数对这个哈希值再做一次简单的转换(比如取余),决定这条数据应该放进底层数组的哪一个桶(bucket,对应到底层数组的某个位置): 1. 如果该桶当前是空的,就直接将对象对应的节点插入到这个桶中。 2. 如果该桶中已经有其他元素,HashSet 会在这个桶对应的链表或红黑树中逐个比较: 对于 哈希值不同 的节点,直接跳过; 对于 哈希值相同 的节点,则会进一步调用 equals() 方法来检查这两个对象是否“相等”: – 如果 返回 true,说明集合中已经存在与当前对象等价的元素, 就不会再次加入它; – 如果返回 false, 则认为是新元素,会将该对象作为一个新节点加入到 同一个桶 的链表或红黑树中。 通过先利用 将候选范围缩小到同一个桶内,再在桶内少量元素上调用 做精确判断, 大大减少了 的调用次数,从而提高了查找和插入的执行效率。 那为什么 JDK 还要同时提供这两个方法呢? 这是因为在一些容器(比如 、 )中,有了 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进 的过程)! 我们在前面也提到了添加元素进 的过程,如果 在对比的时候,同样的 有多个对象,它会继续使用 来判断是否真的相同。也就是说 帮助我们大大缩小了查找成本。 那为什么不只提供 方法呢? 这是因为两个对象的 值相等并不代表两个对象就相等。 那为什么两个对象有相同的 值,它们也不一定是相等的? 因为 所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞就是指不同的对象得到相同的 )。 总结下来就是: 如果两个对象的 值相等,那这两个对象不一定相等(哈希碰撞)。 如果两个对象的 值相等并且 方法也返回 ,我们才认为这两个对象相等。 如果两个对象的 值不相等,我们就可以直接认为这两个对象不相等。 相信大家看了我前面对 和 的介绍之后,下面这个问题已经难不倒你们了。 为什么重写 equals() 时必须重写 hashCode() 方法? 因为两个相等的对象的 值必须是相等。也就是说如果 方法判断两个对象是相等的,那这两个对象的 值也要相等。 如果重写 时没有重写 方法的话就可能会导致 方法判断是相等的两个对象, 值却不相等。 思考 :重写 时没有重写 方法的话,使用 可能会出现什么问题。 总结 : 方法判断两个对象是相等的,那这两个对象的 值也要相等。 两个对象有相同的 值,他们也不一定是相等的(哈希碰撞)。 更多关于 和 的内容可以查看:Java hashCode() 和 equals()的若干问题解答 String ⭐️ String、StringBuffer、StringBuilder 的区别? 可变性 是不可变的(后面会详细分析原因),每次修改都会生成新的对象,并将引用指向新的实例,而 和 都是可变的,它们在修改字符串时不会创建新对象,而是直接在原有字符数组上进行操作。 与 都继承自 类,在 中也是使用字符数组保存字符串,不过没有使用 和 关键字修饰,最关键的是这个 类还提供了很多修改字符串的方法比如 方法。 线程安全性 中的对象是不可变的,也就可以理解为常量,线程安全。 是 与 的公共父类,定义了一些字符串的基本操作,如 、 、 、 等公共方法。 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。 并没有对方法进行加同步锁,所以是非线程安全的。 性能 两者的性能差异主要来源于线程安全机制: 的方法通常是同步的(线程安全),因此会带来一定的性能开销; 没有同步开销(非线程安全),在单线程场景下通常具有更好的性能表现。 相同情况下使用 相比使用 仅能获得 10% 15% 左右的性能提升,但却要冒多线程不安全的风险。 另外,具体的性能差异并不是固定的,在现代 JVM 中由于锁优化(如锁消除),两者在某些场景下性能差距可能较小。 对于三者使用的总结: 操作少量的数据: 适用 单线程操作字符串缓冲区下操作大量数据: 适用 多线程操作字符串缓冲区下操作大量数据: 适用 ⭐️ String 为什么是不可变的? 类中使用 关键字修饰字符数组来保存字符串, 所以 对象是不可变的。 🐛 修正:我们知道被 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此, 关键字修饰的数组保存字符串并不是 不可变的根本原因,因为这个数组保存的字符串是可变的( 修饰引用类型变量的情况)。 真正不可变有下面几点原因: 1. 保存字符串的数组被 修饰且为私有的,并且 类没有提供/暴露修改这个字符串的方法。 2. 类被 修饰导致其不能被继承,进而避免了子类破坏 不可变。 相关阅读:如何理解 String 类型值的不可变? 知乎提问 补充(来自issue 675):在 Java 9 之后, 、 与 的实现改用 数组存储字符串。 Java 9 为何要将 的底层实现由 改成了 ? 新版的 String 其实支持两个编码方案:Latin 1 和 UTF 16。如果字符串中包含的汉字没有超过 Latin 1 可表示范围内的字符,那就会使用 Latin 1 作为编码方案。Latin 1 编码方案下, 占一个字节(8 位), 占用 2 个字节(16), 相较 节省一半的内存空间。 JDK 官方就说了绝大部分字符串对象只包含 Latin 1 可表示的字符。 如果字符串中包含的汉字超过 Latin 1 可表示范围内的字符, 和 所占用的空间是一样的。 这是官方的介绍:<https://openjdk.java.net/jeps/254 。 ⭐️ 字符串拼接用“+” 还是 StringBuilder? Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。 上面的代码对应的字节码如下: 可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 调用 方法实现的,拼接完成之后调用 得到一个 对象。 不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷: 编译器不会创建单个 以复用,会导致创建过多的 对象 。 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 对象。 如果直接使用 对象进行字符串拼接的话,就不会存在这个问题了。 如果你使用 IDEA 的话,IDEA 自带的代码检查机制也会提示你修改代码。 在 JDK 9 中,字符串相加“+”改为用动态方法 来实现,通过提前分配空间从而减少了部分临时对象的创建。然而这种优化主要针对简单的字符串拼接,如: 。对于循环中的大量拼接操作,仍然会逐个动态分配内存(类似于两个两个 append 的概念),并不如手动使用 StringBuilder 来进行拼接效率高。这个改进是 JDK9 的 JEP 280 提出的,关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 StringBuilder?来重温一下字符串拼接吧 以及参考 issue 2442。 String equals() 和 Object equals() 有何区别? 中的 方法是被重写过的,比较的是 String 字符串的值是否相等。 的 方法是比较的对象的内存地址。 ⭐️ 字符串常量池的作用了解吗? 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。 更多关于字符串常量池的介绍可以看一下 Java 内存区域详解 这篇文章。 ⭐️ String s1 = new String("abc");这句话创建了几个字符串对象? 先说答案:会创建 1 或 2 个字符串对象。 1. 字符串常量池中不存在 "abc":会创建 2 个 字符串对象。一个在字符串常量池中,由 指令触发创建。一个在堆中,由 创建,并使用常量池中的 "abc" 进行初始化。 2. 字符串常量池中已存在 "abc":会创建 1 个 字符串对象。该对象在堆中,由 创建,并使用常量池中的 "abc" 进行初始化。 下面开始详细分析。 下面开始详细分析。 1、如果字符串常量池中不存在字符串对象 “abc”,那么它首先会在字符串常量池中创建字符串对象 "abc",然后在堆内存中再创建其中一个字符串对象 "abc"。 示例代码(JDK 1.8): 对应的字节码: 指令的确是从常量池中加载各种类型的常量,包括字符串常量、整数常量、浮点数常量,甚至类引用等。对于字符串常量, 指令的行为如下: 1. 从常量池加载字符串 : 首先检查字符串常量池中是否已经有内容相同的字符串对象。 2. 复用已有字符串对象 :如果字符串常量池中已经存在内容相同的字符串对象, 会将该对象的引用加载到操作数栈上。 3. 没有则创建新对象并加入常量池 :如果字符串常量池中没有相同内容的字符串对象,JVM 会在常量池中创建一个新的字符串对象,并将其引用加载到操作数栈中。 2、如果字符串常量池中已存在字符串对象“abc”,则只会在堆中创建 1 个字符串对象“abc”。 示例代码(JDK 1.8): 对应的字节码: 这里就不对上面的字节码进行详细注释了,7 这个位置的 命令不会在堆中创建新的字符串对象“abc”,这是因为 0 这个位置已经执行了一次 命令,已经在堆中创建过一次字符串对象“abc”了。7 这个位置执行 命令会直接返回字符串常量池中字符串对象“abc”对应的引用。 String intern 方法有什么作用? 是一个 (本地) 方法,用来处理字符串常量池中的字符串对象引用。它的工作流程可以概括为以下两种情况: 1. 常量池中已有相同内容的字符串对象 :如果字符串常量池中已经有一个与调用 方法的字符串内容相同的 对象, 方法会直接返回常量池中该对象的引用。 2. 常量池中没有相同内容的字符串对象 :如果字符串常量池中还没有一个与调用 方法的字符串内容相同的对象, 方法会将当前字符串对象的引用添加到字符串常量池中,并返回该引用。 总结: 方法的主要作用是确保字符串引用在常量池中的唯一性。 当调用 时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;否则,将该字符串添加到常量池并返回其引用。 示例代码(JDK 1.8) : String 类型的变量和常量做“+”运算时发生了什么? 先来看字符串不加 关键字拼接的情况(JDK1.8): 注意 :比较 String 字符串的值是否相等,可以使用 方法。 中的 方法是被重写过的。 的 方法是比较的对象的内存地址,而 的 方法比较的是字符串的值是否相等。如果你使用 比较两个字符串是否相等的话,IDEA 还是提示你使用 方法替换。 对于编译期可以确定值的字符串,也就是常量字符串,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。 在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到: 常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。 对于 编译器会给你优化成 。 并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以: 基本数据类型( 、 、 、 、 、 、 、 )以及字符串常量。 修饰的基本数据类型和字符串变量 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、 、 ) 引用的值在程序编译期是无法确定的,编译器无法对其进行优化。 对象引用和“+”的字符串拼接方式,实际上是通过 调用 方法实现的,拼接完成之后调用 得到一个 对象。 我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 或者 。 不过,字符串使用 关键字声明之后,可以让编译器当做常量来处理。 示例代码: 被 关键字修饰之后的 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。 如果,编译器在运行时才能知道其确切值的话,就无法对其优化。 示例代码( 在运行时才能确定其值): 参考 深入解析 String intern:<https://tech.meituan.com/2014/03/06/in depth understanding string intern.html Java String 源码解读:<http://keaper.cn/2020/09/08/java string mian mian guan/ R 大(RednaxelaFX)关于常量折叠的回答:<https://www.zhihu.com/question/55976094/answer/147302764 写在最后 如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。 JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。 如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。