八股文笔记 #1 Java 基础

再好的项目,也敌不过 HashMap 的 resize 过程没讲清楚

概念

1# 说一下 Java 的特点

  1. 平台无关性:Java 的 “编写一次,运行无处不在” 哲学是其最大的特点之一。Java 编译器将源代码编译成字节码,该字节码可以在任何安装了 JVM 的系统上运行
  2. 面向对象:Java 是一门严格的面向对象的编程语言,几乎一切都是对象。OOP 特性使得代码更易于维护和复用,包括类、对象、继承、多态、抽象和封装
  3. 内存管理:Java 有自己的垃圾回收机制,自动管理内存和回收不再使用的对象

2# Java 的优势和劣势是什么?

优势

  1. 跨平台:因为 JVM 的存在,一次编写到处运行
  2. 面向对象:虽然现在很多语言都支持面向对象,但是 Java 的设计从一开始就是 OPP 的
  3. 强大的生态:比如 Spring 框架,各种库和工具,社区支持大,企业应用广泛
  4. 内存管理:自动垃圾回收机制,减少了内存泄漏的问题,对开发者友好
  5. 多线程支持:内置的线程机制,方便并发编程
  6. 安全性:Java 有安全模型,比如沙箱机制,适合网络环境
  7. 稳定性:企业级应用长期使用,版本更新也比较注重向后兼容

劣势

  1. 性能:虽然 JVM 优化了很多,但相比 CPP 和 Rust 这种原生编译语言,还是有一定开销
  2. 启动时间:比如微服务场景下,可能不如 Go 之类的快
  3. 语法繁琐:样板代码多,之前没有 lambda 的时候更麻烦,现在有了但是相比 Python 还是不够简洁
  4. 内存消耗:JVM 本身占内存,对于资源有限的环境可能不太友好
  5. 面向对象过于严格:有时候写简单程序反而麻烦,虽然 Java 8 引入了函数式编程,但是不如其他语言自然
  6. 开发效率:相比动态语言如 Python,Java需要更多代码,编译过程也可能拖慢开发节奏

3# Java 为什么是跨平台的?

主要依赖于 JVM。JVM也是一个软件,不同平台有不同的版本。编写的 Java 源码在编译后生成一种 .class 文件,称为字节码文件。JVM 就是负责将机器码文件翻译成特定平台下的机器码然后运行

也就是说,只要在不同平台上安装对应的 JVM,就可以运行字节码文件,运行我们编写的 Java 程序

PS:不同平台下编译生成的字节码是一样的,但是由 JVM 翻译成的机器码是不一样的;即使将 Java 程序打包成可执行文件(如 .exe),仍然需要 JVM 的支持;跨平台的是 Java 程序,不是 JVM,JVM 是用 C/CPP 开发的,是编译后的机器码,不能跨平台,不同平台下需要安装不同版本的 JVM

4# JVM、JDK、JRE 三者关系?

JVM、JDK、JRE 关系图
  • JDK 是 Java 开发工具包,是开发 Java 程序所需的工具集合。它包含了 JVM、编译器(javac)、调试器(jdb)等开发工具,以及一系列的类库(如 Java 标准库和开发工具库)。JDK 提供了开发、编译、调试和运行 Java 程序所需的全部工具和环境
  • JRE 是 Java 运行时环境,是 Java 程序运行所需的最小环境。它包含了 JVM 和 一组 Java 类库,用于支持 Java 程序的执行。JRE 不包含开发工具,只提供 Java 程序运行所需的运行环境
  • JVM 是 Java 虚拟机,是 Java 程序运行的环境。它负责将 Java 字节码(由 Java 编译器生成)解释或编译成机器码,并执行程序。JVM 提供了内存管理、垃圾回收、安全性等功能,使得 Java 程序具备跨平台性

5# 为什么 Java 解释和编译都有?

在 Java 经过编译之后生成字节码文件,接下来进入 JVM 中,就有两个步骤编译和解释,如图:

Java 源码执行过程
  • 编译性:Java 源码(.java 文件)通过 javac 编译,生成字节码文件(.class

  • 解释性:早期 JVM 使用解释器,逐行解释执行字节码。现代 JVM 引入 JIT(Just-In-Time)编译器,热点代码会被编译为本地机器码提高执行效率

所以 Java 既是编译型语言,也是解释型语言,默认采用的是解释器和编译器混合的模式

6# JVM 是什么?

JVM 是 Java 虚拟机,主要工作是解释自己的指令集(即字节码)并映射到本地的 CPU 指令集和 OS 的系统调用

JVM 屏蔽了与操作系统平台相关的信息,使得 Java 程序只需要生成在 Java 虚拟机上运行的目标代码(字节码),就可在多种平台上不加修改地运行,这也是 Java 能够 “一次编译,到处运行” 的原因

7# 编译型语言和解释型语言的区别?

  • 编译型语言:在程序执行之前,整个源码会被编译成字节码或机器码,生成可执行文件。执行时直接运行编译后的代码,速度快,但跨平台性较差

  • 解释型语言:在程序执行时,逐行解释执行源码,不生成独立的可执行文件。通常由解释器动态解释并执行代码,跨平台性好,但执行速度相对较慢

典型的编译型语言如 C、CPP,典型的解释型语言如 Python、JavaScript

8# Java 和 Python 的区别是什么?

  • Java 是一种先编译后解释的混合型语言,Java 编译器将源码编译为字节码,而字节码则由 Java 虚拟机执行
  • Python 是一种解释型语言,会在执行程序的同时进行解释

数据类型

1# 8 种基本的数据类型

  • 数值型:整数类型(byteshortintlong)和浮点类型(floatdouble
  • 字符型char
  • 布尔型boolean

8 种基本数据类型的占用大小、取值范围、默认值,如下表所示:

数据类型 占用大小(字节) 取值范围 默认值 描述
byte 1 -2^72^7 - 1 0 最小的整数类型,适合用于节省内存,例如在处理文件或网络流时存储小范围整数数据
short 2 -2^152^15 - 1 0 较少使用,通常用于在需要节省内存且数据范围在该区间的场景
int 4 -2^312^31 - 1 0 最常用的整数类型,可满足大多数日常编程种整数计算的需求
long 8 -2^632^63 - 1 0L 用于表示非常大的整数,当 int 类型无法满足需求时使用,定义时数值后需加 Ll
float 4 -2^312^31 - 1 0.0f 单精度浮点数,用于表示小数,定义时数值后需加 Ff
double 8 -2^632^63 - 1 0.0d 双精度浮点数,精度比 float 高,是 Java 种表示小数的默认类型
char 2 -2^152^15 - 1 ‘\u0000’ 用于表示单个字符,采用 Unicode编码,可表示各种语言的字符
boolean 不确定(理论上1位) truefalse false 用于逻辑判断,只有两个取值,常用于条件判断和循环控制等逻辑场景

floatdouble 的最小值和最大值是以科学计数法的形式输出的,比如 3.14E3 表示 3.14 * 10^3,3.14E-3 表示 3.14 / 10^3

注意:

  • 浮点数的默认类型是 double,声明 float 时必须在末尾加上 Ff
  • 整数的默认类型为 int,声明 long 时必须在末尾加上 Ll
  • char 的包装类是 Characterint 的是 Integer,其他都是首字母大写
  • char 是无符号的,不能为负,所以是 0 开始的

2# long 和 int 可以互转吗?

可以。由于 long 的范围比 int 大,因此将 int 转换为 long 是安全的,而将 long 转换为 int 可能会导致数据丢失或溢出

int 转换为 long 可以通过直接赋值或强制类型转换来实现。例如:

1
2
int intValue = 10;
long longValue = intValue; // 自动转换,安全的

long 转换为 int 需要使用强制类型转换,但需要注意潜在的数据丢失或溢出问题。例如:

1
2
long longValue = 100L;
int intValue = (int) longValue; // 强制类转换,可能会有数据丢失或溢出

如果 longValue 的值超出了 int 的范围,转换结果是截断后的低位部分。因此,在转换之前,建议先检查 longValue 的值是否在 int 范围内,以避免数据丢失或溢出的问题

3# 数据类型转换的方式有哪些?

  1. 自动类型转换(隐式转换):当目标类型的范围大于源类型时,Java 会自动将源类型转换为目标类型,不需要显示的类型转换。例如:将 int 转换为 long、将 float 转换为 double
  2. 强制类型转换(显示转换):当目标类型的范围小于源类型时,需要使用强制类型转换将源类型转换为目标类型。这可能导致数据丢失或溢出。例如:将 long 转换为 int 、将 double 转换为 float 等。语法为:目标类型 变量名 = (目标类型) 源类型
  3. 字符串转换:Java 提供了将字符串表示的数据转换为其他类型数据的方法。例如,将字符串转换为整型 int,可以使用 Integer.parseInt() 方法;将字符串转换为浮点型 double,可以使用 Double.parseDouble() 方法等
  4. 数值之间的转换:Java 提供了一些数据类型之间的转换方法,如将整型转换为字符型、将字符型转换为整型等。这些转换方式可以通过类型的包装类来实现,例如:将 int 转换为 char,可以使用 Character.forDigit() 方法;将 char 转换为 int,可以使用 Character.getNumericValue() 方法

4# 类型互转可能会出现什么问题?

  1. 数据丢失:当将一个范围较大的数据类型转换为一个范围较小的数据类型时,可能会发生数据丢失。例如:将一个 long 转换为 int 时,如果 long 值超出了 int 类型的范围,转换结果将是截断后的低位部分,高位部分的数据丢失
  2. 数据溢出:与数据丢失相反,当将一个范围较小的数据类型转换为一个范围较大的数据类型时,可能会发生数据溢出。例如:将一个 int 转换为 long 时,转换结果会填充额外的高位空间,但原始数据仍然保持不变
  3. 精度损失:在进行浮点数类型的转换时,可能会发生精度损失。由于浮点数的表示方法不同,将一个双精度浮点数 double 转换为单精度浮点数 float 时,精度可能会损失
  4. 类型不匹配导致的错误:在进行类型转换时,需要确保源类型和目标类型是兼容的。如果两者不兼容,会导致编译错误或运行时错误

5# 为什么用 BigDecimal 不用 double?

double 会出现精度丢失的问题,dobule 执行的是二进制浮点运算,二进制表示小数只能使用 1/(2^n) 的和的组合,有些情况下不能准确地表示一个小数。

BigDecimal 是精确计算,一般牵扯到金钱的计算,都使用 BigDecimal。例如:

1
2
3
4
5
BigDecimal num1 = new BigDecimal("0.1");
BigDecimal num1 = new BigDecimal("0.2");

BigDecimal sum = num1.add(num2); // 0.3
BigDecimal product = num1.multiply(num2); // 0.02

这样,BigDecimal 可以确保精确的十进制数值计算,避免了使用 double 可能出现的舍入误差。需要注意的是,在创建 BigDecimal 对象时,应该使用字符串作为参数,而不是直接使用浮点数值,以避免浮点数精度丢失

6# 装箱和拆箱是什么?

装箱(Boxing)和拆箱(Unboxing)是将基本数据类型和对应的包装类之间进行转换的过程,自动装箱主要发送在赋值时和方法调用时,例如:

1
2
3
4
5
6
7
8
9
10
11
12
// 赋值时
Integer i = 10; // 装箱
int n = i; // 拆箱

// 方法调用时
public static Integer show(Integer iParam) {
System.out.println("自动装箱" + iParam);
return iParam;
}

show(3); // 装箱
int result = show(3); // 返回类型是 Integer,拆箱

在一个循环中进行自动装箱操作的情况下可能出现问题,会创建多余的对象,影响程序的性能,例如:

1
2
Integer sum = 0;
for (int i = 0; i < 1000; i++) { sum += 1; }

+ 这个操作符不适用于 Integer 对象,首先 sum 进行自动拆箱操作,进行数值相加操作,最后发生自动装箱操作转换成 Integer 对象。其内部变化如下:

1
2
int result = sum.intValue() + i;
Integer sum = new Integer(result);

因为 sum 声明为 Integer 类型,上面的循环中会创建 1000 个无用的 Integer 对象,会降低程序的性能,加重垃圾回收的工作量。因此在编程的时候,需要正确地声明变量类型,避免因为自动装箱引起的性能问题

Java 为什么要有包装类?

包装类就是将基本数据类型包装成 Object 对象,对象封装有诸多好处:

  1. 可以把属性和方法结合在一起,比如 IntegerparseInte() 方法来专门处理 int 相关的数据
  2. Java 中大部分类和方法都是用来处理引用类型的。像 ArrayList 这样的集合类,不能直接存储基本类型(如 intdouble),而是必须使用其对应的包装类(如 IntegerDouble
  3. 在 Java 中,泛型只能使用引用类型,而不能使用基本数据类型。例如:对一个列表的元素进行排序,如果使用基本类型 int(实际上基本类型无法作为泛型参数,所以连 List<int> 都不合法),无法直接使用 Collections.sort() 方法,如果使用 Integer 包装类,则可以
  4. 在 Java 中,集合中只能存储引用类型,而不能存储基本类型。比如 List<int> 是不合法的,需要使用 List<Integer>

需要注意空指针异常。例如:int 可以赋值为 0,而 Integer 必须通过实例化对象来赋值,如果对一个未经初始化的 Integer 变量进行操作,就会出现 NullPointerException,这是因为它被赋值为 null,而 null 是无法进行自动拆箱的

7# 为什么还要保留基本数据类型?

包装类是引用类型,对象的引用和对象本身是分开存储的,而对于基本数据类型,变量对应的内存块直接存储数据本身。因此,基本数据类型在读写效率方面,要比包装类高效

此外,在 64 位 JVM 上,在开启引用压缩的情况下,一个 Integer 对象占用 16 个字节的内存空间,而一个 int 类型数据只占用 4 字节的内存空间

不管在读写效率,还是存储效率,基本类型都比包装类高效

8# 说一下 Integer 的基本缓存

Java 的 Integer 类内部实现了一个静态缓存池,用于存储特定范围内的整数值对应的 Integer 对象

默认情况下,这个范围是 -128 到 127。当通过 Integer.valueOf(int) 方法创建一个在这个范围内的整数对象时,并不会每次都生成新的对象实例,而是复用缓存中的对象,直接从内存中取出,不需要新建一个对象

面向对象

1# 怎么理解面向对象?简单说说封装、继承、多态

面向对象是一种编程范式,它将现实世界中的事物抽象为对象,对象具有属性和方法。面向对象编程的设计思想是以对象为中心,通过对象之间的交互来完成程序的功能,具有灵活性和可扩展性

Java 面向对象的三大特性包括**:封装、继承、多态**

  • 封装:封装是指将对象的属性(成员变量)和行为(方法)封装在一个类中,并通过访问控制符(如 privatepublic 等)隐藏内部实现细节,只暴露必要的接口供外部使用。封装的作用是增强安全性、提高模块独立性,防止外部对对象状态的非法访问或修改
  • 继承:继承是面向对象编程中实现代码复用的机制,它允许一个类(子类)继承另一个类(父类)的属性和方法。继承使得子类可以在父类基础上扩展功能,从而构建更清晰、更有层次的类结构
  • 多态:多态是指同一个方法调用,在不同对象上可以表现出不同的行为。它分为两种形式:
    • 编译时多态(方法重载):同一类中方法名相同、参数不同
    • 运行时多态(方法重写):子类重写父类方法,并通过父类引用调用子类对象。
      多态使程序具备更好的扩展性和可维护性,是实现面向接口编程的关键

2# 多态体现在哪几个方面?

  1. 方法重载:方法重载是指同一类中可以有多个同名方法,它们具有不同的参数列表。虽然方法名相同,但根据传入的不同参数,编译器会在编译时确定调用哪个方法。注意:方法返回值不同但参数相同,不构成重载

  2. 方法重写:方法重写是指子类能够提供对父类中同名方法的具体实现。在运行时,JVM 会根据对象的实际类型确定调用哪个版本的方法,这是实现多态的主要方式。注意:重写的方法必须具有相同的方法签名,且访问权限不能比父类更严格

  3. 接口与实现:Java 中的接口是一种行为规范,类通过 implements 关键字实现接口。多个类可以实现同一个接口,通过接口引用来调用方法,可以实现调用方与实现方的解耦,体现了多态特性

  4. 向上转型和向下转型

    • 向上转型:子类对象可以被赋值给父类类型的引用(即:Parent p = new Child()),这在 Java 中是自动进行的,也是多态的前提。可以只调用父类中定义的方法,实际运行的是子类的重写实现(如果有)

    • 向下转型:将父类引用转换为子类类型(即:Child c = (Child) p),这通常需要强制类型转换,并应使用 instanceof 检查其实际类型,避免出现 ClassCastException。向下转型常用于访问子类特有的方法

3# 多态解决了什么问题?

多态是指同一个方法调用,在不同对象上可以表现出不同的行为。多态的这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类

多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等

4# 面向对象的设计原则有哪些?

  1. 单一职责原则(SRP):一个类应该只有一个引起它变化的原因,即一个类应该只负责一项职责。例如:考虑一个员工类,它应该只负责管理员工信息,而不负责其他无关的工作
  2. 开放封闭原则(OCP):软件实体应该对扩展开放,对修改封闭。例如:定义一个图形类,然后让不同类型的图形继承这个类,而不需要修改图形类本身
  3. 里式替换原则(LSP):父类出现的地方,子类必须能够替代父类,并且保证原有功能不被破坏。例如:如果一个公司类包含部门类,应该考虑使用合成/聚合关系,而不是将公司类继承自部门类
  4. 接口隔离原则(ISP):客户端不应该依赖那些它不需要的接口,即接口应该小而专。例如:你设计了一个 “多功能设备接口”,里面包含打印扫描传真装订四个方法,结果,公司后来要做一款廉价入门级打印机,它只能打印,其余功能都没有。为了实现接口,这台打印机被迫提供空实现或抛异常
  5. 依赖倒置原则(DIP):高层模块不应该依赖低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。例如:假设有一个通知服务类 ,需要发送消息。错误的做法是它直接创建并依赖一个 EmailSender 类,这样如果以后要改用短信或微信,就必须修改通知服务的代码。正确的做法是先定义一个接口并实现它。通知服务类只依赖接口,而不是具体实现
  6. 最少知识原则(Law of Demeter):一个对象应当对其他对象有最少的了解,只与其直接的朋友交互。例如:你要点一杯咖啡,应该说:“服务员,请给我一杯拿铁。”,由服务员自己决定找谁、怎么做

5# 抽象类和普通类的区别?

  1. 实例化:普通类可以直接实例化对象,而抽象类不能被实例化,只能被继承
  2. 方法实现:普通类中的方法可以有具体的实现,而抽象类中的方法可以有实现也可以没有实现
  3. 实现限制:普通类可以被其他类继承和使用,而抽象类一般用于作为基类,被其他类继承和扩展使用

6# 抽象类和接口的区别是什么?

两者的特点

  • 抽象类用于描述类的共同特性和行为,可以有成员变量、构造方法和具体方法。适用于有明显继承关系的场景
  • 接口用于定义行为规范,可以多实现,只能有常量和抽象方法(Java 8 以后可以有默认方法和静态方法)。适用于定义类的能力或功能

两者的区别:

  1. 实现方式:实现接口的关键字为 implements,继承抽象类的关键字为 extends。一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承
  2. 方法方式:接口只有定义,不能有方法的实现,Java 8 中可以定义 default 方法体,而抽象类可以有定义与实现,方法可在抽象类中实现
  3. 访问修饰符:接口成员变量默认为 public static final,必须赋初值,不能被修改;其所有的成员方法都是 public abstract 的。抽象类中成员变量默认 default,可在子类中被重新定义,也可被重新赋值;抽象方法被 abstract 修饰,不能被 privatestaticsynchronizednative 等修饰,必须以分号结尾,不带花括号
  4. 变量:抽象类可以包含实例变量和静态变量,而接口只能包含常量(静态常量)

7# 抽象类能加 final 修饰吗?

不能。Java 中的抽象类是用来被继承的,而 final 修饰符用于禁止类被继承或方法被重写,因此,抽象类和 final 修饰符是互斥的,不能同时使用

8# 接口里面可以定义哪些方法

  1. 抽象方法:抽象方法是接口的核心部分,所有实现接口的类必须实现这些方法。抽象方法默认是 publicabstract,这些修饰符可以省略
  2. 默认方法:默认方法是在 Java 8 引入的,允许接口提供具体实现,实现类可以选择重写默认方法
  3. 静态方法:静态方法也是在 Java 8 引入的,它们属于接口本身,可以通过接口名直接调用,而不需要实现类的对象
  4. 私有方法:私有方法是在 Java 9 引入的,用于在接口中为默认方法和其他私有方法提供辅助功能,这些方法不能被实现类访问,只能在接口内部使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface Animal {
// 抽象方法
void makeSound();

// 默认方法
default void sleep() {
System.out.println("Sleeping...");
logSleep();
}

// 静态方法
static void staticMethod() {
System.out.println("Static method in interface");
}

// 私有方法
private void logSleep() {
System.out.println("Logging sleep");
}
}

9# 抽象类可以被实例化吗?

不能。这意味着不能使用 new 关键字直接创建一个抽象类的对象。抽象类的存在主要是为了被继承,它通常包含一个或多个抽象方法(由 abstract 关键字修饰且无方法体),这些方法在子类中被实现

抽象类可以有构造器,这些构造器在子类实例化时会被调用,以便进行必要的初始化工作。然而,这个过程并不是直接实例化抽象类,而是创建了子类的实例,间接地使用了抽象类的构造器。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class AbstractClass {
public AbstractClass() {
// 构造器代码
}

public abstract void abstractMethod();
}

public class ConcreteClass extends AbstractClass {
public ConcreteClass {
super(); // 调用抽象类的构造器
}

@Override
public void abstractMethod() {
// 实现抽象方法
}
}

ConcreteClass obj = new ConcreteClass();

10# 接口可以包含构造函数吗?

不能。接口不会有自己的实例,所以不需要有构造函数

这是因为,构造函数就是初始化类的属性或方法,在 new 的一瞬间自动调用,然而接口是不能 new 的

11# 解释 Java 中的静态变量和静态方法

在 Java 中,静态变量和静态方法是与类本身关联的,而不是与类的实例(对象)关联,它们在内存中只存在一份,可以被类的所有实例共享

  • 静态变量:静态变量(类变量)是在类中使用 static 关键字声明的变量,他们属于类而不是任何具体的对象。常用于需要在所有对象间共享的数据,如计数器、常量等。主要的特点:
    • 共享性:所有该类的实例共享同一个静态变量。如果一个实例修改了静态变量的值,其他实例也会看到这个更改
    • 初始化:静态变量在类被加载时初始化,只会对其进行一次内存分配
    • 访问方式:静态变量可以直接通过类名访问,也可以通过实例访问,但推荐使用类名
  • 静态方法:静态方法是在类中使用 static 关键字声明的方法,类似于静态变量,静态方法也属于类而不是任何具体的对象。常用于助手方法、获取类级别的信息或者是没有依赖于实例的数据处理。主要的特点:
    • 访问静态成员:静态方法可以直接调用其他静态变量和静态方法,但不能直接访问非静态成员
    • 无实例依赖:静态方法可以在没有创建类实例的情况下调用。对于静态方法来说,不能直接访问非静态的成员变量或方法,因为静态方法没有上下文的实例
    • 多态性:静态方法不支持重写(Override),但可以被隐藏(Hide)

12# 非静态内部类和静态内部类的区别?

  1. 非静态内部类依赖于外部类的实例,而静态内部类不依赖于外部类的实例
  2. 非静态内部类可以访问外部类的实例变量和方法,而静态内部类只能访问外部类的静态成员
  3. 非静态内部类不能定义静态成员,而静态内部类可以定义静态成员
  4. 非静态内部类在外部类实例化后才能实例化,而静态内部类可以独立实例化
  5. 非静态内部类可以访问外部类的私有成员,而静态内部类不能直接访问外部类的私有成员,需要通过实例化外部类来访问

13# 非静态内部类可以直接访问外部类的实例变量和方法,编译器是怎么做到的?

这是因为编译器在生成字节码时,会为非静态内部类维护一个指向外部类实例的引用,将外部类实例作为参数传入,并在内部类的实例化过程中建立外部类实例与内部类实例之间的联系,从而实现直接访问外部方法的功能

关键字

1# Java 中 final 的作用是什么?

  1. 装饰类:当 final 修饰一个类时,表示这个类不能被继承,是类继承体系中的最终形态。例如:Java 中的 String 类就是用 final 修饰的,着保证了 String 类的不可变性和安全性,防止其他类通过继承来改变 String 类的行为和特性
  2. 修饰方法:用 final 修饰的方法不能在子类中被重写。例如:java.lang.Object 类中的 getClass 方法就是 final 的,因为这个方法的行为是由 JVM 底层实现来保证的,不应该被子类修改
  3. 修饰变量:当 final 修饰基本数据类型的变量时,改变量一旦被赋值就不能再改变。例如:final int num = 10,这里的 num 就是一个常量,不能再对齐进行重新赋值操作,否则会导致编译错误。对于引用类型,final 修饰意味着这个引用变量不能再指向其他对象,但对象本身的内容是可以改变的。例如:final StringBuilder sb = new StringBuilder("Hello");,不能让 sb 再指向其他 StringBuilder 对象,但可以通过 sb.append(" World!"); 来修改字符串的内容

浅拷贝和深拷贝

1# 浅拷贝和深拷贝的区别?

深拷贝和浅拷贝的区别

  • 浅拷贝:只复制对象本身以及其字段中的基本类型值和引用类型的引用地址,不会递归复制引用对象本身。也就是说,浅拷贝会创建一个新的对象,但其中引用类型的字段仍指向原对象中引用的同一内存地址,所以原对象和拷贝对象会共享这些引用
  • 深拷贝:不仅复制对象本身,还会递归复制对象中所有的引用类型字段,确保所有嵌套的对象也被复制一份。这样,深拷贝生成的是一个完全独立的对象及其所有内部对象,原对象和拷贝对象之间互不影响

2# 实现深拷贝的三种方法是什么?

  • 实现 Cloneable 接口并重写 clone() 方法:在 clone() 方法中,通过递归克隆引用类型字段来实现深拷贝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyClass implements Cloneable {
private String field1;
private NestedClass nestedObject;

@Override
protected Object clone() throws CloneNotSupportedException {
MyClass cloned = (MyClass) super.clone();
cloned.nestedObject = (NestedClass) nestedObject.clone(); // 深拷贝内部的引用对象
return cloned;
}
}

class NestedClass implements Cloneable {
private int nestedField;

@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
  • 使用序列化和反序列化:通过将对象序列化为字节流,再从字节流反序列化为对象来实现深拷贝。要求对象及其所有引用类型字段都实现 Serializable 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyClass implements Serializable {
private String field1;
private NestedClass nestedObject;

public Myclass deepCopy() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(this);
oos.flush();
oos.close();
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
return (MyClass) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}

class NestedClass implements Serializable {
private int nestedField;
}
  • 手动递归复制:针对特定对象结构,手动递归复制对象及其引用类型字段。适用于对象结构复杂度不高的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyClass {
private String field1;
private NestedClass nestedObject;

public MyClass deepCopy() {
MyClass copy = new MyClass();
copy.setField1(this.field1);
copy.setNestedObject(this.nestedObject.deepCopy());
return copy;
}
}

class NestedClass {
private int nestedField;
public NestedClass deepCopy() {
NestedClass copy = new NestedClass();
copy.setNestedField(this.nestedField);
return copy;
}
}

泛型

1# 什么是泛型?

泛型是 Java 编程语言中的一个重要特性,它允许类、接口和方法在定义时使用一个或多个类型参数,这些类型参数在使用时可以被指定为具体的类型

泛型的主要目的是在编译时提供更强的类型检查,并且在编译后能够保留类型信息,避免了在运行时出现类型转换异常

2# 为什么需要泛型?

  • 适用于多种数据类型执行相同的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static int add(int a, int b) {
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}

private static float add(float a, float b) {
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}

private static double add(double a, double b) {
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}

如果没有泛型,要实现不同类型的加法,每种类型都需要重载一个 add() 方法;通过泛型,我们可以复用为一个方法:

1
2
3
4
private static <T extends Number> double add (T a, T b) {
System.out.println(a + "+" + b + "=" + (a.doubleValue() + b.doubleValue()));
return a.doubleValue() + b.doubleValue();
}
  • 泛型中的类型在使用时指定,不需要强制类型转换(类型安全,编译器会检查类型)

例如:

1
2
3
4
List list = new ArrayList();
list.add("Hello");
list.add(123); // 添加的是 Integer
String str = (String) list.get(1); // 编译通过,但运行时抛出 CCE

list 中的元素都是 Object 类型(无法约束其中的类型),所以在取出集合元素时需要人为地强制类型转换到具体的目标类型,且很容易出现 ClassCastException 异常

引入泛型,它将提供类型的约束,提供编译前的检查:

1
2
3
4
List<String> list = new ArrayList<String>();
list.add("Hello");
// list.add(123); // 编译错误,不允许添加非 String 类型
String str = list.get(0); // 不需要强制类型转换

对象

1# Java 创建对象有哪些方式?

  1. 使用 new 关键字:通过 new 关键字直接调用类的构造方法来创建对象:

    1
    MyClass obj = new MyClass();
  2. 通过反射创建对象:通过 Java 的反射机制可以再运行时动态地创建对象。可以使用 Class 类的 newInstance() 或者 Constructor 类的 newInstance() 方法创建对象:

    1
    2
    3
    4
    5
    6
    // Class 类
    MyClass obj = (MyClass) Class.forName("com.example.MyClass").newInstance();

    // Construcotr 类
    Constructor<MyClass> constructor = MyClass.class.getConstructor();
    MyClass obj = constructor.newInstance();
  3. 使用 clone() 方法:如果类实现了 Cloneable 接口,可以使用 clone() 方法复制对象:

    1
    2
    MyClass obj1 = new MyClass();
    MyClass obj2 = (MyClass) obj1.clone();
  4. 使用反序列化:通过将对象序列化到文件流中,然后再进行反序列化来创建对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class MyClass implements Serializable {
    // Class definition
    }

    public class Main {
    public static void main(String[] args) throws Exception {
    // Serialize object
    MyClass obj = new MyClass();
    ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("object.ser"))
    out.writeObject(obj);
    out.close();

    // Deserialize object
    ObjectInputStream in = new ObjectInputStream(new FileInputStream("object.ser"));
    MyClass newObj = (MyClass) in.readObject();
    in.close();
    }
    }

2# new 出的对象什么时候回收?

通过关键字 new 创建的对象,由 Java 的**垃圾回收器(Garbage Collector)**负责回收。垃圾回收器的工作是在程序运行过程中自动进行的,它会周期性地检测不再被引用的对象,并将其回收释放内存

具体来说,Java 对象的回收时机是由垃圾回收器根据一些算法来决定的,主要有以下几种情况:

  1. 引用计数法:某个对象的引用计数为 0 时,表示该对象不再被引用,可以被回收
  2. 可达性分析算法:从根对象(如方法区中的类静态属性、方法中的局部变量等)出发,通过对象之间的引用链进行遍历,如果存在一条引用链到达某个对象,则说明该对象是可达的,反之则不可达,不可达的对象将被回收
  3. 终结器(Finalizer):如果对象重写了 finalize() 方法,垃圾回收器会在回收该对象之前调用 finalize() 方法,对象可以在 finalize() 方法中进行一些清理操作。然而,终结器机制的使用不被推荐,因为它的执行时间是不确定的,可能会导致不可预测的性能问题

3# 如何获取私有对象?

在 Java 中,私有对象通常指的是类中被声明为 private 的成员变量或方法。由于 private 访问修饰符的限制,这些成员只能在其所在的类内部被访问

不过,可以通过下面两种方式来间接获取私有对象

  1. 使用公共访问器方法(getter 方法):如果类的设计者遵循良好的编程规范,通常会为私有成员变量提供公共的访问器方法(getter 方法),通过调用这些方法可以安全地获取私有对象

  2. 反射机制:反射机制允许在运行时检查和修改类、方法、字段等信息,通过反射可以绕过 private 访问修饰符的限制来获取私有对象。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class MyClass {
    private String privateField = "私有字段的值";
    }

    public class Main {
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessExcept {
    MyClass obj = new MyClass();
    Class<?> clazz = obj.getClass(); // 获取 Class 对象
    Field privateField = clazz.getDeclaredField("privateField"); // 获取私有字段
    privateField.setAccessible(true); // 设置可访问性
    String value = (String) privateField.get(obj); // 获取私有字段的值
    System.out.println(value);
    }
    }

反射

1# 什么是反射?

Java 反射机制是指在程序运行期间,能够动态地获取一个类的结构信息(如类名、属性、方法、构造函数等),并且可以在运行时操作这些信息,例如创建对象、访问属性、调用方法等。这使得 Java 拥有强大的动态性和灵活性,是实现诸如框架设计、依赖注入、动态代理等高级功能的重要基础

反射机制

反射具有以下特性

  1. 运行时类信息访问:反射允许程序在运行时获取一个类的完整结构信息,包括类名、字段、方法、构造器等,即使在编译时并不知道具体的类
  2. 动态创建对象:通过反射,可以在运行时动态地创建对象实例。即便类名是在运行时才确定,也可以使用 Class.newInstance()Constructor.newInstance() 方法完成对象的创建
  3. 动态方法调用:反射支持在运行时调用对象的方法,包括私有方法。这通常通过 Method.invoke() 实现,允许传入对象实例和参数,从而执行对应的方法
  4. 字段访问与修改:反射还可以访问和修改对象的字段值,包括私有字段。可以使用 Field.set() 方法设置字段的值,必要时可通过 setAccessible(true) 打破访问限制

2# 反射在你平时写代码或者框架中的应用场景有哪些?

一、加载数据库驱动:在使用 JDBC 连接数据库时,常常需要根据实际使用的数据库类型(如 MySQL、Oracle)动态加载对应的驱动类。此时可以使用 Class.forName() 方法通过反射机制加载指定的驱动类,而无需在代码中硬编码具体的驱动实现,从而提升系统的灵活性和可配置性。例如:

1
driverClass=com.mysql.cj.jdbc.Driver
1
2
3
4
5
Properties props = new Properties();
props.load(new FileInputStream("db.properties"));
String driverClass = props.getProperty("driverClass");

Class.forName(driverClass); // 反射方式动态加载驱动

二、配置文件加载:在 Spring 框架中,IoC(控制反转)容器能够根据配置文件(如 XML 或 properties)动态加载和管理 Bean。你只需在配置文件中声明需要的类及其属性,Spring 就会自动通过反射机制实例化相应的对象并注入依赖,极大地提高了程序的可扩展性与解耦性

下面以简单示例说明 Spring 通过配置文件和反射加载 Bean 的过程:

  • 将所有 XML 或 properties 配置文件加载到内存中
  • 通过 Java 代码解析配置文件,提取类的全限定名(即类的完整包名路径)及其属性等配置信息
  • 利用反射机制,根据提取的类名动态获取对应的 Class 实例,并创建对象或调用方法

配置文件(如 config.properties

1
2
className=com.example.reflectdemo.TestInvoke
methodName=printlnState

实体类(TestInvoke.java

1
2
3
4
5
public class TestInvoke {
private void printlnState(){
System.out.println("I am fine");
}
}

配置读取工具方法

1
2
3
4
5
6
7
public static String getName(String key) throws IOException {
Properties properties = new Properties();
FileInputStream in = new FileInputStream("D:\\config.properties");
properties.load(in);
in.close();
return properties.getProperty(key);
}

主方法:使用反射加载类并调用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) throws Exception {
// 1. 通过配置文件读取类名和方法名
String className = getName("className");
String methodName = getName("methodName");

// 2. 加载类
Class<?> clazz = Class.forName(className);

// 3. 获取并访问方法
Method method = clazz.getDeclaredMethod(methodName);
method.setAccessible(true); // 允许访问私有方法

// 4. 实例化对象并调用方法
Object instance = clazz.getDeclaredConstructor().newInstance();
method.invoke(instance); // 输出:I am fine
}

注解

1# 能讲一讲注解的原理吗?

在 Java 中,注解(Annotation)本质上是继承了 java.lang.annotation.Annotation 接口的特殊接口。每个注解类型在编译后都会生成一个 .class 文件,JVM 会根据字节码中的信息,在运行时动态创建该注解的代理对象

当我们通过反射调用 clazz.getAnnotation(MyAnnotation.class) 获取注解时,返回的并不是一个普通对象,而是一个由 JVM 动态生成的代理实例。该代理实现了注解接口,并会将方法调用转发给 AnnotationInvocationHandlerinvoke() 方法

AnnotationInvocationHandler 中维护了一个 memberValuesMap,它存储了注解的属性名与属性值。这个 Map 的内容来源于 .class 文件中的常量池,类加载时被 JVM 解析并缓存

总结整个流程

  1. 注解被编写并编译后,相关信息存入 .class 文件的字节码结构中;
  2. 如果注解的保留策略为 RUNTIME,JVM 在加载类时会解析注解信息;
  3. 调用反射 API 获取注解对象时,JVM 返回的是一个动态代理实例;
  4. 调用注解方法(如 value())时,方法会被代理对象拦截;
  5. 代理将调用转发至 AnnotationInvocationHandler,从 memberValues 中取出实际值并返回。

2# 对注解解析的底层实现了解吗?

注解本质上是一个接口,继承自 java.lang.annotation.Annotation。例如:

1
2
3
public @interface MyAnnotation {
String value();
}

编译后,注解类型会被转换成一个普通接口的形式,并保留相关元数据到 .class 文件中

Java 中通过 @Retention 注解指定注解的保留策略,常见的三种为:

  • SOURCE:仅保留在源码中,编译后丢弃
  • CLASS:编译时保留在 .class 文件中,运行时不可访问
  • RUNTIME:编译后保留,并可通过反射访问(实际参与运行时行为

只有 RUNTIME 注解会被 JVM 加载并参与反射处理,JVM 会将注解元数据存储在 .class 文件的**属性表(Attribute Table)**中,包括:

  • RuntimeVisibleAnnotations:存储运行时可见的注解信息
  • RuntimeInvisibleAnnotations:存储运行时不可见的注解信息
  • RuntimeVisibleParameterAnnotations:存储方法参数上的注解信息

通过反射 API 可以获取类、方法、字段等元素上的注解。反射的核心类 AnnotatedElement(如 ClassMethodField 都实现了它)提供了访问注解的接口,如:

  • getAnnotation(Class<T> annotationClass):获取指定类型的注解
  • getAnnotations():获取所有注解
  • isAnnotationPresent(Class<? extends Annotation> annotationClass):判断是否包含指定注解

这些方法最终调用的是 JVM 的 native 方法,如:

1
native Annotation[] getDeclaredAnnotations0(boolean publicOnly);

这些 native 方法在类加载时解析 .class 文件中保存的注解字节码信息,并生成动态代理对象,用于运行时访问

因此,注解解析的底层实现主要依赖于 Java 的反射机制和字节码文件的存储。通过 @Retention 元注解可以控制注解的保留策略,当使用 RetentionPolicy.RUNTIME 时,可以在运行时通过反射 API 来解析注解信息。在 JVM 层面,会从字节码文件中读取注解信息,并创建注解的代理对象来获取注解的属性值

3# 注解的适用范围(Target)是什么?

Java 中注解的“适用范围”由 @Target 元注解指定,用于定义注解可以作用于哪些程序元素。常见的范围包括:

  • ElementType.TYPE:类、接口(包括注解类型)或枚举
  • ElementType.METHOD:方法
  • ElementType.FIELD:字段(包括枚举常量)
  • ElementType.CONSTRUCTOR:构造方法
  • ElementType.PARAMETER:参数
  • ElementType.LOCAL_VARIABLE:局部变量
  • ElementType.ANNOTATION_TYPE:注解类型本身
  • ElementType.PACKAGE:包
  • ElementType.TYPE_USE:任何使用类型的地方(如泛型、强转、注解类型参数等)

例如:

1
2
3
4
5
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
String value();
}

这个注解就只能用于类或方法

异常

1# 介绍一下 Java 的异常

Java 异常类层次结构图

Java 的异常体系是围绕 Throwable 类及其子类构建的。Throwable 有两个直接子类:ErrorException,它们代表了两类不同性质的问题:

  1. Error(错误):表示 JVM 运行时出现的严重错误,通常是系统级的问题,程序无法控制和恢复。常见的错误包括 OutOfMemoryErrorStackOverflowError 等,这类错误 不建议 程序捕获或处理,因为处理后也难以恢复

  2. Exception(异常):表示程序本身可以感知并处理的问题,又细分为:

    • 受检查异常:编译器会强制检查,必须显式处理(try-catch)或声明抛出(throws)。例如 IOExceptionSQLExceptionClassNotFoundException 等,这些异常多与外部资源或环境因素有关(如文件、数据库、网络),处理它们可以提升程序的健壮性

    • 非受检查异常RuntimeException 及其子类,编译器不会强制检查,通常由程序逻辑错误引起。例如 NullPointerExceptionArrayIndexOutOfBoundsExceptionArithmeticException 等。虽然不是强制处理,但良好的程序设计应尽量避免这类异常

2# Java 的异常处理方式有哪些?

Java 提供了一套完整的异常处理机制,用于捕获和处理运行时可能出现的异常,以增强程序的健壮性和可维护性。主要包括以下几种方式:

一、try-catch-finally 语句块:用于捕获并处理代码执行过程中可能抛出的异常

  • try 块包含可能抛出异常的代码
  • catch 块用于捕获并处理特定类型的异常
  • finally 块(可选)用于释放资源或执行善后操作,无论是否发生异常都会执行,常用于资源释放(如关闭文件流、数据库连接等)
1
2
3
4
5
6
7
8
9
try {
// 可能抛出异常的代码
} catch (ExceptionType1 e1) {
// 处理异常类型1
} catch (ExceptionType2 e2) {
// 处理异常类型2
} finally {
// 无论是否发生异常,都会执行
}

二、throw 语句:用于手动抛出一个异常实例,抛出的对象必须是 Throwable 类型或其子类,通常用于业务逻辑判断,如参数校验失败时主动抛出异常

1
throw new IllegalArgumentException("参数不合法");

三、throws 关键字:用于在方法签名中声明该方法可能抛出的异常类型,通知调用者需要处理这些异常,如果是受检异常,必须显式处理或继续声明抛出

1
2
3
public void readFile(String path) throws IOException {
// 可能抛出 IOException
}

3# 抛出异常为什么不用 throws?

在 Java 中,如果异常属于非受检异常,或者在方法内部已经被捕获并处理,那么就不需要在方法签名中使用 throws 关键字

  1. 非受检异常:非检查异常是指继承自 RuntimeExceptionError 的异常类型。编译器不会强制要求开发者显式地处理这些异常,也不要求在方法签名中声明它们。包括 NullPointerExceptionArrayIndexOutOfBoundsExceptionIllegalArgumentExceptionArithmeticException 等,由于这类异常往往是由程序逻辑错误引起的,Java 更倾向于通过代码修复,而不是强制捕获或声明
  2. 异常已被捕获并处理:如果在方法内部使用了 try-catch 块对异常进行了处理,那么该异常就不会继续向外抛出,因此也不需要在方法签名中使用 throws

4# try-catch 中的语句运行情况

在 Java 中,try 块中的代码会按顺序执行:

  • 如果 try 块中未发生异常,则跳过所有 catch 块,直接执行 finally(如果有),然后继续执行后续代码
  • 如果 try 块中发生了异常,Java 会依次检查每个 catch 块,看是否有匹配的异常类型:
    • 如果匹配成功,对应的 catch 块会执行,然后进入 finally(如果有)
    • 如果没有匹配的 catch,异常将向上抛出,由调用者处理,finally 块仍会执行
  • finally 块始终会执行,无论是否发生异常,除非 JVM 退出或程序崩溃

5# try { return “a” } finally { return “b” } 返回什么?

该语句的返回值是 "b"

在 Java 中,如果 tryfinally 中都包含 return 语句,finally 中的 return 会覆盖 try 中的返回值。即使 try 中已经准备好返回 "a",在执行 finally 时仍会被 "b" 替代

Object

1# == 与 equals() 有什么区别?

在 Java 中,==equals() 都可用于比较两个对象,但它们的行为有本质上的不同:

  • ==:比较的是引用是否相等

    • == 判断两个对象是否指向同一块内存地址,即它们是否是同一个对象的引用

    • 对于基本数据类型,== 比较的是数值本身是否相等

    • 对于引用类型(如字符串、对象等),== 比较的是两个引用是否指向同一对象

    1
    2
    String b = new String("hello");
    System.out.println(a == b); // false:不同对象,地址不同
  • equals():比较的是内容是否相等

    • equals()Object 类中的方法,默认行为与 == 相同,即比较引用地址

    • 但很多类(如 StringIntegerList 等)都重写了 equals() 方法,改为比较对象的内容

    1
    System.out.println(a.equals(b)); // true:内容相同

需要注意:字符串是 Java 中的特殊对象,字面量字符串会被放入字符串常量池,如果你写 String s1 = "abc"; String s2 = "abc";,则 s1 == s2 会返回 true,因为它们引用的是常量池中的同一个对象

2# hashCode() 和 equals() 方法有什么关系?

在 Java 中,对于重写 equals() 方法的类,通常也需要重写 hashCode() 方法,并且需要遵循以下规定:

  • 一致性:如果两个对象使用 equals() 方法比较结果为 true,那么它们的 hashCode() 的值必须相同。也就是说,如果 obj1.equals(obj2) 返回 true,那么 obj1.hashCode() 必须等于 obj2.hashCode()
  • 非一致性:如果两个对象的 hashCode() 的值相同,它们使用 equals() 方法比较的结果不一定为 true。即 obj1.hashCode() == obj2.hashCode() 时,obj1.equals(obj2) 可能为 false,这种情况称为哈希冲突

hashCode()equals() 方法是紧密相关的,重写 equals() 方法时必须重写 hashCode() 方法,以保证在使用哈希表等数据结构时,对象的相等性判断和存储查找操作能够正常工作。而重写 hashCode() 方法时,需要确保相等的对象具有相同的哈希码,但相同哈希码的对象不一定相等

3# String、StringBuilder、StringBuffer 的区别和联系

  1. 可变性String 是不可变的(Immutable),一旦创建,内容无法修改,每次修改都会生成一个新的对象。StringBuilderStringBuffer 是可变的(Mutable),可以直接对字符串内容进行修改而不会创建新对象

  2. **线程安全性 **:String 因为不可变,天然线程安全。StringBuilder 不是线程安全的,适用于单线程环境。StringBuffer 是线程安全的,其方法通过 synchronized 关键字实现同步,适用于多线程环境

  3. **性能 **:String 性能最低,尤其是在频繁修改字符串时会生成大量临时对象,增加内存开销和垃圾回收压力。StringBuilder 性能最高,因为它没有线程安全的开销,适合单线程下的字符串操作。StringBuffer 性能略低于 StringBuilder,因为它的线程安全机制引入了同步开销

  4. **使用场景 **:如果字符串内容固定或不常变化,优先使用 String。如果需要频繁修改字符串且在单线程环境下,使用 StringBuilder。如果需要频繁修改字符串且在多线程环境下,使用 StringBuffer

对比总结如下:

特性 String StringBuilder StringBuffer
不可变性 不可变 可变 可变
线程安全 是(因不可变) 是(同步方法)
性能 低(频繁修改时) 高(单线程) 中(多线程安全)
适用场景 静态字符串 单线程动态字符串 多线程动态字符串

Java 新特性

1# Java 8 有什么新特性?

下面是 Java 8 主要新特性的整理表格,包含关键改进和示例说明:

特性名称 描述 示例或说明
Lambda 表达式 简化匿名内部类,支持函数式编程 (a, b) -> a + b 代替匿名类实现接口
函数式接口 仅含一个抽象方法的接口,可用 @FunctionalInterface 注解标记 Runnable, Comparator 或自定义接口 @FunctionalInterface interface MyFunc { void run(); }
Stream API 提供链式操作处理集合数据,支持并行处理 list.stream().filter(x -> x > 0).collect(Collectors.toList())
Optional 类 封装可能为 null 的对象,减少空指针异常 Optional.ofNullable(value).orElse("default")
方法引用 简化 Lambda 表达式,直接引用现有方法 System.out::println 等价于 x -> System.out.println(x)
接口的默认方法与静态方法 接口可定义默认实现和静态方法,增强扩展性 interface A { default void print() { System.out.println("默认方法"); } }
并行数组排序 使用多线程加速数组排序 Arrays.parallelSort(array)
重复注解 允许同一位置多次使用相同注解 @Repeatable 注解配合容器注解使用
类型注解 注解可应用于更多位置(如泛型、异常等) List<@NonNull String> list
CompletableFuture 增强异步编程能力,支持链式调用和组合操作 CompletableFuture.supplyAsync(() -> "result").thenAccept(System.out::println)

2# Lambda 表达式了解吗?

Lambda 表达式是一种简洁的语法,用于创建匿名函数,主要用于简化函数式接口(只有一个抽象方法的接口)的使用。其基本语法有以下两种形式:

  • (parameters) -> expression:当 Lambda 体只有一个表达式时使用,表达式的结果会作为返回值
  • (parameters) -> { statements; }:当 Lambda 体包含多条语句时,需要使用大括号将语句括起来,若有返回值则需要使用 return 语句

传统的匿名内部类实现方式代码较为冗长,而 Lambda 表达式可以用更简洁的语法实现相同的功能。例如,使用匿名内部类实现 Runnable 接口:

1
2
3
4
5
6
7
8
9
10
11
public class AnonymousClassExample {
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Running using anonymous class");
}
});
t1.start();
}
}

