【面试题】JAVA基础篇

内容来源于网络,如有侵权请联系删除

JAVA基础

你认为 Java 的优势是什么?

我觉得可以从跨平台垃圾回收生态面向对象四个方面来阐述。

跨平台

首先 Java 是跨平台的,不同平台执行的机器码是不一样的,而 Java 因为加了一层中间层 JVM ,所以可以做到一次编写多平台(如 Windows、Linux、macOS)运行,即 「Write once,Run anywhere」。

编译执行过程是先把 Java 源代码编译成字节码,字节码再由 JVM 解释或 JIT 编译执行,而因为 JIT 编译时需要预热的,所以还提供了 AOT(Ahead-of-Time Compilation),可以直接把字节码转成机器码,来让程序重启之后能迅速拉满战斗力。

(解释执行比编译执行效率差,你想想每次给你英语让你翻译阅读,还是直接给你看中文,哪个快?)

垃圾回收

Java 还提供垃圾自动回收功能,虽说手动管理内存意味着自由、精细化地掌控,但是很容易出错。

在内存较充裕的当下,将内存的管理交给 GC 来做,减轻了程序员编程的负担,提升了开发效率,更加划算!

生态

现在 Java 生态圈太全了,丰富的第三方类库、网上全面的资料、企业级框架、各种中间件等等。

面向对象

Java 是一种严格的面向对象编程语言,具有清晰的类、对象、继承、接口等概念,支持封装、继承、多态等 OOP 特性,有助于代码的可维护性和可扩展性。

拓展知识

Java 和 Go 的区别 ?

可以从语言设计理念、并发模型、内存管理、生态系统与应用场景来说:

1)语言设计理念

  • Java:Java 是一种面向对象编程语言,强调继承、多态和封装等 OOP 特性。它运行在 Java 虚拟机(JVM)上,实现了“编写一次,到处运行”的跨平台特性。Java 的设计目标是建立一个具有高度灵活性和可扩展性的通用编程平台。
  • Go:Go 是一种注重简洁性和高效性的编程语言,主要面向系统级编程和并发处理。Go 强调简单的语法和快速编译,并通过 Goroutine 和 Channel 提供了原生的并发支持。Go 的设计目标是提高开发者的生产力,并简化构建高性能服务器应用的过程。

2)并发模型

  • Java:Java 的并发模型基于操作系统线程,使用 Thread 类或 Executor 框架来管理并发任务。Java 并发编程中,通常需要显式地管理线程的创建、同步和资源共享。
  • Go:Go 的并发模型是基于 Goroutine 的,这是一种比操作系统线程更轻量级的线程。通过 Goroutine 和 Channel,Go 实现了轻量级的并发处理,并简化了线程间的通信和同步。

3)内存管理

  • Java:Java 使用垃圾回收(GC)机制自动管理内存。Java 的 GC 算法种类繁多,开发者可以根据应用需求选择合适的 GC 策略来优化性能。
  • Go:Go 也使用垃圾回收,但设计上更加简洁,专注于减少 GC 对应用性能的影响。Go 的 GC 更适合处理大量并发请求,具有较低的暂停时间。

4)生态系统与应用场景

  • Java:Java 具有庞大的生态系统和丰富的库支持,广泛应用于企业级应用开发、Web 开发、大数据处理、Android 开发等领域。
  • Go:Go 在云计算、微服务、容器化技术(如 Docker 和 Kubernetes)以及高性能服务器开发中得到广泛应用,特别是在需要高并发处理和低延迟的场景中表现突出。

Java Object 类中有什么方法,有什么作用?

以下是 Object 类中的主要方法及其作用:

1. public boolean equals(Object obj)

  • 作用:用于比较两个对象是否相等。默认实现比较对象的内存地址,即判断两个引用是否指向同一个对象。
  • 使用:通常会重写此方法来比较对象的内容或特定属性,以定义对象的相等性。

2. public int hashCode()

  • 作用:返回对象的哈希码,是对象的整数表示。哈希码用于支持基于哈希的集合(如 HashMapHashSet)。
  • 使用:如果重写了 equals 方法,则通常也需要重写 hashCode 方法,以保证相等的对象具有相同的哈希码。

3. public String toString()

  • 作用:返回对象的字符串表示。默认实现返回对象的类名加上其哈希码的十六进制表示。
  • 使用:通常会重写此方法以提供对象的更有意义的描述。

4. public final Class<?> getClass()

  • 作用:返回对象的运行时类(Class 对象)。此方法是 Object 类中的一个 final 方法,不能被重写。
  • 使用:可以用来获取对象的类信息,常用于反射操作。

5. public void notify()

  • 作用:唤醒在对象的监视器上等待的一个线程。该方法需要在同步块或同步方法中调用。
  • 使用:用于在多线程环境中进行线程间的通信和协调。

6. public void notifyAll()

  • 作用:唤醒在对象的监视器上等待的所有线程。该方法需要在同步块或同步方法中调用。
  • 使用:与 notify() 相似,但唤醒所有等待线程,用于处理多个线程之间的协作。

7. public void wait()

  • 作用:使当前线程等待,直到其他线程调用 notify()notifyAll() 方法。此方法需要在同步块或同步方法中调用。
  • 使用:用于线程间的通信,线程会等待直到被唤醒或超时。

8. public void wait(long timeout)

  • 作用:使当前线程等待,直到指定的时间到期或被唤醒。超时后线程会自动被唤醒。
  • 使用:用于实现带有超时的等待机制。

9. public void wait(long timeout, int nanos)

  • 作用:使当前线程等待,直到指定的时间和纳秒数到期或被唤醒。
  • 使用:用于实现更精细的等待控制,允许指定等待时间的精确到纳秒。

10. protected Object clone()

  • 作用:创建并返回当前对象的一个副本。默认实现是进行浅拷贝。
  • 使用:通常会重写此方法来实现深拷贝,以确保克隆对象的完整性。

11. protected void finalize()

  • 作用:在垃圾回收器确定不存在对该对象的更多引用时调用,用于进行资源释放等清理工作。
  • 使用:不建议使用,因为它依赖于垃圾回收器的实现,可能会导致不确定的性能问题。推荐使用 try-with-resourcesAutoCloseable 接口进行资源管理。

Java 中的字节码是什么?

Java 字节码是 Java 编译器将 Java 源代码编译后生成的中间表示形式,位于 Java 源代码与 JVM 执行的机器码之间。Java 字节码由 JVM 解释或即时编译(JIT)为机器码执行。

Java 字节码的关键点

1)字节码结构

  • Java 字节码是平台无关的指令集,存储在 .class 文件中。每个 .class 文件包含类的定义信息、字段、方法,以及方法对应的字节码指令。

2)字节码指令集

  • Java 字节码包含一系列指令,如加载、存储、算术运算、类型转换、对象操作、控制流等。常见的指令包括 aload, iload, astore, iadd, if_icmpgt 等。

3)执行过程

  • JVM 通过解释器逐条执行字节码,或通过 JIT 编译器将热点字节码片段即时编译为机器码,提高执行效率。

4)反射与动态代理

  • 通过 Java 反射 API,可以在运行时动态生成或修改字节码,从而创建代理对象或实现动态方法调用。

5)字节码增强与框架

  • 许多 Java 框架(如 Hibernate, Spring AOP)使用字节码增强技术,通过修改类的字节码来实现功能增强。常用工具包括 ASM、Javassist、CGLIB 等。

什么是 Java 中的继承机制?

Java 中的继承机制是面向对象编程的核心特性之一,允许一个类(子类)继承另一个类(父类)的属性和方法。继承机制使得类之间可以形成层次结构,支持代码重用和扩展。它是实现多态、抽象和代码复用的关键机制。

继承的优缺点

优点

  • 代码复用:子类可以复用父类的代码,减少重复实现。
  • 易于维护:可以通过修改父类代码来影响所有子类。

缺点

  • 紧耦合:子类依赖于父类的实现,父类的修改可能会影响子类。
  • 灵活性差:继承层次结构可能会变得复杂,不易于调整或扩展。

基本概念

子类继承父类的字段和方法,可以重用和扩展父类的功能。Java 使用 extends 关键字来表示类的继承关系。

Java 支持单继承,即一个类只能直接继承一个父类。子类可以继承父类的所有公共和受保护的成员,但不能继承父类的私有成员。

子类构造方法首先调用父类的无参构造方法,如果父类没有无参构造方法,子类必须显式调用父类的其他构造方法。

示例代码

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
// 父类
public class Animal {
protected String name;

public Animal(String name) {
this.name = name;
}

public void eat() {
System.out.println(name + " is eating.");
}
}

// 子类
public class Dog extends Animal {
public Dog(String name) {
super(name);
}

public void bark() {
System.out.println(name + " is barking.");
}
}

// 使用继承
public class Main {
public static void main(String[] args) {
Dog dog = new Dog("Buddy");
dog.eat(); // 继承自 Animal
dog.bark(); // Dog 自有的方法
}
}

super 关键字

super 关键字可以用来调用父类的方法或构造方法。

1
2
3
public void eat() {
super.eat(); // 调用父类的 eat 方法
}

super 关键字也可以用来访问父类的字段。

1
2
3
public void display() {
System.out.println(super.name); // 访问父类的 name 字段
}

重写和重载

抽象类和接口

为什么 Java 不支持多重继承

什么是 Java 的封装特性?

Java 的封装特性是面向对象编程的核心原则之一,它指的是将对象的状态(数据)和行为(方法)封装在一个类内部,并通过公开的接口与外部进行交互。封装的主要目的是隐藏对象的内部实现细节,只暴露必要的功能,从而保护数据的完整性和减少系统的复杂性。

基本概念

  • 数据隐藏:通过将类的字段(成员变量)声明为 privateprotected,避免直接被外部访问。只有通过类提供的公共方法(如 getter 和 setter)才能访问和修改这些字段。
  • 公共接口:通过公共方法(如 getter 和 setter)提供访问对象数据的方式。这样可以对数据进行控制和验证,确保数据的一致性和合法性。
  • 保护数据:封装通过限制对数据的直接访问,减少了对对象状态的不安全修改和潜在的错误。

示例代码

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
public class Person {
// 私有字段
private String name;
private int age;

// 公共构造方法
public Person(String name, int age) {
this.name = name;
this.age = age;
}

// 公共 getter 方法
public String getName() {
return name;
}

// 公共 setter 方法
public void setName(String name) {
this.name = name;
}

// 公共 getter 方法
public int getAge() {
return age;
}

// 公共 setter 方法
public void setAge(int age) {
if (age > 0) {
this.age = age;
} else {
throw new IllegalArgumentException("Age must be positive");
}
}
}

封装的好处

  • 数据保护:通过隐藏数据和提供受控的访问方法,可以防止外部代码对数据进行不合法的修改。
  • 维护性:封装使得对象的内部实现与外部接口分离,可以更容易地对内部实现进行更改,而不影响外部使用者。
  • 简化接口:提供简洁的公共接口,减少外部代码对类的复杂性理解,从而降低系统的耦合度。
  • 代码复用:通过封装,类可以重用已有的功能而不必重新实现,有助于构建模块化和可维护的代码。

访问控制修饰符:

  • **private**:只允许类内部访问,无法被外部访问。
  • **protected**:允许同一包内的类以及子类访问。
  • **public**:允许任何类访问。
  • 默认(包级别):只允许同一包内的类访问。

Java 中的基本数据类型有哪些?

Java 提供了 8 种基本数据类型(Primitive Types),用于处理不同类型的值:

整型

  • byte:占用 1 字节(8 位),取值范围为 -128 到 127。
  • short:占用 2 字节(16 位),取值范围为 -32,768 到 32,767。
  • int:占用 4 字节(32 位),取值范围为 -2^31 到 2^31-1。
  • long:占用 8 字节(64 位),取值范围为 -2^63 到 2^63-1。

浮点型

  • float:占用 4 字节(32 位),符合 IEEE 754 单精度标准。
  • double:占用 8 字节(64 位),符合 IEEE 754 双精度标准。

字符型

  • char:占用 2 字节(16 位),存储单个 Unicode 字符,取值范围为 0 到 65,535。

布尔型

  • boolean:用于表示 truefalse 两个值,具体存储大小依赖于虚拟机实现。

基本数据类型的特性

  • 大小固定:每种基本类型在不同的操作系统和平台上占用的内存大小是固定的,保证了跨平台的一致性。
  • 不支持 null:基本类型不能为 null,它们在声明时会有默认值,例如 int 的默认值是 0,boolean 的默认值是 false
  • 性能更高:基本类型直接存储在栈内存中,操作效率高于包装类型(如 IntegerDouble)。

默认值

  • byteshortintlong 的默认值是 0
  • floatdouble 的默认值是 0.0
  • char 的默认值是 '\u0000'
  • boolean 的默认值是 false

类型转换

  • 隐式转换:当小类型赋值给大类型时(例如 intlong),会进行隐式转换,不会发生数据丢失。
  • 强制类型转换:当大类型转换为小类型时(例如 doublefloat),需要显式进行强制类型转换,可能会造成精度丢失或溢出。

boolean 的存储

  • 虽然 boolean 类型在逻辑上只占用 1 位,但 Java 的虚拟机对 boolean 的存储通常会根据系统架构分配 1 字节或更多位数的空间。这是因为 CPU 通常按字节操作内存,而非按位。

什么是 Java 中的自动装箱和拆箱?

自动装箱(Autoboxing):指的是 Java 编译器自动将基本数据类型转换为它们对应的包装类型。比如,将 int 转换为 Integer

自动拆箱(Unboxing):指的是 Java 编译器自动将包装类型转换为基本数据类型。比如,将 Integer 转换为 int

主要作用

  • 它在 Java 5 中引入,主要是为了提高代码的可读性,减少手动转换操作,简化了代码编写,开发者可以更方便地在基本类型和包装类型之间进行转换。

常见于

  • 集合类如 List<Integer> 中无法存储基本类型,通过自动装箱,可以将 int 转换为 Integer 存入集合。
  • 自动装箱和拆箱经常在算术运算中出现,尤其是包装类型参与运算时。

自动装箱与拆箱的底层实现

自动装箱和拆箱并不是通过语法糖实现的,它是通过调用包装类型的 valueOf()xxxValue() 方法实现的。

  • 自动装箱调用:Integer.valueOf(int i)
  • 自动拆箱调用:Integer.intValue()

示例

1
2
Integer a = Integer.valueOf(10);  // 自动装箱
int b = a.intValue(); // 自动拆箱

自动装箱与拆箱的注意点

性能影响

自动装箱和拆箱虽然简化了编码,但在频繁使用的场景,可能导致性能开销,尤其是在循环中频繁发生装箱或拆箱时,容易引入不必要的对象创建和垃圾回收。

所以尽量避免在性能敏感的代码中频繁使用自动装箱和拆箱。例如:

1
2
3
4
Integer sum = 0;
for (int i = 0; i < 10000; i++) {
sum += i; // sum 是包装类型,导致多次装箱和拆箱
}

NullPointerException

在进行拆箱操作时,如果包装类对象为 null,会抛出 NullPointerException。

1
2
Integer num = null;
int n = num; // 抛出 NullPointerException

Java 中静态方法和实例方法的区别是什么?

静态方法

  • 使用 static 关键字声明的方法。
  • 属于类,而不是类的实例。
  • 可以通过类名直接调用,也可以通过对象调用,但这种方式不推荐,因为它暗示了实例相关性。
  • 可以访问类的静态变量和静态方法。不能直接访问实例变量和实例方法(因为实例变量和实例方法属于对象)。
  • 随着类的加载而加载,随着类的卸载而消失。

典型用途:

  • 工具类方法,如 Math 类中的静态方法 Math.sqrt(), Math.random().
  • 工厂方法,用于返回类的实例。

实例方法

  • 不使用 static 关键字声明的方法。
  • 属于类的实例。
  • 必须通过对象来调用。
  • 可以访问实例变量和实例方法。也可以访问类的静态变量和静态方法。
  • 随着对象的创建而存在,随着对象的销毁而消失。

典型用途:

  • 操作或修改对象的实例变量。
  • 执行与对象状态相关的操作。

表格总结:

特性 静态方法 实例方法
关键字 static
归属 对象
调用方式 通过类名或对象调用 通过对象调用
访问权限 只能访问静态变量和静态方法 可以访问实例变量、实例方法、静态变量和静态方法
典型用途 工具类方法、工厂方法 操作对象实例变量、与对象状态相关的操作
生命周期 类加载时存在,类卸载时消失 对象创建时存在,对象销毁时消失

注意事项

1)静态方法中不能使用 this 关键字,因为 this 代表当前对象实例,而静态方法属于类,不属于任何实例。

2)静态方法可以被重载(同类中方法名相同,但参数不同),但不能被子类重写(因为方法绑定在编译时已确定)。实例方法可以被重载,也可以被子类重写。

3)实例方法中可以直接调用静态方法和访问静态变量。

4)静态方法不具有多态性,即不支持方法的运行时动态绑定。

Java 中 for 循环与 foreach 循环的区别是什么?

for

for 是一种传统的循环结构,允许开发者控制循环的初始化、条件判断和迭代步进。

主要特点:

  • 灵活性:可以控制循环的初始值、终止条件和步进方式。可以使用任何条件和任何步进表达式,还可以通过多种变量进行复杂的控制。
  • 适用于数组:可以通过索引访问数组的元素。
  • 支持修改集合:可以在循环体中修改集合中的元素。

foreach

foreach 是 Java 5 引入的一种简化的循环结构,用于遍历数组或实现了 Iterable 接口的集合。

主要特点:

  • 简洁性:语法更简单,减少了初始化、条件检查和更新的样板代码。适合用于遍历数组和实现了 Iterable 接口的集合
  • 只读访问:不提供对当前索引的访问,因此不适合需要根据索引进行复杂操作的场景。
  • 安全性:在遍历过程中不能修改集合的结构(例如,不能在遍历 List 的同时添加或删除元素),否则会抛出 ConcurrentModificationException。

使用场景

for 循环

  • 适用于需要对索引进行操作的情况,例如根据索引访问数组元素,或需要在循环中进行复杂的迭代控制。
  • 适用于需要修改数组或集合元素的场景(例如,根据索引更新数组元素)。

foreach 循环

  • 适用于仅需要遍历集合或数组的场景,特别是当不需要操作索引时。
  • 有助于提高代码的可读性和维护性,减少循环变量错误。

总结

  • 使用 for 循环:当需要对集合进行复杂的控制和操作时,例如根据索引进行操作、反向遍历、跳步遍历。或者需要访问当前索引时。
  • 使用 foreach 循环:当需要简单遍历数组或集合,并且不需要访问当前索引时。

语法介绍

for 循环

语法

1
2
3
for (initialization; condition; update) {
// code to be executed
}

示例

1
2
3
4
int[] numbers = {1, 2, 3, 4, 5};
for (int i = 0; i < numbers.length; i++) {
System.out.println(numbers[i]);
}

foreach 循环(增强型 for 循环):

语法

1
2
3
for (type element : collection) {
// code to be executed
}

示例

1
2
3
4
int[] numbers = {1, 2, 3, 4, 5};
for (int number : numbers) {
System.out.println(number);
}

什么是 Java 中的迭代器(Iterator)?

