本文章持续更新

Java 基础概念和常识内容

JVM vs JDK vs JRE

  • Java 虚拟机(Java Virtual Machine, JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。

    image-20260205134622558

    JVM 并不是只有一种!只要满足 JVM 规范,每个公司、组织或者个人都可以开发自己的专属 JVM。 也就是说我们平时接触到的 HotSpot VM 仅仅是是 JVM 规范的一种实现而已。

    维基百科上就有常见 JVM 的对比:Comparison of Java virtual machines ,感兴趣的可以去看看。并且,你可以在 Java SE Specifications 上找到各个版本的 JDK 对应的 JVM 规范。

  • JDK(Java Development Kit)是一个功能齐全的 Java 开发工具包,供开发者使用,用于创建和编译 Java 程序。它包含了 JRE(Java Runtime Environment),以及编译器 javac 和其他工具,如 javadoc(文档生成器)、jdb(调试器)、jconsole(监控工具)、javap(反编译工具)等。

  • JRE 是运行已编译 Java 程序所需的环境,主要包含以下两个部分:

    1. JVM : 也就是我们上面提到的 Java 虚拟机。
    2. Java 基础类库(Class Library):一组标准的类库,提供常用的功能和 API(如 I/O 操作、网络通信、数据结构等)。

    简单来说,JRE 只包含运行 Java 程序所需的环境和类库,而 JDK 不仅包含 JRE,还包括用于开发和调试 Java 程序的工具。

不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具 (随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块) 。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。

模块化系统被引入之后,JDK 被重新组织成 94 个模块,Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像,这样可以极大的减少 Java 运行时环境的大小。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。

简要介绍一下 Java 和 C++ 的区别?

  1. C++是平台相关的,java是平台无关的。原因就是因为 JVM 带给了 Java 一次编译到处运行的跨平台能力
  2. C++对所有的数字类型有标准范围限制,但是字节长度是跟具体实现相关的,相同类型,在不同操作系统长度可能不一样;java在所有平台上对所有的基本类型都有标准的范围限制和字节长度。
  3. C++允许直接调用本地的系统库;Java要通过JNI
  4. C++支持指针,引用,传值调用等内容,Java只有值传递
  5. C++需要显式的的内存管理,但是有第三方框架提供垃圾搜集的支持,支持析构函数。Java是自动垃圾收集的,没有析构函数的概念。
  6. C++支持多继承,包括虚拟继承;Java只允许单继承,需要多继承的场景要使用接口。

什么是字节码?采用字节码的好处是什么?

在 Java 中,JVM 可以理解的代码就叫做字节码,也就是扩展名为 .class 的文件,它不面向任何特定的处理器,只面向虚拟机。

Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。

由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行

img

.class->机器码 这一步中,JVM 类加载器首先加载字节码文件,然后就通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而有些方法和代码块是经常需要被调用的,所以引入了 JIT,JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。

这也解释了我们为什么经常会说 Java 是编译与解释共存的语言

HotSpot 采用了惰性评估(Lazy Evaluation)的做法,根据二八定律,消耗大部分系统资源的只有那一小部分的代码(热点代码),而这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执行的情况收集信息并相应地做出一些优化,因此执行的次数越多,它的速度就越快

什么是AOT?有什么好处?为什么不全部使用 AOT 呢?

JDK 9 引入了一种新的编译模式 AOT(Ahead of Time Compilation) 。和 JIT 不同的是,这种编译模式会在程序被执行前就将其编译成机器码,属于静态编译,像 Rust,GO,C++ 等语言就是静态编译,所以都调侃 Rust 编译的特别慢

AOT 避免了 JIT 预热等各方面的开销,可以提高 Java 程序的启动速度,避免预热时间长

并且,AOT 还能减少内存占用和增强 Java 程序的安全性,因为 AOT 编译后的代码不容易被反编译和修改,特别适合云原生场景。

对比维度 JIT(即时编译) AOT(提前编译)
编译时机 运行时编译 运行前编译
启动速度 较慢(需要预热) 快(无需预热)
峰值性能 更高(运行时优化) 较低(缺少运行时信息)
内存占用 较高 较低
打包体积 较小 较大(包含机器码)
动态特性支持 完全支持 受限(反射、动态代理等)
适用场景 长时间运行的服务 云原生、Serverless、CLI 工具

可以看出,AOT 的主要优势在于启动时间、内存占用和打包体积JIT 的主要优势在于具备更高的极限处理能力,可以降低请求的最大延迟。

既然 AOT 这么多优点,那为什么不全部使用这种编译方式呢?

  • 首先,AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。我们都知道,开发离不开这些动态特性。
  • 而且,AOT 编译这种静态编译,确实比较慢

为什么Java解释和编译共存?编译型语言和解释型语言的区别是?

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

image-20260205133228941

编译型语言和解释型语言的区别在于:

  • 编译型语言:在程序执行之前,整个源代码会被编译成机器码或者字节码,生成可执行文件。执行时直接运行编译后的代码,速度快,但跨平台性较差。
  • 解释型语言:在程序执行时,逐行解释执行源代码,不生成独立的可执行文件。通常由解释器动态解释并执行代码,跨平台性好,但执行速度相对较慢。
编译型语言和解释型语言

为了改善解释语言的效率而发展出的即时编译技术 JIT,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成字节码。到执行期时,再将字节码直译,之后执行。Java与 LLVM 是这种技术的代表产物。

为什么说 Java 语言 编译与解释并存?

这是因为 Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为 Java 程序要经过先编译,后解释两个步骤,由 Java 编写的程序需要先经过编译步骤,生成字节码(.class 文件),这种字节码必须要经过 JVM 的再次翻译才能执行

然而,跨平台的是Java程序,不是JVM。JVM是用C/C++开发的,是编译后的机器码,不能跨平台,不同平台下需要安装不同版本的JVM。

值传递和引用传递的区别?

在 Java 中,参数传递只有 值传递 一种方式,不存在真正的 引用传递。但很多人会混淆这两个概念,核心区别在于传递的是 “值的副本” 还是 “引用的副本”。

无论是基本类型还是对象,传递的都是“值”的副本,只不过对于对象来说,这个“值”是引用(内存地址)的副本

  • 对于值传递。传递的是实际值的副本,适用于基本数据类型,修改方法内的参数副本,不会影响原变量的值

    1
    2
    3
    4
    5
    6
    7
    8
    public static void main(String[] args) {
    int a = 10;
    change(a);
    System.out.println(a); // 输出 10
    }
    static void change(int x) {
    x = 20; // 修改的是副本
    }
    • a 的值(10)被复制给 x,方法内修改 x 不影响原变量。这是典型的值传递
  • 对于对象类型,也就是引用类型,传递的是对象引用的副本,而非对象本身

    两个引用(原引用和副本)指向同一个对象,因此通过副本修改对象内部数据,会影响原对象。但如果修改副本的指向,如重新赋值,不会影响原引用的指向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("A");
    modify(list);
    System.out.println(list); // [A, B] —— 内容变了!

    reassign(list);
    System.out.println(list); // 仍是 [A, B] —— 引用没变!
    }

    static void modify(List<String> param) {
    param.add("B"); // 修改对象内容
    }

    static void reassign(List<String> param) {
    param = new ArrayList<>(); // 重新赋值引用
    param.add("X");
    }
    • modify 方法能改内容,是因为在内存中 listparam 都指向同一个堆对象,通过副本引用仍可操作原对象。
    • reassign 方法不能改变 list 的指向,因为 param = ... 只是让局部变量 param 指向了新对象,不影响原调用方 list 的引用。所以说,这是值传递,传的是地址的副本,不是地址本身