使用 Lambda 表达式实现相同功能:

1
2
3
4
5
6
public class LambdaExample {
public static void main(String[] args) {
Thread t1 = new Thread(() -> System.out.println("Running using lambda expression"));
t1.start();
}
}

Lambda 表达式能够更清晰地表达代码的意图,尤其是在处理集合操作时,如过滤、映射等。例如,过滤出列表中所有偶数:

1
2
3
4
5
6
7
8
9
10
public class ReadabilityExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
// 使用 Lambda 表达式结合 Stream API 过滤偶数
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evenNumbers);
}
}

Lambda 表达式使得 Java 支持函数式编程范式,允许将函数作为参数传递,从而可以编写更灵活、可复用的代码。例如,定义一个通用的计算函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface Calculator {
int calculate(int a, int b);
}

public class FunctionalProgrammingExample {
public static int operate(int a, int b, Calculator calculator) {
return calculator.calculate(a, b);
}

public static void main(String[] args) {
// 使用 Lambda 表达式传递加法函数
int sum = operate(3, 5, (x, y) -> x + y);
System.out.println("Sum: " + sum);

// 使用 Lambda 表达式传递乘法函数
int product = operate(3, 5, (x, y) -> x * y);
System.out.println("Product: " + product);
}
}

虽然 Lambda 表达式优点蛮多的,不过也有一些缺点,比如会增加调试困难,因为 Lambda 表达式是匿名的,在调试时很难定位具体是哪个 Lambda 表达式出现了问题。尤其是当 Lambda 表达式嵌套使用或者比较复杂时,调试难度会进一步增加