Iterator 是 Java 集合框架中用于遍历集合元素的接口,允许开发者依次访问集合中的每一个元素,而不需要关心集合的具体实现。它提供了一种统一的方式来遍历 ListSet 等集合类型,通常与 Collection 类接口一起使用。

Iterator 的核心方法

  • hasNext():返回 true 表示集合中还有下一个元素,返回 false 则表示遍历完毕。
  • next():返回集合中的下一个元素,如果没有更多元素则抛出 NoSuchElementException
  • remove():从集合中移除最近一次通过 next() 方法返回的元素,执行时只能在调用 next() 之后使用。这个方法是可选的,不是所有的实现都支持该操作。如果不支持,调用时会抛出 UnsupportedOperationException。

主要作用

  • 迭代器使得遍历不同类型的集合更加简洁、统一,避免直接操作索引,提升了代码的可读性和可维护性。
  • 它支持在遍历过程中动态修改集合内容(例如删除元素,这在 for-each 循环中是会报错的)。

使用示例

1
2
3
4
5
6
List<String> list = Arrays.asList("A", "B", "C");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
System.out.println(item);
}

Iterator 与 for-each 循环的关系

for-each 循环实际上是对 Iterator 的一种简化形式,背后是通过 Iterator 实现的。

不过 for-each 适合只遍历集合而不进行删除等操作。如果需要在遍历过程中修改集合内容,则需要使用 Iterator

因为Iterator 在遍历集合的过程中,如果检测到集合的结构发生了非迭代器自身的修改(比如使用 List#add()List#remove() 直接修改集合),会抛出 ConcurrentModificationException。这种机制称为“fail-fast”。

为了避免这种情况发生,修改集合时应使用 Iteratorremove() 方法,而非直接操作集合。

示例

1
2
3
4
5
6
7
8
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if ("B".equals(item)) {
iterator.remove(); // 正确,避免 fail-fast
}
}

Iterator 的缺点

  • Iterator 只能单向遍历集合,不能向前遍历。
  • 使用 Iteratorremove() 方法删除元素时,每次只能删除最近一次通过 next() 方法获取的元素,删除的灵活性有限。

ListIterator

ListIteratorIterator 的子接口,专门用于操作 List 类型集合。与 Iterator 不同,它支持双向遍历和元素修改。

ListIterator 的方法

  • hasPrevious():判断是否有上一个元素。
  • previous():返回上一个元素。
  • set(E e):将当前元素替换为指定的元素。
  • add(E e):在当前迭代位置之前插入一个新元素。

示例

1
2
3
4
5
6
7
8
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
ListIterator<String> listIterator = list.listIterator();
while (listIterator.hasNext()) {
String item = listIterator.next();
if ("B".equals(item)) {
listIterator.set("D"); // 修改当前元素
}
}

什么是 Java 中的不可变类?

不可变类是指在创建后其状态(对象的字段)无法被修改的类。一旦对象被创建,它的所有属性都不能被更改。这种类的实例在整个生命周期内保持不变。

关键特征

  1. 声明类为 final,防止子类继承。
  2. 类的所有字段都是privatefinal,确保它们在初始化后不能被更改。
  3. 通过构造函数初始化所有字段。
  4. 不提供任何修改对象状态的方法(如 setter 方法)。
  5. 如果类包含可变对象的引用,确保这些引用在对象外部无法被修改。例如 getter 方法中返回对象的副本(new 一个新的对象)来保护可变对象。

Java 中的经典不可变类有:StringIntegerBigDecimalLocalDate 等。

不可变类的优缺点

优点

  1. 线程安全:由于不可变对象的状态不能被修改,它们天生是线程安全的,在并发环境中无需同步。
  2. 缓存友好:不可变对象可以安全地被缓存和共享,如 String 的字符串常量池。
  3. 防止状态不一致:不可变类可以有效避免因意外修改对象状态而导致的不一致问题。

缺点

  1. 性能问题:不可变对象需要在每次状态变化时创建新的对象,这可能会导致性能开销,尤其是对于大规模对象或频繁修改的场景(例如 String 频繁拼接)。

举例 String

String 就是典型的不可变类,当你创建一个 String 对象之后,这个对象就无法被修改。

因为无法被修改,所以像执行s += “a”; 这样的方法,其实返回的是一个新建的 String 对象,老的 s 指向的对象不会发生变化,只是 s 的引用指向了新的对象而已。

所以不要在字符串拼接频繁的场景使用 + 来拼接,因为这样会频繁的创建对象。

不可变类的好处就是安全,因为知晓这个对象不可能会被修改,因此可以放心大胆的用,在多线程环境下也是线程安全的。

如何实现一个不可变类?

具体按照回答重点内的关键特征实现就行了。我们来一起看下 String 的设计。

String 类用 final 修饰,表示无法被继承。

img

String 本质是一个 char 数组,然后用 final 修饰,不过 final 限制不了数组内部的数据,所以这还不够。

所以 value 是用 private 修饰的,并且没有暴露出 set 方法,这样外部其实就接触不到 value 所以无法修改。

当然还是有修改的需求,比如 replace 方法,所以这时候就需要返回一个新对象来作为结果。

image-20241030110610486

image-20241030110554241

总结一下就是私有化变量,然后不要暴露 set 方法,即使有修改的需求也是返回一个新对象。

什么是 Java 的多态特性?

多态是指同一个接口或父类引用变量可以指向不同的对象实例,并根据实际指向的对象类型执行相应的方法。

它允许同一方法在不同对象上表现出不同的行为,是面向对象编程(OOP)的核心特性之一。

多态的优点

  • 通过多态,程序可以灵活地处理不同类型的对象,降低代码耦合度,增强系统的可扩展性。新增子类或实现类时,无需修改原有代码,只需通过接口或父类引用调用即可。

多态的意义(理解版)

多态其实是一种抽象行为,它的主要作用是让程序员可以面对抽象编程而不是具体的实现类,这样写出来的代码扩展性会更强。

大家可能不是很理解什么是抽象什么是具体,我举个可能不是很恰当,但是很好理解的例子:比如某个人很喜欢吃苹果,我们在写文章描述他的时候可以写他很喜欢吃苹果,也可以写他很喜欢吃水果。

水果就是抽象,苹果就是具体的实现类。

假设这个人某天开始换口味了,他喜欢吃桃子了,如果我们之前的文章写的是水果,那么完全不需要改,如果写的是苹果,是不是需要把苹果替换成桃子了?

这就是多态的意义。

再举个代码的例子:

比如 Person person = new Student()

Person 是父类,含有一个工作的方法,student 重写工作方法,比如上学。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person {
void work() {
System.out.println("工作");
}
}

class Student extends Person {
@Override
void work() {
System.out.println("上学");
}
}

public class Test {
public static void main(String[] args) {
Person person = new Student();
person.work(); // 输出 "上学"
}
}

这样在使用的时候,对象都是 person,但是 new 不同的实现类,表现的形式不同,这也就从字面上解释的什么叫多态。

编译时多态和运行时多态

编译时多态和运行时多态是面向对象编程中多态性的两种实现方式,它们分别在不同的阶段决定方法的绑定。

  • 编译时多态:通过方法重载实现,在编译时确定方法的调用。
  • 运行时多态:通过方法重写实现,在运行时确定方法的调用。

1. 编译时多态(Compile-time Polymorphism)

编译时多态,也称为静态多态,是在编译阶段确定方法的调用。编译时多态主要通过 方法重载(Method Overloading) 实现。

方法重载:指在同一个类中定义多个方法,这些方法的名称相同但参数列表(参数的类型或数量)不同。Java 编译器在编译时会根据方法调用时传入的参数类型和数量,决定调用哪一个重载方法。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Example {
void display(int a) {
System.out.println("Integer: " + a);
}

void display(double a) {
System.out.println("Double: " + a);
}

void display(String a) {
System.out.println("String: " + a);
}
}

public class Main {
public static void main(String[] args) {
Example obj = new Example();
obj.display(5); // 调用 display(int a)
obj.display(3.14); // 调用 display(double a)
obj.display("Hello"); // 调用 display(String a)
}
}

在这个例子中,根据传入的参数类型,编译器在编译时决定调用哪个 display 方法。

2. 运行时多态(Runtime Polymorphism)

运行时多态,也称为动态多态,是在运行时确定方法的调用。运行时多态通过 方法重写(Method Overriding) 实现。

方法重写:子类重写父类的一个或多个方法。通过父类引用调用方法时,实际执行的是子类重写后的方法。这种多态性是在运行时根据对象的实际类型决定的。

示例

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
class Animal {
void sound() {
System.out.println("Animal makes a sound");
}
}

class Dog extends Animal {
@Override
void sound() {
System.out.println("Dog barks");
}
}

class Cat extends Animal {
@Override
void sound() {
System.out.println("Cat meows");
}
}

public class Main {
public static void main(String[] args) {
Animal myDog = new Dog(); // Animal reference but Dog object
Animal myCat = new Cat(); // Animal reference but Cat object

myDog.sound(); // 输出: Dog barks
myCat.sound(); // 输出: Cat meows
}
}

Java 中的参数传递是按值还是按引用?

在 Java 中,参数传递只有按值传递,不论是基本类型还是引用类型。

  • 基本数据类型(如 int, char, boolean 等):传递的是值的副本,即基本类型的数值本身。因此,对方法参数的任何修改都不会影响原始变量。
  • 引用数据类型(如对象引用):传递的是引用的副本,即对象引用的内存地址。因此,方法内可以通过引用修改对象的属性,但不能改变引用本身,使其指向另一个对象。

基本类型与引用类型的区别

  • 基本类型:包括 int, float, double, char, boolean 等,存储在栈内存中。方法中对基本类型参数的操作只会影响传递的副本,原始变量的值不受影响。

  • 引用类型:包括所有的对象和数组,引用类型的变量存储的是对象在堆内存中的地址。当引用类型作为参数传递时,传递的是这个地址的副本。因此,方法内的修改可以影响到传入的对象的内容,但不会影响对象引用本身的地址。

    image-20241030115101718

示例代码分析

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
public class ParameterPassing {
public static void main(String[] args) {
int a = 5;
modifyPrimitive(a);
System.out.println("After modifyPrimitive: " + a); // 输出: 5

MyObject obj = new MyObject();
obj.value = 10;
modifyObject(obj);
System.out.println("After modifyObject: " + obj.value); // 输出: 20

resetReference(obj);
System.out.println("After resetReference: " + obj.value); // 输出: 20
}

public static void modifyPrimitive(int num) {
num = 10; // 仅仅修改了副本,不影响原始变量
}

public static void modifyObject(MyObject obj) {
obj.value = 20; // 修改了对象的属性,会影响原始对象
}

public static void resetReference(MyObject obj) {
obj = new MyObject(); // 修改的是引用的副本,不影响原始对象
obj.value = 30;
}
}

class MyObject {
int value;
}

  • modifyPrimitive 方法中,num 是基本类型的副本,因此对它的修改不影响原始变量 a
  • modifyObject 方法中,obj 是引用类型的副本,但这个副本仍指向原始对象,因此修改 value 属性会影响原始对象。
  • resetReference 方法中,obj 被重新赋值为一个新对象,这个变化只影响副本,不影响原始引用

为什么 Java 不支持多重继承?

主要是因为多继承会产生菱形继承(也叫钻石继承)问题,Java 之父就是吸取 C++ 他们的教训,因此不支持多继承。

所谓的菱形继承很好理解,我们来看下这个图:

Snipaste_2024-05-07_19-32-55.jpg

是不是看起来很像一个菱形,BC 继承了 A,然后 D 继承了 BC, 假设此时要调用 D 内定义在 A 的方法,因为 B 和 C 都有不同的实现,此时就会出现歧义,不知道应该调用哪个了。

这里很可能会被面试官追问:既然多继承不行,那为什么接口多实现可以?

为什么接口可以多实现?

在 Java8 之前接口是无法定义具体方法实现的,所以即使有多个接口必须子类自己实现,所以并不会发生歧义。

Java8 之后出了默认方法(default method),此时不就又出现的多继承的菱形继承问题了?

所以 Java 强制规定,如果多个接口内有相同的默认方法,子类必须重写这个方法。

不然,编译期就会报错:

image.png

面向对象编程与面向过程编程的区别是什么?

面向对象编程(Object Oriented Programming,OOP)是一种对象为中心的编程范式或者说编程风格。把类或对象作为基本单元来组织代码,并且运用提炼出的:封装、继承和多态来作为代码设计指导。

面向过程编程是一种以过程或函数为中心的编程范式或者说编程风格,以过程作为基本单元来组织代码。过程其实就是动作,对应到代码中来就是函数,面向过程中函数和数据是分离的,数据就是成员变量。

总结来看:面向对象编程注重对象之间的交互和模块化设计,而面向过程编程注重逻辑的分步实现。

主要区别如下

1)思维方式

  • 面向对象:通过定义对象的属性和行为来解决问题,关注对象之间的关系和交互。
  • 面向过程:通过函数或过程一步步实现业务逻辑,关注执行的步骤和顺序。

2)数据与行为的关系

  • 面向对象:数据和行为封装在对象内部,数据操作由对象方法进行管理。
  • 面向过程:数据和函数是分离的,函数对数据进行操作。

3)可扩展性和复用性

  • 面向对象:通过继承、接口、多态等机制支持代码的高复用性和扩展性。
  • 面向过程:复用性较低,扩展需要修改已有代码,影响整体稳定性。

4)适用场景

  • 面向对象:适合处理复杂的系统和模块化设计,便于维护和扩展。
  • 面向过程:适用于一些简单、顺序性强的小型程序,开发效率较高。

面向对象的三大特性

  • 封装:将数据和行为封装在对象内部,提供接口进行访问,隐藏实现细节,提高安全性。
  • 继承:子类可以继承父类的属性和方法,实现代码复用和扩展。
  • 多态:对象可以通过父类或接口进行多态性调用,不同对象在运行时执行不同的行为。

面向对象/过程优缺点总结

面向对象的优缺点

  • 优点:高复用性、扩展性、维护性强,适合复杂系统的开发。
  • 缺点:开发和理解成本较高,对于简单项目可能显得繁琐。

面向过程的优缺点

  • 优点:开发简单,代码执行速度快,适合小型项目。
  • 缺点:可维护性差,代码复用性低,扩展性差。

面向过程与面向对象的理解

面向过程其实就是一条道的思路,因为起初就是按计算机的思路来编写程序。

我就拿用咖啡机煮咖啡为例,按照面向过程的流程是:

  1. 执行加咖啡豆方法
  2. 执行加水方法
  3. 执行煮咖啡方法
  4. 执行喝咖啡方法

很简单直观的操作,你可能没什么感觉,我再按面向对象思想来分析下这个流程。

在执行煮咖啡操作前要抽象出:人和咖啡机(分类),然后开始执行:

  1. 人.加咖啡豆
  2. 人.加水
  3. 咖啡机.煮
  4. 人.喝咖啡

是不是有点感觉了?

面向过程,从名字可以得知重点是过程,而面向对象的重点是对象。

从这个例子可以看出两者的不同:面向过程是很直接的思维,一步步的执行,一条道走到底。

而面向对象是先抽象,把事物分类得到不同的类,划分每个类的职责,暴露出每个类所能执行的动作,然后按逻辑执行时调用每个类的方法即可,不关心内部的逻辑。

从例子可以看出面向对象编程执行的步骤没有变少,整体执行流程还是一样的,都是先加咖啡豆、加水、煮咖啡、喝,这个逻辑没有变。

无非就是划分了类,把每一步骤具体的实现封装了起来,散布在不同的类中。

对我们程序员来说是最最直接的感受:变的其实就是代码的分布,煮咖啡的代码实现被封装在咖啡机内部,喝咖啡的代码实现被封装在人内部,而不是在一个方法中写出来。

代码的分布确实是最直观的,但是变得不仅只是分布,而是思想上的变化。

就是上面提到的计算机思维到人类思维的变化。

我认为这个变化是因为软件的发展,业务越来越复杂。

人们用面向过程语言编写复杂的软件时,需要按照不同的功能把一些数据和函数放到不同的文件中,渐渐地人们就发现这不就是先分类吗?

并且好像业务分析下来都能和现实世界的东西对应上?

于是人们慢慢地总结、提炼就演变成了面向对象,再根据面向对象的特性提炼出关键点:封装、继承和多态。

而这个面向对象思想就类似我们人类面对复杂场景时候的分析思维:归类、汇总。

所以面向对象编程就成为了现在主流的编程风格,因为符合人类的思考方式。

面向过程编程和面向对象编程从思想上的变化是:从计算机思维转变成了人类的思维来编写编码。

而面向对象编程的类中数据和动作是在一起的,这也是两者的一个显著的区别。

Java 方法重载和方法重写之间的区别是什么?

方法重载(Overloading) :在同一个类中,允许有多个同名方法,只要它们的参数列表不同(参数个数、类型或顺序)。主要关注方法的签名变化,适用于在同一类中定义不同场景下的行为。

方法重写(Overriding):子类在继承父类时,可以重写父类的某个方法(参数列表、方法名必须相同),从而为该方法提供新的实现。主要关注继承关系,用于子类改变父类的方法实现,实现运行时多态性。

区别主要如下:

区别 重载 重写
发生的场所 在同一个类中 在继承关系的子类和父类之间
参数列表 必须不同(参数的数量、类型或顺序不同) 必须相同,不能改变参数列表
返回类型 可以不同 必须与父类方法的返回类型相同,或者是父类返回类型的子类(协变返回类型)
访问修饰符 不受访问修饰符影响 子类方法的访问修饰符不能比父类更严格,通常是相同或更宽泛
静态和非静态方法 可以是静态方法或非静态方法 只能重写非静态方法,静态方法不能被重写(静态方法可以被隐藏)
异常处理 方法的异常处理可以不同 子类的异常不能抛出比父类更多的异常(可以抛出更少的或相同类型的异常)

重载注意点

重载中提到的方法同名但参数列表不同(参数个数、类型或顺序),这里要注意和返回值没有关系,方法的签名仅是名字和参数列表,不包括返回值。

重载通常用于提供同一操作的不同实现,例如构造函数的重载、不同类型输入的处理等

重载简单示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OverloadingExample {
// 重载方法:参数数量不同
public void print(int a) {
System.out.println("Printing int: " + a);
}

// 重载方法:参数类型不同
public void print(String a) {
System.out.println("Printing String: " + a);
}

// 重载方法:参数类型和数量不同
public void print(int a, int b) {
System.out.println("Printing two ints: " + a + ", " + b);
}
}

重写注意点

在重写时,子类方法不能使用比父类更严格的访问级别。例如,父类的方法是 protected,子类不能将其修改为 private,但可以改为 public

且子类方法抛出的异常必须与父类一致,或者是其父类异常的子类。

重写通常用于在子类中提供父类方法的具体实现,以实现多态性。例如,子类对父类方法进行扩展或修改以适应特定需求

重写简单示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Parent {
public void display() {
System.out.println("Parent display");
}
}

class Child extends Parent {
@Override
public void display() {
System.out.println("Child display");
}
}

public class OverridingExample {
public static void main(String[] args) {
Parent obj = new Child();
obj.display(); // 输出 "Child display"
}
}

还有一个 @Override 注解,在重写方法时使用 @Override 注解,编译器可以帮助检查是否正确实现了重写,以防误操作。