所以说,Java 中所有参数传递都是值传递

  • 基本类型传递 “值的副本”,修改副本不影响原值
  • 引用类型传递 “引用的副本”,通过副本可以修改对象的内容,改变副本的指向不会影响原引用的指向

数据类型

long和int可以互转吗 ?类型互转可能产生什么问题?

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

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

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

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

在将long转换为int时,如果longValue的值超出了int类型的范围,转换结果将是截断后的低位部分。

所以说,基本数据类型转换

  • 当把小范围数据类型赋值给大范围数据类型时,Java 会自动转型,这种转型是安全的
  • 但是大范围数据类型赋值给小范围数据类型时,需要强制转型,会发生数据数据溢出或者精度损失的问题。因为一般是截断。

数据类型转换方式你知道哪些?简单说一下?

  • 隐式转换:当目标类型的范围大于源类型时,Java会自动将源类型转换为目标类型,不需要显式的类型转换。例如,将int转换为long、将float转换为double等。

  • 显式转换:当目标类型的范围小于源类型时,需要使用强制类型转换将源类型转换为目标类型。这可能导致数据丢失或溢出。例如,将long转换为int、将double转换为int等。这种触发了一般是截断,低位截断或者丢失小数这种

  • 字符串转换:Java提供了将字符串表示的数据转换为其他类型数据的方法。例如,将字符串转换为整型int,可以使用Integer.parseInt()方法;将字符串转换为浮点型double,可以使用Double.parseDouble()方法等

  • 数值之间的转换:Java提供了一些数值类型之间的转换方法,如将整型转换为字符型、将字符型转换为整型等。这些转换方式可以通过类型的包装类来实现,例如Character类、Integer类等提供了相应的转换方法

  • 对象之间的向上向下转型:向上转型是自动进行的,而且是安全的。但是向下转型需要手动进行,并且存在风险。而且如果父类对象实际上并不是目标子类的实例,转换后就会抛出异常。解决方式是需要使用 instanceof 检查

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Animal {}
    class Dog extends Animal {}

    Dog dog = new Dog();
    Animal animal = dog; // 自动向上转型

    // 向下转型失败
    Animal animal = new Animal();
    Dog dog = (Dog) animal; // 运行时抛出ClassCastException

    if (animal instanceof Dog) {
    Dog dog = (Dog) animal; // 只有确认animal是Dog的实例时才进行转型
    }

装箱和拆箱是什么?什么时候会发生自动拆装箱?

Java 中 8 种原始类型,如 intdoubleboolean 等,特点是存储值本身,不具备对象特性

  • 装箱:将基本类型用它们对应的引用类型包装起来;
  • 拆箱:将包装类型转换为基本数据类型;

装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。

自动拆装箱是 Java 编译器的 “语法糖”,以下五种常见场景会触发

  1. 赋值场景:

    以直接赋值的形式进行了基本类型和包装类的互转

    1
    2
    3
    4
    // 自动装箱:int → Integer
    Integer num1 = 100;
    // 自动拆箱:Integer → int
    int num2 = num1;
  2. 方法参数传递:实参类型与形参类型不匹配时

    当方法的形参是包装类,你传入基本类型值;或形参是基本类型,你传入包装类对象时,会触发自动拆装箱。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class BoxUnbox {
    // 形参是包装类Integer
    public static void printInteger(Integer num) {
    System.out.println(num);
    }

    // 形参是基本类型int
    public static void printInt(int num) {
    System.out.println(num);
    }

    public static void main(String[] args) {
    int basic = 50;
    Integer wrapper = 60;

    // 传入基本类型int,自动装箱为Integer
    printInteger(basic);
    // 传入包装类Integer,自动拆箱为int
    printInt(wrapper);
    }
    }
  3. 方法返回值:返回值类型与实际返回值类型不匹配时

    当方法声明的返回值是包装类,你返回基本类型值;或返回值是基本类型,你返回包装类对象时,会触发自动拆装箱。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class BoxUnbox {
    // 返回值是包装类Integer
    public static Integer getInteger() {
    // 返回基本类型int,自动装箱为Integer
    return 70;
    }

    // 返回值是基本类型int
    public static int getInt() {
    // 返回包装类Integer,自动拆箱为int
    return new Integer(80);
    }

    public static void main(String[] args) {
    System.out.println(getInteger()); // 输出 70
    System.out.println(getInt()); // 输出 80
    }
    }
  4. 运算场景:包装类参与算术运算时

    包装类本身不能直接参与 +-*/ 等算术运算,编译器会先自动拆箱为基本类型,再进行运算;如果运算结果赋值给包装类,还会触发自动装箱。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class BoxUnbox {
    public static void main(String[] args) {
    Integer a = 10;
    Integer b = 20;

    // 第一步:a和b自动拆箱为int(10、20)
    // 第二步:计算 10+20=30(int类型)
    // 第三步:结果30自动装箱为Integer,赋给c
    Integer c = a + b;
    System.out.println(c); // 输出 30

    // 包装类与基本类型运算,同样触发拆箱
    int d = a + 5; // a拆箱为int,计算10+5=15,赋给d
    System.out.println(d); // 输出 15
    }
    }
  5. 集合操作:泛型集合存储 / 获取基本类型时

    Java 集合只能存储对象,不能直接存储基本类型。当你往泛型集合中添加基本类型值时,会自动装箱;从集合中取出元素赋值给基本类型变量时,会自动拆箱。

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

    public class BoxUnbox {
    public static void main(String[] args) {
    // 泛型指定为Integer,只能存Integer对象
    List<Integer> list = new ArrayList<>();

    // 自动装箱:int(10) → Integer,存入集合
    list.add(10);
    list.add(20);

    // 自动拆箱:从集合取出Integer对象,拆箱为int
    int first = list.get(0);
    System.out.println(first); // 输出 10
    }
    }

如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。

自动拆装箱需要注意如下内容:

  1. NPE 风险:如果包装类对象为 null,自动拆箱时会抛出空指针异常

    1
    2
    Integer num = null;
    int basic = num; // 运行时抛出 NullPointerException
  2. 缓存池影响IntegerByteShort 等包装类有缓存池,自动装箱时会优先使用缓存对象

面向对象

怎么理解面向对象?简单说一下面向对象的三大特征?