3# 介绍一下 Stream API

Java 8 引入了 Stream API,它提供了一种高效且易于使用的数据处理方式,特别适合集合对象的操作,如过滤、映射、排序等。Stream API 不仅可以提高代码的可读性和简洁性,还能利用多核处理器的优势进行并行处理

案例1:过滤并收集满足条件的元素

问题场景:从一个列表中筛选出所有长度大于 3 的字符串,并收集到一个新的列表中

没有 Stream API 的做法

1
2
3
4
5
6
7
8
List<String> originalList = Arrays.asList("apple", "fig", "banana", "kiwi");
List<String> filteredList = new ArrayList<>();

for (String item : originalList) {
if (item.length() > 3) {
filteredList.add(item);
}
}

这段代码需要显式地创建一个新的 ArrayList,并通过循环遍历原列表,手动检查每个元素是否满足条件,然后添加到新列表中

使用 Stream API 的做法

1
2
3
4
List<String> originalList = Arrays.asList("apple", "fig", "banana", "kiwi");
List<String> filteredList = originalList.stream()
.filter(s -> s.length() > 3)
.collect(Collectors.toList());

这里,我们直接在原始列表上调用 stream() 方法创建了一个流,使用 filter() 中间操作筛选出长度大于 3 的字符串,最后使用 collect(Collectors.toList()) 终端操作将结果收集到一个新的列表中。代码更加简洁明了,逻辑一目了然