什么是 Java 内部类?它有什么作用?

Java 内部类 是指在一个类的内部定义的类,Java 支持多种类型的内部类,包括成员内部类局部内部类匿名内部类静态内部类。内部类可以访问外部类的成员变量和方法,甚至包括私有的成员。

内部类的作用 主要包括:

  1. 封装性:将逻辑相关的类封装在一起,提高类的内聚性。
  2. 访问外部类成员:内部类可以方便地访问外部类的成员变量和方法,尤其在需要操作外部类对象的场景下非常有用。
  3. 简化代码:对于只在一个地方使用的小类,内部类能减少冗余代码,简化结构。
  4. 事件处理:匿名内部类广泛用于实现回调函数或事件监听,简化了代码结构,特别是对于实现接口或抽象类的场景。

内部类的类型

  • 成员内部类:非静态类,作为外部类的一个成员。它可以直接访问外部类的所有成员,包括私有成员
  • 静态内部类:定义为 static,无法访问外部类的非静态成员,只能访问外部类的静态成员。
  • 局部内部类:定义在方法或代码块中的类,仅在该方法或代码块内可见,通常用于临时的对象构建。
  • 匿名内部类:没有类名的内部类,通常用于创建短期使用的类实例,尤其是在接口回调或事件处理时被广泛使用。

内部类举例

1)成员内部类,定义在另一个类中的类,可以使用外部类的所有成员变量以及方法,包括 private 的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OuterClass {
private String outerField = "Outer Field";

class InnerClass {
void display() {
System.out.println("Outer Field: " + outerField);
}
}

public void createInner() {
InnerClass inner = new InnerClass();
inner.display();
}
}

2)静态内部类,只能访问外部类的静态成员变量以及方法,其实它就等于一个顶级类,可以独立于外部类使用,所以更多的只是表明类结构和命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OuterClass {
private static String staticOuterField = "Static Outer Field";

static class StaticInnerClass {
void display() {
System.out.println("Static Outer Field: " + staticOuterField);
}
}

public static void createStaticInner() {
StaticInnerClass staticInner = new StaticInnerClass();
staticInner.display();
}
}

3)局部内部类,指在方法中定义的类,只在该方法内可见,可以访问外部类的成员以及方法中的局部变量(需要声明为 final 或 effectively final)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OuterClass {
void outerMethod() {
final String localVar = "Local Variable";

class LocalInnerClass {
void display() {
System.out.println("Local Variable: " + localVar);
}
}

LocalInnerClass localInner = new LocalInnerClass();
localInner.display();
}
}

4)匿名类,指的是没有类名的内部类。用于简化实现接口和继承类的代码,仅在创建对象时使用,例如回调逻辑定义场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class OuterClass {
interface Greeting {
void greet();
}

public void sayHello() {
Greeting greeting = new Greeting() {
@Override
public void greet() {
System.out.println("Hello, World!");
}
};
greeting.greet();
}
}

局部内部类用的比较少,常用成员内部类、静态内部类和匿名内部类。

实际上内部类是一个编译层面的概念,像一个语法糖一样,经过编译器之后其实内部类会提升为外部顶级类,和外部类没有任何区别,所以在 JVM 中是没有内部类的概念的

JDK8 有哪些新特性?

JDK8 较为重要和平日里经常被问的特性如下:

1)用元空间替代了永久代
2)引入了 Lambda 表达式
3)引入了日期类、接口默认方法、静态方法
4)新增 Stream 流式接口
5)引入 Optional 类
6)新增了 CompletableFuture 、StampedLock 等并发实现类。

如果你对 HashMap、ConcurrentHashMap 面试题有准备的话,这时候也可以抛出来,引导面试官来询问。比如:Java 8 修改了 HashMap 和 ConcurrentHashMap 的实现。

元空间替代了永久代

因为 JDK8 要把 JRockit 虚拟机和 Hotspot 虚拟机融合,而 JRockit 没有永久代,所以把 Hotspot 永久代给去了(本质也是永久代回收效率太低)。

详细可看:面试鸭《为什么 Java8 移除了永久代,加了元空间?》 这题。

Lambda 表达式

Lambda 是 Java 8 引入的一种匿名函数,可以把 Lambda 表达式理解为是一段可以传递的代码(将代码像数据一样进行传递)。使用它可以写出更简洁、更灵活的代码。

其本质是作为函数式接口的实例。例如:

1
2
3
4
5
6
7
8
9
10
// 传统方式
Runnable runnable1 = new Runnable() {
@Override
public void run() {
System.out.println("mianshiya.com");
}
};

// Lambda 表达式
Runnable runnable2 = () -> System.out.println("mianshiya.com");

日期类

Java 8 引入了新的日期和时间 API(位于 java.time 包中),它们更加简洁和易于使用,解决了旧版日期时间 API 的许多问题。

例如 DateCalendar 都是可变类且线程不安全。而新的日期类都是不可变的,一旦创建就不能修改,这样可以避免意外的修改,提升代码的安全性和可维护性。

1
2
3
LocalDate date = LocalDate.now();
LocalTime time = LocalTime.now();
LocalDateTime dateTime = LocalDateTime.now();

Date 本身不包含时区信息,必须使用 Calendar 类来处理时区,但使用起来非常复杂且容易出错。

新 API 提供了专门的时区类(如 ZonedDateTime, OffsetDateTime, ZoneId 等),简化了时区处理,并且这些类的方法更加直观和易用。

接口默认方法、静态方法

默认方法允许在接口中定义方法的默认实现,这样接口的实现类不需要再实现这些方法。之所以提供静态方法,是为了将相关的方法内聚在接口中,而不必创建新的对象。

1
2
3
4
5
6
7
8
9
interface MyInterface {
default void defaultMethod() {
System.out.println("Default Method");
}

static void hello() {
System.out.println("Hello, New Static Method Here");
}
}

Stream 流式接口

Stream API 提供了一种高效且易于使用的方式来处理数据集合。它支持链式操作、惰性求值和并行处理。

1
2
3
4
List<String> list = Arrays.asList("a", "b", "c", "d");
List<String> result = list.stream()
.filter(s -> s.startsWith("a"))
.collect(Collectors.toList());

Optional

Optional 类用来解决可能出现的 NullPointerException 问题,提供了一种优雅的方式来处理可能为空的值。

1
2
Optional<String> optional = Optional.of("mianshiya.com");
optional.ifPresent(System.out::println);

Optional 详细可查看面试鸭《什么是 Optional 类?》 这题

CompletableFuture

CompletableFuture 提供了一个新的异步编程模型,简化了异步任务的编写和管理。

1
2
3
CompletableFuture.supplyAsync(() -> "Hello")
.thenApply(s -> s + " World")
.thenAccept(System.out::println);

StampedLock 可查看面试鸭 《StampedLock 用过吗?》 这题。

Java 的 Optional 类是什么?它有什么用?

Optional 是 Java 8 引入的一个容器类,用于表示可能为空的值。它通过提供更为清晰的 API,来减少程序中出现 null 的情况,避免 NullPointerException(空指针异常)的发生。

Optional 可以包含一个值,也可以为空,从而表示“值存在”或“值不存在”这两种状态。

作用

  • 减少 NullPointerException:通过 Optional 提供的操作方法,避免直接使用 null 进行空值检查,从而降低空指针异常的风险。
  • 提高代码可读性Optional 提供了一套简洁的 API,例如 isPresent()ifPresent()orElse(),可以让代码更具表达性,清晰地展示处理空值的逻辑。

基本方法

  • of(T value):创建一个包含非空值的 Optional
  • empty():创建一个空的 Optional
  • get():获取 Optional 中的值(不推荐直接使用,可能抛出异常)。
  • isPresent():判断 Optional 中是否包含值。
  • ifPresent(Consumer<? super T>):如果 Optional 中包含值,则执行指定的操作。
  • orElse(T other):如果 Optional 为空,返回默认值。
  • orElseGet(Supplier<? extends T>):如果 Optional 为空,执行 Supplier 提供的操作,返回值。

Optional 进一步分析

Optional 是 Java 8 引入的一个容器类,它用来表示一个值可能存在或不存在。

常见的使用方式如下:

1
2
Optional<User> userOption = Optional.ofNullable(userService.getUser(...));
if (!userOption.isPresent()) {....}

Optional 设计出来的意图是什么, Java 语言架构师 Brian Goetz 是这么说的:

Our intention was to provide a limited mechanism for library method return types where there needed to be a clear way to represent “no result”, and using null for such was overwhelmingly likely to cause errors.

意思就是:Optional 可以给返回结果提供了一个表示无结果的值,而不是返回 null。

简单理解下,Optional 其实就是一个壳,里面放着原先的值,至于这个值是不是 null 另说,反正拿到的这个壳肯定不是 null。

企业微信截图_c7362dfe-26af-4ae0-be83-57ebefcdcbfc.png

网上比较流行的说法是 Optional 可以避免空指针,我不太赞同这种说法。因为最终的目的是拿到 Optional 里面存储的值,如果这个值是 null,不做额外的判断,直接使用还是会有空指针的问题。

我认为 Optional 的好处在于可以简化平日里一系列判断 null 的操作,使得用起来的时候看着不需要判断 null,纵享丝滑,表现出来好像用 Optional 就不需要关心空指针的情况。

而事实上是 Optional 在替我们负重前行,该有的判断它替我们完成了,而且用了 Optional 最后拿结果的时候还是小心的,盲目 get 一样会抛错,Brian Goetz 说 get 应该叫 getOrElseThrowNoSuchElementException。

我们来看一下代码就很清楚 Optional 的好处在哪儿了。比如现在有个 yesSerivce 能 get 一个 Yes,此时需要输出 Yes 所在的省,此时的代码是这样的:

1
2
3
4
5
6
7
8
9
Yes yes = getYes();
if (yes != null) {
Address yesAddress = yes.getAddress();
if (yesAddress != null) {
Province province = yesAddress.getProvince();
System.out.println(province.getName());
}
}
throw new NoSuchElementException(); //如果没找到就抛错

如果用 Optional 的话,那就变成下面这样:

1
2
3
4
5
Optional.ofNullable(getYes())
.map(a -> a.getAddress())
.map(p -> p.getProvince())
.map(n -> n.getName())
.orElseThrow(NoSuchElementException::new);

可以看到,如果用了 Optional,代码里不需要判空的操作,即使 address 、province 为空的话,也不会产生空指针错误,这就是 Optional 带来的好处!

Optional 性能问题

关于 Optional 还有个性能问题,我们看一下:

Optional 里有 orElseGet 和 orElse 这两个看起来挺相似的方法,都是处理当值为 null 时的兜底逻辑。可能你也在一些文章上看到说用 orElseGet 不要用 orElse ,因为在 Optional 有值时候 orElse 仍然会调用方法,所以后者性能比较差。其实从上面分析我们知道不论 Optional 是否有值,orElse 和 orElseGet 都会被执行,所以是怎么回事呢?

看下这个代码:

企业微信截图_01705210-0d9e-4acb-aff4-c8a99b27e7b5.png

这样看来 orElse 确实性能会差,奇怪了,难道是 bug?

我们来看下源码:

企业微信截图_8f3f1e44-8baf-45a5-9bc3-5803f0b5008f.png

可以看到两者的入参不同,一个就是普通参数,一个是 Supplier。我们已经得知不论Optional.ofNullable 返回的是否是空 Optional,下面的逻辑还是会执行,所以 orElse 和 orElseGet 这两个方法无论如何都会执行。

因此 orElse(createYes()) 会被执行,在参数入栈之前,执行了 createYes 方法得到结果,然后入栈,而 orElseGet 的参数是 Supplier,所以直接入栈,然后在调用 other.get 的时候,createYes 方法才会被触发执行,这就是两者的区别之处。

所以才会造成上面表现出的性能问题,因此不是 BUG,也不是有些文章说的 Optional 有值 orElse 也会被执行而 orElseGet 不会执行这样不准确的说法,相信现在你的心里很有数了。

Java 中 String、StringBuffer 和 StringBuilder 的区别是什么?

它们都是 Java 中处理字符串的类,区别主要体现在可变性线程安全性性能上:

1)String

  • 不可变String 是不可变类,字符串一旦创建,其内容无法更改。每次对 String 进行修改操作(如拼接、截取等),都会创建新的 String 对象。
  • 适合场景String 适用于字符串内容不会频繁变化的场景,例如少量的字符串拼接操作或字符串常量。

2)StringBuffer

  • 可变StringBuffer 是可变的,可以进行字符串的追加、删除、插入等操作。
  • 线程安全StringBuffer 是线程安全的,内部使用了 synchronized 关键字来保证多线程环境下的安全性。
  • 适合场景StringBuffer 适用于在多线程环境中需要频繁修改字符串的场景。

3)StringBuilder

  • 可变StringBuilder 也是可变的,提供了与 StringBuffer 类似的操作接口。
  • 非线程安全StringBuilder 不保证线程安全,性能比 StringBuffer 更高。
  • 适合场景StringBuilder 适用于单线程环境中需要大量修改字符串的场景,如高频拼接操作。

总结

  • String:不可变,适合少量字符串操作。
  • StringBuffer:可变且线程安全,适合多线程环境中的频繁字符串修改。
  • StringBuilder:可变且非线程安全,适合单线程环境中的高性能字符串处理。

Java 8 中的优化

在 Java 8 及以后,编译器会对字符串的常量拼接做优化,将字符串拼接转换为 StringBuilder 操作。这种优化提高了代码性能,但是在动态拼接或多线程场景下,手动使用 StringBuilderStringBuffer 仍然更合适。

从演进角度看待三者

String 是 Java 中基础且重要的类,并且 String 也是 Immutable 类的典型实现,被声明为 final class,除了 hash 这个属性其它属性都声明为 final

因为它的不可变性,所以例如拼接字符串时候会产生很多无用的中间对象,如果频繁的进行这样的操作对性能有所影响。

StringBuffer 就是为了解决大量拼接字符串时产生很多中间对象问题而提供的一个类,提供 appendinsert 方法,可以将字符串添加到已有序列的末尾或指定位置。

它的本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上了 synchronized。但是保证了线程安全是需要性能的代价的。

在很多情况下我们的字符串拼接操作不需要线程安全,这时候 StringBuilder 登场了,StringBuilderJDK1.5发布的,它和 StringBuffer 本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销

StringBufferStringBuilder 二者都继承了 AbstractStringBuilder ,底层都是利用可修改的 char 数组(JDK 9 以后是 byte 数组)。

所以如果我们有大量的字符串拼接,如果能预知大小的话最好在 new StringBuffer 或者 StringBuilder 的时候设置好 capacity,避免多次扩容的开销(扩容要抛弃原有数组,还要进行数组拷贝创建新的数组)。

Java 的 StringBuilder 是怎么实现的?

StringBuilder 主要是为了解决 String 对象的不可变性问题,提供高效动态的字符串拼接和修改操作。大致需要实现 append、insert…等功能。

大致核心实现如下:

  • 内部使用字符数组 (char[] value) 来存储字符序列
  • 通过方法如 append()、insert() 等操作,直接修改内部的字符数组,而不会像 String 那样创建新的对象。
  • 每次进行字符串操作时,如果当前容量不足,它会通过扩展数组容量来容纳新的字符,按 2 倍的容量扩展,以减少扩展次数,提高性能。

扩展深入剖析 StringBuilder

对于这类题目,因为已经有现有的实现作为参考,所以回答诸如此类的问题,不要急,先回想一下平日用这个StringBuilder 都用了哪些方法。

  • append
  • insert
  • delete
  • replace
  • charAt
  • ….

大致就这么几个,没必要说太全,这不是小学课文背诵,关键方法提出来就行了。

脑子浮现这几个方法之后,直接按上述的回答重点说出来即可。

实际上 StringBuilder 底层使用 char 数组来存储字符,并且用 count 来记录存放的字符数。

image.png

回答重点提到了 char 数组,这里可能会被面试官插入问:String 底层不也是用的 char 数组存放吗?两者有啥区别?

展示的机会就来了呀!String 被 final 修饰,且内部的 char 也被 private 和 final 修饰了,所以是不可变的,是典型的 Immutable 类,因此其不可变性,保证了线程安全,能实现字符串常量池等。

由于 StringBuilder 底层是用 char 数组存放字符,而数组是连续内存结构,为了防止频繁地复制和申请内存,需要提供 capacity 参数来设置初始化数组的大小,这样在预先已经知晓大字符串的情况下,可以减少数组的扩容次数,有效的提升效率!

image.png

这里一定要点破:数组是连续内存的结构,并且要体现出你有节省内存和提高效率的意识,熟悉 HashMap 的同学对这类操作应该很有经验。

我们来看下调用 AbstractStringBuilder 这个父类的构造器。

image.png

可以看到,就是直接 new 申请数组没啥花头。

append

我们来看下 append 操作。

image.png

可以看到 append 有多个实现,毕竟我们平日啥都类型都直接 append ,那底层是怎么实现这些类型转换的呢?

我们拿 append(int) 来举个例子,其他类型本质都是一样的。

image.png

主要逻辑已经在图中标识了,熟悉 HashMap 八股文的同学一看就知道老套路了,先看看 append 的 int值转成 char 需要占数组的几位,然后计算一下现在的数组够不够放,如果不够就扩容一下,然后再把 int 转成 char 放进去,再更新现有的字符数。

所以面试回答 append 实现的时候,直接把上面那段话的思路说一下即可。

面试官可能会追问:怎么扩容的呀?

我们直接看下 ensureCapacityInternal 这个方法的实现

image.png

直接就是 Arrays.copyOf,进行一波扩容加拷贝,扩容之后的数组容量为之前的两倍+2。

这时候想必有很多同学好奇,前面是如何根据传入的 int 来计算得知所占的字符位数?即上面代码的Integer.stringSize 方法,注意这个方法已经跑到 Integer 这个类中啦!不是 AbstractStringBuilder的实现了。

image.png

哈哈,你以为会经过一番看不懂的位运算?

实际上就是查表法!直接列了各个位数的边界值依次存放在数组中,然后判断大小再根据数组下标算出位数,就是这么简单、方便、高效!

再来看下 int 是如何转换成 char 然后插入到数组中的,即Integer.getChars方法。

image.png

身为底层实现,还是很细的,可以仔细看下上面的逻辑,位运算看不懂没事,注释已经把原有的公式写出来的,对照着看看,还是能理解的,这里我就不再赘述了。

然后各位也应该注意到上面的DigitOnes、DigitTens这两个数组了,没错还是熟悉的查表法!

image.png

你们可以选几个数字带入算一算,很准的,哈哈,至于 digits 也一样,还是查表。

图片其实我们常用的 String.valueOf(int i),内部实现一样也是通过Integer.stringSizeInteger.getChars 来完成的。

image.png

好了,这波操作下来,想必拨开了很多对 StringBuilder 的迷雾吧~

insert

再来看看 insert,我们还是拿 int 的插入来举例:

image.png

可以看到,这里是把 int 转成 string 了,然后调用以下的方法:

image.png

注释写的很明白了,没什么花头,主要逻辑就是插入前先判断下数组长度足够,若不够就扩容,然后移动字符,给待插入的位置腾出空间,然后往对应位置插入字符,最后更新 StringBuilder 已有的字符数。