面向对象是一种编程范式,它将现实世界中的事物抽象为对象,对象具有属性(字段)和行为(方法)。面向对象编程的设计思想是以对象为中心,通过对象之间的交互来完成程序的功能,具有灵活性和可扩展性,通过封装和继承可以更好地应对需求变化。

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
public class Student {
private int id;//id属性私有化
private String name;//name属性私有化

//获取id的方法
public int getId() {
return id;
}

//设置id的方法
public void setId(int id) {
this.id = id;
}

//获取name的方法
public String getName() {
return name;
}

//设置name的方法
public void setName(String name) {
this.name = name;
}
}

继承

继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用性。而且通过继承可以建立类与类之间的层次关系,使得结构更加清晰。

  1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有
  2. 子类可以新增自己属性和方法,即子类可以对父类进行扩展。
  3. 子类可以用自己的方式 重写(override)父类方法。

多态

多态,顾名思义,表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。父类引用指向子类,同一方法表现不同行为。一般实现方式是编译时多态(重载)和运行时多态(重写)

多态特点:

  1. 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
  2. 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
  3. 多态不能调用“只在子类存在但在父类不存在”的方法;
  4. 如果子类重写了父类的方法,真正执行的是子类重写的方法,如果子类没有重写父类的方法,执行的是父类的方法。
image-20260304081558724

面向对象的设计原则你知道有哪些吗

面向对象编程中的六大原则:

  • 单一职责原则(SRP):一个类应该只有一个引起它变化的原因,即一个类应该只负责一项职责。例子:考虑一个员工类,它应该只负责管理员工信息,而不应负责其他无关工作。
  • 开闭原则(OCP)软件实体应该对扩展开放,对修改封闭。例子:通过制定接口来实现这一原则,比如定义一个图形类,然后让不同类型的图形继承这个类,而不需要修改图形类本身。
  • 里氏替换原则(LSP):子类对象应该能够替换掉所有父类对象。例子:一个正方形是一个矩形,但如果修改一个矩形的高度和宽度时,正方形的行为应该如何改变就是一个违反里氏替换原则的例子。
  • 接口隔离原则(ISP):客户端不应该依赖那些它不需要的接口,即接口应该小而专。例子:通过接口抽象层来实现底层和高层模块之间的解耦,比如使用依赖注入。
  • 依赖倒置原则(DIP):高层模块不应该依赖低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。例子:如果一个公司类包含部门类,应该考虑使用合成/聚合关系,而不是将公司类继承自部门类。
  • 最少知识原则 (Law of Demeter):一个对象应当对其他对象有最少的了解,只与其直接的朋友交互

深拷贝和浅拷贝区别了解吗?什么是引用拷贝?

浅拷贝是复制对象本身,但是对象内部的引用类型成员变量仍指向原地址,仅基本类型的成员变量是独立的

深拷贝完全复制对象及内部所有引用类型成员变量,也就是递归拷贝,而不是共享引用。新旧对象完全独立,无任何关联。

换句话说,深拷贝会递归复制对象内部所有引用类型的字段,生成一个全新的对象以及其内部的所有对象

引用拷贝是仅复制对象的引用,新旧引用指向同一个对象,本质是 “别名”。

浅拷贝的实现方式可以通过 Object 的 clone() ,它默认是浅拷贝

深拷贝的实现方式

  1. 实现 Cloneable 接口并重写 clone() 方法

    这种方法要求对象及其所有引用类型字段都实现 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();
    }
    }
  2. 使用序列化和反序列化

    通过将对象序列化为字节流,再从字节流反序列化为对象来实现深拷贝。要求对象及其所有引用类型字段都实现 Serializable 接口。

  3. 手动递归复制

image-20260307203824181

成员变量与局部变量的区别是什么?

所属不同:从语法形式上看,成员变量是属于类的,而局部变量是出现在代码块,方法中的被定义的变量或是方法的参数;

语法层次不同:成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。

存储方式不同:从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。局部变量就属于它所在的那个方法或者代码块中。而对象存在于堆内存,局部变量则存在于栈内存中。

生存时间:从变量的生存时间上来看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。