案例2:计算列表中所有数字的总和

问题场景:计算一个数字列表中所有元素的总和

没有 Stream API 的做法

1
2
3
4
5
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = 0;
for (Integer number : numbers) {
sum += number;
}

这个传统的 for-each 循环遍历列表中的每一个元素,累加它们的值来计算总和

使用 Stream API 的做法

1
2
3
4
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();

通过 Stream API,我们可以先使用 mapToInt() 将 Integer 流转换为 IntStream(这是为了高效处理基本类型),然后直接调用 sum() 方法来计算总和,极大地简化了代码

4# Stream 流的并行 API 是什么?

是 ParallelStream

并行流(ParallelStream)就是将源数据分为多个子流对象进行多线程操作,然后将处理的结果再汇总为一个流对象,底层是使用通用的 fork/join 池来实现,即将一个任务拆分成多个 “小任务” 并行计算,再把多个 “小任务” 的结果合并成总的计算结果

Stream 串行流与并行流的主要区别:

Stream 串行流与并行流的主要区别

对 CPU 密集型的任务来说,并行流使用 ForkJoinPool 线程池,为每个 CPU 分配一个任务,这是非常有效率的,但是如果任务不是 CPU 密集的,而是 IO 密集的,并且任务数相对线程数比较大,那么直接用 ParallelStream 并不是很好的选择