是吧,很直白的逻辑。

delete

这个就更简单了,就是一个数组的删除操作,没什么花头。

image.png

剩下的replace、charAt 等等方法就不提了,没有什么花头,总结来说都是数组的操作,有兴趣的自行去看看吧~

总结

这样看下来,想必对 StringBuilder 的内部实现已经很清晰了吧!就是数组的操作,而数组的特性就是内存连续,下标访问快。

针对内存连续这点,又要保持 StringBuilder 的动态性,那不可避免的就需要扩容操作,扩容操作简单来说就是申请一个更大 char 数组,把老 char 数组的数据拷贝过去。

对了,从源码来看,StringBuilder 没有实现缩容操作。

所以回答这个设计题的时候,先说下需要实现哪些关键方法:append、delete 等等,然后点明底层是 char 数组实现,在执行 append、insert 等操作的时候需要先判断数组容量是否足够容纳字符来判断是否需要扩容,然后修改之类的操作就是调用 System.arraycopy 来完成字符串的变更。

因为原生的 StringBuilder 没有实现缩容操作,所以你可以提一下在 delete 的时候,判断下,如果删除的字符过多,为了节省内存,实现缩容的操作。

然后还可以再提一下,char 数组是可以优化的,底层可以用 byte 数组+一个 coder 标志位来实现,这样更节省内存,因为 char 占用两个字节,这样对于 latin 系的字符来说,太大了,就很浪费,所以用 byte 数组,然后配备一个 coder 来标识所用的编码。

嘿嘿,其实 jdk 9 之后就是这样实现的,但是你可以假装不知道呀,装的像你自己想出来的优化,你看看这多细呀~疯狂加分!

来看下源码,我的是 jdk 11版本~,可以看到已经变成 byte 数组了, coder 也用一个 byte 标识。

image.png

再看下 append 的方法的实现:

image.png

没骗你吧,用 coder 来判断所用的编码,进行区分操作~

好了,这样一波回答下来,细节就很细了,满分!

Java 中包装类型和基本类型的区别是什么?

基本类型:Java 中有 8 种基本数据类型(intlongfloatdoublecharbytebooleanshort),它们是直接存储数值的变量,位于栈上(局部变量),性能较高,且不支持 null

包装类型:每个基本类型都有一个对应的包装类型(IntegerLongFloatDoubleCharacterByteBooleanShort)。包装类型是类,存储在堆中,可以用于面向对象编程,并且支持 null

区别总结

1)性能区别

  • 基本类型:占用内存小,效率高,适合频繁使用的简单操作。
  • 包装类型:因为是对象,涉及内存分配和垃圾回收,性能相对较低。

2)比较方式不同

  • 基本类型:比较用 ==,直接比较数值。
  • 包装类型:比较时,== 比较的是对象的内存地址,而 equals() 比较的是对象的值。

3)默认值不同

  • 基本类型:默认值是 0,false 等。
  • 包装类型:默认为 null。

4)初始化的方式不同

  • 基本类型:直接赋值。
  • 包装类型:需要采用 new 的方式创建。

5)存储方式不同

  • 基本类型:如果是局部变量则保存在栈上面,如果是成员变量则在堆中。
  • 包装类型:保存在堆上(成员变量,在不考虑 JIT 优化的栈上分配时,都是随着对象一起保存在堆上的)。

自动装箱与拆箱

因为 Java 是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们是无法将int 、double等类型放进去的。因为集合的容器要求元素是 Object类型。

为了让基本类型也具有对象的特征,就出现了包装类型,它相当于将基本类型“包装起来”,使得它具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。

  • 装箱:基本类型自动转换为包装类型对象。
  • 拆箱:包装类型对象自动转换为基本类型的值。

缓存机制

包装类型中的 ByteShortIntegerLong 对某些范围内的值(例如 Integer 缓存 -128 到 127)会使用对象缓存来提升性能。因此,同一数值的包装类型对象可能是同一个实例。

例如:

1
2
3
4
5
6
Integer a = 100;
Integer b = 100;
System.out.println(a == b); // true
Integer c = 200;
Integer d = 200;
System.out.println(c == d); // false

基础类型与包装类长度和范围

分类 基本数据类型 包装类 长度 表示范围
布尔型 boolean Boolean / /
byte Byte 1 字节 -128 ~ 127
short Short 2 字节 -32768 ~ 32767
整型 int Integer 4 字节 -2,147,483,648 ~ 2,147,483,647
long Long 8 字节 -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
字符型 char Character 2 字节 Unicode 字符集中的任何字符
浮点型 float Float 4 字节 约 -3.4E38 ~ 3.4E38
double Double 8 字节 约 -1.7E308 ~ 1.7E308

接口和抽象类有什么区别?

接口和抽象类在设计动机上有所不同。

接口的设计是自上而下的。我们知晓某一行为,于是基于这些行为约束定义了接口,一些类需要有这些行为,因此实现对应的接口。

抽象类的设计是自下而上的。我们写了很多类,发现它们之间有共性,有很多代码可以复用,因此将公共逻辑封装成一个抽象类,减少代码冗余。

所谓的 自上而下 指的是先约定接口,再实现。

自下而上的 是先有一些类,才抽象了共同父类(可能和学校教的不太一样,但是实战中很多时候都是因为重构才有的抽象)。

其他区别:

1)方法实现

接口中的方法默认是 public 和 abstract(但在 Java8 之后可以设置 default 方法或者静态方法)。

抽象类可以包含 abstract 方法(没有实现)和具体方法(有实现)。它允许子类继承并重用抽象类中的方法实现。

2)构造函数和成员变量

接口不能包含构造函数,接口中的成员变量默认为 public static final,即常量。

抽象类可以包含构造函数,成员变量可以有不同的访问修饰符(如 private、protected、public),并且可以不是常量。

3)多继承

抽象类只能单继承,接口可以有多个实现。

为什么 Java 不支持多重继承?

JDK 和 JRE 有什么区别?

JRE(Java Runtime Environment)指的是 Java 运行环境,包含了 JVM、核心类库和其他支持运行 Java 程序的文件。

  • JVM(Java Virtual Machine):执行 Java 字节码,提供了 Java 程序的运行环境。
  • 核心类库:一组标准的类库(如 java.lang、java.util 等),供 Java 程序使用。
  • 其他文件:如配置文件、库文件等,支持 JVM 的运行。

JDK(Java Development Kit)可以视为 JRE 的超集,是用于开发 Java 程序的完整开发环境,它包含了 JRE,以及用于开发、调试和监控 Java 应用程序的工具。

  • JRE:JDK 包含了完整的 JRE,因此它也能运行 Java 程序。
  • 开发工具:如编译器(javac)、调试器(jdb)、打包工具(jar)等,用于开发和管理 Java 程序。
  • 附加库和文件:支持开发、文档生成和其他开发相关的任务。

列举一下 JDK 提供的主要工具

  • javac:Java 编译器,用于将 Java 源代码(.java 文件)编译成字节码(.class 文件)。
  • java:Java 应用程序启动器,用于运行 Java 应用程序。
  • javadoc:文档生成器,用于从 Java 源代码中提取注释并生成 HTML 格式的 API 文档。
  • jar:归档工具,用于创建和管理 JAR(Java ARchive)文件。
  • jdb:Java 调试器,用于调试 Java 程序。
  • jps:Java 进程状态工具,用于列出当前所有的 Java 进程。
  • jstat:JVM 统计监视工具,用于监视 JVM 统计信息。
  • jstatd:JVM 统计监视守护进程,用于在远程监视 JVM 统计信息。
  • jmap:内存映射工具,用于生成堆转储(heap dump)、查看内存使用情况。
  • jhat:堆分析工具,用于分析堆转储文件。
  • jstack:线程栈追踪工具,用于打印 Java 线程的栈追踪信息。
  • javap:类文件反汇编器,用于反汇编和查看 Java 类文件。
  • jdeps:Java 类依赖分析工具,用于分析类文件或 JAR 文件的依赖关系。

你使用过哪些 JDK 提供的工具?

JDK 提供的主要工具

  • javac:Java 编译器,负责将 Java 源代码编译成字节码(.class 文件)。
  • java:运行 Java 应用程序的命令,使用 JVM 来解释并执行编译后的字节码文件。
  • javadoc:生成 API 文档的工具,能够根据源代码中的注释生成 HTML 格式的文档。
  • jar:用于创建和管理 JAR 文件的工具,可以将多个 .class 文件打包为单一文件,便于分发和管理。
  • jdb:Java 调试工具,用于在命令行中调试 Java 应用程序,支持断点设置、变量查看等功能。

性能监控和分析工具

  • jps:Java 进程工具,显示所有正在运行的 Java 进程,便于监控和诊断。
  • jstack:生成线程堆栈信息的工具,常用于分析死锁和线程问题。
  • jmap:内存映射工具,可以生成堆转储(heap dump)文件,便于内存泄漏分析和垃圾回收优化。
  • jhat:堆分析工具,配合 jmap 使用,分析生成的堆转储文件,帮助开发者了解内存使用情况。
  • jstat:JVM 统计监控工具,实时监控垃圾回收、内存、类加载等信息,帮助开发者调优 JVM 性能。
  • jconsole:图形化的 JVM 监控工具,可以监控应用程序的内存、线程和类加载情况,常用于监控和调试。
  • jvisualvm:功能强大的性能分析工具,支持堆、线程、GC 的详细监控,还提供内存分析和 CPU 性能分析。

诊断工具

  • jinfo:用于查看和修改正在运行的 JVM 参数,便于动态调优和调整 JVM 行为。
  • jstatd:远程 JVM 监控工具,可以通过网络远程监控 JVM 的状态,适合分布式系统中的性能监控。

高级调试和性能优化工具

  • **Java Mission Control (JMC)**:一个功能强大的工具,用于分析和优化 Java 应用程序的性能,提供了基于飞行记录器(Java Flight Recorder,JFR)的性能分析功能,可以详细查看垃圾回收、线程活动、CPU 使用率等指标,是进行深度性能分析的利器。
  • **Java Flight Recorder (JFR)**:低开销的监控工具,能够记录 JVM 的运行时数据,适合生产环境中的性能分析,尤其是在高并发系统中使用频率较高。

GC 调优和内存分析

jmap 和 jhat 常用于排查内存泄漏或内存占用过高的问题。通过 jmap 生成堆转储文件后,开发者可以使用 jhat 或其他工具(如 Eclipse MAT)分析对象的引用链,从而发现潜在的内存问题。

线程和死锁分析

jstack 是用于调试线程状态的利器,尤其是在分析线程死锁时。当应用卡死或响应时间异常时,通过 jstack 可以捕获应用的线程状态,并分析是否存在死锁情况。

建议

虽然面试这样答可能就差不多了,但还是希望大家可以自己找机会用用,没机会就自己给自己创造机会。

因为这属于线上排查能力,只有真正实践了,到时候自己负责的项目真的出了问题,才不至于手忙脚乱。我们毕竟是工程师,问题解决能力必须掌握。

Java 中 hashCode 和 equals 方法是什么?它们与 == 操作符有什么区别?

hashCode、equals== 都是 Java 中用于比较对象的三种方式,但是它们的用途和实现还是有挺大区别的。

  • hashCode 用于散列存储结构中确定对象的存储位置。可用于快速比较两个对象是否不同,因为如果它们的哈希码不同,那么它们肯定不相等。
  • equals 用于比较两个对象的内容是否相等,通常需要重写自定义比较逻辑。
  • == 用于比较两个引用是否指向同一个对象(即内存地址)。对于基本数据类型,比较它们的值。

hashCode

方法返回对象的哈希码(整数),主要用于支持基于哈希表的集合,用来确定对象的存储位置,如 HashMap、HashSet 等。

Object 类中的默认实现会根据对象的内存地址生成哈希码(native 方法)。

image.png

Java 中,hashCode 方法和 equals 方法之间有一个 “合约”

  • 如果两个对象根据 equals 方法被认为是相等的,那么它们必须具有相同的哈希码。
  • 如果两个对象具有相同的哈希码,它们并不一定相等,但会被放在同一个哈希桶中。

equals

用于比较两个对象的内容是否相等。Object 类中的默认实现会使用==操作符来比较对象的内存地址。

img

通常我们需要在自定义类中重写 equals 方法,以基于对象的属性进行内容比较。比如你可以自定义两个对象的名字一样就是相等的、年龄一样就是相等,可以灵活按照需求定制。

如果两个对象的 equals 方法返回 true,则它们的 hashCode 方法必须返回相同的值,反之则不需要。

对于 equals 定义的比较,实际上还有以下五个要求:

  • 自反性:对于任何非空引用值 xx.equals(x) 必须返回 true
  • 对称性:对于任何非空引用值 xy,如果 x.equals(y) 返回 true,则 y.equals(x) 也必须返回 true
  • 传递性:对于任何非空引用值 xyz,如果 x.equals(y) 返回 truey.equals(z) 返回 true,则 x.equals(z) 也必须返回 true
  • 一致性:对于任何非空引用值 xy,只要对象在比较中没有被修改,多次调用 x.equals(y) 应返回相同的结果。
  • 对于任何非空引用值 xx.equals(null) 必须返回 false

==

== 操作符用于比较两个引用是否指向同一个对象(即比较内存地址),如果是基本数据类型,== 直接比较它们的值。

Java 中的 hashCode 和 equals 方法之间有什么关系?

在 Java 中,hashCode()equals() 方法的关系主要体现在集合类(如 HashMapHashSet)中。

它俩决定了对象的逻辑相等性哈希存储方式

equals() 方法

  • 用于判断两个对象是否相等。默认实现是使用 == 比较对象的内存地址,但可以在类中重写 equals() 来定义自己的相等逻辑。

hashCode() 方法

  • 返回对象的哈希值,主要用于基于哈希的集合(如 HashMapHashSet)。同一个对象每次调用 hashCode() 必须返回相同的值,且相等的对象必须有相同的哈希码。

两者的关系

如果两个对象根据 equals() 相等,它们的 hashCode() 值必须相同。即a.equals(b) == true,那么 a.hashCode() == b.hashCode() 必须为 true

但是反过来不要求成立:即两个对象的 hashCode() 相同,不一定 equals() 相等。

注意:如果违背上述关系会导致在基于哈希的集合中出现错误行为。例如,HashMap 可能无法正确存储和查找元素。

为什么要重写 hashCode() 和 equals()

因为在使用 HashMapHashSet 等集合时,这些集合内部依赖 hashCode()equals() 方法来确定元素的存储位置。如果没有正确地重写这两个方法,集合可能无法正确判断对象的相等性,导致重复存储、查找失败等问题。

重写 equals() 方法的基本规则:

  • 自反性:对于任何非空对象引用 xx.equals(x) 必须为 true
  • 对称性:对于任何非空对象引用 xyx.equals(y) 应当等于 y.equals(x)
  • 传递性:如果 x.equals(y) == truey.equals(z) == true,那么 x.equals(z) 必须为 true
  • 一致性:只要对象未发生改变,多次调用 x.equals(y) 结果应该一致。
  • 对于 null:对于任何非空对象引用 xx.equals(null) 必须返回 false

重写 hashCode()方法的基本规则:

  • 在相同的应用程序执行过程中,对于同一个对象多次调用 hashCode() 必须返回相同的值。
  • 如果两个对象根据 equals() 方法相等,则它们的 hashCode() 值必须相等。
  • 但是,如果两个对象 equals() 不相等,则它们的 hashCode() 值不必不同,但不同的 hashCode() 值可以提高哈希表的性能。

hashCode & equals & 集合源码分析

hashCode 是属于 Object 的一个方法,并且是个 native 方法,本质就是返回一个哈希码,即一个 int 值,一般是一个对象的内存地址转成的整数。

image.png

equals,我们知道是用来判断两个对象是否相同的,也是属于 Object 的一个方法,并且默认实现如下:

img

看到这,是不是觉得 hashCode 和 equals 没啥关系啊?为什么要放在一起说?

确实,一般情况下两者是没啥关系。但,如果是将一个对象用在散列表的相关类的时候,是有关系的。

比如 HashSet,我们常用来得到一个不重复的集合。

现在有个 Yes 类的 HashSet 集合,我只重写了 Yes 类的 equals 方法,表明如果 name 相同就返回 true。

1
2
3
4
5
6
7
8
9
10
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj instanceof Yes) {
Yes other = (Yes) obj;
return name.equals(other.name);
}
return false;
}

就重写一个 equals 的话,HashSet 中会出现相同 name 的 Yes 对象。

原因就是 hashCode 没有重写,那为什么会这样呢?因为 HashSet 是复用 HashMap 的能力存储对象,而塞入 key 的时候要计算 hash 值,可以看到这里实际会调用对象的 hashCode 方法来计算 hash 值。

image-20220123131304189.png

然后在具体执行 putVal 方法的时候,相关的判断条件会先判断 hash 值是否相等,如果 hash 值都不同,那就认为这两个对象不相等,这与我们之前设定的 name 一样的对象就是相等的条件就冲突了,我们简单看下源码就清楚了:

image-20220123131544363.png

可以看到,相关的判断条件都是先判断 hash 值,如果 hash 值相等,才会接着判断 equals。如果 hash 值不等,这个判断条件直接就 false 了。

因此规定,重写 equals 方法的时候,也要重写 hashCode 方法,这样才能保持条件判断的同步。我建议不管会不会用到散列表,只要你重写 equals 就一起重写 hashCode ,这样肯定不会出错。

Java 泛型的作用是什么?

Java 泛型的作用是通过在编译时检查类型安全,允许程序员编写更通用和灵活的代码,避免在运行时发生类型转换错误。

总结作用

  • 类型安全:泛型允许在编译时进行类型检查,确保在使用集合或其他泛型类时,不会出现类型不匹配的问题,减少了运行时的 ClassCastException 错误。
  • 代码重用:泛型使代码可以适用于多种不同的类型,减少代码重复,提升可读性和维护性。
  • 消除显式类型转换:泛型允许在编译时指定类型参数,从而消除了运行时需要显式类型转换的麻烦。

示例:

1
2
3
List<String> list = new ArrayList<>();
list.add("Hello");
String str = list.get(0); // 不需要类型转换

泛型的实际应用

  • 集合框架:Java 的集合框架广泛使用了泛型。List<T>Set<T>Map<K, V> 等接口可以针对不同的数据类型实现统一的操作。
  • 泛型方法:不仅可以定义泛型类,还可以定义泛型方法,使得方法能够处理多种不同的数据类型。

例如:

1
2
3
4
5
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}

为什么需要泛型通俗理解

在 Java5 之前是没有泛型的,没泛型都能用的好好的,那为什么要加个泛型呢,能给我们带来什么呢?

我们先来看下下面这段代码:

1
2
3
List list = new ArrayList();
list.add("yes"); // 加入string
list.add(233); // 加入int

在没有泛型的时候,加入的集合的数据并不会做任何约束,都会被当作成 Object 类型

可能有人说,这很好呀,多自由!确实,自由是自由了,但是代码的约束能力越低,就越容易出错,使用上也有诸多不便,比如获取的时候需要强转。

image.png

如果一不小心取错类型,编译的时候能过,但是运行的时候却抛错。

image.png

综上,Java 引入了泛型。

而泛型的作用就是加了一层约束,约束了类型。