默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(这里就是JVM提到的零值,而一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值,需要手动赋初始值。

静态方法和实例方法有何不同?

在方法调用的方式上

  1. 在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象.方法名 的方式,而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象
  2. 不过,需要注意的是一般不建议使用 对象.方法名 的方式来调用静态方法。这种方式非常容易造成混淆,因味我们认为静态方法不属于类的某个对象而是属于这个类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Person {
public void method() {
//......
}

public static void staicMethod(){
//......
}
public static void main(String[] args) {
Person person = new Person();
// 调用实例方法
person.method();
// 调用静态方法
Person.staicMethod()
}
}

访问类成员是否存在限制

静态方法在访问本类的成员时,只允许访问静态类成员,即静态成员变量和静态方法,不允许访问实例的成员,也就是实例变量和实例方法,而实例方法不存在这个限制。

抽象类和普通类区别?抽象类和接口的区别?

首先抽象类和普通类区别很明显:

  • 抽象类是用 abstract 修饰的类,某种意义上它不完整,只是为子类提供通用的模板,不可直接实例化,可包含抽象方法(无实现)和具体方法(有实现)。而且子类必须实现抽象类中所有抽象方法,除非子类也是抽象类
  • 普通类就是一个完整的类,可以直接被 new 实例化,包含具体的属性和方法实现,而且只能包含具体方法,普通类的子类无强制实现要求,按需重写方法

然后是抽象类和接口

  • 抽象类的继承关系是 is-a,侧重代码复用,包含子类的通用属性和方法。而且抽象类是单继承,成员方法是public abstract抽象方法。抽象类中成员变量默认default,可在子类中被重新定义,也可被重新赋值。而且抽象类可以有构造器,这些构造器在子类实例化时会被调用,以便进行必要的初始化工作。
  • 接口则是侧重 has-a,侧重子类的方法规范,强调”有什么”,子类可以自己实现”怎么做”。接口是多实现,接口中方法默认 public abstract,JDK8后可以添加staticdefault方法。接口成员变量默认为public static final,必须赋初值,不能被修改。接口没有自己的实例,因此不能包含构造函数。

JavaSE

语法

标识符和关键字的区别是什么?

标识符是程序中定义的名字,如类名、变量名、方法名,一般需遵循命名的约定规则,而且不能与关键字重名。标识符必须以字母、下划线或美元符号开头,后续可以包含数字,但不能与关键字重复。

而关键字是 Java 语言预定义、具有特殊意义的保留词,不能用作标识符,编译器会对其进行特殊处理。

关键字是 Java 中已被赋予特定语法含义并正在使用的保留字,如 class、public、if 等,编译器会对其进行特殊处理,绝对不能作为标识符使用。保留字是 Java 语言预留但目前未被使用的单词,它们可能在未来版本中被赋予特定含义,如 goto、const。虽然当前未被激活,但为避免未来冲突,也不允许作为标识符使用。

简单说一下重载和重写的区别

重载发生在同一个类中,就是多个同名方法,只要参数列表不同就行,这样就能够根据不同的传参来执行不同的逻辑处理(编译时多态)

  • 重载方法名必须相同,参数类型、个数、顺序,方法返回值和访问修饰符什么的可以不同。这样能够根据调用的情况做出选择处理

    如果多个方法(比如 StringBuilder 的构造方法)有相同的名字、不同的参数, 便产生了重载

    编译器必须挑选出具体执行哪个方法,它通过用各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。 如果编译器找不到匹配的参数, 就会产生编译时错误

重写发生在继承关系中,子类重写父类的某个方法,参数一样,但要做出有别于父类的响应时,你就要重写父类方法进行自定义化来提供新的实现(运行时多态)

  • 重写发生在运行期,是是子类对父类的允许访问的方法的实现过程进行重新编写。

    1. 方法名、参数列表必须相同,子类方法的返回值类型必须小于等于父类方法的返回值,抛出的异常也是如此,访问修饰符范围需要大于等于父类。

    2. 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法。

      但是被 static 修饰的方法能够被再次声明。也就是子类定义一个与父类静态方法签名完全相同的方法,只是 “隐藏” 了父类的静态方法,不满足多态,本质是两个独立的方法,因为上面说的就是父类静态方法属于父类,子类静态方法属于子类,这叫做,方法隐藏

    3. 构造方法无法被重写

static 关键字的作用?static 代码块什么时候执行?解释一下 Java 中的静态内容?那么 final 呢?

static 意为静态的,关键字主要用于修饰类的成员,包括变量、方法、代码块,和内部类,使其变为静态的

那么,把他们变成静态的目的就是让他们脱离对象或者对象的实例,属于类本身的一部分

  1. 静态变量:所有对象共享同一个值,存储在方法区,不影响GC但是注意泄露;无需创建对象就能直接访问类名.变量名,初始化的时候就创建

  2. 静态方法:属于类本身,而非对象的实例,它不能访问非静态成员,直接通过类名.方法名调用,工具类方法常用

    而且,静态方法不能重写,可被隐藏,子类同名静态方法不会覆盖父类

  3. 静态代码块:类加载的时候执行且只执行一次的代码块,一般是用于初始化,优先级高于构造方法

  4. 修饰内部类:静态内部类不依赖于外部类的实例,可以独立实例化,静态内部类不能直接访问外部类的非静态成员,而且静态内部类需要通过实例化外部类才能访问外部类的私有成员,静态内部类可以定义静态成员。

static 的代码库在类加载时执行(首次访问该类的任意成员时),仅执行一次,执行优先级:

1
静态代码块 > 构造代码块 > 构造方法

然后多个静态代码块按书写顺序从上到下执行

子类加载时,先执行父类的静态代码块,再执行子类的静态代码块;

接口中的变量默认是 public static final,那么再来说说 final,为什么很多情况下和 static 一起用

final 意为 最终的、不可变的,可修饰类、方法、变量,核心含义是常量,也就是限制修改

  • final 修饰类:最终类,不能被继承,所有方法也都是默认是 final

  • final 修饰方法:该方法不能被重写,这一般是防止子类篡改其逻辑

  • final 修饰变量:常量,不可重新赋值,但引用类型变量的内部属性可以修改

    必须在声明 / 构造方法 / 初始化块中赋值;

  • 方法参数加 final:表示方法内不能修改 a 的值,多用于匿名内部类访问局部变量

不可变类就是对象一旦创建完成,状态就定死在那了,后面没法再改。Java 里最典型的就是 String,还有 Integer、Long、Boolean 这些基本类型的包装类。

Java 的四种引用类型(强、软、弱、虚)代表什么?有什么区别?各自的应用场景?

Java 四种引用类型的核心目的是让 JVM 灵活控制对象的回收时机,不同引用类型有不同的 GC优先级,开发者可通过引用类型精准管理内存

  1. 强引用

    日常开发中最常见的引用,只要强引用存在,JVM 永远不会 GC 有强引用的对象,即使 OOM 也不回收。只有手动把引用置为 null,对象才会失去强引用,进入可回收状态。

    1
    2
    3
    4
    5
    // 强引用
    Object strongRef = new Object();

    // 断开强引用(只有手动置null,对象才可能被回收)
    strongRef = null;
  2. 软引用

    SoftReference 包装的引用,JVM 会在内存不足时回收软引用关联的对象,适合做缓存。内存充足时,即使触发 GC 也不会回收。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 创建软引用(关联一个大对象,比如缓存数据)
    SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024 * 10]); // 10MB

    // 获取软引用对象(需判空,可能已被回收)
    byte[] data = softRef.get();
    if (data == null) {
    // 重新加载数据(缓存失效,从数据库/Redis加载)
    data = new byte[1024 * 1024 * 10];
    softRef = new SoftReference<>(data);
    }

    一般,项目中点数据缓存这种内存敏感的数据,可以用软引用存储,既保证热点数据快速访问,又避免 OOM;

  3. 弱引用

    WeakReference 包装的引用,无论内存是否充足,只要触发 GC,都会回收弱引用关联的对象,生命周期比软引用更短。

    1
    2
    3
    4
    5
    6
    // 创建弱引用
    WeakReference<Object> weakRef = new WeakReference<>(new Object());
    // 触发GC(模拟)
    System.gc();
    // 此时weakRef.get()返回null,对象已被回收
    System.out.println(weakRef.get()); // null

    ThreadLocalThreadLocalMap 中,Key 是弱引用,避免 ThreadLocal 内存泄漏

  4. 虚引用

    PhantomReference 包装的引用,无法获取对象,完全不影响对象的回收,唯一作用是监听对象的 GC 回收事件,因为虚引用对象被 GC 回收时,会收到一个系统通知

    • 无法通过虚引用获取对象(虚引用的 get() 方法永远返回 null)
    • 必须和 ReferenceQueue 配合使用,否则无意义。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 引用队列(接收回收通知)
    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    // 创建虚引用
    PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);

    // 触发GC
    System.gc();

    // 从队列中获取回收的引用(监听对象是否被回收)
    Reference<?> ref = queue.poll();
    if (ref != null) {
    System.out.println("对象已被GC回收");
    // 做资源清理(如释放文件句柄、关闭连接)
    }

    因为其 GC 后有个通知的特性,一般做资源回收监听,通过虚引用监听对象回收,确保资源被彻底释放;

四种引用的核心区别是回收时机和应用场景,从强到虚,回收优先级越来越高,适用场景也从业务层转向底层资源管理。”

对象

简单说一下 Stringbuffer 和 Stringbuilder 的区别,和它们都能在项目中什么地方用到

String 是不可变的,每次拼接都会创建新对象,也就可以理解为常量,线程安全。

StringBufferStringBuilder,均为可变字符数组,底层是用 char[] 实现的,都支持动态扩容,避免频繁创建对象,适合大量字符串拼接场景。

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBufferStringBuilder 每次都对对象本身进行操作,而不是生成新的对象并改变对象引用。

StringBuffer线程安全,因为方法加了 synchronized 同步锁,因此效率低,同步锁的时候会带来性能开销,

StringBuilder 非线程安全,无锁,因此效率高

StringBufferStringBuilder 一般就是字符串缓冲区,需要针对字符串进行操作,例如业务逻辑中的字符串拼接,循环中拼接字符串,JSON 字符串处理,日志处理都可以用到他们

String 为什么是不可变的?底层实现和不可变性带来的好处?