5# CompletableFuture 怎么用的?

CompletableFuture 是由 Java 8 引入的,在 Java 8 之前我们一般通过 Future 实现异步

  • Future 用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,Java 8 之前若要设置回调一般会使用 guava 的 ListenableFuture ,回调的引入又会导致臭名昭著的回调地狱
  • CompletableFutureFuture 进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题

FutureListenableFuture)的实现(回调地狱)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
ExecutorService executor = Executors.newFixedThreadPool(5);
ListeningExecutorService guavaExecutor = MoreExecutors.listeningDecorator(executor);
ListenableFuture<String> future1 = guavaExecutor.submit(() -> {
//step 1
System.out.println("执行step 1");
return "step1 result";
});
ListenableFuture<String> future2 = guavaExecutor.submit(() -> {
//step 2
System.out.println("执行step 2");
return "step2 result";
});
ListenableFuture<List<String>> future1And2 = Futures.allAsList(future1, future2);
Futures.addCallback(future1And2, new FutureCallback<List<String>>() {
@Override
public void onSuccess(List<String> result) {
System.out.println(result);
ListenableFuture<String> future3 = guavaExecutor.submit(() -> {
System.out.println("执行step 3");
return "step3 result";
});
Futures.addCallback(future3, new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
System.out.println(result);
}
@Override
public void onFailure(Throwable t) {
}
}, guavaExecutor);
}