有了这一层约束就好办事儿了,由于声明了类型,可以在编译的时候就识别出不准确的类型元素。使得错误提早抛出,避免运行时才发现。

image.png

并且也不需要在代码上显示的强转,从以下代码可以看出,能直接获取 String 类型元素。

image.png

我们再小结一下泛型的好处:

  • 提高了代码的可读性,一眼就能看出集合(其它泛型类)的类型
  • 可在编译期检查类型安全,增加程序的健壮性
  • 省心不需要强转(其实内部帮做了强转,下面会说)
  • 提高代码的复用率,定义好泛型,一个方法(类)可以适配所有类型 (其实以前 Object 也行,就是比较麻烦)

为什么都说 Java的泛型是伪泛型

我们来看一段代码:

image.png

可以看到,我声明的是一个 String 类型的集合,但是通过反射往集合中插入了 int 类型的数据,居然成功了?

这说明在运行时泛型根本没有起作用!也就是说在运行的时候 JVM 获取不到泛型的信息,也会不对其做任何的约束

你可以认为 Java 的泛型就是编译的时候生效,运行的时候没有泛型,所以大家才说 Java 是伪泛型!

因此,虽然在 IDE 写代码的时候泛型生效了,而实际上在运行的时候泛型的类型是被擦除的

一言蔽之,Java的泛型只在编译时生效,JVM 运行时没有泛型

Java 泛型擦除是什么?

泛型擦除指的是 Java 编译器在编译时将所有泛型信息删除的过程,以确保与 Java 1.4 及之前的版本保持兼容。

泛型参数在运行时会被替换为其上界(通常是 Object),这样一来在运行时无法获取泛型的实际类型。

作用:泛型擦除确保了 Java 代码的向后兼容性,但它也限制了在运行时对泛型类型的操作。

影响:由于类型擦除,无法在运行时获取泛型的实际类型,也不能创建泛型类型的数组或对泛型类型使用 instanceof 检查。

示例:

1
2
3
4
5
public <T> void printList(List<T> list) {
for (T element : list) {
System.out.println(element);
}
}

在编译时,类型 T 会被擦除为 Object,因此编译后的代码类似于:

1
2
3
4
5
public void printList(List list) {
for (Object element : list) {
System.out.println(element);
}
}

为什么 Java 泛型的实现是类型擦除?

回答重点提到主要原因是为了向下兼容,即兼容 Java5 之前的编译的 class 文件。

例如 Java 1.2 上正在跑的代码,可以在 Java 5 的 JRE 上运行。

也是因为需要向下兼容,才使得 Java 实现的是伪泛型

我从现有的实现倒推伪泛型的设计可能思路(我个人瞎掰的,您随意听听)是这样的:

  1. 这 Java 5 以前的版本,线上已经有很多应用在跑了,我好像不能新加一套,影响推广还可能被骂的很惨
  2. 咋办,泛型毕竟是加一个约束,以前的代码没这个约束啊,该如何兼容?
  3. 有了,要不我在编译器上动手脚,在编译的时候识别和约束泛型,然后编译过了就把泛型的信息擦除了。这样运行的时候约束不是没了吗?不就和之前保持一致了吗?好,就这样干了!

总而言之,就是为了向下兼容才采用类型擦除来实现的。

这里还有个坑,也就是泛型不支持基本类型,比如 int。因为泛型擦除后就变成了Object,这个 int 和 Object 兼容有点麻烦。

参考网上 R 大的解释:

GJ / Java 5说:这个问题有点麻烦,赶不及在这个版本发布前完成了,就先放着不管吧。于是Java 5 的泛型就不支持原始类型,而我们不得不写恶心的 ArrayList<Integer>ArrayList<Long> … 这就是一个偷懒了的地方。

这说明啥?写 Java 的也是程序员,也是要发版有上线需求的,所以说…

既然擦除了类型,为什么在运行期通过反射可以获得类型?

我们来看下这段代码:

image.png

上述定义了泛型类型为 String 的 list,并且获取的 str 不需要强转,这一步是怎么做的呢?

javap -c 看下字节码:

image.png

我们从反编译看生成的字节码可以看到, new 的 list 没有保存泛型的信息,所以是被擦除了。

然后看到 #7 没,有个 checkcast ,强转的类型是 String,看到这大伙儿应该都明白,为什么类型擦除了,但是我们 get 的时候不需要强转呢?

因为编译器隐性的帮我们插入了强转的代码!所以我们的 Java 代码中不需要写强转。

再回到此小节标题:既然擦除了类型,为什么在运行期仍能反射获得类型?

答案就藏在 class 文件中。我们来看下这段代码:

image.png

通过反射,我确实获得了 list 的类型。那既然类型被擦除了,这又是怎么做到的呢?

我们直接进行一手 javap -v,反编译看到字节码里面有这样的记录:

image.png

这下很好理解了,class 文件里面存了这个信息,所以我们通过反射自然而然的就能得到这个类型。没错,就是这么简单。

也正因为原理如此,所以我们只能对以下三种情况利用反射获取泛型类型:

  • 成员变量的泛型
  • 方法入参的泛型
  • 方法返回值的泛型

对于局部变量这种是无能为力的。

什么是 Java 泛型的上下界限定符?

Java 泛型的上下界限定符 用于对泛型类型参数进行范围限制 ,主要有上界限定符(Upper Bound Wildcards)下界限定符(Lower Bound Wildcards)

1)**上界限定符 (? extends T)**:

  • 定义? extends T 表示通配符类型必须是 T 类型或 T 的子类。

  • 作用:允许使用 T 或其子类型作为泛型参数,通常用于读取操作,确保可以读取为 TT 的子类的对象。

  • 示例

    1
    2
    3
    4
    public void process(List<? extends Number> list) {
    Number num = list.get(0); // 读取时是安全的,返回类型是 Number 或其子类
    // list.add(1); // 编译错误,不能往其中添加元素
    }

2)**下界限定符 (? super T)**:

  • 定义? super T 表示通配符类型必须是 T 类型或 T 的父类。

  • 作用:允许使用 T 或其父类型作为泛型参数,通常用于写入操作,确保可以安全地向泛型集合中插入 T 类型的对象。

  • 示例

    1
    2
    3
    4
    public void addToList(List<? super Integer> list) {
    list.add(1); // 可以安全地添加 Integer 类型的元素
    // Integer value = list.get(0); // 编译错误,不能安全地读取
    }

上界限定符 (extends) 使用示例

上界限定符通常用于限定泛型必须是某个类或接口的子类。如果使用了不符合上界的类型,将导致编译错误。

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
import java.util.ArrayList;
import java.util.List;

public class UpperBoundErrorExample {

// 泛型方法,T 必须是 Number 的子类
public static <T extends Number> void printNumbers(List<T> list) {
for (T num : list) {
System.out.println(num);
}
}

public static void main(String[] args) {
List<Integer> integerList = new ArrayList<>();
integerList.add(1);
integerList.add(2);

List<Double> doubleList = new ArrayList<>();
doubleList.add(1.1);
doubleList.add(2.2);

// 正确的调用:Integer 和 Double 都是 Number 的子类
printNumbers(integerList);
printNumbers(doubleList);

// 编译错误:String 不是 Number 的子类
List<String> stringList = new ArrayList<>();
stringList.add("Hello");
// printNumbers(stringList); // 编译错误
}
}
  • printNumbers() 方法要求 T 必须是 Number 的子类,因此当我们尝试传入 List<String> 时会导致编译错误,因为 String 不是 Number 的子类。
  • 编译错误的代码:printNumbers(stringList)

下界限定符 (super) 使用示例

下界限定符用于限定泛型必须是某个类或接口的父类。如果不符合下界要求,同样会导致编译错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.ArrayList;
import java.util.List;

public class LowerBoundErrorExample {

// 泛型方法,T 是 Integer 或其父类
public static void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
}

public static void main(String[] args) {
List<Number> numberList = new ArrayList<>();
addIntegers(numberList); // 正确,Number 是 Integer 的父类

List<Object> objectList = new ArrayList<>();
addIntegers(objectList); // 正确,Object 是 Integer 的父类

// 编译错误:List<Double> 不符合下界条件
List<Double> doubleList = new ArrayList<>();
// addIntegers(doubleList); // 编译错误
}
}
  • addIntegers() 方法要求 T 必须是 Integer 或其父类,因此 List<Number>List<Object> 是有效的,但 List<Double> 不符合条件,因为 Double 不是 Integer 的父类。
  • 编译错误的代码:addIntegers(doubleList)

泛型类型转换使用示例

使用泛型时,类型的转换必须严格遵循泛型的定义,否则会产生编译错误。例如,下界不能保证类型安全写入,这会导致问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.List;
import java.util.ArrayList;

public class GenericsTypeErrorExample {

public static void main(String[] args) {
List<? extends Number> numberList = new ArrayList<>();

// 编译错误:不能向上界类型中添加元素
// numberList.add(10); // 编译错误

List<? super Integer> integerList = new ArrayList<>();
integerList.add(10); // 正确,可以添加 Integer 类型
integerList.add(20);

// 编译错误:虽然是 Integer 的父类,但不能保证是 Integer 类型,因此不能读取为 Integer
// Integer num = integerList.get(0); // 编译错误
}
}
  • List<? extends Number> 限定了泛型上界为 Number 的子类,但无法保证具体是哪种类型,因此不能添加元素(除了 null),否则会违反类型安全性。尝试执行 numberList.add(10) 时会导致编译错误。
  • List<? super Integer> 限定了泛型下界为 Integer 的父类,虽然可以向列表中添加 Integer 类型的元素,但从列表中读取时无法确定具体类型,因此不能安全地将其赋值为 Integer,编译器会阻止这种操作。

为何需要上下界限定符

泛型提供了类型安全性,但有时我们希望泛型参数的类型在某个范围内,这样可以确保在不同场景下使用泛型时既能获得灵活性,又能保证类型安全。上下界限定符正是为此设计的,允许我们定义类型的范围,而不是具体类型。

常见使用场景

  • 上界限定符(? extends T:常用于协变场景,允许我们对泛型集合进行只读操作。比如,我们可以从 List<? extends Number> 中读取 Number 或其子类,但不能往其中添加对象。
  • 下界限定符(? super T:常用于逆变场景,允许我们对泛型集合进行写入操作。比如,我们可以向 List<? super Integer> 中添加 Integer 或其父类,但不保证读取到的对象类型。

PECS 原则

PECS 原则是 Producer Extends, Consumer Super 的缩写,帮助理解何时使用上界和下界限定符:

  • Producer Extends:如果某个对象提供数据(即生产者),使用 extends(上界限定符)。
  • Consumer Super:如果某个对象使用数据(即消费者),使用 super(下界限定符)。

类型擦除与泛型边界

Java 泛型是通过类型擦除实现的,即在编译时会将泛型信息移除,用实际类型替代泛型参数。上下界限定符通过边界限制(Bounded Type Parameters)确保在擦除时可以限制类型的范围,保证了类型的安全性和灵活性。

例如:

1
2
3
public <T extends Number> void print(T value) {
System.out.println(value);
}

Java 中的深拷贝和浅拷贝有什么区别?

深拷贝(Deep Copy):深拷贝不仅复制对象本身,还递归复制对象中所有引用的对象。这样新对象与原对象完全独立,修改新对象不会影响到原对象。即包括基本类型和引用类型,堆内的引用对象也会复制一份。

浅拷贝(Shallow Copy):拷贝只复制对象的引用,而不复制引用指向的实际对象。也就是说,浅拷贝创建一个新对象,但它的字段(若是对象类型)指向的是原对象中的相同内存地。

深拷贝创建的新对象与原对象完全独立,任何一个对象的修改都不会影响另一个。而修改浅拷贝对象中引用类型的字段会影响到原对象,因为它们共享相同的引用。

图示深浅拷贝区别

比如现在有个 teacher 对象,然后成员里面有一个 student 列表。

teacher 深拷贝之后堆内有 2 个 student 列表,之间不会影响,而浅拷贝的话堆内还是只有一个 student 列表。

image-20210303201307397.png

所以,如果是深拷贝,那么原对象对 student 列表的修改并不会影响拷贝对象,而浅拷贝则会影响。

如何实现浅拷贝

使用 Object.clone() 方法是浅拷贝的常见方式。默认情况下,clone() 方法只是对对象的字段进行字段拷贝,对于基本类型的字段会复制值,对于引用类型的字段则复制引用。

示例代码:

1
2
3
4
5
6
7
8
9
class Person implements Cloneable {
String name;
int age;

@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 浅拷贝
}
}

如何实现深拷贝

深拷贝可以通过递归调用 clone() 方法手动实现,也可以通过序列化与反序列化实现。序列化方式简单易用,但性能相对较低,尤其是在深层嵌套对象或大对象的情况下。

递归方式

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
class Address implements Cloneable {
String city;
public Address(String city) { this.city = city; }
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 浅拷贝
}
}

class Person implements Cloneable {
String name;
int age;
Address address;

public Person(String name, int age, Address address) {
this.name = name;
this.age = age;
this.address = address;
}

@Override
protected Object clone() throws CloneNotSupportedException {
Person cloned = (Person) super.clone();
cloned.address = (Address) address.clone(); // 深拷贝
return cloned;
}
}

序列化方式

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Object deepCopy(Object object) {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(object);
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
return ois.readObject();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

什么是 Java 的 Integer 缓存池?

Java 的 Integer 缓存池(Integer Cache) 是为了提升性能和节省内存。根据实践发现大部分的数据操作都集中在值比较小的范围,因此缓存这些对象可以减少内存分配和垃圾回收的负担,提升性能。

-128127 范围内的 Integer 对象会被缓存和复用。

原理

  • Java 在自动装箱时,对于值在 -128127 之间的 int 类型,会直接返回一个已经缓存的 Integer 对象,而不是创建新的对象。

缓存池的使用场景

  • 自动装箱(Auto-boxing):当基本类型 int 转换为包装类 Integer 时,若数值在缓存范围内,返回缓存对象。
  • 值比较:由于相同范围内的整数使用同一个缓存对象,使用 == 可以正确比较它们的地址(引用相同),而不需要使用 equals()。但是要注意对于超过缓存范围的 Integer 对象,== 比较的是对象引用,而不是数值。要比较数值,应使用 equals() 方法。

缓存池的可配置范围

在 Java 8 及以后的版本中,可以通过 JVM 参数 -XX:AutoBoxCacheMax=size 来调整缓存池的上限。

比如:

1
java -XX:AutoBoxCacheMax=500

这样可以将缓存范围扩展到 -128500

缓存池实现原理分析

实现的原理是 int 在自动装箱的时候会调用 Integer.valueOf,进而用到了 IntegerCache。

image-20210228112742081.png

实现很简单,就是判断下值是否在范围之内,如果是的话去 IntegerCache 中取。

IntegerCache 在静态块中会初始化好缓存值。

image-20210228112757226.png

所以这里还有个面试题,就是为什么 Integer 127 之内的相等,而超过 127 的就不等了,因为 127 之内的就是同一个对象,所以当然相等。

不仅 Integer 有缓存池,Long 也是有的,不过范围是写死的 -128 到 127(无法配置)。

image-20210228112817173.png

其他包装类型的缓存机制

  • LongShortByte 这 3 种包装类缓存范围也是-128127 的。
  • FloatDouble 没有缓存池,因为是小数,能存的数太多了。
  • Character 缓存范围是 \u0000\u007F(即 0 到 127,代表 ASCII 字符集)
  • Boolean 只缓存两个值,即 true 和 false

Java 的类加载过程是怎样的?

类加载指的是把类加载到 JVM 中。把二进制流存储到内存中,之后经过一番解析、处理转化成可用的 class 类。

二进制流可以来源于 class 文件,或通过字节码工具生成的字节码或来自于网络。只要符合格式的二进制流,JVM 来者不拒。

类加载流程分为:

  1. 加载
  2. 连接
  3. 初始化

连接还能拆分为:验证、准备、解析三个阶段。

所以总的来看可以分为 5 个阶段:

1)加载(Loading)

将二进制流读入内存中,生成一个 Class 对象。

加载阶段是类加载过程的第一个阶段,JVM在这个阶段会做以下事情:

  • 通过类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2)验证/确认(Verification)

主要是验证加载进来的二进制流是否符合一定格式,是否规范,是否符合当前 JVM 版本等等之类的验证。

  • 确保加载的类信息符合JVM规范,没有安全方面的问题。
  • 检查类的字节码是否合法,是否会被JVM接受。

3)准备(Preparation)

为静态变量(类变量)赋初始值,也即为它们在方法区划分内存空间。这里注意是静态变量,并且是初始值,比如 int 的初始值是 0。

  • 为类变量分配内存,并设置默认初始值(例如,int类型的默认值为0,引用类型的默认值为null)。
  • 这时候还没有执行任何的Java代码。

4)解析(Resolution)

将常量池的符号引用转化成直接引用。

  • 将类、接口、字段和方法的符号引用转换为直接引用。
  • 符号引用来自于类文件的常量池,直接引用指的是指向方法区的指针、偏移量或者是指向对象的引用。

符号引用可以理解为只是个替代的标签,比如你此时要做一个计划,暂时还没有人选,你设定了个 A 去做这个事。然后等计划真的要落地的时候肯定要找到确定的人选,到时候就是小明去做一件事。解析就是把 A(符号引用) 替换成小明(直接引用)。符号引用就是一个字面量,没有什么实质性的意义,只是一个代表。

直接引用指的是一个真实引用,在内存中可以通过这个引用查找到目标。

5)初始化(Initialization)

这时候就执行一些静态代码块,为静态变量赋值,这里的赋值才是代码里面的赋值,准备阶段只是设置初始值占个坑。

初始化是类加载过程的收尾阶段,在这个阶段,JVM负责执行类的初始化代码,为类变量赋予正确的初始值,也就是执行类构造器<clinit>()方法的过程。这个方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。

什么是 Java 中的双亲委派模型?

双亲委派模型是 Java 类加载机制的设计模式之一。它的核心思想是:类加载器在加载某个类时,会先委派给父类加载器去加载,父类加载器无法加载时,才由当前类加载器自行加载。

工作流程

  • 当一个类加载器(如自定义类加载器)试图加载某个类时,先将加载请求向上委派给父类加载器,父类加载器再向上委派给它的父类,直到根类加载器(Bootstrap ClassLoader)。

三种类加载器

在 JDK 9 之前,Java 自身提供了 3 种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader),它是属于虚拟机自身的一部分,用 C++ 实现的,主要负责加载<JAVA_HOME>\lib目录中或被-Xbootclasspath指定的路径中的并且文件名是被虚拟机识别的文件。它是所有类加载器的爸爸。
  2. 扩展类加载器(Extension ClassLoader),它是 Java 实现的,独立于虚拟机,主要负责加载<JAVA_HOME>\lib\ext目录中或被java.ext.dirs系统变量所指定的路径的类库。
  3. 应用程序类加载器(Application ClassLoader),它是 Java 实现的,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器那这玩意就是我们程序中的默认加载器。

所以一般情况类加载会从应用程序类加载器委托给扩展类再委托给启动类,启动类找不到然后扩展类找,扩展类加载器找不到再应用程序类加载器找。

image-20210228112334820.png

为什么要有双亲委派机制?