String 的 “不可变性” 指,一旦一个 String 对象被创建,其内部的字符序列就无法被修改,看似修改的操作如substring()replace(),实际都是创建新的 String 对象

String 类中使用 private final 关键字修饰字符byte[]数组,并且String 类没有提供/暴露修改这个字符串的方法。

而且String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。

在 Java 9 之后,StringStringBuilderStringBuffer 的实现改用 byte 数组存储字符串。以前是 char[] 字符数组

String 的不可变性是 JDK 设计的核心决策,带来的好处很多

  1. 线程安全:天然支持多线程并发

    不可变对象的状态一旦创建就固定,无需加锁即可在多线程中安全使用。

  2. 字符串常量池节省了内存

    字符串常量池是 JVM 为 String 设计的缓存机制,存储在堆中

    若 String 是可变的,修改 s1 会导致所有引用该常量的变量都被修改,常量池就失去意义

    不可变性保证了常量池中的字符串始终不变,实现 一处存储、多处引用,大幅节省内存。

  3. 哈希值缓存,提升集合性能

    String 的hashCode()方法会缓存哈希值

    不可变性保证哈希值永远不变,无需每次调用hashCode()都重新计算,这对 HashMap、HashSet 等集合至关重要

String s1 = new String("abc");这句话创建了几个字符串对象?

首先,最多会创建两个对象,但是情况也不是都一概而论的

  1. 首先,字符串常量池里没有 abc,此时会创建两个对象

    abc 是字符串字面量,JVM 会先检查字符串常量池,如果没有,就会在字符串常量池中创建一个内容为abc 的字符串对象

    new String() 关键字会强制在堆内存中创建一个新的字符串对象,这个对象的内容和常量池中的 abc 一致,但地址不同。

  2. 字符串常量池里已有abc

    此时只会创建 1 个对象,因为字符串常量池里已有abc,JVM 不会重复创建,只会因为new String() 在堆中创建一个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StringObjectTest {
public static void main(String[] args) {
// 第一次执行:常量池无"abc",创建2个对象(常量池+堆)
String s1 = new String("abc");
// 第二次执行:常量池已有"abc",仅创建1个堆对象
String s2 = new String("abc");

// 验证地址:堆对象 != 常量池对象
String s3 = "abc"; // 指向常量池的"abc"
System.out.println(s1 == s3); // false(地址不同)
System.out.println(s1.equals(s3)); // true(内容相同)
}
}

所以说,实际开发中,尽量使用 String s = "abc" 而非 new String("abc"),避免堆内存中创建多余对象,节省内存。

== 和 equals 有什么区别?在什么时候使用它们?

== 本质是运算符,对于基本类型,比较是否相等(栈中存储的数值),对于引用类型,比较内存地址是否相等(对象在堆中的内存地址),也就是是否指向同一个对象

equals()本质是 Object 类中的方法,仅引用类型使用,基本类型无此方法,默认情况下,Object 类中的equals()等价于==,所以很多类会重写,重写后一般是比较对象内容

1
2
3
4
// Object的equals默认实现源码
public boolean equals(Object obj) {
return (this == obj); // 等价于==,比较地址
}

== 的场景

  1. 基本类型比较值是否相等
  2. 引用类型比较 “是否是同一个对象”,比如判断对象是否为 null、判断是否是同一个缓存对象

equals() 的场景

  1. 引用类型比较 内容是否相等

  2. 包装类型比较值

    包装类型是引用类型,==可能因为缓存池问题踩坑,必须用 equals()

    1
    2
    3
    4
    Integer a = 128;
    Integer b = 128;
    System.out.println(a == b); // false(超出-128~127缓存池,是不同对象)
    System.out.println(a.equals(b)); // ✅ true(比较值)

自定义类需要重写 equals(),如果自定义类不重写 equals(),默认用 Object 的实现比较地址,会导致 内容相同但返回 false

String 的 equals()== 的区别?intern() 方法的作用?

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门在堆内存中开辟的一块区域,存储唯一的字符串常量

什么意思,也就是说,相同的字符串只存储一份

字面量创建 String 会进入字符串常量池,普通 new String() 创建的字符串对象,存储在堆的非常量池区域。

==运算符,对于基本数据类型含义是值相等,对于引用数据类型,比较内存地址是否相等,也就是是否指向同一个对象

String 用 == 比较时,只有指向常量池同一个字符串才会返回 true。

equals()Object 类的方法,默认实现是 ==,但 String 重写了该方法,改为比较字符串的内容是否相等

intern()String 类的本地方法,作用是,检查字符串常量池中是否存在当前字符串的内容

  1. 如果存在,返回常量池中的该字符串引用
  2. 如果不存在,将当前字符串的内容加入常量池,并返回常量池中的引用。
  • JDK6:常量池在永久代,intern() 会复制字符串内容到常量池,返回新地址;
  • JDK7+:常量池移到堆,intern() 不会复制内容,而是存储堆中字符串的引用,节省内存。

使用场景:大量创建重复字符串时(如海量日志解析),调用 intern() 可将字符串入池,减少内存占用。

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

hashCodeequals== 是 Java 中三种不同层面的比较方式:

  • == 比较的是内存地址,看两个引用是不是指向堆里同一块内存。对于基本类型,直接比值。
  • equals 比较的是对象内容,Object 类默认实现就是 ==,但我们通常会重写它来定义”内容相等”的逻辑。比如两个 User 对象,id 一样就算相等。
  • hashCode 返回一个整数哈希值,快速定位对象在哈希表中的位置。哈希码不同,对象肯定不相等;哈希码相同,对象不一定相等。主要给 HashMap、HashSet 这类哈希结构用

hashCode 和 equals 存在契约关系

  • 如果 equals 返回 true,那 hashCode 必须相同
  • 如果 hashCode 不同,那么 equals 一定返回 false
  • 但是如果 hashCode 相同,equals 不一定返回 true

如果追问,如果我只重写了 equals 没重写 hashCode,会出什么问题?

HashMap 和 HashSet 会出问题。比如你 new 两个内容一样的对象去当 key,equals 返回 true,但 hashCode 没重写,他们不同了,HashMap 和 HashSet 会认为来了两个不同的对象,分配了不同的哈希位置,取的时候会因为哈希码不一样取不出来

Integer a b c d,a 和 b 都是100,c 和 d 都是1000,它们相等吗?为什么?背后的机制能不能讲清楚?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class IntegerTest {
public static void main(String[] args) {
// 情况1:值为100
Integer a = 100;
Integer b = 100;
System.out.println(a == b); // 输出 true

// 情况2:值为1000
Integer c = 1000;
Integer d = 1000;
System.out.println(c == d); // 输出 false

// 补充:用equals比较(无论值大小都相等)
System.out.println(c.equals(d)); // 输出 true
}
}

Java 为了优化性能,给Integer设置了缓存池),默认缓存范围是 [-128, 127]

  • 当装箱的值在 [-128, 127] 范围内时,valueOf 方法会直接返回缓存池中的已有对象,不会新建;
  • 当值超出这个范围时,valueOf 方法会新建一个Integer对象返回。