@Override
public void onFailure(Throwable t) {
}}, guavaExecutor);

CompletableFuture 的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ExecutorService executor = Executors.newFixedThreadPool(5);
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("执行step 1");
return "step1 result";
}, executor);
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> {
System.out.println("执行step 2");
return "step2 result";
});
cf1.thenCombine(cf2, (result1, result2) -> {
System.out.println(result1 + " , " + result2);
System.out.println("执行step 3");
return "step3 result";
}).thenAccept(result3 -> System.out.println(result3));

显然,CompletableFuture 的实现更为简洁,可读性更好

CompletableFuture

CompletableFuture 实现了两个接口:FutureCompletionStage

  • Future 表示异步计算的结果,CompletionStage 用于表示异步执行过程中的一个步骤(Stage),这个步骤可能是由另外一个 CompletionStage 触发的,随着当前步骤的完成,也可能会触发其他一系列 CompletionStage 的执行
  • 从而我们可以根据实际业务对这些步骤进行多样化的编排组合,CompletionStage 接口正是定义了这样的能力,我们可以通过其提供的 thenAppythenCompose 等函数式编程方法来组合编排这些步骤

6# Java 21 新特性知道哪些?

新语言特性

  1. Switch 语句的模式匹配:该功能在 Java 21 中也得到了增强。它允许在 switchcase 标签中使用模式匹配,使操作更加灵活和类型安全,减少了样板代码和潜在错误。例如:对于不同类型的账户类,可以在 switch 语句中直接根据账户类型的模式来获取相应的余额,如 case savingsAccount sa -> result = sa.getSavings();
  2. 数组模式:将模式匹配扩展到数组中,使开发者能够在条件语句中更高效地解构和检查数组内容。例如:if (arr instanceof int[] {1, 2, 3}),可以直接判断数组 arr 是否匹配指定的模式
  3. 字符串模板(预览版):提供了一种更可读、更易维护的方式来构建复杂字符串,支持在字符串字面量中直接嵌入表达式。例如,以前可能需要使用 "hello " + name + ", welcome to the geeksforgeeks!" 这样的方式来拼接字符串,在 Java 21 中可以使用 hello {name}, welcome to the geeksforgeeks! 这种更简洁的写法

新并发特性方面

  1. 虚拟线程:这是 Java 21 引入的一种轻量级并发的新选择。它通过共享堆栈的方式,大大降低了内存消耗,同时提高了应用程序的吞吐量和响应速度。可以使用静态构建方法、构建器或 ExecutorService 来创建和使用虚拟线程
  2. Scoped Values(范围值):提供了一种在线程间共享不可变数据的新方式,避免使用传统的线程局部存储,促进了更好的封装性和线程安全,可用于在不通过方法参数传递的情况下,传递上下文信息,如用户会话或配置设置

序列化

1# 怎么把一个对象从一个 JVM 转移到另一个 JVM

  1. 使用序列化和反序列化:将对象序列化为字节流,并将其发送到另一个 JVM,然后在另一个 JVM 中反序列化字节流恢复对象。这可以通过 Java 的 ObjectOutputStreamObjectInputStream 来实现。
  2. 使用消息传递机制:利用消息传递机制,比如使用消息队列(如 RabbitMQ、Kafka)或者通过网络套接字进行通信,将对象从一个 JVM 发送到另一个。这需要自定义协议来序列化对象并在另一个 JVM 中反序列化
  3. 使用远程方法调用(RPC):可以使用远程方法调用框架,如 gRPC,来实现对象在不同 JVM 之间的传输。远程方法调用可以让你在分布式系统中调用远程 JVM 上的对象的方法
  4. 使用共享数据库或缓存:将对象存储在共享数据库(如 MySQL、PostgreSQL)或共享缓存(如 Redis)中,让不同的 JVM 可以访问这些共享数据。这种方法适用于需要共享数据但不需要直接传输对象的场景

2# 让你自己实现序列化和反序列化,你会怎么做?

Java 默认的序列化虽然实现方便,但却存在安全漏洞、不跨语言以及性能差等缺陷:

  1. 无法跨语言: Java 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议。因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化
  2. 容易被攻击:Java 序列化是不安全的,我们知道对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的
  3. 序列化后的流太大:序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量

我会考虑用主流序列化框架,比如 FastJson、Protobuf 来替代 Java 序列化

如果追求性能的话,Protobuf 序列化框架会比较合适,Protobuf 的这种数据存储格式,不仅压缩存储数据的效果好, 在编码和解码的性能方面也很高效。Protobuf 的编码和解码过程结合 .proto 文件格式,加上 Protocol Buffer 独特的编码格式,只需要简单的数据运算以及位移等操作就可以完成编码与解码。可以说 Protobuf 的整体性能非常优秀

3# 将对象转为二进制字节流具体怎么实现?

其实,像序列化和反序列化,无论这些可逆操作是什么机制,都会有对应的处理和解析协议,例如加密和解密,TCP的粘包和拆包,序列化机制是通过序列化协议来进行处理的,和 .class 文件类似,它其实是定义了序列化后的字节流格式,然后对此格式进行操作,生成符合格式的字节流或者将字节流解析成对象