它使得类有了层次的划分。就拿 java.lang.Object 来说,加载它经过一层层委托最终是由 Bootstrap ClassLoader来加载的,也就是最终都是由Bootstrap ClassLoader 去找\librt.jar里面的java.lang.Object加载到 JVM 中。

这样如果有不法分子自己造了个java.lang.Object,里面嵌了不好的代码,如果我们是按照双亲委派模型来实现的话,最终加载到 JVM 中的只会是我们rt.jar里面的东西,也就是这些核心的基础类代码得到了保护。

因为这个机制使得系统中只会出现一个java.lang.Object。不会乱套了。你想想如果我们 JVM 里面有两个 Object,那岂不是天下大乱了。

总结

  • 安全性:避免重复加载类。例如,java.lang.Object 类只能由根类加载器加载,防止恶意代码加载不受信任的类来替代系统核心类。
  • 一致性:保证同一个类在 JVM 中只会被加载一次,确保在整个应用中使用的是同一个类对象。

那你知道有违反双亲委派的例子吗?

典型的例子就是:JDBC

JDBC 的接口是类库定义的,但实现是在各大数据库厂商提供的 jar 包中,那通过启动类加载器是找不到这个实现类的,所以就需要应用程序加载器去完成这个任务,这就违反了自下而上的委托机制了。

具体做法是搞了个线程上下文类加载器,通过 setContextClassLoader() 默认设置了应用程序类加载器,然后通过 Thread.current.currentThread().getContextClassLoader() 获得类加载器来加载。

这是一个具体的例子,实际上 Java 的 SPI 机制都违反了双亲委派模型。因为 SPI 允许开发者在类路径中自定义服务实现,通常通过线程上下文类加载器来加载 SPI 实现类,绕过了父类加载器。

除此之外,在 Java EE 容器(如 Tomcat、WebLogic)中,每个 Web 应用有自己的类加载器,应用级别的类加载器优先加载应用的类库,而不是父类加载器。所以它们也违反了双亲委派。

自定义类加载器的简单示例

重写 findClass 即可实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 自定义加载类的逻辑
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
}
return defineClass(name, classData, 0, classData.length);
}

private byte[] loadClassData(String name) {
// 读取 class 文件的字节码
return null; // 简单示例,此处省略实际实现
}
}

什么是 Java 的 BigDecimal?

BigDecimal 是 Java 中提供的一个用于高精度计算的类,属于 java.math 包。它提供对浮点数和定点数的精确控制,特别适用于金融和科学计算等需要高精度的领域。

主要特点:

  • 高精度:BigDecimal 可以处理任意精度的数值,而不像 float 和 double 存在精度限制。
  • 不可变性:BigDecimal 是不可变类,所有的算术运算都会返回新的 BigDecimal 对象,而不会修改原有对象(所以要注意性能问题)。
  • 丰富的功能:提供了加、减、乘、除、取余、舍入、比较等多种方法,并支持各种舍入模式。

通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。

image-20211213101646884.png

想要解决浮点数运算精度丢失这个问题,可以直接使用 BigDecimal 来定义浮点数的值,然后再进行浮点数的运算操作即可。

1
2
3
4
5
6
7
8
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");

BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);

System.out.println(x.compareTo(y));// 0

如何创建 BigDecimal 对象

可以通过多种方式创建 BigDecimal 对象:

1)使用字符串(推荐方式,因为字符串可以精确表示数值):

1
BigDecimal bd1 = new BigDecimal("123.45");

2)使用数值(不推荐,因为 double 和 float 有精度问题)

1
BigDecimal bd2 = new BigDecimal(123.45); // 可能会引入精度问题

3)使用 BigDecimal.valueOf 方法(推荐方式):

1
BigDecimal bd3 = BigDecimal.valueOf(123.45);

四舍五入模式介绍

  • RoundingMode.UP:向远离零的方向舍入。
  • RoundingMode.DOWN:向接近零的方向舍入。
  • RoundingMode.CEILING:向正无穷方向舍入。
  • RoundingMode.FLOOR:向负无穷方向舍入。
  • RoundingMode.HALF_UP:向“最近”的数字舍入,如果有两个相等的最近数字,则向上舍入。
  • RoundingMode.HALF_DOWN:向“最近”的数字舍入,如果有两个相等的最近数字,则向下舍入。
  • RoundingMode.HALF_EVEN:向“最近”的数字舍入,如果有两个相等的最近数字,则向相邻的偶数舍入

使用 new String(“yupi”) 语句在 Java 中会创建多少个对象?

会创建 1 或 2 个字符串对象。

主要有两种情况:

  1. 如果字符串常量池中不存在字符串对象“yupi”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。
  2. 如果字符串常量池中已存在字符串对象“yupi”的引用,则只会在堆中创建 1 个字符串对象“yupi”。

可以看下这个图再理解一下:

image.png

详细分析

1、如果字符串常量池中不存在字符串对象“yupi”的引用,那么它会在堆上创建两个字符串对象,其中一个字符串对象的引用会被保存在字符串常量池中。

示例代码(JDK 1.8):

1
String s = new String("yupi");

对应的字节码:

Snipaste_2024-04-27_22-18-22.jpg

ldc 命令用于判断字符串常量池中是否保存了对应的字符串对象的引用,如果保存了的话直接返回,如果没有保存的话,会在堆中创建对应的字符串对象并将该字符串对象的引用保存到字符串常量池中。

2、如果字符串常量池中已存在字符串对象“yupi”的引用,则只会在堆中创建 1 个字符串对象“yupi”。

示例代码(JDK 1.8):

1
2
3
4
// 字符串常量池中已存在字符串对象“yupi”的引用
String s1 = "yupi";
// 下面这段代码只会在堆中创建 1 个字符串对象“yupi”
String s2 = new String("yupi");

对应的字节码:

Snipaste_2024-04-27_22-24-12.jpg

这里的过程与上面差不多,我们可以看一下,7 这个位置的 ldc 命令不会在堆中创建新的字符串对象 “yupi”,这是因为 0 这个位置已经执行了一次 ldc 命令,已经在堆中创建过一次字符串对象 “yupi” 了。 7 这个位置执行 ldc 命令会直接返回字符串常量池中字符串对象“yupi”对应的引用。

Java 中 final、finally 和 finalize 各有什么区别?

**1)final**:用于修饰类、方法、和变量,主要用来设计不可变类、确保类的安全性、优化性能(编译器优化)。

  • :被 final 修饰的类不能被继承。
  • 方法:被 final 修饰的方法不能被重写。
  • 变量:被 final 修饰的变量不可重新赋值,常用于定义常量。

**2)finally**:与 try-catch 语句块结合使用,用于确保无论是否发生异常,finally 代码块都会执行。

主要用于释放资源(如关闭文件、数据库连接等),以保证即使发生异常,资源也会被正确释放。

**3)finalize()**:是 Object 类中的方法,允许对象在被垃圾回收前进行清理操作。

较少使用,通常用于回收非内存资源(如关闭文件或释放外部资源),但不建议依赖于它,因为 JVM 不保证 finalize() 会被及时执行。

JDK 9 之后finalize() 方法已被标记为废弃,因为Java 提供了更好的替代方案(如 AutoCloseable 接口和 try-with-resources 语句)。

finally 的注意事项

不推荐在 finally 中使用 return,这样会覆盖 try 块中的返回值,容易引发难以发现的错误。

finalize() 的替代方案

当 JVM 检测到对象不可达时,会标记对象,标记后将调用 finalize() 方法进行清理(如果重写了该方法),之后才会真正回收对象。

但 JVM 并不承诺一定会等待 finalize() 运行结束,因此可能会造成内存泄漏或性能问题,所以在实际开发中,尽量避免使用 finalize() 进行清理操作。

Java 7 引入了 try-with-resources,它比依赖 finalize() 更加安全有效,能够自动关闭实现 AutoCloseable 接口的资源。因此推荐使用try-with-resources

或者可以依赖对象生命周期管理机制(如 Spring 的 DisposableBean)来实现更精细的资源回收。

try-with-resources 语法介绍

try-with-resources 是 Java 7 引入的一种资源管理机制,旨在简化资源的自动管理和释放。在传统的 try-catch-finally 结构中,开发者需要手动管理资源的关闭操作,而 try-with-resources 则可以自动处理这些资源的关闭,从而减少代码量并降低资源泄漏的风险。

try-with-resources 的基本语法如下:

1
2
3
4
5
6
try (ResourceType resource1 = new ResourceType();
ResourceType resource2 = new ResourceType()) {
// 使用资源的代码
} catch (ExceptionType e) {
// 异常处理
}

关键点

  1. 资源类型try-with-resources 中的资源必须是实现了 java.lang.AutoCloseable 接口的类。AutoCloseable 接口定义了一个 close() 方法,该方法会在 try 块结束时自动调用。

  2. 自动关闭:无论 try 块中的代码是否抛出异常,资源都会在 try 块结束时自动调用 close() 方法。这确保了资源的正确释放,避免了资源泄漏。

  3. 多个资源:可以在 try 语句中声明多个资源,它们会按照声明的顺序逆序关闭。

  4. 异常处理:如果在 try 块中抛出异常,并且资源在关闭时也抛出异常,try-with-resources 会优先处理 try 块中的异常。资源关闭时抛出的异常会被抑制(suppressed),可以通过 Throwable.getSuppressed() 方法获取。

以下是一个使用 try-with-resources 的示例,展示了如何自动关闭 BufferedReader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class TryWithResourcesExample {
public static void main(String[] args) {
try (BufferedReader reader = new BufferedReader(new FileReader("example.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

在这个示例中:

  • BufferedReader 实现了 AutoCloseable 接口,因此可以在 try-with-resources 中使用。
  • try 块结束时,BufferedReaderclose() 方法会自动调用,无需手动关闭。

优点

  • 简化代码:减少了手动关闭资源的代码,使代码更简洁。
  • 避免资源泄漏:自动关闭资源,减少了因忘记关闭资源而导致的资源泄漏问题。
  • 异常处理:在资源关闭时抛出的异常会被抑制,确保主要异常的处理不被干扰。

注意事项

  • 资源声明顺序:资源会按照声明的顺序逆序关闭,因此需要注意资源的依赖关系。
  • 资源状态:在 try 块中使用资源时,应确保资源的状态是有效的,避免在资源已经关闭的情况下继续使用。

try-with-resources 是 Java 中一种非常有用的特性,特别是在处理文件、网络连接、数据库连接等需要手动关闭的资源时,能够显著提高代码的可读性和健壮性。

为什么在 Java 中编写代码时会遇到乱码问题?

主要原因是字符编码与解码不一致。在 Java 中,乱码问题常常由字符编码(比如 UTF-8、GBK)和解码过程的不一致引起。如果在编码时使用了一种字符集,而在解码时使用了另一种,字符将无法正确显示,从而出现乱码。

常见的有:

1)默认编码设置问题:Java 默认使用操作系统的字符编码,如果程序在不同操作系统上运行且未明确指定编码,就可能导致字符处理时出现差异,引发乱码。

2)流处理中的编码问题:在文件或网络流处理中,读取或写入字符时没有指定编码格式,可能会默认使用平台编码,造成乱码问题。

3)数据库乱码问题:数据库字符集和应用字符集不匹配,也会导致从数据库读取的数据出现乱码,特别是存取多字节字符(如中文)时。

各类乱码类型及其产生原因

各类乱码类型及其产生原因

“锟斤拷锟斤拷锟斤拷锟叫癸拷锟斤拷”,是不是似曾相识?很多人在编程的时候,都会遇到乱码问题。

甚至你拿上面这些乱码在网上搜,都能找到对应错误的网页:

image.png

下面是常见的乱码类型

例如下面的示例(注:默认编码为UTF-8):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//1 以GBK方式读取UTF-8码的中文  古文+日韩
String str = "我爱你啊分为了交流大多为不认识的古";
String s0 = new String(str.getBytes(), "GBK");
System.out.println("s0 = " + s0);
//2 以UTF-8的方式读取GBK码的中文 小方块
String s1 = new String(str.getBytes("GBK"), "UTF-8");
System.out.println("s1 = " + s1);

//3 以IS08859-1方式读取UTF-8编码的中文
String s3 = new String(str.getBytes(), "ISO8859-1");
System.out.println("s3 = " + s3);
//4 以IS03859-1方式读取GBK结码的中文
String s4 = new String(str.getBytes("GBK"), "ISO8859-1");
System.out.println("s4 = " + s4);
//5 以GBK方式读取UTF-8编码的中文,然后又用UTF-8的格式再次读取
String s5 = new String(str.getBytes(), "GBK");
String s6 = new String(s5.getBytes("GBK"), "UTF-8");
System.out.println("s6 = " + s6);
//6 以 UTF-8 方式读取GBK编码的中文,然后又用GBK的格式再次读取
String s7 = new String(str.getBytes("GBK"), "UTF-8");
String s8 = new String(s7.getBytes(), "GBK");
System.out.println("s8 = " + s8);

运行结果如下

1
2
3
4
5
6
s0 = 鎴戠埍浣犲晩鍒嗕负浜嗕氦娴佸ぇ澶氫负涓嶈璇嗙殑鍙�
s1 = �Ұ��㰡��Ϊ�˽������Ϊ����ʶ�Ĺ�
s3 = æˆ‘çˆ±ä½ å•Šåˆ†ä¸ºäº†äº¤æµå¤§å¤šä¸ºä¸è®¤è¯†çš„å¤
s4 = ÎÒ°®Äã°¡·ÖΪÁ˽»Á÷´ó¶àΪ²»ÈÏʶµÄ¹Å
s6 = 我爱你啊分为了交流大多为不认识的�?
s8 = 锟揭帮拷锟姐啊锟斤拷为锟剿斤拷锟斤拷锟斤拷锟轿拷锟斤拷锟绞讹拷墓锟�

那为什么会这样呢?

先了解下什么是编解码:

  • 编码:将字符按照一定的格式转换成字节流的过程。
  • 解码:就是将字节流解析成字符。

用专业的术语来说,乱码是因为编解码时使用的字符集不一致导致的。比如你将字符利用 UTF-8 编码后,传输给别人,然后这个人用 GBK 来解码,那解出来的不就是乱码吗?

就好比加密算法和解密算法对不上,那解出来的是啥?不就是一堆乱七八糟的东西。

那为什么要需要编解码呢?

因为计算机底层的存储都是 0101,它可不认识什么字符。所以我们需要告诉计算机什么数字代表什么字符。

比如告诉它 0000 代表 面试,0001 代表 鸭 ,这样我输入 0000 0001 后,计算机就可以展示面试鸭三个字了。

这样的一套对应规则就是字符集,所以编解码用的字符集不同,就乱码了。其实就是类似一个翻译的过程,如果翻译成英文,我们按照中文的语法就再翻过来,不就乱了吗。

标准字符编码

ASCII 是美国国家标准协会 ANSI 就制定的一个标准规定了常用字符集的集合和对应的数字编号

image.png

从图可以看到,共 8 位,但是第一位都是 0,实际上就用了 7 位。可以看到完全就是美国标准,中文啥的完全没有。

所以我们中国制定了 GB2312 字符集,后续又发布了 GBK,基于 GB2312 增加了一些繁体字等字符,这里的 K 是扩展的意思。

Unicode

中国需要中国的字符编码,美国需要美国的,韩国还需要韩国的,所以每个国家都弄一个无法统一。

所以就指定了一个统一码 Unicode,又译作万国码、统一字符码、统一字符编码,是信息技术领域的业界标准,其整理、编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字,不但减轻在不同编码系统间切换和转换的困扰,更提供了一种跨平台的乱码问题解决方案!

Unicode 和之前的编码不太一样,它将字符集和编码实现解耦了

来看下这张图就理解了:

image.png

所以 Unicode 是一种全球通用的字符编码标准,旨在为全球的所有书写系统提供一个统一的编码方案。主要目的就是为了克服计算机字符编码中存在的地域和语言兼容性问题。

Unicode 字符集和编码解耦的进一步解析

Unicode 的设计包含两个重要的概念:字符集(Character Set)字符编码(Encoding),它们在 Unicode 中是分离的,称为字符集和编码的“解耦”。

字符集

字符集是一种逻辑集合,用于定义特定字符的编号,也称为 码位(Code Point)。在 Unicode 中,每个字符被分配一个唯一的码位,形式为 U+ 后跟十六进制数字(如 U+0041 表示字母 A)。

Unicode 的字符集包含了几乎所有现代和历史上的书写系统字符,包括:

  • 拉丁字母、阿拉伯字母、希腊字母等常见字母
  • 中文、日文、韩文(CJK)字符
  • 符号、表情符号、标点、数学符号等

Unicode 字符集从逻辑上定义了字符与其唯一的码位的对应关系,这与实际的编码实现无关。

字符编码

字符编码是指将字符集中的码位转换为计算机存储和传输的字节序列的规则。在 Unicode 中,字符编码方案包括 UTF-8、UTF-16 和 UTF-32,它们是 Unicode 字符集的不同实现方式。

由于字符集和编码的解耦,Unicode 字符集可以通过不同的编码方式实现:

  • UTF-8:一种变长编码方式,使用 1 到 4 个字节编码一个字符,向后兼容 ASCII。UTF-8 是目前互联网上最常用的 Unicode 编码,因为它节省了存储空间,且对 ASCII 字符的处理较为高效。
  • UTF-16:也是一种变长编码,使用 2 或 4 个字节编码一个字符。UTF-16 对 BMP(基本多文种平面)字符(即常用字符)使用 2 个字节,对补充字符使用 4 个字节编码。
  • UTF-32:一种定长编码方式,使用 4 个字节编码所有字符。虽然 UTF-32 编码简单,但占用存储空间较大,通常不用于存储大量文本的场合。

为什么 JDK 9 中将 String 的 char 数组改为 byte 数组?

主要是为了节省内存空间,提高内存利用率

在 JDK 9 之前,String 类是基于 char[] 实现的,内部采用 UTF-16 编码,每个字符占用两个字节。但是,如果当前的字符仅需一个字节的空间,这就造成了浪费。例如一些 Latin-1 字符用一个字节即可表示。

因此 JDK 9 做了优化采用 byte[] 数组来实现,ASCII 字符串(单字节字符)通过 byte[] 存储,仅需 1 字节,减小了内存占用。

并引入了 coder 变量来标识编码方式(Latin-1 或 UTF-16)。如果字符串中只包含 Latin-1 范围内的字符(如 ASCII),则使用单字节编码,否则使用 UTF-16。这种机制在保持兼容性的同时,又减少了内存占用。

Latin1

Latin1 是国际标准编码 ISO-8859-1 的别名。Latin1 也是单字节编码,在 ASCII 编码的基础上,利用了 ASCII 未利用的最高位,扩充了 128 个字符,因此 Latin1 可以表示 256 个字符,并向下兼容 ASCII。

Latin1收录的字符除 ASCII 收录的字符外,还包括西欧语言、希腊语、泰语、阿拉伯语、希伯来语对应的文字符号。欧元符号出现的比较晚,没有被收录在 ISO-8859-1 当中,在后来的修订版 ISO-8859-15 加入了欧元符号。

Latin1的编码范围是 0x00-0xFF,ASCII的编码范围是 0x00-0x7F。

Latin1 相对 ASCII 而言,较少被提及,其实 Latin1 的使用还是比较广泛的,比如 MySQL(8.0之前)的数据表存储默认编码就是 Latin1。

如何在 Java 中调用外部可执行程序或系统命令?

面试中一般不会问这题,大家仅做了解即可(除非是特殊岗位的一些场景)

在 Java 中,可以使用 Runtime 类或 ProcessBuilder 类来调用外部可执行程序或执行系统命令。这两种方法都能创建一个子进程来执行指定的命令或程序。接下来就是这两个类的简单使用:

1)使用 Runtime.exec()

Runtime 类提供了 exec() 方法,它允许你执行外部命令。相对于 ProcessBuilder 比较简单。

1img.png

如果还需要获取返回的内容,可以通过 Process 对象中的 getInputStream 方法来获取字符输入流对象。

简单解释一下:

  • 执行命令:使用 Runtime.getRuntime().exec 方法执行命令。
  • 等待进程结束:使用 waitFor 方法等待进程结束并获取退出码。

2)使用 ProcessBuilder ProcessBuilder 类提供了一个更灵活和强大的方式来管理外部进程。它允许你设置环境变量、工作目录,以及重定向输入和输出流。