当你直接写 Integer a = 100 时,然后去进行比较,JVM 会执行自动拆装箱,底层调用的了 Integer.valueOf(int i) 方法,这个方法就是缓存机制的核心。a 和 b 指向了缓存池的同一个对象,所以 true,c 和 的 因为没有缓存的关系,是两个新对象,它们地址不同,所以 false

所以说,包装类数值它也是对象,比较优先用equals(),而非==,避免缓存机制带来的判断错误。

其他包装类的缓存情况如下:

  • Byte:缓存所有值;
  • Short/Long:默认缓存 [-128, 127];
  • Character:缓存 [0, 127];
  • Boolean:缓存 true 和 false 两个对象;
  • Float/Double:无缓存(因为浮点数取值范围广,缓存无意义)。

反射

什么是反射?反射的原理和机制了解吗?反射有什么优缺点?

简单来说,Java 反射 (Reflection) 是一种在程序运行时,动态地获取类的信息并操作类或对象(方法、属性)的能力

通常情况下,我们写的代码在编译时类型就已经确定了,要调用哪个方法、访问哪个字段都是明确的。

但反射允许我们在运行时才去探知一个类有哪些方法、哪些属性、它的构造函数是什么样的,然后我们可以动态地获取对应对象的信息、创建对象、调用方法或修改属性,哪怕这些方法或属性是私有的。

正是这种在运行时 能够反射到其他类 并进行操作的能力,使得反射成为许多通用框架和库的基石。它让代码更加灵活,能够处理在编译时未知的类型。

优点:

  1. 灵活性和动态性:反射允许程序在运行时动态地加载类、创建对象、调用方法和访问字段。这样可以根据实际需求动态地适应和扩展程序的行为
  2. 解耦合和通用性:通过反射,可以编写更通用、可重用和高度解耦的代码,降低模块之间的依赖。

缺点

  1. 性能开销:反射操作通常比直接代码调用要慢。因为涉及到动态类型解析、方法查找以及 JIT 编译器的优化受限等因素。不过,对于大多数框架场景,这种性能损耗通常是可以接受的,或者框架本身会做一些缓存优化。
  2. 安全性问题:反射可以绕过 Java 语言的访问控制机制,破坏了封装。此外,反射还可以绕过泛型检查,带来类型安全隐患。
  3. 代码可读性和维护性:过度使用反射会使代码变得复杂、难以理解和调试。错误通常在运行时才会暴露,不像编译期错误那样容易发现

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

我们平时写业务代码可能很少直接跟 Java 的反射(Reflection)打交道。直接使用反射其实并不是很好,而且性能也一般。

但是使用框架开发中我们天天都享受反射带来的便利性,框架的底层都大量运用了反射机制,这才让它们能够那么灵活和强大。下面简单列举几个最常见的场景。

  1. 依赖注入与控制反转 DI 和 IoC

    以 Spring/Spring Boot 为代表的 IoC 框架,会在启动时扫描带有特定注解,如 @Component, @Service, @Repository, @Controller等类,利用反射实例化对象作为 Bean,并通过反射注入依赖,如 @Autowired、构造器注入等

  2. 动态代理与 AOP

    想在调用某个方法前后自动处理一些事情,例如收集用户操作,处理日志,打开事务,权限检查等。这都涉及到AOP,而动态代理是实现 AOP 的常用手段。JDK 自带的动态代理(ProxyInvocationHandler)就离不开反射。代理对象在内部调用真实对象的方法时,就是通过反射的 Method.invoke 来完成的。

  3. ORM 对象映射关系框架

    像 MyBatis、Hibernate 这种框架,能帮你把数据库查出来的一行行数据,自动变成一个个 Java 对象。它是怎么知道数据库字段对应哪个 Java 属性的?还是靠反射。它通过反射获取 Java 类的属性列表,然后把查询结果按名字或配置对应起来,再用反射调用 setter 或直接修改字段值。反过来,保存对象到数据库时,也是用反射读取属性值来拼 SQL。

注解

能讲一讲Java注解的原理吗?

注解是 Java 的元数据,用来给类、方法、字段附加说明信息,不直接影响代码逻辑,但可以被编译器 / 框架 / 反射读取并执行逻辑。

主要用于编译检查(@Override@NonNull)、代码生成(Lombok那一套)和运行期框架扩展(Spring中的大部分注解)。

注解本质是接口,继承 java.lang.annotation.Annotation,其具体实现类是 Java 运行时生成的动态代理类

我们通过反射获取注解时,返回的是 Java 运行时生成的动态代理对象。通过代理对象调用自定义注解的方法,会最终调用 AnnotationInvocationHandlerinvoke 方法。

而框架就是扫描注解,然后反射获取信息,自动执行逻辑(动态代理/AOP等)

Java注解的作用域呢?

注解的作用域(Scope)指的是注解可以应用在哪些程序元素上,例如类、方法、字段等。Java注解的作用域可以分为三种:

  1. 类级别作用域:用于描述类的注解,通常放置在类定义的上面,可以用来指定类的一些属性,如类的访问级别、继承关系、注释等。
  2. 方法级别作用域:用于描述方法的注解,通常放置在方法定义的上面,可以用来指定方法的一些属性,如方法的访问级别、返回值类型、异常类型、注释等。
  3. 字段级别作用域:用于描述字段的注解,通常放置在字段定义的上面,可以用来指定字段的一些属性,如字段的访问级别、默认值、注释等。

除了这三种作用域,Java还提供了其他一些注解作用域,例如构造函数作用域和局部变量作用域。这些注解作用域可以用来对构造函数和局部变量进行描述和注释。

自定义注解怎么写?核心的元注解有哪些?

自定义注解用 @interface 定义,必须搭配4 个元注解

  • @Target:注解能用在哪里(类 / 方法 / 字段 / 参数)
  • @Retention:生命周期(SOURCE / CLASS / RUNTIME)
  • @Documented:生成 javadoc
  • @Inherited:允许子类继承父类注解

最常用的就是@Retention (RetentionPolicy.RUNTIME),而且框架几乎都用运行期注解,通过反射读取。

异常

final、finally、finalize 的区别?

三者完全不同

关键字 作用
final 修饰类 / 方法 / 变量,限制修改
finally 异常处理的块,无论是否抛异常,finally 块都会执行(除非 JVM 退出),用于释放资源
finalize Object 类的方法,GC 回收对象前调用(不确定执行时机,已被标记为过时)

try-catch-finally 中,finally 一定会执行吗?

不一定,finally只是保证是否发生异常都会执行的代码块。通常用于释放资源,确保资源的正确关闭。

所以说,只要 try/catch 块被正常进入,无论以下情况,finally 都会执行

  • try 块正常执行完毕;
  • try 块中抛出异常(被 catch 捕获或未捕获);
  • try/catch 块中包含 returnbreakcontinue 等跳转语句。(JVM 会优先执行 finally 块,再返回)

但是,try-catch-finally中,finally 不是一定会执行的,例如假如 finally 执行之前 JVM 被终止运行的话,finally 中的代码就不会被执行,但是这些属于极端情况。

  • 程序所在的线程死亡。如调用 System.exit(0)
  • 关闭 CPU。
  • JVM 进程异常终止