在 Java 中通过序列化对象流来完成序列化和反序列化:

  • ObjectOutputStream:通过 writeObject() 方法做序列化操作
  • ObjectInputStrean:通过 readObject() 方法做反序列化操作

只有实现了 SerializableExternalizable 接口的类的对象才能被序列化,否则抛出异常

一、实现对象序列化

  • 让类实现 Serializable 接口:
1
2
3
public class MyClass implements Serializable {
// class code
}
  • 创建输出流并写入对象:
1
2
3
4
5
6
7
8
9
10
MyClass obj = new MyClass();
try {
FileOutputStream fileOut = new FileOutputStream("object.ser");
ObjectOutputStream out = new ObjectOutputStream(fileOut);
out.writeObject(obj);
out.close();
fileOut.close();
} catch (IOException e) {
e.printStackTrace();
}

二、实现对象反序列化

  • 创建输入流并读取对象:
1
2
3
4
5
6
7
8
9
10
MyClass newObj = null;
try {
FileInputStream fileIn = new FileInputStream("object.ser");
ObjectInputStream in = new ObjectInputStream(fileIn);
newObj = (MyClass) in.readObject();
in.close();
fileIn.close();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}

通过以上步骤,对象 obj 会被序列化并写入到文件 object.ser 中,然后通过反序列化操作,从文件中读取字节流并恢复为对象 newObj。这种方式可以方便地将对象转换为字节流用于持久化存储、网络传输等操作。需要注意的是,要确保类实现了 Serializable 接口,并且所有成员变量都是 Serializable 的才能被正确序列化

设计模式

1# volatile 和 sychronized 如何实现单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SingleTon {

// 防止指令重排,保证可见性
private static volatile SingleTon instance = null;

private SingleTon() {}

public static SingleTon getInstance() {
if (instance == null) {
synchronized (SingleTon.class) {
if (instance == null) {
instance = new SingleTon();
}
}
}
return instance;
}
}

volatile 的作用:

  1. 保证可见性:多个线程访问 instance 时,能看到最新的值
  2. 禁止指令重排序:确保对象在完全构造前,其他线程不会看到它的引用,避免出现 “半初始化” 对象

双重检查锁定(DCL)原理:

  • 第一次检查:避免进入同步块(提升性能)
  • 同步块内部再次检查:保证线程安全,仅在首次创建对象时同步

2# 代理模式和适配器模式有什么区别?

  1. 目的不同:代理模式主要关注控制对对象的访问,而适配器模式则用于接口转换,使不兼容的类能够一起工作
  2. 结构不同:代理模式一般包含抽象主题、真实主题和代理三个角色,适配器模式包含目标接口、适配器和被适配者三个角色
  3. 应用场景不同:代理模式常用于添加额外功能或控制对对象的访问,适配器模式常用于让不兼容的接口协同工作

IO

1# Java 怎么实现网络 IO 高并发编程?

可以用 Java NIO,是一种同步非阻塞的 IO 模型,也是 IO 多路复用的基础

传统的 BIO 里面 socket.read(),如果 TCP RecvBuffer 里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据, 如果使用 BIO 要想要并发处理多个客户端的 IO,那么会使用多线程模式,一个线程专门处理一个客户端 IO,这种模式随着客户端越来越多,所需要创建的线程也越来越多,会急剧消耗系统的性能

BIO

NIO 是基于 IO 多路复用实现的,它可以只用一个线程处理多个客户端 IO,如果你需要同时管理成千上万的连接,但是每个连接只发送少量数据,例如一个聊天服务器,用NIO实现会更好一些

NIO

2# BIO、NIO、AIO区别是什么?

模型 特点 应用场景
BIO 同步阻塞,每个连接一个线程,简单但性能差 适用于连接数少、对实时性要求不高的系统
NIO 同步非阻塞,使用 Selector 管理多个通道,一个线程可处理多个连接 适用于高并发、大量连接的服务端程序
AIO 异步非阻塞,基于回调通知,IO 操作完全由系统异步完成 适用于连接数非常多、IO 操作频繁的高性能场景
  • BIO(Blocking IO):传统 java.io,线程阻塞直至完成读写操作,编程简单但不适合高并发
  • NIO(Non-blocking IO):Java 1.4 引入,引入 ChannelSelectorBuffer,可以实现单线程处理多连接
  • AIO(Asynchronous IO):Java 1.7 引入,真正的异步非阻塞,系统完成 IO 后自动回调通知业务线程处理

3# NIO 是怎么实现的?

NIO 是一种同步非阻塞的 IO 模型

同步是指线程不断轮询 IO 事件是否就绪,同步的核心是 Selector(IO多路复用),Selector 代替了线程本身轮询 IO 事件,避免了阻塞,同时减少了不必要的线程消耗;非阻塞是指线程在等待 IO 的时候,可以同时做其他任务,非阻塞的核心就是通道和缓冲区,当 IO 事件就绪时,可以通过写到缓冲区,保证 IO 的成功,而无需线程阻塞式地等待

NIO 通常由一个专门的线程负责监听所有 IO 事件,并进行分发处理。它基于事件驱动机制:当感兴趣的事件(如连接就绪、数据可读)发生时,线程被唤醒进行处理,而不是长时间阻塞等待

NIO 的核心组件

  • Channel(通道)
    类似传统 IO 的流,但既可读也可写,支持非阻塞模式

  • Buffer(缓冲区)
    所有数据读写都要通过 Buffer,Channel 与 Buffer 相互配合实现数据交换

  • Selector(选择器)
    允许单线程监听多个 Channel 的 IO 事件(如连接建立、数据可读),实现 IO 多路复用

NIO 原理

4# 你知道有哪个框架用到 NIO 了吗?

Netty

Netty 的 IO 模型是基于非阻塞 IO 实现的,底层依赖的是 NIO 框架的多路复用器 Selector。采用 epoll 模式后,只需要一个线程负责 Selector 的轮询。当有数据处于就绪状态后,需要一个事件分发器(Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。事件分发器有两种设计模式:Reactor 和 Proactor,Reactor 采用同步 IO, Proactor 采用异步 IO

Netty 的 IO 模型

Reactor 实现相对简单,适合处理耗时短的场景,对于耗时长的 IO 操作容易造成阻塞。Proactor 性能更高,但是实现逻辑非常复杂,适合图片或视频流分析服务器,目前主流的事件驱动模型还是依赖 select 或 epoll 来实现

其他

1# 有一个学生类,想按照分数排序,再按学号排序,应该怎么做?

在学生类中实现 Comparable 接口,并重写 compareTo 方法,然后在 compareTo 方法中实现按照分数排序和按照学号排序的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Student implements Comparable<Student> {
private int id;
private int score;

// 构造方法和其他属性、方法省略

@Override
public int compareTo(Student other) {
if (this.score != other.score) {
return Integer.compare(other.score, this.score); // 按照分数降序排序
} else {
return Integer.compare(this.id, other.id); // 如果分数相同,则按照学号升序排序
}
}
}

然后在需要对学生列表进行排序的地方,使用 Collections.sort() 方法对学生列表进行排序即可:

1
2
List<Student> students = new ArrayList<>(); // 添加学生对象到列表中
Collections.sort(students);

2# 解释一下 native 方法

在 Java 中,native 方法是一种特殊类型的方法,它允许 Java 代码调用外部的本地代码,即用 C、CPP 或其他语言编写的代码

native 方法看起来与其他方法相似,只是其方法体由 native 关键字代替,标记一个方法的实现将在外部定义,没有实际的实现代码。例如:

1
2
3
public class NativeExample {
public native void nativeMethod(); // 声明为 native,无方法体
}

使用 native 方法的一般流程

  1. 声明 native 方法:在 Java 类中使用 native 关键字声明方法,无需提供方法体
  2. 生成 JNI 头文件:使用 javac 编译 Java 类,然后用 javah(或 javac -h,较新版本)生成对应的 C/CPP 头文件(.h),该文件包含 native 方法的函数签名
  3. 实现本地代码:编写本地代码,实现 .h 文件中声明的方法逻辑。函数名需严格匹配 JNI 的命名规范
  4. 编译为本地库:将本地代码编译成动态链接库(.dll,在 Windows 上),共享库(.so,在 Linux 上)
  5. 加载本地库:使用 System.loadLibrary("库名") 加载编译好的本地库,使 JVM 能调用 native 方法
1
2
3
4
5
6
7
8
9
10
11
public class NativeExample {
static {
System.loadLibrary("nativeLib"); // 加载名为 nativeLib 的本地库
}

public native void nativeMethod(); // 声明 native 方法

public static void main(String[] args) {
new NativeExample().nativeMethod(); // 调用本地方法
}
}