使用 ProcessBuilder 的例子:

1img_1.png

简单解释一下:

  • 创建 ProcessBuilder 实例:使用 ProcessBuilder 创建一个新的进程。
  • 设置命令:通过 command 方法指定要执行的命令及其参数或像例子直接在构造函数内写入。
  • 启动进程:使用 start 方法启动进程。
  • 读取输出:通过 getInputStream 获取进程的输入流,并读取输出。
  • 等待进程结束:使用 waitFor 方法等待进程结束并获取退出码。

如果一个线程在 Java 中被两次调用 start() 方法,会发生什么?

会报错!因为在 Java 中,一个线程只能被启动一次!所以尝试第二次调用 start() 方法时,会抛出 IllegalThreadStateException 异常。

这是因为一旦线程已经开始执行,它的状态不能再回到初始状态。线程的生命周期不允许它从终止状态回到可运行状态。

线程的生命周期

在 Java 中,线程的生命周期可以细化为以下几个状态:

  • New(初始状态):线程对象创建后,但未调用 start() 方法。
  • Runnable(可运行状态):调用 start() 方法后,线程进入就绪状态,等待 CPU 调度。
  • Blocked(阻塞状态):线程试图获取一个对象锁而被阻塞。
  • Waiting(等待状态):线程进入等待状态,需要被显式唤醒才能继续执行。
  • Timed Waiting(含等待时间的等待状态):线程进入等待状态,但指定了等待时间,超时后会被唤醒。
  • Terminated(终止状态):线程执行完成或因异常退出。

而 Blocked、Waiting、Timed Waiting 其实都属于休眠状态。

一开始线程新建的时候就是初始状态,还未 start。

调用可运行状态就是可以运行。可能正在运行,也可能正在等 CPU 时间片。

造成线程等待的操作有:Object.waitThread.joinLockSupport.park

含等待时间的等待就是上面这些操作设置了 timeout 参数的方法,例如Object.wait(1000)

操作系统中线程的生命周期

操作系统中线程的生命周期通常包括以下五个阶段:

  • 新建(New):线程对象被创建,但尚未启动。
  • 就绪(Runnable):线程被启动,处于可运行状态,等待CPU调度执行。
  • 运行(Running):线程获得CPU资源,开始执行run()方法中的代码。
  • 阻塞(Blocked):线程因为某些操作(如等待锁、I/O操作)被阻塞,暂时停止执行。
  • 终止(Terminated):线程执行完成或因异常退出,生命周期结束。

Java 中 wait() 和 sleep() 的区别?

wait()sleep() 都是用于暂停线程的操作,但它们有明显的区别(先说面试官最关心的):

1)使用要求不同

  • wait() 方法必须在同步块或同步方法内调用,否则会抛出 IllegalMonitorStateException。这是因为 wait() 依赖于对象锁来管理线程的等待和唤醒机制。调用后,当前线程会释放它持有的对象锁,并进入等待状态。
  • sleep() 方法可以在任何上下文中调用,不需要获取对象锁。调用后,线程会进入休眠状态,但不会释放它持有的任何锁。

2)方法所属类不同

  • **wait()**:属于 Object 类。
  • **sleep()**:属于 Thread 类。

3)恢复方式不同

  • **wait()**:需要被其他线程通过 notify()notifyAll() 显式唤醒,或被 wait(long timeout) 的超时参数唤醒。
  • **sleep()**:在指定时间后自动恢复运行,或通过抛出 InterruptedException 恢复。

4)用途不同

  • **wait()**:通常用于线程间通信,配合 notify()notifyAll() 来实现线程的协调工作。
  • **sleep()**:用于让线程暂停执行一段时间,通常用于控制线程的执行频率或模拟延时。

常见错误

  • **误用 sleep()**:有时开发者会错误地使用 sleep() 进行线程间通信,但是 sleep() 不释放锁,可能会导致其他线程无法进入同步块,造成线程饥饿或死锁。
  • 忽略中断sleep() 可能抛出 InterruptedException,如果不正确处理中断信号,可能会导致线程提前退出或错误行为。

栈和队列在 Java 中的区别是什么?

栈(Stack):遵循后进先出(LIFO,Last In, First Out)原则。即,最后插入的元素最先被移除。主要操作包括 push(入栈)和 pop(出栈)。Java 中的 Stack 类(java.util.Stack)实现了这个数据结构。

队列(Queue):遵循先进先出(FIFO,First In, First Out)原则。即,最早插入的元素最先被移除。主要操作包括 enqueue(入队)和 dequeue(出队)。Java 中的 Queue 接口(java.util.Queue)提供了此数据结构的实现,如 LinkedListPriorityQueue

使用场景

  • :常用于函数调用、表达式求值、回溯算法(如深度优先搜索)等场景。
  • 队列:常用于任务调度、资源管理、数据流处理(如广度优先搜索)等场景。

栈的变体

  • 双端队列(Deque):支持在两端插入和删除元素,可以用作栈或队列。java.util.ArrayDequejava.util.LinkedList 都实现了 Deque 接口,提供了栈和队列的功能。

队列的变体

  • 优先队列(PriorityQueue):队列中的元素按优先级排序,而不是按插入顺序。适用于需要按优先级处理任务的场景。
  • 阻塞队列(BlockingQueue):支持阻塞操作,特别适合多线程环境中的生产者-消费者问题。常用实现包括 ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue

你使用过 Java 中的哪些阻塞队列?

什么是 Java 的网络编程?

这题一般会出现在笔试题中,例如让你手写一个基于 Java 实现网络通信的代码。

Java 的网络编程主要利用 java.net 包,它提供了用于网络通信的基本类和接口。

Java 网络编程的基本概念:

  • IP 地址:用于标识网络中的计算机。
  • 端口号:用于标识计算机上的具体应用程序或进程。
  • Socket(套接字):网络通信的基本单位,通过 IP 地址和端口号标识。
  • 协议:网络通信的规则,如 TCP(传输控制协议)和 UDP(用户数据报协议)。

Java 网络编程的核心类:

  • Socket:用于创建客户端套接字。
  • ServerSocket:用于创建服务器套接字。
  • DatagramSocket:用于创建支持 UDP 协议的套接字。
  • URL:用于处理统一资源定位符。
  • URLConnection:用于读取和写入 URL 引用的资源。

示例代码参考(以下代码时基于 TCP 通信的,一般笔试考察的都是 TCP):

服务端代码:

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
36
37
38
39
40
41
import java.io.*;
import java.net.*;

public class TCPServer {
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(8080)) {
System.out.println("Server is listening on port 8080");

while (true) {
Socket socket = serverSocket.accept();
//异步处理,优化可以用线程池
new ServerThread(socket).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

class ServerThread extends Thread {
private Socket socket;

public ServerThread(Socket socket) {
this.socket = socket;
}

public void run() {
try (PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

// 读取客户端消息
String message = in.readLine();
System.out.println("Received: " + message);

// 响应客户端
out.println("Hello, client!");
} catch (IOException e) {
e.printStackTrace();
}
}
}

客户端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.*;
import java.net.*;

public class TCPClient {
public static void main(String[] args) {
try (Socket socket = new Socket("localhost", 8080);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

// 发送消息给服务器
out.println("Hello, server!");

// 接收服务器的响应
String response = in.readLine();
System.out.println("Server response: " + response);
} catch (IOException e) {
e.printStackTrace();
}
}
}

IO

Java 的 I/O 流是什么?

Java 的 I/O(输入/输出)流是用于处理输入和输出数据的类库。通过流,程序可以从各种输入源(如文件、网络)读取数据,或将数据写入目标位置(如文件、控制台)。

I/O 流分为两大类:字节流字符流,分别用于处理字节级和字符级的数据:

  • 字节流:处理 8 位字节数据,适合于处理二进制文件,如图片、视频等。主要类是 InputStreamOutputStream 及其子类。
  • 字符流:处理 16 位字符数据,适合于处理文本文件。主要类是 ReaderWriter 及其子类。

输入流与输出流

输入流(Input Stream):用于读取数据的流。

输出流(Output Stream):用于写入数据的流。

按照处理的数据类型,基于这两种输入输出的类型进行分类:

1)字节流(Byte Streams):

输入流:InputStream,常用以下几个输入流:

  • FileInputStream:从文件中读取字节数据。
  • BufferedInputStream:为输入流提供缓冲功能,提高读取性能。
  • DataInputStream:读取基本数据类型的数据。

输出流:OutputStream,常用以下几个输出流:

  • FileOutputStream:将字节数据写入文件。
  • BufferedOutputStream:为输出流提供缓冲功能,提高写入性能。
  • DataOutputStream:写入基本数据类型的数据。

2)字符流(Character Streams):

输入流:Reader,常用以下几个输入流:

  • FileReader:从文件中读取字符数据。
  • BufferedReader:为字符输入流提供缓冲功能,提高读取性能。
  • InputStreamReader:将字节流转换为字符流。

输出流:Writer,常用以下几个输出流:

  • FileWriter:将字符数据写入文件。
  • BufferedWriter:为字符输出流提供缓冲功能,提高写入性能。
  • OutputStreamWriter:将字符流转换为字节流。

缓冲流

缓冲流是对基础流的包装,可以显著提高 I/O 性能。常见的缓冲流有 BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter,它们通过内部缓冲区减少实际 I/O 操作的次数

在处理大文件或频繁 I/O 操作时,使用缓冲流可以有效提高性能。

什么是 BIO、NIO、AIO?

BIO(Blocking I/O)NIO(Non-blocking I/O)、和AIO(Asynchronous I/O) 是三种不同的I/O模型,它们在处理数据传输时的行为有所不同,适用于不同的应用场景:

BIO(Blocking I/O)

  • 传统的阻塞I/O模式,调用方在发起I/O操作时会被阻塞,直到操作完成后才继续执行。适用于连接数较少、逻辑简单的场景。
  • 例如,在Java中,ServerSocketSocketaccept()方法是阻塞的,需要等待客户端连接。

NIO(Non-blocking I/O)

  • 非阻塞I/O模式,调用方在发起I/O操作后可以立即返回,即使操作未完成。通常结合I/O多路复用技术(如selectpollepoll),使得一个线程可以同时管理多个连接。
  • 例如,Java中的java.nio包提供了SelectorChannel等类,可以实现高效的非阻塞I/O。

AIO(Asynchronous I/O)

  • 异步I/O模式,调用方在发起I/O请求后,不需要等待操作完成。操作系统或底层库在I/O操作完成后,通过回调或事件通知的方式告知调用方。
  • 例如,Java 7引入了java.nio.channels.AsynchronousSocketChannel类,可以实现异步I/O,适用于对响应时间要求较高的应用场景。

什么是 Channel?

Channel 是Java NIO(New I/O)中的一个核心概念,用于数据的读写操作,它提供了一种比传统流更高效的I/O操作方式:

Channel:

  • 是双向的,可以同时支持读取和写入(读/写),与传统的I/O流相比更灵活。传统的流只能单向,要么是输入流要么是输出流。
  • 常用于非阻塞I/O操作,可以结合Selector来实现多路复用,从而处理多个并发连接。

Channel的种类:

  • SocketChannel:用于基于TCP的网络通信,可以与服务器或客户端进行连接。
  • ServerSocketChannel:用于监听TCP连接,类似于传统I/O中的ServerSocket。
  • DatagramChannel:用于基于UDP的网络通信。
  • FileChannel:用于从文件中读取或向文件中写入数据。

SocketChannel 和 ServerSocketChannel

SocketChannel 主要在两个地方出现:

  1. 客户端,客户端创建一个 SocketChannel 用于连接至远程的服务端。
  2. 服务端,服务端利用 ServerSocketChannel 接收新连接之后,为其创建一个 SocketChannel 。

随后,客户端和服务端就可以通过这两个 SocketChannel 相互发送和接收数据。

ServerSocketChannel 主要出现在一个地方:服务端。

服务端需要绑定一个端口,然后监听新连接的到来,这个活儿就由 ServerSocketChannel 来干。

服务端内常常会利用一个线程,一个死循环,不断地接收新连接的到来。

1
2
3
4
5
6
7
8
9
ServerSocketChannel serverSocketChannel 
= ServerSocketChannel.open();
......
while(true){
// 接收的新连接
SocketChannel socketChannel =
serverSocketChannel.accept();
.......
}

至此,想必你应该清楚 ServerSocketChannel 和 SocketChannel 的区别和作用了。

什么是 Selector?

Selector 是Java NIO(New I/O)中用于实现I/O多路复用的组件,它可以通过一个单独的线程同时监视多个通道(Channel)的事件。

Selector的作用

  • 管理多个Channel:通过一个Selector实例,程序可以同时监听多个通道的I/O事件(如可读、可写、连接就绪等),从而使一个线程管理多个连接变得高效。
  • 非阻塞I/OSelector通常与非阻塞通道(如SocketChannel)配合使用,实现高效的非阻塞I/O操作。它使得程序无需为每个连接创建一个线程,减少了线程的开销。

Selector的事件类型

  • OP_READ:表示通道中有数据可读。
  • OP_WRITE:表示通道可以向其中写入数据。
  • OP_CONNECT:表示通道完成连接操作。
  • OP_ACCEPT:表示通道可以接受新的连接。

Java中的序列化和反序列化是什么?

序列化

是将对象转换为字节流的过程,这样对象可以通过网络传输、持久化存储或者缓存。Java 提供了 java.io.Serializable 接口来支持序列化,只要类实现了这个接口,就可以将该类的对象进行序列化。

反序列化

是将字节流重新转换为对象的过程,即从存储中读取数据并重新创建对象。

  • 应用场景:包括网络传输、远程调用、持久化存储(如保存到文件或数据库)、以及分布式系统中数据交换。
  • Java 序列化关键类和接口ObjectOutputStream 用于序列化,ObjectInputStream 用于反序列化。类必须实现 Serializable 接口才能被序列化。
  • transient 关键字:在序列化过程中,有些字段不需要被序列化,例如敏感数据,可以使用 transient 关键字标记不需要序列化的字段。
  • serialVersionUID:每个 Serializable 类都应该定义一个 serialVersionUID,用于在反序列化时验证版本一致性。如果没有明确指定,Java 会根据类的定义自动生成一个 UID,版本不匹配可能导致反序列化失败。
  • 序列化性能问题:Java 的默认序列化机制可能比较慢,尤其是对于大规模分布式系统,可能会选择更加高效的序列化框架(如 Protobuf、Kryo)。
  • 安全性:反序列化是一个潜在的安全风险,因为通过恶意构造的字节流,可能会加载不安全的类或执行不期望的代码。因此,反序列化过程需要进行输入验证,避免反序列化漏洞。

序列化与反序列化理解

序列化其实就是将对象转化成可传输的字节序列格式,以便于存储和传输。

因为对象在 JVM 中可以认为是“立体”的,会有各种引用,比如在内存地址 Ox1234 引用了某某对象,那此时这个对象要传输到网络的另一端时候就需要把这些引用“压扁”。

因为网络的另一端的内存地址 Ox1234 可以没有某某对象,所以传输的对象需要包含这些信息,然后接收端将这些扁平的信息再反序列化得到对象。

所以,反序列化就是将字节序列格式转换为对象的过程

image-20241030103529829

Java 序列化 Serializable 的意义

首先说一下 Serializable,这个接口没有什么实际的含义,就是起标记作用。

来看下源码就很清楚了,除了 String、数组和枚举之外,如果实现了这个接口就走 writeOrdinaryObject ,否则就序列化就抛错。

image-20241030104304387

serialVersionUID 又有什么用?

1
private static final long serialVersionUID = 1L;

想必经常会看到这样的代码,这个 ID 其实就是用来验证序列化的对象和反序列化对应的对象的 ID 是否是一致的。

所以这个 ID 的数字其实不重要,无论是 1L 还是 idea 自动生成的,只要序列化的时候对象的 serialVersionUID 和反序列化的时候对象的 serialVersionUID 一致的话就行。

如果没有显式指定 serialVersionUID ,则编译器会根据类的相关信息自动生成一个,可以认为是一个指纹。

所以如果你没有定义一个 serialVersionUID 然后序列化一个对象之后,在反序列化之前把对象的类的结构改了,比如增加了一个成员变量,则此时的反序列化会失败。

因为类的结构变了,生成的指纹就变了,所以 serialVersionUID 就不一致了。

所以 serialVersionUID 就是起验证作用

Java 序列化不包含静态变量

简单地说就是序列化之后存储的内容不包含静态变量的值,看一下下面的代码就很清晰了。

img

序列化无法存储静态变量是因为静态变量属于类级别的,与类的定义相关联。

更具体的说序列化是通过调用对象的 writeObject 方法和 readObject 来实将对象写入输出流和读取输入流的,而静态变量由于不属于对象的一部分,因此调用这两个方法时候静态变量都不参与其中,也行成一开始说的序列化无法存储静态变量的值。

异常

Java 中 Exception 和 Error 有什么区别?

ExceptionError 都是 Throwable 类的子类(在 Java 代码中只有继承了 Throwable 类的实例才可以被 throw 或者被 catch)它们表示在程序运行时发生的异常或错误情况。

总结来看:**Exception** 表示可以被处理的程序异常,**Error** 表示系统级的不可恢复错误。

详细说明

1)Exception:是程序中可以处理的异常情况,表示程序逻辑或外部环境中的问题,可以通过代码进行恢复或处理。

常见子类有:IOExceptionSQLExceptionNullPointerExceptionIndexOutOfBoundsException 等。

Exception 又分为 Checked Exception(编译期异常)和 Unchecked Exception(运行时异常)。

  • Checked Exception:在编译时必须显式处理(如使用 try-catch 块或通过 throws 声明抛出)。如 IOException
  • Unchecked Exception:运行时异常,不需要显式捕获。常见的如 NullPointerExceptionIllegalArgumentException 等,继承自 RuntimeException

2)Error:表示严重的错误,通常是 JVM 层次内系统级的、无法预料的错误,程序无法通过代码进行处理或恢复。例如内存耗尽(OutOfMemoryError)、栈溢出(StackOverflowError)。

Error 不应该被程序捕获或处理,因为一般出现这种错误时程序无法继续运行。