finally 里的 return 会覆盖 try/catch 的返回值吗?

会覆盖,所以,别在 finally 里处理返回值或直接 return,会发生很诡异的事情,比如覆盖了 try 中的 return,而且往往还会吞掉 try/catch 中抛出的异常,导致问题难以排查,实际开发中绝对禁止这么写。

throw语句:用于手动抛出异常。一般写在 catch 块throws关键字:用于在方法声明中声明可能抛出的异常类型。

try-with-resources是什么?如何使用 try-with-resources 代替try-catch-finally

try-with-resources 是 Java 7 引入的语法糖,专门用于自动关闭实现了 AutoCloseable 接口的资源,如文件流、数据库连接、Socket 连接等,替代传统的 try-catch-finally 手动关闭资源,解决资源泄漏

对于try-with-resources ,有一些特点

  • 资源声明在 try() 括号内,JVM 会在 try 代码块执行完毕后自动调用资源的 close() 方法

    Java 9 进一步优化:资源可在外部声明,仅在 try() 中引用。

  • 即使发生异常,资源也会被保证关闭

    try-with-resources 中,try 块的异常是 主异常,close() 抛出的异常是 抑制异常,会被附加到主异常中,不会覆盖

传统 try-catch-finally 关闭资源,需要为每个资源都在 finally 中写关闭逻辑,而且手动调用 close() 可能忘记,导致资源泄漏,并且finally 中的 close() 抛出如果异常,会覆盖 try 块的原始异常,难以排查问题

例如

1
2
3
4
5
6
7
8
// 资源外部声明
FileInputStream fis = new FileInputStream("test.txt");
// try() 中仅引用资源
try (fis) {
// 读取文件逻辑
} catch (IOException e) {
e.printStackTrace();
}

注意,如果 try() 中声明多个资源,JVM 会按声明的逆序关闭

Java 中 Exception 和 Error 有什么区别?

首先,ExceptionError 都是 Throwable 类的直接子类,只有继承了 Throwable 的对象才能被 throwcatch。二者核心区别在于

  • Exception 代表程序运行过程中可以预料、可以恢复的异常情况,属于业务逻辑范畴,可以被合理的处理,比如 NullPointerExceptionIOException
  • Error 代表 JVM 本身或者系统环境的严重错误,程序通常无法恢复,出现后往往要终止进程,比如 OutOfMemoryErrorStackOverflowError

Exception 是可以被处理的程序异常,Error 是系统级的不可恢复错误。

Exception 又分为 Checked Exception受检异常 和 Unchecked Exception非受检异常:

  • Checked Exception继承自 Exception 但不继承 RuntimeException,设计初衷是强制调用方处理可能出现的异常,编译时必须显式处理,要么 try-catch 要么 throws 声明抛出,比如 IOExceptionSQLException
  • Unchecked Exception继承自 RuntimeException,通常是编程错误导致的,不需要显式捕获,运行时才抛出,比如 NullPointerExceptionIndexOutOfBoundsException
image-20260308111351954

Checked Exception 和 Unchecked Exception 有什么区别?你更倾向于使用哪个?

Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检查异常

Unchecked Exception不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常

开发中,默认使用 Unchecked Exception,只在必要时才用 Checked Exception

我们可以把 Unchecked Exception(比如 NullPointerException)看作是代码 Bug。对待 Bug,最好的方式是让它暴露出来然后去修复代码,而不是用 try-catch 去掩盖它。

一般来说,只在一种情况下使用 Checked Exception:当这个异常是业务逻辑的一部分,并且调用方必须处理它时。例如,一个余额不足异常,这不是 bug,而是一个正常的业务分支,需要抛出 Checked Exception 来强制调用者去处理这种情况,比如提示用户去充值。这样就能在保证关键业务逻辑完整性的同时,让代码尽可能保持简洁。

自定义异常的实现方式?什么时候需要自定义异常?

自定义异常是为了让异常信息更贴合业务场景、便于问题定位、统一异常处理,实现方式分为两位,针对受检异常 和 非受检异常,实际开发中优先用非受检异常。

Java 中异常分为两类,自定义异常也可以这样分类,然后去实现:

  • 受检异常:继承 Exception,必须显式捕获 / 抛出(编译器强制检查);
    • 继承 Exception后,其余和非受检异常一致,但调用处必须 try-catchthrows 声明
  • 非受检异常:继承 RuntimeException,无需显式捕获 / 抛出(推荐使用)。
    • 继承 RuntimeException后,一般需要提供提供无参构造、带异常信息的构造、带异常信息 + 根因的构造各种重载

当系统自带异常无法精准描述业务场景时,就需要自定义异常

  1. 业务逻辑异常

    一般是各种情况下的校验失败

  2. 业务模块的特定功能异常

    一般是需要针对性的模块,需要针对性处理的业务,可以自定义异常方便处理

  3. 统一异常处理

    配合 SpringMVC 的 @RestControllerAdvice 做全局异常拦截,统一返回格式。

泛型

Java 泛型的作用是什么?

泛型的作用是把 类型检查从运行时提前到编译时。编译器能在你写代码的时候就发现类型不匹配,不用等到程序跑起来才抛 ClassCastException

泛型带来的改变

  • 类型安全了,编译期间就检查类型是否匹配,就是 String 集合塞不进去 Integer
  • 消除强转,泛型取出元素时编译器会自动进行类型转换
  • 代码复用,一个泛型类可以处理各种类型,不用为每种类型写一份代码

最直观的例子是集合类:

1
2
3
4
5
6
7
8
9
10
// 没泛型的时代
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 必须强转,转错了运行时才知道

// 有泛型之后
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // 不用强转,编译器帮你搞定
list.add(123); // 编译直接报错,塞不进去

Java 5 之前是没有泛型的,集合里塞什么都行,取出来都是 Object,用的时候自己强转。如果一不小心取错类型,编译的时候能过,但是运行的时候却抛错。

泛型就是给类型加了一层约束,声明了类型之后,编译器能在写代码的时候就识别出错误的类型。

别忘了,编译时检查,运行时擦除

Java 泛型擦除是什么?如何理解泛型的编译时检查,运行时擦除

泛型擦除就是 Java 编译器在编译期做类型检查和约束,检查完就把泛型信息擦掉,也就是编译时检查,运行时擦除

代码里写的 List<String>Map<Integer, User> 这些泛型,编译成 class 文件后全变成了 ListMap,泛型参数被替换成它的上界,没写上界就默认是 Object

代价就是 Java 的泛型是伪泛型,运行时类型信息丢了,运行时拿不到泛型的实际类型,所以可以用反射往 List<String> 里塞 Integer 是完全可以的,因为绕过了字节码编译检查,运行时根本不管

泛型不支持基本类型。你得写 ArrayList<Integer> 而不是 ArrayList<int>。因为擦除后泛型变成 Object,而 int 不是 Object 的子类,要兼容起来太麻烦。

你在项目中哪里用到了泛型?能说说你在开发过程中针对泛型的实践吗?

在项目中大量使用泛型,主要用来做三件事

  1. 统一返回结果、或者做通用分页

    用泛型做了通用返回封装 Result<T>,不管返回什么类型数据(用户、项目、文档),都能统一结构,不需要每个接口写一套,前端解析也统一。PageResult<T>通用分页也是类似,统一处理列表数据、总条数、页码,所有列表接口复用一套,不用重复写分页逻辑。

    1
    2
    3
    4
    5
    public class Result<T> {
    private int code;
    private String msg;
    private T data;
    }
  2. 通用工具类、对 DAO 层封装

    例如封装了 RedisUtil<T> 泛型工具类,能接收任意类型的对象,然后统一处理对象序列化 / 反序列化

    而且 JPA 的 JpaRepository<T, ID> 泛型也是很好的实践例子,每个实体只需继承接口,就能直接拥有增删改查、分页、排序,不用写重复 DAO 代码

  3. AOP 记录操作日志

    AOP 记录用户的操作日志,肯定需要用泛型来接收任意方法返回值,在切面里统一处理,让日志切面能适配所有 Controller/Service,一套切面全系统通用。

  4. RPC 接口、Dubbo 调用的类型安全

泛型让代码类型安全、不用强转、高度复用,大大减少重复代码,提升了项目可维护性。

序列化

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

Java 中序列化就是把内存中的 Java 对象转成二进制字节流,这样才能存储到硬盘或者通过网络传输。

反序列化顾名思义,就是把二进制字节流反序列化为 Java 对象。

因为硬盘和网络只认 0 和 1,不认 Java 对象,所以必须有这么一层转换。

image-20260308110556640
  • 怎么做:类必须实现 Serializable 接口,这就是个标记接口,没它 JDK 序列化机制压根不让你序列化

  • 怎么控制:敏感字段比如密码,加个 transient 关键字,序列化的时候自动跳过

  • 怎么稳:显式定义 serialVersionUID,相当于给类打个版本戳,在反序列化时校验类版本,防止改了类结构后反序列化旧数据时报错

    ID 的具体数值不重要,1L 还是 IDEA 自动生成的一长串数字都行,关键是保持一致。如果你没显式定义,编译器会根据类的结构自动生成一个,但这带来一个坑:你只是加了个字段,自动生成的 ID 就变了,之前序列化的数据就废了。

而且注意,静态变量不参与序列化,序列化存的是对象的状态,静态变量属于类,不属于对象,所以不会被序列化。

transient关键字的作用是什么?transient 和 static 修饰的字段都不会被序列化,它们有什么区别?

transient 是 Java 中的一个关键字,用于修饰类的成员变量,表示这些变量在序列化过程中不会被保存。它的主要作用是保护敏感数据或减少不必要的存储。

而两者本质不同。static 字段属于类,压根就不是对象实例状态的一部分,所以不存在 序列化不序列化 的问题,它就不在序列化的范畴内。transient 是专门告诉序列化机制”这个实例字段我不想存”,比如密码、缓存这类不适合持久化的数据。

关于 transient 还有几点注意:

  • transient 只能修饰变量,不能修饰类和方法。
  • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0
  • static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化

Java 默认的序列化有什么问题?序列化和反序列化让你自己实现你会怎么做?

我们很少或者说几乎不会直接使用 JDK 自带的序列化方式,主要原因有下面这些原因:

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

所以我们会考虑主流的反序列化框架,例如 Spring Boot 项目中,官方默认的 Jackson,是日常开发中最常用的反序列化工具,一个ObjectMapper加各种注解就可以实现序列化(Java → JSON)和反序列化。然后也偶尔使用 Fastjson,但它历史安全漏洞较多。如果是二进制序列化,例如 Dubbo 中的 RPC 背景下,Protobuf二进制序列化框架则是非常好的实现。

Java 自己的序列化是如何将对象转为二进制字节流的,具体怎么实现的?

其实,像序列化和反序列化,无论这些可逆操作是什么机制,都会有对应的处理和解析协议

序列化机制是通过序列化协议来进行处理的,和 .class 文件类似,它其实是定义了序列化后的字节流格式,然后对此格式进行操作,生成符合格式的字节流或者将字节流解析成对象。

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

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

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

动态代理

如何实现动态代理?JDK 动态代理和 CGLIB 动态代理有什么区别?

动态代理是一种非常强大的设计模式,它允许我们在不修改源代码的情况下,对一个类或对象的方法进行功能增强(Enhancement)

在 Java 中,实现动态代理最主流的方式有两种:JDK 动态代理CGLIB 动态代理

  1. JDK 动态代理基于接口,代理类实现目标接口,持有目标对象引用,JDK 动态代理在运行时通过 Proxy.newProxyInstance() 生成代理对象,调用方法时通过 InvocationHandler 转发到invoke方法中,最终用反射调用目标方法。
  2. CGLIB 动态代理基于字节码工具,通过继承目标类生成子类来实现代理,不需要接口,但是 final 类和 final 方法因为不能继承的特性没法被动态代理。方法调用时通过 MethodInterceptor 拦截,可以用 invokeSuper 直接调用父类方法,不走反射。

为什么 JDK 动态代理必须要有接口呢?

  • JDK 动态代理生成的代理类已经继承了 java.lang.reflect.Proxy 这个类,而 Java 不支持多继承,所以只能通过实现接口的方式来代理目标类。代理类和目标类之间唯一的纽带就是共同实现的接口。

JDK 动态代理用 java.lang.reflect.Proxy 类和 InvocationHandler 接口实现

  • 通过实现 InvocationHandler 接口得到一个切面类
  • 利用 Proxy 根据目标类的类加载器、接口和切面类得到一个代理类
  • 代理类把所有接口方法的调用转发到切面类的 invoke() 方法上,然后根据反射调用目标方法

CGLIB 生成的代理类会为每个方法创建两个方法:一个是重写的方法用来拦截调用,一个是 CGLIB$xxx$0 这种命名的方法用来调用父类原始方法。

Spring AOP 会根据目标类自动选择代理方式,但是 Spring Boot 2.x 之后默认全用 CGLIB,原因是不用强制写接口,开发体验更好。如果想切回 JDK 代理:

  • 目标类实现了接口,默认用 JDK 动态代理
  • 目标类没有接口,自动切换到 CGLIB

介绍一下动态代理在框架中的实际应用场景

动态代理最典型的应用场景就是 Spring AOP

AOP 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 CGlib 生成一个被代理对象的子类来作为代理,如下图所示:

image-20260308161807859

IO NIO

新特性

Java 8 中引入的 Stream 了解吗?Stream 流的并行API是什么?

Java 8 引入了 Stream API,它提供了一种高效且易于使用的数据处理方式,特别适合集合对象的操作,如过滤、映射、排序等。

Stream API 不仅可以提高代码的可读性和简洁性,还能利用多核处理器的优势进行并行处理。

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

1
2
3
4
5
6
7
8
9
10
11
12
// 没有 Stream API 的做法
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = 0;
for (Integer number : numbers) {
sum += number;
}

// 使用 Stream API 的做法
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();

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

image-20260311201444602

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