异常处理时需要注意的六个点

1、尽量不要捕获类似Exception这样通用的异常,而应该捕获特定的异常。

软件工程是一门协作的艺术,在日常的开发中我们有义务使自己的代码能更直观、清晰地表达出我们想要表达的信息。

但是如果你什么异常都用了 Exception,那别的开发同事就不能一眼得知这段代码实际想要捕获的异常,并且这样的代码也会捕获到可能你希望它抛出而不希望捕获的异常。

2、不要 “吞” 了异常

如果我们捕获了异常,不把异常抛出,或者没有写到日志里,那会出现什么情况?线上除了 bug 莫名其妙的没有任何的信息,你都不知道哪里出错以及出错的原因。

这可能会让一个简单的bug变得难以诊断,而且有些同学比较喜欢用 catch 之后用e.printStackTrace(),在我们产品中通常不推荐用这种方法,一般情况下这样是没有问题的但是这个方法输出的是个标准错误流。

image-20210303201223649.png

比如是在分布式系统中,发生异常但是找不到 stacktrace。

所以最好是输入到日志里,我们产品可以自定义一定的格式,将详细的信息输入到日志系统中,适合清晰高效的排查错误。

3、不要延迟处理异常

比如你有个方法,参数是个 name,函数内部调了别的好几个方法,其实你的 name 传的是 null 值,但是你没有在进入这个方法或者这个方法一开始就处理这个情况,而是在你调了别的好几个方法然后爆出这个空指针。

这样的话明明你的出错堆栈信息只需要抛出一点点信息就能定位到这个错误所在的地方,经过了好多方法之后可能就是一坨堆栈信息。

4、只在需要try-catch的地方try-catch,try-catch的范围能小则小

只要必要的代码段使用try-catch,不要不分青红皂白try住一坨代码,因为try-catch中的代码会影响JVM对代码的优化,例如重排序。

5、不要通过异常来控制程序流程

一些可以用if/else的条件语句来判断例如null值等,就不要用异常,异常肯定是比一些条件语句低效的,有 CPU 分支预测的优化等。

而且每实例化一个 Exception 都会对栈进行快照,相对而言这是一个比较重的操作,如果数量过多开销就不能被忽略了。

6、不要在finally代码块中处理返回值或者直接return

在 finally 中 return 或者处理返回值会让发生很诡异的事情,比如覆盖了 try 中的 return ,或者屏蔽的异常。

Java 运行时异常和编译时异常之间的区别是什么?

主要有三大区别:分别是发生时机捕获和处理方式设计意图

1)发生时机

  • 编译时异常(Checked Exception)发生在编译阶段,编译器会检查此类异常,程序必须对这些异常进行处理(通过 try-catch 或抛出 throws),否则程序将无法通过编译。
  • 运行时异常(Unchecked Exception)发生在程序运行期间,编译器不会强制要求处理这些异常。程序员可以选择是否处理它们,通常是程序逻辑错误导致的。

2)捕获和处理方式的区别

  • 编译时异常:必须在代码中显式处理,使用 try-catch 或者 throws 关键字声明抛出。
  • 运行时异常:可以不用显式处理,可以选择使用 try-catch 捕获处理,或者让程序终止时由 JVM 抛出。

3)设计意图区别:

  • 编译时异常:通常是由外部因素引发的异常(如文件 I/O 操作、数据库连接失败等),开发者无法完全预知这些问题,因此编译器强制要求进行处理。
  • 运行时异常:一般是由开发者的编程错误或逻辑漏洞引发的,属于程序内部的问题,开发者理论上可以预知,可以在调试阶段发现处理。

常见的编译时异常

  • SQLException:数据库访问出错。
  • FileNotFoundException:文件未找到。
  • ClassNotFoundException:无法找到指定类。
  • InterruptedException:线程在阻塞状态被打断。

常见的运行时异常

  • ArithmeticException:数学运算错误,例如除以零。
  • ClassCastException:强制类型转换失败。
  • ArrayIndexOutOfBoundsException:数组索引越界。

反射

你使用过 Java 的反射机制吗?如何应用反射?

Java 的反射机制是指在运行时获取类的结构信息(如方法、字段、构造函数)并操作对象的一种机制。反射机制提供了在运行时动态创建对象、调用方法、访问字段等功能,而无需在编译时知道这些类的具体信息。

反射机制的优点

  • 可以动态地获取类的信息,不需要在编译时就知道类的信息。
  • 可以动态地创建对象,不需要在编译时就知道对象的类型。
  • 可以动态地调用对象的属性和方法,在运行时动态地改变对象的行为。

一般在业务编码中不会用到反射,在框架上用的较多,因为很多场景需要很灵活,不确定目标对象的类型,届时只能通过反射动态获取对象信息。

例如 Spring 使用反射机制来读取和解析配置文件,从而实现依赖注入和面向切面编程等功能。

反射的性能考虑

反射操作相比直接代码调用具有较高的性能开销,因为它涉及到动态解析和方法调用。

所以在性能敏感的场景中,尽量避免频繁使用反射。可以通过缓存反射结果。例如把第一次得到的 Method 缓存起来,后续就不需要再调用 Class.getDeclaredMethod 也就不需要再次动态加载了,这样就可以避免反射性能问题。

反射基本概念:

Class 类:反射机制的核心,通过 Class 类的实例可以获取类的各种信息。

反射的主要功能

  • 创建对象:通过 Class.newInstance()Constructor.newInstance() 创建对象实例。
  • 访问字段:使用 Field 类访问和修改对象的字段。
  • 调用方法:使用 Method 类调用对象的方法。
  • 获取类信息:获取类的名称、父类、接口等信息。

反射的使用

1)获取 Class 对象

1
2
3
4
5
Class<?> clazz = Class.forName("com.mianshiya.MyClass");
// 或者
Class<?> clazz = MyClass.class;
// 或者
Class<?> clazz = obj.getClass();

2)创建对象

1
2
3
Object obj = clazz.newInstance(); // 已过时
Constructor<?> constructor = clazz.getConstructor();
Object obj = constructor.newInstance();

3)访问字段

1
2
3
4
Field field = clazz.getField("myField");
field.setAccessible(true); // 允许访问 private 字段
Object value = field.get(obj);
field.set(obj, newValue);

4)调用方法

1
2
Method method = clazz.getMethod("myMethod", String.class);
Object result = method.invoke(obj, "param");

反射的最佳实践

  • 限制访问:尽量避免过度依赖反射,尤其是在性能关键的代码中。
  • 使用缓存:缓存反射获取的类、方法、字段等信息,减少反射操作的频率。
  • 遵循设计原则:在设计系统时,尽量使用更稳定和易于维护的设计方案,只有在确实需要时才使用反射。

什么是 Java 中的动态代理?

Java 中的动态代理是一种在运行时创建代理对象的机制。动态代理允许程序在运行时决定代理对象的行为,而不需要在编译时确定。它通过代理模式为对象提供了一种机制,使得可以在不修改目标对象的情况下对其行为进行增强或调整。

代理可以看作是调用目标的一个包装,通常用来在调用真实的目标之前进行一些逻辑处理,消除一些重复的代码。

静态代理指的是我们预先编码好一个代理类,而动态代理指的是运行时生成代理类。

动态代理主要用途

  • 简化代码:通过代理模式,可以减少重复代码,尤其是在横切关注点(如日志记录、事务管理、权限控制等)方面。
  • 增强灵活性:动态代理使得代码更具灵活性和可扩展性,因为代理对象是在运行时生成的,可以动态地改变行为。
  • 实现 AOP:动态代理是实现面向切面编程(AOP, Aspect-Oriented Programming)的基础,可以在方法调用前后插入额外的逻辑。

Java 动态代理与 CGLIB 代理

  • Java 动态代理:只能对接口进行代理,不支持对类进行代理。
  • CGLIB 代理:通过字节码技术动态生成目标类的子类来实现代理,支持对类(非接口)进行代理。

JDK 示例代码

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
36
37
38
39
40
41
42
43
44
45
46
47
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

// 目标接口
interface MyService {
void doSomething();
}

// 目标对象的实现
class MyServiceImpl implements MyService {
@Override
public void doSomething() {
System.out.println("Doing something...");
}
}

// 动态代理处理器
class MyInvocationHandler implements InvocationHandler {
private final Object target;

public MyInvocationHandler(Object target) {
this.target = target;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Before method call");
Object result = method.invoke(target, args);
System.out.println("After method call");
return result;
}
}

// 使用动态代理
public class Main {
public static void main(String[] args) {
MyService target = new MyServiceImpl();
MyService proxy = (MyService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new MyInvocationHandler(target)
);

proxy.doSomething();
}
}

CGLIB 示例代码

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
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

// 目标类
class MyService {
public void doSomething() {
System.out.println("Doing something...");
}
}

// CGLIB 代理处理器
class MyInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("Before method call");
Object result = proxy.invokeSuper(obj, args);
System.out.println("After method call");
return result;
}
}

// 使用 CGLIB 动态代理
public class Main {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MyService.class);
enhancer.setCallback(new MyInterceptor());

MyService proxy = (MyService) enhancer.create();
proxy.doSomething();
}
}

JDK 动态代理和 CGLIB 动态代理有什么区别?

DK 动态代理是基于接口的,所以要求代理类一定是有定义接口的。

CGLIB 基于 ASM 字节码生成工具,它是通过继承的方式生成目标类的子类来实现代理类,所以要注意 final 方法。

它们之间的性能随着 JDK 版本的不同而不同,以下内容取自:haiq的博客

  • jdk6 下,在运行次数较少的情况下,jdk动态代理与 cglib 差距不明显,甚至更快一些;而当调用次数增加之后, cglib 表现稍微更快一些
  • jdk7 下,情况发生了逆转!在运行次数较少(1,000,000)的情况下,jdk动态代理比 cglib 快了差不多30%;而当调用次数增加之后(50,000,000), 动态代理比 cglib 快了接近1倍
  • jdk8 表现和 jdk7 基本一致

扩展 JDK 动态代理

JDK 动态代理是基于接口的代理,因此要求代理类一定是有定义的接口,使用 java.lang.reflect.Proxy 类和 java.lang.reflect.InvocationHandler 接口实现。

以下为一个简单 JDK 动态代理示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 接口
public interface Service {
void perform();
}

// 需要被代理的实现类
public class ServiceImpl implements Service {
@Override
public void perform() {
System.out.println("mianshiya.com");
}
}

JDK 动态代理处理类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class ServiceInvocationHandler implements InvocationHandler {
private final Object target;

public ServiceInvocationHandler(Object target) {
this.target = target;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Before method invoke");
Object result = method.invoke(target, args);
System.out.println("After method invoke");
return result;
}
}

创建并使用动态代理对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.lang.reflect.Proxy;

public class DynamicProxyDemo {
public static void main(String[] args) {
Service target = new ServiceImpl();
Service proxy = (Service) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new ServiceInvocationHandler(target)
);

proxy.perform();
}
}

我们再看看 JDK 动态代理实现原理:

  • 首先通过实现 InvocationHandler 接口得到一个切面类。
  • 然后利用 Proxy 根据目标类的类加载器、接口和切面类得到一个代理类。
  • 代理类的逻辑就是把所有接口方法的调用转发到切面类的 invoke() 方法上,然后根据反射调用目标类的方法。

image-20210228112159415.png

再深一点点就是代理类会现在静态块中通过反射把所有方法都拿到存在静态变量中,我之前反编译看过代理类,我盲写了一下,大致长这样:

image-20210228112223174.png

这一套下来 JDK 动态代理原理应该就很清晰了。

扩展 CGLIB

CGLIB 基于 ASM 字节码生成工具,它是通过继承的方式来实现代理类,所以不需要接口,可以代理普通类,但需要注意 final 方法(不可继承)。

同样来看个示例:

1
2
3
4
5
public class Service {
public void perform() {
System.out.println("mianshiya.com");
}
}

CGLIB 动态代理处理类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

public class ServiceMethodInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("Before method invoke");
Object result = proxy.invokeSuper(obj, args);
System.out.println("After method invoke");
return result;
}
}

创建并使用动态代理对象:

1
2
3
4
5
6
7
8
9
10
11
12
import net.sf.cglib.proxy.Enhancer;

public class CglibDynamicProxyDemo {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Service.class);
enhancer.setCallback(new ServiceMethodInterceptor());

Service proxy = (Service) enhancer.create();
proxy.perform();
}
}

它是通过字节码生成技术而不是反射来实现调用的逻辑,具体就不再深入了。

什么是 Java 的 SPI(Service Provider Interface)机制?

SPI 是一种插件机制,用于在运行时动态加载服务的实现。它通过定义接口(服务接口)并提供一种可扩展的方式来让服务的提供者(实现类)在运行时注入,实现解耦和模块化设计。

SPI 机制的核心概念

  • 服务接口:接口或抽象类,定义某个服务的规范或功能。
  • 服务提供者:实现了服务接口的具体实现类。
  • 服务加载器(ServiceLoader:Java 提供的工具类,负责动态加载服务的实现类。通过 ServiceLoader 可以在运行时发现和加载多个服务提供者。
  • 配置文件:服务提供者通过在 META-INF/services/ 目录下配置服务接口的文件来声明自己。这些文件的内容是实现该接口的类的完全限定名。

SPI 机制的优势

  • 解耦:接口与实现分离,客户端不需要依赖具体实现,能够在运行时灵活加载不同的实现类。
  • 可扩展性:提供了一种易于扩展的机制,允许后期添加或替换实现类,而不需要修改现有代码。

SPI 通俗理解

SPI 可以通俗地理解为一种插件机制,用于在程序运行时动态加载某些功能的实现

打个比方:

假设你有一个音乐播放器(相当于一个程序),这个播放器可以播放不同格式的音乐,比如 MP3、WAV、AAC 等格式。你作为用户,并不关心播放器内部是如何解码这些格式的,你只需要它能正常播放音乐。

  • SPI 就像是播放器的插槽:播放器本身并不内置所有的解码器(MP3 解码器、WAV 解码器等),而是有一个标准接口(SPI),允许外部开发者(服务提供者)开发并“插入”解码器(不同格式的处理实现)。
  • 插件机制:当播放器启动时,它通过 SPI 机制去寻找并加载外部提供的解码器,选择合适的解码器来处理不同的音乐格式。这些解码器可以是程序事先知道的,也可以是后期动态加入的,只要遵循 SPI 规定的接口规范。

带入 Java 中理解:

  • Java SPI 就是一个类似的机制。你定义一个接口(类似播放器的插槽),然后不同的开发者实现这个接口,提供不同的实现(类似各种解码器)。
  • Java 会通过 SPI 自动加载这些实现,在运行时决定用哪个实现,而不用你手动去修改代码。

总结:SPI 机制的好处是灵活,能让程序根据需求动态地加载或更换某些功能实现,就像给一个音乐播放器加装不同的解码器插件,而不需要每次都修改播放器的核心代码。

一个典型的 SPI 应用场景是 JDBC(Java 数据库连接库),不同的数据库(mysql、oracle、sqlserver 等)有不同的实现,它们根据 JDBC 定制自己的数据库驱动程序,我们根据 SPI 机制使用它们的实现,而不需要修改 JDBC 核心代码。

如何实现一个 SPI ?

1)创建一个服务接口:MyService.java

1
2
3
public interface MyService {
void execute();
}

2)创建一个服务提供者:MyServiceImpl.java

1
2
3
4
5
6
public class MyServiceImpl implements MyService {
@Override
public void execute() {
System.out.println("Executing MyServiceImpl");
}
}

3)创建配置文件(需要在 META-INF/services 目录下创建文件,文件名为接口的全限定名)

META-INF/services/com.example.MyService

文件的内容就是实现类的全限定名:

1
com.example.MyServiceImpl

4)通过 ServiceLoader load 接口类型即可加载配置文件中的实现类:

1
2
3
4
ServiceLoader<MyService> serviceLoader = ServiceLoader.load(MyService.class);
for (MyService service : serviceLoader) {
service.execute();
}

5)如果要替换实现类,仅需新建一个实现类,然后修改配置文件中的全限定名即可替换,无需修改使用代码

例如,新建了一个实现类 MyServiceImplA

1
2
3
4
5
6
public class MyServiceImplA implements MyService {
@Override
public void execute() {
System.out.println("Executing MyServiceImplA");
}
}

仅需将META-INF/services/com.example.MyService文件中的内容改成

1
com.example.MyServiceImplA

注解

Java 中的注解原理是什么?

注解其实就是一个标记,是一种提供元数据的机制,用于给代码添加说明信息。可以标记在类上、方法上、属性上等,标记自身也可以设置一些值。

注解本身不影响程序的逻辑执行,但可以通过工具或框架来利用这些信息进行特定的处理,如代码生成、编译时检查、运行时处理等。

注解的使用

定义注解:注解是一种特殊的接口,以 @interface 关键字

1
2
3
@interface MyAnnotation {
String value() default ""; //可以在注解中为属性指定默认值
}

使用注解:在类、方法、字段等代码元素上。

1
2
3
4
5
@MyAnnotation(value = "example")
public class MyClass {
@MyAnnotation
public void myMethod() {}
}

处理注解

  • 编译时处理:使用 javax.annotation.processing 包进行注解处理器的开发。
  • 运行时处理:使用反射机制访问注解,通过 Class.getAnnotation()Field.getAnnotation() 等方法获取注解信息。

示例代码(运行时处理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyAnnotation {
String value();
}

public class AnnotationProcessor {
public static void main(String[] args) throws Exception {
Method method = MyClass.class.getMethod("myMethod");
if (method.isAnnotationPresent(MyAnnotation.class)) {
MyAnnotation annotation = method.getAnnotation(MyAnnotation.class);
System.out.println("Annotation value: " + annotation.value());
}
}
}

元注解

元注解,即注解的注解,如 @Retention、@Target、@Inherited(表示注解是否可以被继承)。

注解的三大保留策略

**@Retention**:定义注解的保留策略,即注解的有效范围。

  • **RetentionPolicy.SOURCE**:注解仅在源码中存在,编译时被丢弃。
  • **RetentionPolicy.CLASS**:注解存在于编译后的 .class 文件中,但运行时不可用。
  • **RetentionPolicy.RUNTIME**:注解在运行时可用,可以通过反射机制访问。

Target

**@Target**:指定注解可以应用于哪些代码元素。

  • **ElementType.TYPE**:类、接口(包括注解类型)或枚举。
  • **ElementType.FIELD**:字段(包括枚举常量)。
  • **ElementType.METHOD**:方法。
  • **ElementType.PARAMETER**:方法参数。
  • **ElementType.CONSTRUCTOR**:构造方法。
  • **ElementType.LOCAL_VARIABLE**:局部变量。
  • **ElementType.ANNOTATION_TYPE**:注解类型。
  • **ElementType.PACKAGE**:包。

常见注解例子

例如 Override:

image-20210228112243058.png

是给编译器用的,编译器编译的时候检查没问题就 ok 了,class 文件里面不会有 Override 这个标记。

再比如 Spring 常见的 Autowired,是 RUNTIME 的。所以在运行的时候可以通过反射得到注解的信息,还能拿到标记的值 required。

image-20210228112312107.png

所以注解就是一个标记,可以给编译器用、也能运行时候用。