GQ八股-Java基础+集合

java基础

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

序列化:指将一个对象转化为字节流的过程,通常用于存储或者通过网络传输。ObjectOutputStream用于将对象序列化。

反序列化:指将字节流还原为原始对象的过程,使用ObjectInputStream来实现。

关键要求

  1. Serializable接口:类必须实现Serializable接口,才能被序列化。
  2. transient关键字:标记为transient的字段会被序列化时忽略。
  3. serialVersionUID:每个类应定义serialVersionUID,用于验证序列化和反序列化版本一致性。
  4. 性能与安全:序列化可能导致性能下降,同时反序列化存在安全风险(例如反序列化攻击),因此需要对输入数据进行验证。
  5. 静态变量:静态变量不会被序列化,因为它们与实例无关。

总结:序列化和反序列化为Java对象的存储和传输提供了便利,但需要关注性能和安全性问题。

Exception 和 Error 的区别

Exception 和 Error 都是 Throwable 的子类,
区别在于:

  • Exception 表示程序可以处理的异常,比如业务逻辑错误、文件找不到等。
  • Error 表示系统层面的严重错误,比如内存溢出(OutOfMemoryError)或栈溢出(StackOverflowError),程序一般无法恢复。

Exception 又分为两类:

  • Checked Exception(受检异常):编译期必须处理,比如 IOException;
  • Unchecked Exception(运行时异常):运行期才抛出,比如 NullPointerException。

通常我们只捕获 Exception,不处理 Error。

如果面试官追问

Q:那为什么 Error 不推荐捕获?

因为 Error 通常由 JVM 抛出,代表系统层问题,程序恢复不了,捕获也没意义。比如堆内存溢出时,就算 catch 了也无法分配对象继续运行。

Q:Exception 和 RuntimeException 的关系呢?

RuntimeException 是 Exception 的子类,它代表运行时异常,不需要强制 try-catch,比如空指针、数组越界。

Java的优势

  1. 跨平台性
    Java的“写一次,运行多平台”特性通过JVM(Java虚拟机)实现,保证了Java程序能够在不同操作系统上运行,不需要针对每个系统重新编译。
  2. 垃圾回收
    Java提供了自动垃圾回收功能(GC),可以管理内存的分配与回收,减轻了开发者的负担,且不需要手动释放内存,避免了内存泄漏问题。
  3. 生态系统
    Java有强大的生态支持,包括丰富的第三方库、工具、框架和资源,使开发更加高效,适用的领域广泛。
  4. 面向对象
    Java采用面向对象的编程模型,支持继承、多态、封装等特性,代码易于维护和扩展,提升了开发效率和系统的可维护性。

这些特点使Java成为了一个高度可移植、可靠并且在企业级开发中广泛应用的语言。

java中的多态特性

Java 多态特性:

Java 的多态是指同一方法在不同的对象上执行时,表现出不同的行为。它是面向对象编程的核心特性之一,通过方法重载和方法重写来实现。主要有两种类型:

  1. 编译时多态(方法重载)
    • 同一个类中,可以定义多个同名的方法,但是它们的参数列表(参数的数量、类型或顺序)必须不同。
    • 这种多态性是在编译阶段决定的,编译器会根据方法的参数类型来决定调用哪个版本的重载方法。
  2. 运行时多态(方法重写)
    • 子类继承父类,并可以重写父类的方法,运行时决定具体调用哪个类的方法。
    • 这种多态性发生在程序运行时,当父类引用指向子类对象时,实际执行的是子类的重写方法。

多态的优点:

  • 代码的可扩展性:可以编写更灵活的代码,减少重复代码。
  • 解耦:通过接口或继承来解耦代码,易于修改和维护。

面试中简洁回答:

Java 多态是指同一个方法调用,在不同的对象上会产生不同的执行效果。它主要分为编译时多态(方法重载)和运行时多态(方法重写)。编译时多态通过方法的参数差异来决定调用哪个方法,而运行时多态通过子类重写父类的方法来决定实际调用的版本。

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

在Java中,方法参数的传递是基于“值传递”机制的,但这里有一些细节需要注意:

  • 对于基本数据类型(例如 int, charboolean),参数传递的是值的副本,因此修改副本不会影响原始变量。
  • 对于引用数据类型(如类的实例),传递的是对象的引用,也就是对象的内存地址。在方法内部修改对象的属性会影响原始对象,但无法改变引用,使得引用指向另一个对象。

总结来说,Java中方法参数的传递是值传递,但对于对象引用来说,实际上是传递引用的副本,所以能修改对象的内容,但不能修改引用本身。

为什么java不支持多重继承

Java不支持多重继承的主要原因是避免“钻石问题”,即当一个类继承了多个父类,并且这些父类有相同的方法或属性时,会导致冲突。为了防止这种复杂性,Java选择了单继承模型。通过接口(interface)提供了灵活的多继承方式,允许一个类实现多个接口,从而避免了传统多继承的缺点。

img

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

在Java中,面向过程编程和面向对象编程的区别,主要体现在思维方式和代码组织方式上。

  1. 面向过程编程(Procedure-Oriented Programming)
    它以“过程”为核心,把程序看作一系列顺序执行的步骤,强调“先做什么、再做什么”。程序的基本单元是函数或过程,更关注执行的流程和算法逻辑,适合用来处理结构简单、流程清晰的任务。

举个例子,我们要写一个“煮咖啡”的程序,面向过程的思路可能是:

  • 打开咖啡机;

  • 加水;

  • 加咖啡粉;

  • 煮咖啡;

  • 倒出咖啡。
    整个过程就像一条流水线,关注的是步骤和执行顺序。

  1. 面向对象编程(Object-Oriented Programming, OOP)
    它以“对象”为核心,将数据和操作数据的方法封装在一起,程序的基本单元是对象。OOP更注重封装、继承、多态等特性,强调代码的复用性、可扩展性和灵活性。

如果用面向对象的方式来实现“煮咖啡”,我们会先抽象出对象,比如“人”和“咖啡机”。

  • “人”负责发出“煮咖啡”的指令;

  • “咖啡机”对象内部封装了加水、加豆、加热等方法。

  • 当人调用咖啡机的 makeCoffee() 方法时,内部会自动完成一系列步骤。这样,代码更清晰、更易维护,也便于后期扩展,比如增加“加奶”“打泡”等新功能。

总结来说:

  • 面向过程关注“做事的步骤”,适合小型、简单的任务;

  • 面向对象关注“谁来做这件事”,更适合复杂、可扩展的系统开发。

Java作为典型的面向对象语言,正是通过“封装、继承、多态”这些特性,让开发者能够更高效地管理复杂业务逻辑。

  1. 一句总结:

“面向过程关注流程,面向对象关注对象。前者适合解决问题,后者适合构建系统。就像煮咖啡——面向过程一步步写流程,而面向对象则让‘咖啡机对象’自己完成整个动作。”

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

Java 里的 重载(Overload) 和 重写(Override) 最大区别在于:

  • 重载 **发生在同一个类中,方法名相同但参数不同(**类型、数量或顺序不同),和返回值无关,主要用于提高代码的灵活性,比如构造函数的重载。
  • 而 重写 发生在父类和子类之间,子类对父类方法进行改写或扩展以实现多态。重写要求方法名、参数列表都相同,返回值类型相同或是其子类,访问修饰符权限不能更低。
    此外,private、static、final 方法不能被重写。
对比项 方法重载(Overload) 方法重写(Override)
定义 同一个类中定义多个同名方法,参数列表不同 子类中重写父类的方法,以实现多态
发生位置 同一个类中 父类与子类之间
参数列表 必须不同(类型、数量、顺序至少有一处不同) 必须完全相同
返回值类型 可以不同(但仅靠返回值不同不能构成重载) 必须相同,或是父类返回值的子类(协变返回类型)
访问修饰符 无限制 子类重写方法的访问权限不能低于父类方法
异常声明 可随意定义 子类方法抛出的异常不能比父类更宽泛
static / final / private 方法 可以被重载 不能被重写(static 是隐藏,final/private 无法重写)
调用方式 由编译器在编译时确定(编译时多态) 由 JVM 在运行时动态绑定(运行时多态)
目的 提高代码灵活性和可读性(同一功能不同实现) 实现多态性,让子类定制父类行为
示例 void print(int a) vs void print(String s) 子类重写 void eat() 改变父类默认实现

重载是编译时多态,同类中同名不同参;
重写是运行时多态,子类改写父类方法,方法名与参数都必须一致。

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

Java 内部类是定义在另一个类中的类,根据定义位置不同分为四种:成员内部类、静态内部类、局部内部类和匿名内部类

  • 成员内部类可以访问外部类的所有成员,包括 private;
  • 静态内部类只能访问外部类的静态成员;
  • 局部内部类定义在方法中,只在方法内部可见;
  • 匿名内部类没有类名,常用于接口回调或事件处理。
类型 定义位置 可访问范围 是否有类名 常见用途
成员内部类 外部类中,非静态位置 可访问外部类所有成员(含 private) 外部类与内部类强关联时使用
静态内部类 外部类中,static 修饰 只能访问外部类静态成员 辅助逻辑类,不依赖外部类实例
局部内部类 方法或代码块中定义 可访问 final 或 effectively final 的局部变量 临时逻辑封装
匿名内部类 无类名,直接定义实例 同局部类 简化接口实现或回调逻辑

它的主要作用是提高封装性和代码内聚性,并且在只在一个地方使用的情况下可以简化代码结构。

速答版
Java 内部类就是定义在类内部的类,分为成员内部类、静态内部类、局部内部类和匿名内部类。
它能访问外部类的成员,提高封装性和内聚性,常用于回调、事件处理等场景。

Java8 有哪些新特性?

Java 8 是一次非常重要的版本更新,引入了很多影响后续生态的特性。

  • 最核心的是 Lambda 表达式 和 函数式接口,让 Java 能以更简洁的方式实现函数式编程;

  • 然后是 Stream API,可以用链式操作来处理集合,比如过滤、排序、映射这些操作,代码更优雅;

  • 还新增了 接口的默认方法和静态方法,让接口在扩展时不破坏原有实现;

  • 同时引入了 全新的日期时间 API(LocalDate、LocalTime、LocalDateTime),解决了老的 Date 不可变与线程安全问题;

  • 在内存管理方面,用 元空间(Metaspace)替代了永久代(PermGen),解决了内存不足和 GC 效率低的问题;

  • 此外还有 Optional 类 避免空指针,CompletableFuture 和 StampedLock 等并发增强类,以及 :: 方法引用语法。

可以说 Java8 的这些改进让代码更简洁、更安全,也为后续的响应式编程打下了基础。

速答版
Java8 的主要新特性有 Lambda 表达式、Stream API、接口默认方法、新日期时间 API、Optional 类,以及用元空间替代永久代。这些特性让 Java 更简洁、更高效、更现代化。

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

String、StringBuffer、StringBuilder用于表示一串字符,即字符序列。StringJoiner是JDK8引入的一个String拼接工具

在 Java 中,String、StringBuffer 和 StringBuilder 的主要区别在于可变性线程安全性性能

首先,String 是不可变的,底层用 final char[] 修饰,每次修改都会创建新的对象,所以适合字符串内容不频繁变化的场景,比如常量池或日志输出。

而 StringBuffer 和 StringBuilder 都是可变的,修改内容不会新建对象,底层通过 append() 来操作。

不同的是,StringBuffer 是线程安全的,因为内部方法使用了 synchronized,适合多线程环境下的字符串操作;
StringBuilder 则是非线程安全的,但没有同步锁,性能更高,适合单线程环境。

img

总结一句话:String 用于少变内容,StringBuffer 多线程改字符串,StringBuilder 单线程拼接最快。

速答版:

  • String 不可变,线程安全;
  • StringBuffer 可变、线程安全、性能一般;
  • StringBuilder 可变、线程不安全,但性能最好。

Java 的 StringBuilder 是怎么实现的?

StringBuilder 的底层是通过一个 可变的 char 数组 来存储字符序列的。

和 String 不同的是,String 的底层也是 char[],但被 final 修饰且是私有的,所以内容不可变;而 StringBuilder 没有这个限制。

当我们调用 append()、insert() 等方法时,StringBuilder 会 直接修改底层字符数组的内容,而不是像 String 那样创建新对象。

如果数组容量不够,会通过 Arrays.copyOf() 扩容,扩容规则是:新容量 = 旧容量 × 2 + 2,这样可以减少扩容次数、提升性能。

另外,它的所有方法没有加同步锁,所以是 线程不安全 的,但单线程下性能最好。

速答版:
StringBuilder 底层是可变的 char[],通过 append 直接修改,不会创建新对象;容量不足时会按 2 倍加 2 扩容;线程不安全但性能最好。

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

在 Java 中,基本类型和包装类型的区别主要体现在 性能存储比较方式默认值 这几个方面。

  • 首先,基本类型 不是对象,占用内存小、效率高,适合频繁使用的简单运算;
    包装类型 是对象,会涉及堆内存分配和垃圾回收,性能相对低。

  • 在比较时,基本类型用 == 比较数值,而包装类型的 == 比较的是地址,若要比较内容要用 equals()。

  • 默认值也不同:基本类型如 int 默认是 0,boolean 默认 false,而包装类型的默认值是 null。

  • 存储上,基本类型作为局部变量存在线程栈中,若是成员变量则在堆中;包装类型一定是对象,存在堆上。

总结一句话:基本类型轻量高效,包装类型更灵活但开销更大。

速答版:
基本类型存数值、效率高、不占内存;包装类型是对象,占堆内存、支持泛型和 null。
比较上一个比值、一个比地址,默认值也不同:0 vs null。

附上对比表

对比项 基本类型(Primitive) 包装类型(Wrapper)
定义位置 位于 JVM 基本数据类型体系(如 int、double) 位于 java.lang 包下的类(如 Integer、Double)
是否对象 ❌ 不是对象 ✅ 是对象(继承自 Object)
存储位置 局部变量在栈上,成员变量在堆上 永远在堆中(对象引用存于栈上)
默认值 数值型为 0,布尔型为 false 全部为 null
比较方式 == 比较 == 比较地址equals() 比较
性能表现 占用内存小、执行快 占用内存大、执行慢(涉及 GC)
用途场景 常用于数值计算、频繁运算 适用于集合框架(如 List)和需要对象的场景
初始化方式 直接赋值(如 int a = 1; 需 new 或自动装箱(如 Integer a = 1;
可空性 ❌ 不能为 null ✅ 可为 null
泛型支持 ❌ 不支持泛型 ✅ 可用于泛型(例如 List<Integer>

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

为什么 Java 要同时存在抽象类和接口?它们在设计思想上分别解决了什么问题?

我们从语法 + 思想 + 场景 三层逻辑来回答

概念层(简述区别)

抽象类是对事物共性的抽象,可以包含属性和方法;
接口是对行为规范的抽象,更关注对象能做什么。

语法层(核心对比表)

对比点 抽象类(abstract class) 接口(interface)
继承实现 单继承 多实现
成员内容 可有属性、构造方法、普通方法、抽象方法 仅能有常量和方法(JDK8 起可有默认方法、静态方法,JDK9 可有私有方法)
设计定位 模板:抽象出相同部分,提高复用 协议:定义行为规范,解耦调用方与实现方
访问修饰 可有各种修饰符 默认 public、static、final(常量)
场景 父类通用功能扩展 不同类的行为统一

思想层

抽象类体现的是模板复用思想,
接口体现的是解耦和规范思想。

举个例子

比如我们有 Bird、Airplane、Superman 都能飞。

  • 它们的“飞”是一种行为能力,应该定义在接口 Flyable 中(规定必须实现 fly())。
  • 而“鸟类”有共同特征,比如翅膀、吃虫等,就应该抽象为一个抽象类 Bird,让子类继承它共享实现。

所以接口是「能做什么」,抽象类是「是什么 + 怎么做」。

一句话总结

抽象类和接口最大的区别在于“目的”。

  • 抽象类是对类的共性抽象,可以包含属性和方法,强调复用;
  • 接口是对行为的抽象,强调规范与解耦。

比如我们有 Bird、Airplane、Superman 都能飞。

  • 它们的“飞”是一种行为能力,应该定义在接口 Flyable 中(规定必须实现 fly())。
  • 而“鸟类”有共同特征,比如翅膀、吃虫等,就应该抽象为一个抽象类 Bird,让子类继承它共享实现。

所以接口只关注「能做什么」,抽象类则关注「是什么 + 怎么做」。

抽象类解决代码复用问题,接口解决多继承和行为统一问题。

一句话:抽象类是模板,接口是契约。

JDK 和 JRE 有什么区别?

img

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

分类 工具 功能简述
基础开发工具 javac Java 编译器,将 .java 源码编译为 .class 字节码
java 运行 Java 应用的命令,启动 JVM
javadoc 根据注释生成 API 文档
jar 打包/解压 JAR 文件
jdb Java 调试工具,命令行调试、断点、变量查看
性能监控与分析工具 jps 查看当前正在运行的 Java 进程
jstack 查看线程堆栈,用于分析死锁和卡顿
jmap 导出内存映射(heap dump)文件,分析内存泄漏
jhat 堆分析工具,配合 jmap 使用
jstat JVM 统计监控工具(GC 次数、加载类数等)
jconsole 图形化 JVM 监控工具
jvisualvm 综合性能分析工具(线程、GC、CPU 等)
诊断与远程工具 jinfo 查看和调整 JVM 参数
jstatd 远程 JVM 监控守护进程

但面试官想听到的不是“你会背”,而是“你会用”
所以不要机械背诵工具清单,而要讲出你真的用过、解决过问题的场景。

我平时在开发和排查问题时用过一些 JDK 自带的工具。

  1. 首先是开发类工具,比如 javac、java、jar、javadoc 这些是日常必用的。
  2. 其次是性能排查类工具:
  • 我常用 jps 查看运行中的 Java 进程;
  • 遇到线程死锁或 CPU 占用高时,会用 jstack 导出线程堆栈排查问题;
  • 如果怀疑内存泄漏,会通过 jmap -dump 导出堆文件,然后用 jvisualvm 或 Eclipse MAT 分析;
  • GC 频繁或 Full GC 慢的时候,我会用 jstat -gc 实时观察垃圾回收情况。
  1. 最后是调优与监控类:
    我也用过 jconsole 和 jvisualvm 做 JVM 性能监控,比如查看线程数量、内存占用、GC 情况等。

补充

如果被问:“那你更喜欢哪个工具?”

  • 可以说:jvisualvm 因为它图形化、集成度高、可以远程连接。

如果被问:“这些工具和 Arthas、SkyWalking 有什么区别?”

  • 可以说:JDK 工具偏底层调试,而 Arthas / SkyWalking 更适合线上系统监控和诊断。

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

在 Java 中:

  • == 比较的是两个引用是否指向同一个对象;
  • equals() 用于比较对象的内容是否相等,很多类(比如 String、Integer)都重写了它;
  • hashCode() 用于计算对象的哈希值,在 HashMap、HashSet 等集合中定位元素。

实际上,equals() 与 hashCode() 有一致性要求——如果两个对象相等,它们的哈希码必须相同,否则会导致哈希集合中无法正确识别重复元素。

一句话总结
== 比“是不是同一个对象”,equals 比“内容”,hashCode 用来“快速查找”。

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

  1. 概念简述

在 Java 中,equals() 和 hashCode() 是判断对象相等性的重要方法,
它们在基于哈希的数据结构(如 HashMap、HashSet)中共同决定了对象的存取行为。
2. 关系规则(必须背熟)
它们之间遵循一致性约定(Contract):

  • 如果两个对象通过 equals() 相等,那么它们的 hashCode() 必须相等。
  • 但如果 hashCode() 相等,equals() 不一定相等(可能是哈希冲突)。
  • 同一个对象多次调用 hashCode(),在程序运行期间必须返回同一个值。

一句话总结:equals 决定“逻辑相等”,hashCode 决定“存储位置”。

  1. 为什么要重写 hashCode()

如果我们只重写了 equals() 而没有重写 hashCode(),
那么即使两个对象在逻辑上相等,哈希集合(HashSet/HashMap)也会认为它们是两个不同对象。
这会导致:

  • HashSet 出现重复数据;
  • HashMap 的 key 无法正确覆盖旧值;
  • contains()、remove() 等操作失败。

img
4. 总结一句话

  • equals() 决定对象“是否相等”,
  • hashCode() 决定对象“放在哪”。

两者必须一起重写,才能保证基于哈希的集合正确工作。

面试时口语答题版

在 Java 中,equals() 和 hashCode() 是判断对象相等性的两个核心方法。
equals() 判断两个对象的内容是否相等,hashCode() 用于计算对象在哈希结构中的位置。
它们之间有一致性约定:如果两个对象 equals() 相等,它们的 hashCode() 必须相等,否则在 HashMap 或 HashSet 中会出现定位错误。

比如我们定义了自定义类做 HashSet 的 key,只重写 equals() 不重写 hashCode(),会导致集合中出现重复数据。
因此,重写 equals() 时必须同时重写 hashCode(),保证逻辑相等和哈希一致。

什么是 Java 中的动态代理?

动态代理是 Java 提供的一种在运行时动态创建代理对象的机制
它允许我们在不修改目标对象代码的情况下,对方法调用进行增强,比如添加日志、权限验证或事务控制。
Java 中主要有两种实现方式:一种是基于接口的 JDK 动态代理,另一种是基于继承的 CGLIB 动态代理。
JDK 代理通过 InvocationHandler 拦截方法调用;CGLIB 通过生成目标类的子类实现。
它的核心作用是让代码更灵活、更解耦,也是 Spring AOP 的基础。

延伸
问1:动态代理底层是怎么生成类的?
答:JDK 动态代理会在运行时使用 ProxyGenerator 生成 $Proxy0.class 字节码文件,然后由 ClassLoader 加载到内存中。

问2:Spring 是用哪种?
答:Spring AOP 优先使用 JDK 动态代理;如果目标类没有接口,则自动切换到 CGLIB。

JDK 动态代理和 CGLIB 动态代理有什么区别?

JDK 动态代理是基于接口的实现,通过反射机制在运行时生成代理类,并通过 InvocationHandler 调用目标对象的方法。
它只能代理实现了接口的类。

CGLIB 动态代理是基于继承机制的,通过 ASM 字节码生成技术创建目标类的子类来实现代理,
因此可以代理没有接口的普通类,但不能代理被 final 修饰的类或方法。

在 Spring AOP 中,如果目标类实现了接口,会优先使用 JDK 动态代理,否则自动切换到 CGLIB。
性能上 JDK8 后两者几乎没有差别。

总结口诀

口诀 含义
“接口用 JDK,类用 CGLIB” 代理对象类型区分
“继承受限 final,接口灵活无限” 限制区别
“JDK 反射调,CGLIB 生成类” 实现原理
“AOP 双轮驱动,版本无性能坑” 框架应用

Java 中的注解原理是什么?

注解是 Java 提供的一种元数据机制,用于在代码中添加结构化信息。
它本质上是继承自 Annotation 接口的特殊接口,编译器会将注解信息写入字节码文件中。

在运行时,可以通过反射机制读取这些元数据,完成如依赖注入、配置映射等逻辑。

根据保留策略不同,注解分为编译期(SOURCE、CLASS)和运行期(RUNTIME)。
运行期注解是框架如 Spring、MyBatis 的核心机制。

总结口诀

口诀 含义
“注解是标记,反射是钥匙” 反射是读取注解的关键
“保留到运行期,反射能识别” 只有 RUNTIME 才能反射读取
“编译做检查,运行做增强” 区分注解用途
“框架靠注解,灵魂是反射” 框架底层实现原理

你使用过 Java 的反射机制吗?如何应用反射?

Java 的反射机制是指在运行时动态获取类的结构信息(包括属性、方法、构造器等),并能对对象进行动态创建、访问和修改的机制。

它的核心类包括 Class、Method、Field、Constructor。

  • 反射的最大优点是灵活性强,不需编译期确定类型,很多框架如 Spring、MyBatis、Junit 都依赖它实现依赖注入和动态代理。(框架三件套 = 反射 + 动态代理 + 注解。)

  • 缺点是性能较低、安全性降低,但可以通过缓存 Method 对象或使用 MethodHandle 来优化性能。

小结
Java 的反射机制是一种在运行时动态获取类信息并操作对象的机制,是 Spring、MyBatis 等框架的核心基础,但要注意性能与安全性问题。

补充: 反射常用操作代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 获取类对象的三种方式
Class<?> cls1 = Class.forName("com.example.User");
Class<?> cls2 = User.class;
Class<?> cls3 = new User().getClass();

// 2. 创建对象
Object obj = cls1.newInstance(); // 调用无参构造

// 3. 获取并调用方法
Method method = cls1.getMethod("setName", String.class);
method.invoke(obj, "Alice");

// 4. 访问私有属性
Field field = cls1.getDeclaredField("age");
field.setAccessible(true); // 暴力反射
field.set(obj, 25);
System.out.println(field.get(obj)); // 输出 25

什么是 Java 中的不可变类?

不可变类(Immutable Class)是指对象一旦被创建,其状态(成员变量)就不能被修改的类。
一旦初始化完成,对象的属性在整个生命周期中都保持不变,例如 String、Integer、BigDecimal 等。
不可变类的核心特征:

特征 说明
1️⃣ 类必须用 final 修饰 防止被继承后修改行为
2️⃣ 所有字段必须是 privatefinal 防止外部直接修改属性
3️⃣ 不提供任何 setter 方法 确保状态一经初始化无法改变
4️⃣ 所有可变对象字段必须防御性复制 Getter 返回副本,防止外部修改原对象
5️⃣ 通过构造函数初始化全部属性 保证对象在创建时即处于完整状态

面试回答:

不可变类是指对象在创建后,其状态就不能再被修改的类
类通常被声明为 final,所有成员变量是 private final,并且没有 setter 方法
如果类包含可变对象字段,需要通过防御性复制来保护内部状态不被外部修改。

不可变类的典型例子有 String、Integer、BigDecimal 等。
它们的优点是线程安全、可缓存、调试简单;
缺点是频繁修改会产生大量中间对象,浪费内存。

什么是 Java 的 SPI(Service Provider Interface)机制?

Java 的 SPI(Service Provider Interface)是一种服务发现机制,
用于在运行时动态加载接口的不同实现类,实现系统解耦与模块化扩展。

它的核心是通过接口定义规范,第三方在 META-INF/services 目录下注册实现类,
由 ServiceLoader 在运行时自动发现并加载这些类。

SPI 的典型应用是 JDBC、Dubbo、SLF4J 等。
优点是扩展性强、解耦好;缺点是性能较低且无法动态刷新。

  • 一句话总结:
    Java SPI 是一种运行时加载第三方实现类的机制,
    通过接口 + 配置文件 + ServiceLoader 实现解耦与可插拔扩展,
    是 Java 框架(如 JDBC、Dubbo、Spring Boot Starter)底层的重要基础。

  • 一句话概括了 SPI 和 API 的区别:
    “API 是我提供接口和实现,SPI 是我提供接口,别人实现。”

Java 泛型的作用是什么?

Java 泛型的作用主要有三点:
第一,类型安全,它可以在编译阶段检查类型,避免运行时 ClassCastException(类型转换异常);
第二,代码重用,通过泛型类和泛型方法,可以让同一份代码适用于多种数据类型;
第三,消除显式类型转换,取出集合元素时不需要再手动强制类型转换,代码更简洁安全。

泛型只在编译期有效,编译后会进行“类型擦除/泛型擦除”,JVM 实际运行时使用的是原始类型(如 Object),所以泛型不会影响程序运行效率,只是提供编译期的安全检查。

Java 泛型擦除是什么?

定义:

泛型擦除(Type Erasure)是 Java 编译器在编译阶段执行的一种机制,
它会在生成字节码时移除所有泛型类型信息,
将类型参数替换为其限定类型(上界),
若无上界则替换为 Object,以保证运行时与非泛型代码的兼容性。

作用:

  • 保持向下兼容(与 Java 5 之前版本兼容);
  • 减少 JVM 的复杂性(运行时不需要泛型类型检查);

影响:

  • 运行时无法获取泛型实际类型(List 和 List 在运行时完全相同);
  • 无法直接创建泛型数组或进行泛型类型判断;
  • 反射只能获取泛型声明信息,不能获取运行时真实类型。

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

Java 泛型的上下界限定符用于约束泛型参数的取值范围。

  • ? extends T 表示泛型类型必须是 T 或 T 的子类,常用于“只读”场景,保证读取时类型安全。
  • ? super T 表示泛型类型必须是 T 或 T 的父类,常用于“只写”场景,保证插入时类型安全。

它们遵循 PECS 原则(Producer Extends, Consumer Super):

  • 生产者用 extends,消费者用 super。
1
2
3
List<? extends Number> list1 = new ArrayList<Integer>(); // 只能读
List<? super Integer> list2 = new ArrayList<Number>(); // 只能写

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

在 Java 中,拷贝分为浅拷贝和深拷贝:

  • 浅拷贝 只复制对象的基本类型字段,对于引用类型字段只复制引用地址,新旧对象共享同一堆内实例,因此修改一个对象会影响另一个。
  • 深拷贝 不仅复制对象本身,还会递归复制其引用对象,两个对象完全独立,互不影响。

Java 默认的 clone() 方法是浅拷贝,如需深拷贝可通过手动实现 clone()、对象序列化或工具类来完成。

用一个例子可以更好的理解-身份证复印件 vs 克隆人 :

类型 说明
浅拷贝 复印了身份证。看起来信息一样(名字、地址相同),但你拿复印件去办事,所有操作其实都还是对“原人”生效。
深拷贝 造了一个“克隆人”,这个人和你一模一样(数据完全复制),但独立存在。他改发型、搬家都不会影响你。

浅拷贝:两个人共用一个脑袋。
深拷贝:每个人有自己的脑袋。

什么是 Java 的 Integer 缓存池?

Java 的 Integer 缓存池是为了节省内存和提升性能而设计的。
在 -128 ~ 127 范围内的 Integer 对象会被缓存并复用,
当自动装箱或调用 Integer.valueOf() 时,若值在此范围内,会直接返回缓存对象,而不是新建。
若使用 new Integer() 或超出该范围,则会创建新对象。
缓存池通过静态内部类 IntegerCache 实现,范围可通过 JVM 参数调整。

补充:
面试陷阱题

1
2
3
4
5
6
7
8
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;

System.out.println(a == b); // ✅ true
System.out.println(c == d); // ❌ false

原因:

  • 127 在缓存范围内 → a、b 引用同一对象
  • 128 超出缓存范围 → c、d 各自新建对象

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

Java 类加载分为三个主要阶段:加载、链接和初始化。
其中“链接”又分为 验证、准备、解析 三个子阶段。

  • 加载阶段:把类字节码加载进内存,生成 Class 对象;
  • 验证阶段:确保字节码符合 JVM 规范;
  • 准备阶段:为静态变量分配内存并赋默认值;
  • 解析阶段:把符号引用替换为直接引用;
  • 初始化阶段:执行静态变量赋值与静态代码块逻辑。

最终类就可以被 JVM 使用。

总结口诀(方便背诵)
加载 → 验证 → 准备 → 解析 → 初始化
简称:“加 验 准 解 初

口诀记法:

加载类进内存,验证格式真;
准备分配值,解析换引用;
初始化执行码,一切都齐全。

什么是 Java 的 BigDecimal?

BigDecimal 是 Java 提供的用于高精度小数计算的类,属于 java.math 包。
它能避免 float 和 double 的精度误差,常用于金融、结算等高精度场景。
BigDecimal 是不可变类,每次运算都会生成新的对象,提供加减乘除、比较、取整等方法,
并支持多种舍入模式(如四舍五入 HALF_UP)。
建议使用字符串构造方法以避免精度丢失。

BigDecimal 为什么能保证精度不丢失?

BigDecimal 之所以能保证精度不丢失,是因为它采用了 任意精度的整数表示法
BigDecimal 通过将数值部分(intVal)和小数位数(scale)分开存储
在计算时进行整数运算,最后通过 scale 调整小数点位置,避免了二进制浮点表示导致的精度丢失。

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

答:其实啊,new String(“abc”) 这句代码最多会创建两个对象,最少创建一个对象。
我这么解释你就懂了:
首先,Java 里有个字符串常量池(String Pool),JVM 在看到 “abc” 这个字面量的时候,会先去常量池里看看有没有 “abc” 这个字符串。

  • 如果已经有了,那就直接用,不再创建;
  • 如果还没有,那 JVM 就会在常量池里先放一个 “abc”。

接着呢,new String(“abc”) 这个操作,
一定会在堆内存(Heap)里再创建一个新的对象,这个新对象的内容就是拷贝常量池里的 “abc”。

所以最后的情况是:

  • 如果 “abc” 在常量池里早就存在了 → 只在堆中创建 1 个对象;
  • 如果 “abc” 在常量池里是第一次出现 → 会创建 2 个对象(常量池 1 个 + 堆里 1 个)。

一句话总结
new String(“abc”) 至少创建一个对象,最多两个。
常量池管字面量,堆内存管 new 出来的对象。

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

final ->修饰符(防修改):final 可以修饰类、方法、变量,意思是“不能再改了”。
finally ->异常处理语句块(保证执行):finally 一定会和 try-catch 一起出现,无论是否发生异常,finally 块里的代码都会执行。
finalize() -> 对象回收前的“遗言方法”(已过时):finalize() 是 Object 类的一个方法,当垃圾回收器(GC)准备回收对象时,会调用它来让对象“临终清理”。(不推荐使用,因为 JVM 不保证它什么时候执行,甚至可能根本不执行。)

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

其实 Java 出现乱码,最根本的原因就是——编码和解码方式不一致

比如写文件的时候用 UTF-8,但读的时候用 GBK,那 JVM 就会把字节解释错,原本的中文就变成了乱码。

常见的情况有三种:

  1. 默认编码不同(Windows 是 GBK,Linux 是 UTF-8);
  2. 文件流读写没指定编码;
  3. 数据库字符集不统一。

只要统一用 UTF-8 就行,编码、解码、数据库都保持一致就不会乱。

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

JDK 9 把 String 的底层从 char[] 换成了 byte[]

  • 因为char数组,每个字符占用2个字节,但实际上,大量字符串(比如英文、数字、符号)只需要 1 个字节。这就造成空间浪费。
  • 改用了byte数组后,通过一个 coder 字段来标记编码方式(0 → LATIN1(单字节),1 → UTF-16(双字节)),JVM 会根据字符串内容自动选择合适的编码。

这样英文只占 1 字节,中文用 2 字节,不但省了一半内存,还提升了性能

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

  • 一个线程在 Java 中只能启动一次。调用 start() 后,线程状态会从“新建”(NEW)变成“就绪”(RUNNABLE),再由 JVM 调度执行。
  • 如果再次调用 start(),线程已经不是新建状态了,JVM 就会抛出IllegalThreadStateException,因为线程生命周期是单向的,不能重复启动。

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

栈和队列的主要区别在于数据的进出顺序。
栈是后进先出(LIFO),像叠盘子,最后放上去的先拿走;
队列是先进先出(FIFO),像排队买票,先来的人先走。

在 Java 里,Stack 类实现了栈结构,现在更推荐用 Deque。
队列则由 Queue 接口实现,比如 LinkedList、PriorityQueue。

栈常用于函数调用、递归、表达式求值;
队列常用于任务调度、消息队列或广度优先搜索(BFS)。

数据结构 特点 常见场景
栈(Stack) 后进先出 LIFO 函数调用栈、表达式求值、括号匹配、深度优先搜索(DFS)
队列(Queue) 先进先出 FIFO 任务调度、消息队列、数据流处理、广度优先搜索(BFS)

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

Optional 是 Java 8 引入的一个容器类,用来表示一个可能为空的值。
它的主要作用是防止空指针异常,通过像 isPresent()、orElse()、ifPresent() 这样的 API,
让我们不用到处写 if (obj != null),代码更清晰、更安全。

你可以理解成它是一个“带状态的盒子”,
盒子里可能有值,也可能是空的,但我们不用再直接碰 null。

Java 的 I/O 流是什么?

Java 的 I/O 流其实就是用来读写数据的“通道”,
程序可以通过它从文件、网络等地方读取数据,或者把数据输出到文件、控制台。

它分为两大类:

  • 字节流(InputStream / OutputStream),处理二进制数据;
  • 字符流(Reader / Writer),处理文本数据。

举个例子:读图片就用字节流,读写文本就用字符流。
这样设计是为了让程序更灵活、更高效地操作不同类型的数据。

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

Java 一共有 8 种基本数据类型,用来存放最基础的数据,
它们不属于对象类型,也不存放在堆里,而是在栈中。

整数类型有 4 个:byte、short、int、long;
浮点类型 2 个:float、double;
字符类型是 char;
布尔类型是 boolean。

这些类型的设计是为了性能更高,不需要对象包装。

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

自动装箱和拆箱是 Java 编译器帮我们做的类型转换。
比如,当我们把 int 放进 List 时,会自动变成 Integer —— 这就是装箱;
当我们从 List 取出来再参与运算时,它又会自动变回 int —— 这就是拆箱。

它是在 Java 5 引入的,主要是为了让代码更简洁。
底层其实就是调用 valueOf() 和 xxxValue() 方法。

不过要注意两点:
一是频繁装拆箱会影响性能,二是拆箱时如果是 null 会直接抛 NullPointerException。

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

Iterator 是什么?

Iterator 是 Java 集合框架中的一个接口,用于顺序遍历集合中的元素。
它提供了一种统一访问集合元素的方式,开发者不需要关心集合的底层实现。
常与 Collection 接口及其子类(如 List、Set)配合使用。

核心方法

  • boolean hasNext():判断集合中是否还有下一个元素;
  • E next():返回下一个元素,若没有则抛出 NoSuchElementException;
  • void remove():删除最近一次通过 next() 返回的元素,仅在调用 next() 之后使用,否则抛出 IllegalStateException。

主要作用

  • 提供统一、简洁、安全的遍历方式,避免直接使用索引;
  • 支持在遍历过程中删除元素;
  • for-each 语法实际上是基于 Iterator 实现的语法糖。

Java 运行时异常和编译时异常之间的区别是什么?

Java 的异常分两类:编译时异常和运行时异常。
举例:

  • 编译时异常->FileNotFoundException、IOException
  • 运行时异常->算数异常 (ArithmeticException)、空指针异常 (NullPointerException)、数组越界 (ArrayIndexOutOfBoundsException)

二者的区别:

  • 编译时异常在编译阶段就会被检查,比如文件读写错误,必须用 try-catch 或 throws 处理,否则编译不过;
  • 而运行时异常是程序运行时才抛出的,比如空指针或数组越界,编译器不会强制你处理。

设计上是因为编译时异常多来自外部环境,而运行时异常多是代码逻辑错误。

什么是 Java 中的继承机制?

Java 的继承是面向对象的三大特性之一,
它允许子类通过 extends 关键字继承父类的属性和方法,从而实现代码复用和功能扩展。
Java 只支持单继承,但支持接口的多继承。
优点是结构清晰、便于维护,缺点是耦合性高、灵活性差。

什么是 Java 的封装特性?

封装就是把对象的属性和方法包装在类里面,对外只提供访问接口,比如通过 getter/setter 控制访问权限。
这样做可以隐藏内部实现细节,保护数据安全,也让代码更容易维护和扩展。

Java 中的访问修饰符有哪些?

Java 有四种访问修饰符:public、protected、default、private。
它们控制类和成员的访问范围。
private 最严格,只能在当前类访问;default 是包级访问;
protected 可以被同包或不同包的子类访问;public 最开放,任何地方都能访问。
代码示例:

1
2
3
4
5
6
7
public class Person {
public String name; // 所有人可访问
protected int age; // 同包或子类可访问
String gender; // 默认访问,同包可访问
private String idCard; // 仅类内可访问
}

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

  • 静态方法用 static 定义,属于类本身,不需要创建对象就能调用,只能访问静态成员,常用于工具类或工厂方法。
  • 实例方法属于对象,必须通过对象调用,可以访问类中所有成员,常用于操作对象属性或实现对象行为。

总结一句话:
静态方法属于类,实例方法属于对象;
静态方法不依赖对象,只能访问静态成员;
实例方法依赖对象,可以访问一切成员。

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

for 循环比较灵活,可以通过索引访问、修改、删除元素,适合需要控制循环逻辑的场景;
foreach 是语法糖,用起来更简洁,但不能访问索引,也不能在遍历时修改集合内容,否则会报错。

总结一句话:
for 灵活、能控制;foreach 简洁、安全但不能改。

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

什么是双亲委派机制?

Java 的双亲委派机制是类加载的一种设计模式。
当类加载器收到加载请求时,它不会自己立刻加载,而是把请求交给父加载器,一层层向上委派,直到 Bootstrap 尝试加载。
如果上层加载器都加载不了,才由当前加载器执行加载。
这样做可以防止同一个类被重复加载,同时保证核心类的安全性,比如 java.lang.Object 永远由启动类加载器加载。

  • 一句话记忆:先找爸爸,爸爸找不到我再上!

工作流程:

1️ 当前类加载器接收到一个类加载请求;
2️ 它不会立刻加载,而是将请求交给父类加载器;
3️ 父类加载器再交给更上层的父类加载器;
4️ 一直到最顶层的 Bootstrap ClassLoader(启动类加载器);
5️ 如果父加载器都无法完成加载,才由当前加载器执行加载。
即:自下而上委派,自上而下加载

三种主要类加载器

类加载器 实现方式 加载内容 加载路径
Bootstrap ClassLoader C++ 实现(JVM 内部) JRE 核心类库 <JAVA_HOME>/lib
Extension ClassLoader Java 实现 扩展类库 <JAVA_HOME>/lib/ext
Application ClassLoader Java 实现 用户类(classpath) classpath 路径
                    启动类加载器(Bootstrap ClassLoader)
                            ↑
                    扩展类加载器(ExtClassLoader)
                            ↑
                    应用类加载器(AppClassLoader)

一般加载顺序是:应用类加载器 → 扩展类加载器 → 启动类加载器
子类 → 父类 → Bootstrap → 父类返回 → 子类加载

设计目的

“为什么要设计双亲委派机制?”
1️ 防止类被重复加载
→ 同一个类只会被一个加载器加载,避免内存中出现多个副本。

2️ 保证核心类安全
→ 比如 java.lang.Object 永远由 Bootstrap 加载,
即使用户自定义同名类也不会被替代。

3️ 实现类隔离
→ 不同类加载器加载的类互相独立,互不干扰,方便模块化和沙箱机制。

破坏双亲委派的场景

实际开发中,有些框架“故意”打破双亲委派。

场景 说明
SPI 机制(Service Provider Interface) 典型如 JDBC 的 DriverManager,需要由子加载器加载厂商实现类。
Web 容器热加载机制(热部署) 如 Tomcat / Spring Boot,为实现模块热替换,需要自定义类加载器。
插件化框架 比如 OSGi、Dubbo 插件系统,为实现模块隔离也会自定义加载逻辑。

面试简答

双亲委派机制是 Java 类加载器的一种设计模式。
当类加载器加载类时,不会自己先去加载,而是把请求交给父类加载器,
父类加载器再向上委派,直到最顶层的 Bootstrap ClassLoader。
如果父类无法加载,才由当前加载器执行加载。
这样可以避免类重复加载、保证核心类安全、实现类加载隔离。
常见的类加载器包括 Bootstrap、Extension 和 Application 三种。
在 SPI 或 Tomcat 热加载中,出于扩展性考虑会打破这一机制。

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

在 Java 中,wait() 和 sleep() 都能让线程暂停,但它们的作用和机制不同。

  • wait() 属于 Object 类,必须在同步块中使用,会释放锁,通常配合 notify() 或 notifyAll() 实现线程通信。
  • sleep() 属于 Thread 类,不需要同步块,不会释放锁,只是让线程休眠指定时间后自动恢复。
    简单来说:wait 用于线程通信,sleep 用于线程延时;wait 放锁,sleep 不放锁。

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

Java 的 Object 类是所有类的父类,它定义了一组通用方法,
主要包括对象比较(equals、hashCode)、对象拷贝(clone)、
对象字符串表示(toString)、反射(getClass)、
多线程协调(wait、notify、notifyAll)以及垃圾回收钩子(finalize)。
这些方法几乎构成了所有 Java 对象的基本行为。

Java 中的字节码是什么?

  • Java 字节码(Bytecode)是 Java 编译器将 .java 源文件编译后生成的中间表示形式,它位于 Java 源代码与 JVM 执行的机器码之间。
  • 存储在 .class 文件中。
  • 它是平台无关的指令集,由JVM 解释器或 JIT 编译器将其翻译为机器码运行。
  • 字节码是 Java 实现 “一次编译,到处运行” 的核心机制。

什么是 BIO、NIO、AIO?

在 Java 中:

  • BIO(Blocking I/O):是传统的同步阻塞模型,一个请求一个线程,调用方在 I/O 操作完成前会一直被阻塞。适合连接数少、逻辑简单的场景。
  • NIO(Non-blocking I/O):是同步非阻塞模型,I/O 操作立即返回,通过轮询(Selector)检查状态,一个线程可处理多个连接,提高并发性能。
  • AIO(Asynchronous I/O):是异步非阻塞模型,I/O 请求发出后由操作系统完成,完成后通过回调或事件通知应用层。适用于高并发高响应场景。

🔹 用比喻:
BIO 是“自己一直盯着水烧开”;
NIO 是“自己时不时来看看水开没”;
AIO 是“请别人帮忙看,水开了他会通知你”。

什么是 Channel?

在 Java NIO 中,Channel 是一个数据通道(Data Channel), 用于在程序与 I/O 设备(文件、网络套接字等)之间进行数据的读写。 与传统的 I/O 流不同,Channel 是双向的、可读可写,并且支持非阻塞模式

它通常与 Buffer 和 Selector 配合使用:

  • Buffer 用于临时存储数据;
  • Selector 用于实现多路复用,从而用一个线程同时处理多个连接。

常见的实现类包括:

  • FileChannel(文件 I/O)
  • SocketChannel(TCP 客户端)
  • ServerSocketChannel(TCP 服务端)
  • DatagramChannel(UDP 通信)

一句话总结:

Channel 就是 NIO 的“管道”,负责在程序与外部资源之间传输数据,是高效非阻塞 I/O 的核心。

什么是 Selector?

Selector 是 Java NIO(New I/O) 中用于实现 I/O 多路复用(Multiplexing) 的核心组件。
它允许一个线程同时监听多个 Channel(通道)上的事件(如可读、可写、连接就绪等),
从而实现一个线程管理多个网络连接,大幅减少线程开销,提高系统并发性能。

工作原理:
Channel 向 Selector 注册感兴趣的事件(如 OP_READ、OP_WRITE 等),
Selector 通过 select() 方法检测哪些 Channel 就绪,然后再由程序去处理这些就绪事件。

类比理解
可以把 Selector 理解成一个“监控中心”:
它不停地查看哪些连接(Channel)有事要处理(比如有数据可读、有新连接等),
然后把这些“有事的连接”交给程序去处理。

所以 Selector 的核心优势就是——一个线程就能看管成百上千个连接
不再像传统 BIO 一样一个连接就要一个线程。

Float 经过一系列的操作后(加减乘除),如何判断是否和另一个数相等呢?

在 Java 中,浮点数(float、double)不能直接用 == 比较是否相等,
因为二进制浮点数无法精确表示所有十进制小数,会产生精度误差。

常见解决方案有三种:

1.容差比较法:
Math.abs(a - b) < epsilon,通过设置精度范围判断是否相等。

2.使用 BigDecimal:
用于高精度计算场景(如金融),比较时用 compareTo() 而不是 equals()。

3.整数化处理:
若业务允许(如金额以“分”为单位),可转为整数后再比较。

一句话总结:
不能直接用 == 比较浮点数,推荐使用“误差范围法”或 BigDecimal.compareTo()。

PO、VO、BO、DTO、DAO、POJO 有什么区别?

PO、VO、BO、DTO、DAO、POJO 到底怎么区分?

让我一句话概括它们:

PO 放数据库、VO 给前端看、DTO 用来传数据、BO 干业务逻辑、DAO 操作数据库、POJO 是最普通的 Java 类。
——简单记:存储→PO、展示→VO、传输→DTO、业务→BO、访问数据库→DAO

① PO(持久化对象)——和数据库最贴近的对象

PO 就是和数据库表一一对应的类。字段基本等于表字段。

  • 主要目的是“读库/写库”
  • 字段一般不会删减(不展示也得存)
  • 不会加展示用字段(如 createTimeStr)

什么时候用?
Service / Mapper 在与数据库交互时,取出来的就是 PO;最终转成其它对象再返回。

② VO(视图对象)——返回给前端/页面用的

你可以把 VO 理解为:

“为了让前端更好用而设计的对象”

  • 字段是展示友好的(如格式化时间、状态文案)
  • 可以组合多个来源的数据
  • 不带数据库字段,不暴露敏感信息

项目里的 VO 例子

  • ResponseVO:统一响应结构
  • PaginationResultVO:分页数据包装
  • 有时会自定义业务 VO,例如组合多个来源的展示字段

也就是说:
Controller 返回值永远是 VO 或分页 VO,不直接给前端 PO。

③ DTO(数据传输对象)——用来传输数据的“契约”

DTO 的核心点是:

”我不是给前端看的,也不是用来存数据库的,我就是用来传数据的。”

  • 可能作为 Controller 的入参(CreateDTO/UpdateDTO)
  • 也可能作为 Service 之间的传输载体
  • 字段按业务含义组织,不按展示/数据库组织

项目中 的 DTO 例子

  • TokenUserInfoDto:用户登录态,会发到 Redis
  • UserCountInfoDto:统计粉丝数/点赞数
  • VideoInfoEsDto:搜索时传给 ES
  • VideoPlayInfoDto:跨层使用的数据结构

也就是说:
DTO 是中立的数据模型,既不是前端展示,也不绑定表结构。

④ BO(业务对象)——处理业务逻辑用的

BO 属于业务域内的模型:

  • 用来封装业务逻辑(如“订单结算结果”“视频发布结果”)
  • 可以包含多个 PO/DTO/外部数据的组合
  • 业务层专用,不对外暴露

很多项目不显式写 BO,但概念上它就是“业务处理阶段的对象”。

⑤ DAO(数据访问对象)——操作数据库

DAO 就是:

  • 提供 save/get/update/delete 的接口
  • Service 层不会直接操作数据库,而是调 DAO

在 MyBatis/Spring 项目里你看到的:

1
2
UserMapper.java
VideoInfoMapper.java

其实就是 DAO,只不过现在大家叫 Mapper。

⑥ POJO(普通 Java 对象)——最基础的类

POJO 不是一个“职责角色”,只是指:

  • 最普通的 Java 类
  • 没继承、没实现特殊接口

PO/VO/DTO/BO 这些其实都是 POJO 的“变种”。

把它们串起来

在我们后端分层里,几个对象的职责非常明确:
PO 是和数据库表对应的,用来持久化存储;
DTO 是输入输出的契约,是服务之间或前后端之间的数据载体;
VO 是返回给前端展示的对象,会做格式化/文案等处理;
BO 是业务处理阶段的对象,可以组合多个来源的数据;
DAO/Mapper 则负责具体的数据库 CRUD。
而它们本质上都是 POJO,只是职责不同。

结合你的 项目 来讲

你可以这样讲(非常推荐):

在我的 项目里,这些对象分得更明确:

  • VO:比如 ResponseVOPaginationResultVO,都是返回给前端的展示类;有些接口会拼组多个来源的数据再封装成自定义 VO。
  • DTO:比如 TokenUserInfoDtoVideoInfoEsDto,用于服务之间、缓存、搜索层的传输,不会直接暴露给前端。
  • Query 对象:像 VideoInfoPostQueryUserInfoQuery,用于分页/条件查询,是输入专用。
  • PO:例如 UserInfo 这种实体类,只负责映射数据库,不直接返回给前端。

因为分层清晰,所以 Controller 只处理 DTO/Query,Service 处理 PO 和业务逻辑,最终用 VO 返回前端,整个系统观察性和扩展性都很好。

面试官最爱的问题:

为什么要搞这么多对象?不能用一个对象走天下吗?

不能,因为不同阶段关心点不一样:

  • PO:字段多、和数据库强绑定,不适合暴露
  • VO:展示字段往往需要组合/格式化
  • DTO:输入输出的契约要稳定、可校验
  • BO:业务逻辑可能需要多个数据源的组合

如果混在一起,就会出现“数据丢了没人发现、数据库字段改动导致前端挂掉”等问题,项目越大越混乱。
分成多个对象其实是“职责隔离”,保证系统稳定可维护。

Java 集合面试题

Java 中有哪些集合类?请简单介绍

Java 集合主要分为两大体系:

  • Collection单列集合(存单值)包括:

    • List(有序可重复,如 ArrayList、LinkedList)

    • Set(无序不重复,如 HashSet、TreeSet)

    • Queue(队列,如 PriorityQueue)

  • Map双列集合(键值对存储)包括:

    • HashMap、LinkedHashMap、TreeMap、Hashtable。

各集合底层一般是数组、链表、哈希表或红黑树,根据应用场景选择即可。

img

数组和链表在 Java 中的区别是什么?

数组和链表的主要区别在于存储方式访问效率插入删除性能空间利用率

  • 数组在内存中是连续存储的,支持 O(1) 时间的随机访问,但插入删除需要移动元素,效率较低。
  • 链表在内存中是非连续存储的,每个节点包含数据和指针,插入删除效率高 O(1),但访问需要遍历 O(n)。
  • 数组空间利用率高,但扩容麻烦;链表空间开销大,但结构灵活。

适用场景:

  • 数组适合数据量固定、频繁访问的场景;
  • 链表适合数据量不固定、频繁插入删除的场景。

一句话总结:

“数组快在访问,慢在插删;链表快在插删,慢在访问。”

Java 中的 List 接口有哪些实现类?

Java 中常见的 List 实现类有:ArrayList、LinkedList、Vector、StackCopyOnWriteArrayList

  • ArrayList:基于动态数组实现,随机访问快,插入删除慢,线程不安全。
  • LinkedList:基于双向链表实现,插入删除快,随机访问慢,也不是线程安全的。
  • Vector:早期线程安全版本的 ArrayList,方法同步,性能较低。
  • Stack:继承自 Vector,实现了先进后出(LIFO)的栈结构。
  • CopyOnWriteArrayList:线程安全,读操作无锁,写操作会复制一份新数组,适合读多写少的并发场景。

一句话总结:
“ArrayList 访问快,LinkedList 增删快,Vector 是同步老版本,CopyOnWriteArrayList 适合并发读多写少。”

Java 中 ArrayList 和 LinkedList 有什么区别?

ArrayList 和 LinkedList 都实现了 List 接口,区别主要在底层结构和性能上:

  • ArrayList 基于动态数组实现,内存连续,随机访问快(O(1)),但插入删除慢(O(n)),适合读多写少场景;
  • LinkedList 基于双向链表实现,内存不连续,插入删除快(O(1)),访问慢(O(n)),适合频繁插删的场景;
  • 二者都线程不安全,如需线程安全可用 CopyOnWriteArrayList。

一句话总结:
“ArrayList 快在访问,慢在增删;LinkedList 快在增删,慢在访问。”

Java ArrayList 的扩容机制是什么?

在 Java 中,ArrayList 是基于动态数组实现的。
当元素数量超过当前数组的容量时,就会触发扩容机制

默认情况下,ArrayList 的初始容量是 10。当发生扩容时,会创建一个新的数组,容量大约是旧容量的** 1.5 倍**,也就是通过 oldCapacity + (oldCapacity >> 1) 这个计算式来得到的。

然后,会把原来数组中的元素通过 Arrays.copyOf() 方法复制到新数组里。

扩容的过程是比较耗时的,因为涉及到内存分配和数据复制
所以如果能提前指定容量,性能会更好。

此外,ArrayList 没有负载因子(loadFactor),这是和 HashMap 不同的地方

另外在 JDK 1.7 和 1.8 版本之间有个小区别:

  • 在 JDK 1.7 中,调用无参构造方法时会直接创建一个长度为 10 的数组;
  • 而在 JDK 1.8 中,采用了懒加载机制,只有在第一次调用 add() 方法时才真正分配空间。

一句话总结:
“ArrayList 的扩容机制就是——当容量不够时,自动按 1.5 倍增长,并复制旧数据到新数组中。”

面试回答:

ArrayList 底层是动态数组实现的,当元素数量超过当前容量时就会触发扩容。默认初始容量是 10,扩容时新容量是旧容量的 1.5 倍,实际上是通过 oldCapacity + (oldCapacity >> 1) 计算出来的。然后用 Arrays.copyOf() 把旧数组的内容复制到新数组里。

扩容过程会涉及内存分配和数据复制,性能相对较低,
所以在预知元素数量时最好在构造时指定初始容量。

ArrayList 没有负载因子,和 HashMap 不一样。

在 JDK 1.7 中是构造时分配数组,1.8 以后是懒加载,只有第一次 add() 才真正分配空间。

总结:
“ArrayList 的扩容机制 = 触发时自动按 1.5 倍新建数组 + 拷贝旧数据。”

说说 Java 中 HashMap 的原理?

HashMap 的底层是 数组 + 链表 + 红黑树 的结构。
1.当我们向 HashMap 中存入一个键值对时,会先根据 key 的 hashCode 计算哈希值,再定位到数组下标。
2.如果该位置为空,就直接插入;如果已有元素,就会比较 key:

  • 相同则覆盖 value;
  • 不同则判断是链表还是红黑树结构。
    • 若为链表 → 遍历插入尾部
    • 若为TreeNode → 插入红黑树;

3.插入后判断

  • 链表长度 > 8 且 table 大小 ≥ 64 → 转红黑树;(在 JDK 1.8 以后,为了避免链表过长导致性能下降,当链表长度超过 8 且数组容量 ≥ 64 时,会自动转换为 红黑树 提升查询效率。)
  • 若红黑树节点数小于6->转回链表
  • 若 size 超过 threshold(阈值) → 扩容。(HashMap 默认初始容量是 16,负载因子是 0.75,当元素数量超过 capacity × loadFactor 时会触发扩容(容量 ×2)。

不过要注意的是:HashMap 不是线程安全的,多线程环境下建议使用 ConcurrentHashMap。

Java 中 HashMap 的扩容机制是怎样的?

HashMap的扩容机制
HashMap 在存储元素超过一定数量时会触发扩容,具体机制如下:

1.触发扩容
当 HashMap 中的元素数量超过 负载因子(默认是 0.75)与当前容量的乘积时,扩容操作就会触发。

2.扩容规则
扩容时,HashMap 的容量会 翻倍。例如,从 16 扩容到 32。
扩容后,新的数组大小为原来的一倍,因此元素将重新分配到新的数组中。

3.扩容时jdk7会重新计算每个元素的哈希值,jdk8时不会重新计算哈希值,jdk8是通过hash值与上老数组的容量 (hash & oldCapacity )来判断hash值的最高位是不是1,如果是1,则扩容后元素存放的位置为旧数组下标+老数组的容量,如果是0,说明元素还是存放在原来的下标位置。

为什么 HashMap 在 Java 中扩容时采用 2 的 n 次方倍?

HashMap 采用 2 的 n 次方作为容量,是为了让哈希值分布更均匀、减少冲突,并通过位运算代替取模运算来提升索引效率。
同时在扩容时,只需判断哈希值新增一位的 0/1,即可快速确定新位置,避免重新计算哈希,提高扩容性能。

为什么 Java 中 HashMap 的默认负载因子是 0.75?

HashMap 的默认负载因子是 0.75,这个值是为了在时间复杂度空间利用率之间取得一个合理的平衡。

  • 如果负载因子太小,比如 0.5,虽然哈希冲突少,查询快,
  • 但会导致频繁扩容,浪费空间;
  • 如果负载因子太大,比如 0.9,虽然空间利用率高,
  • 但冲突多,查找效率会下降。
    所以 0.75 是经过大量实践验证的经验值,既能保证较低的冲突概率,又能减少扩容次数。

总结:
“0.75 是 HashMap 在空间利用率与查找效率之间的最优平衡点。”

为什么 JDK 1.8 对 HashMap 进行了红黑树的改动?

在 JDK1.8 之前,HashMap 使用链表来解决哈希冲突。
当冲突比较严重时,链表会变得很长,查找、插入、删除的时间复杂度从 O(1) 退化为 O(n)。

为了避免性能急剧下降,从 JDK1.8 开始,当桶中元素数量超过 8 时,
并且数组长度大于 64,HashMap 会将链表转换为红黑树。
红黑树是一种自平衡二叉查找树,操作复杂度是 O(log n),可以显著提升性能。

当元素数量减少到 6 以下时,红黑树又会退化回链表,以节省内存。

另外,之所以不直接使用红黑树,是因为树节点体积更大,占用更多内存。
因此,只有在冲突严重时才树化,是在性能与内存之间的平衡设计

一句话总结:
“JDK1.8 引入红黑树是为了解决哈希冲突导致的性能退化问题,
将最坏时间复杂度从 O(n) 优化为 O(log n),实现时间与空间的最佳折中。”

JDK 1.8 对 HashMap 除了红黑树还进行了哪些改动?

除了引入红黑树以外,JDK1.8 对 HashMap 还做了三项关键优化:

第一,优化了哈希函数
通过 (h = key.hashCode() ^ (h >>> 16)) 通过高 16 位与低 16 位异或,使高位信息参与索引计算,让哈希值分布更均匀,减少冲突。

第二,改进了扩容机制
JDK1.7 每次扩容都要重新计算 hash 值,而 JDK1.8 直接通过 (hash & oldCap) 旧容量(oldCap)的高位判断节点的新位置,无需重新计算 hash。

第三,插入方式从头插法改为尾插法
头插法在多线程扩容时可能造成链表反转和死循环,改为尾插法后可以有效避免这一问题。

一句话总结:
“JDK1.8 的 HashMap 通过优化哈希函数、改进扩容机制、改为尾插法,在性能、稳定性和安全性上都比 JDK1.7 有显著提升。”

使用 HashMap 时,有哪些提升性能的技巧?

在使用 HashMap 时,主要可以从以下几个方面提升性能:

预估初始容量,避免频繁扩容
HashMap 扩容会触发 rehash,代价高;可以通过 new HashMap(expectedSize / 0.75f) 预设容量。

合理调整负载因子(LoadFactor)
默认 0.75 是空间与时间的平衡点,数据密集或读多写少的场景可略调低。

确保 hashCode 分布均匀
设计良好的 hashCode 可避免冲突,减少链表/红黑树退化,提高访问效率。

根据场景选用合适 Map 实现类

  • LinkedHashMap:保持插入顺序;
  • ConcurrentHashMap:高并发场景;
  • TreeMap:需要排序的场景。

一句话总结:
提前规划容量、优化哈希函数、合理配置负载因子,才是 HashMap 高性能的关键。

什么是 Hash 碰撞?怎么解决哈希碰撞?

Hash 碰撞是指不同的 key 通过哈希函数计算后得到相同的哈希值,从而映射到哈希表中的同一个槽位。

常见的解决方式包括:

① 拉链法(链地址法)
每个槽位对应一个链表或红黑树,所有哈希值相同的元素存在该链表中。
(Java 的 HashMap 就是采用这种方式)

② 开放寻址法
当发生碰撞时,继续在数组中寻找下一个空闲槽位(如线性探测、二次探测)。

③ 再哈希法(双重哈希)
使用多个哈希函数,在发生碰撞时重新计算新的索引位置。

🔹总结一句话:
“Hash 碰撞是不可避免的,关键在于通过合理的冲突解决策略(如拉链法+红黑树)保持高效性能。”

Java 的 CopyOnWriteArrayList 和 Collections.synchronizedList 有什么区别?分别有什么优缺点?

CopyOnWriteArrayList 和 Collections.synchronizedList 都是线程安全的 List 实现,但机制完全不同。

  • CopyOnWriteArrayList 采用“写时复制”策略,写操作会复制一个新数组再修改,读操作无锁,适合读多写少的并发场景。优点是读性能极高、迭代安全;缺点是写操作慢且内存占用高。
  • Collections.synchronizedList 通过 synchronized 关键字为所有操作加锁,实现线程安全。优点是实现简单,写操作无额外内存开销;缺点是读写都加锁,性能较差,且迭代需手动同步。

一句话总结:
读多写少用 CopyOnWriteArrayList,读写频繁用 Collections.synchronizedList(或直接用 ConcurrentHashMap、CopyOnWriteArraySet 等 JUC 类更优)。

Java 中的 HashMap 和 Hashtable 有什么区别?

在 Java 中,HashMap 和 Hashtable 的区别主要有四点:

第一,线程安全性不同
HashMap 是非线程安全的,多线程环境下可能出现数据不一致;
而 Hashtable 是线程安全的,因为内部所有方法都加了 synchronized。

第二,性能不同
由于没有同步锁,HashMap 在单线程环境下性能更好。
而 Hashtable 因为锁粒度太粗,性能偏低。

第三,对 null 的支持不同
HashMap 允许一个 null 键和多个 null 值;
Hashtable 不允许任何 null 键或 null 值。

第四,迭代器机制不同
HashMap 使用 fail-fast 的 Iterator,迭代时修改会抛出ConcurrentModificationException(并发修改异常);
Hashtable 使用旧的 Enumeration,不会抛异常,但已经过时。

另外,HashMap 继承自 AbstractMap,Hashtable 继承自过时的 Dictionary 类。
一句话总结:
“HashMap 非线程安全、性能高;Hashtable 线程安全但过时,建议用 ConcurrentHashMap 替代。”

ConcurrentHashMap 和 Hashtable 的区别是什么?

在 Java 中,ConcurrentHashMap 和 Hashtable 都是线程安全的哈希表实现,但它们在实现线程安全的方式上完全不同。

Hashtable 使用的是整表锁,即每次读写都要竞争同一把锁,所以并发性能比较低。

而 ConcurrentHashMap 在 JDK7 采用分段锁机制
在 JDK8 之后采用了 CAS + synchronized 的方式
CAS 用于无锁写入,如果冲突严重再退化为锁定特定桶的头结点。

这样锁的粒度更细,可以让多个线程同时访问不同桶,从而大幅提高并发性能。

此外,ConcurrentHashMap 的底层结构也更优化,
它在高冲突时会将链表转换为红黑树,进一步提升查询效率。

一句话总结:
“Hashtable 锁整张表,而 ConcurrentHashMap 只锁冲突桶 + 使用 CAS,无锁读写,并发性能更高。”

Java 中的 HashSet 和 HashMap 有什么区别?

  1. HashSet 不允许重复元素,只存储一个元素
    HashSet 是一个不允许重复元素的集合。在 HashSet 中,存储的每个元素都是唯一的。当你尝试插入一个已经存在的元素时,插入操作会失败。

  2. HashMap 存储键值对,键必须唯一,值可以重复
    HashMap 是一个由键值对组成的集合。它的键(key)必须是唯一的,但值(value)可以重复。如果你尝试将一个已经存在的键对应的值更新为一个新的值,它会替换掉旧的值。

  3. HashSet 底层基于 HashMap 实现,HashSet 存储的元素是存储在 HashMap 的键中,value 就是一个 Object 对象
    HashSet 的底层是通过 HashMap 来实现的。在 HashSet 中,元素被存储为 HashMap 的键,而每个键的值(value)在 HashMap 中其实并不关心,它是一个常量对象,通常使用 Object 类型。

Java 中的 LinkedHashMap 是什么?

LinkedHashMap 是 HashMap 的子类,它在 HashMap 的基础上通过维护一个 双向链表,来记录键值对的 插入顺序访问顺序

默认情况下按插入顺序排序,如果在构造时将 accessOrder 设为 true,
则按最近访问顺序(LRU)排序。

它的查找、插入、删除操作依然是 O(1) 时间复杂度,并且允许键和值为 null。

常用于实现LRU 缓存 或需要保持插入顺序的场景。

Java 中的 TreeMap 是什么?

TreeMap 是 Java 中实现 Map 接口的一种数据结构,它是基于红黑树的。红黑树是一种平衡的二叉查找树,它能确保在执行插入、删除和查找操作时,时间复杂度都能保持在 O(log n)。在 TreeMap 中,所有的键都会根据自然顺序或者我们提供的 Comparator 进行排序

另外,TreeMap 不允许 null 作为键,因为 null 不能进行比较排序,但它允许存储 null 值。TreeMap 的一个特点是,它在实现上是一个有序的集合,能保证键值对始终按照升序排列

Java 中的 IdentityHashMap 是什么?

IdentityHashMap 主要用在那些需要根据对象的内存地址来判断是否相等的场景而不是判断两个对象的内容是否相同。简单来说,IdentityHashMap 会使用 == 来判断两个键是否相同,而不是像 HashMap 使用 equals() 方法来判断。它通常在一些特殊的情况下使用,比如缓存管理或者需要进行对象唯一性的场景

此外,IdentityHashMap 和 HashMap 的存储方式也有所不同,它存储键的方式更注重引用而不是对象内容,因此它适用于某些特定的使用场景。

Java 中的 WeakHashMap 是什么 ?

  • WeakHashMap 是一种使用弱引用(Weak Reference)作为键(key)的哈希表实现。与普通的 HashMap 不同,WeakHashMap 会允许垃圾回收器去回收不再被其他对象引用的键。也就是说,当 WeakHashMap 中的键对象不再被其他任何地方引用时,该键值对会自动从 WeakHashMap 中移除。
  • 通过这种机制,WeakHashMap 能够避免因对象持续存在于内存中而导致的内存泄漏问题。
  • WeakHashMap适用于需要对缓存对象进行动态管理的场景,它通过弱引用机制,确保对象可以在没有强引用时自动被垃圾回收。

Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别?

1.数据结构

JDK 1.7 :ConcurrentHashMap 采用了Segment(分段锁)+ HashEntry(哈希表)来组成数据结构。

JDK 1.8 : ConcurrentHashMap 去掉了 Segment,使用数组+链表和红黑树的结构(与HashMap类似)。

2.锁的类型与粒度

JDK 1.7: 分段锁(Segment)继承了 ReentrantLock,每个 Segment 是独立的,因此可以支持更多的并发线程,默认情况下有 16 个 Segment

JDK 1.8: 使用 synchronized 和 CAS 来保证线程安全,相比于 1.7 更细粒度的锁。

3. 扩容机制:

JDK 1.7: 每个segment里单独进行自己的扩容。

JDK 1.8: JDK 1.8 的扩容机制引入了渐进式扩容,当我们的元素个数达到扩容阈值时,首先创建一个 2 倍大小的数组,然后后续每次有线程对当前数据结构进行操作(新增、修改等),都会帮忙迁移部分的数组槽位上的数据(使用tranferIndex进行标记),直到旧数组的数据完全迁移到新数组为止。扩容过程更加高效,减少了锁的竞争。

Java 中 ConcurrentHashMap 的 get 方法是否需要加锁?

在 Java 中的 ConcurrentHashMap 中,get() 方法不需要加锁。这个设计是为了提升性能,并减少锁的开销

  • get方法是读取数据操作,不对资源做处理,所以只要使用 volatile 关键字来确保每次读取操作都能获取到最新的数据即可。

  • 具体来说,get() 方法是在数组节点上执行的,它通过 Unsafe 类的 getObjectVolatile() 方法来保证线程可见性,确保每次读取到的值是最新的。

为什么 Java 的 ConcurrentHashMap 不支持 key 或 value 为 null?

Java 中的 ConcurrentHashMap 不支持 key 或 value 为 null 主要是为了避免并发操作时出现不可预知的行为。比如,在多线程环境下执行 get(key) 时,如果 key 对应的值是 null,就无法判断这是因为 key 不存在,还是它的 value 就是 null。如果允许 null,那么就需要额外的代码来区分这些情况,增加了复杂度。

此外,ConcurrentHashMap 的设计是为了避免歧义和并发问题。如果允许 null 作为 key 或 value,就会使得例如 get、put、containsKey 等操作更加复杂,可能导致频繁的状态检查或异常情况

hahsmap为什么可以?

hashmap设计的初衷是单线程,它有containsKey方法可以判断key是否存在。
ConcurrentHashMap不能用containsKey, 因为多线程环境下也会有歧义。

Java 中的 CopyOnWriteArrayList 是什么?

CopyOnWriteArrayList 是 Java 提供的线程安全的动态数组实现。这个类通过“写时复制”(Copy on Write)机制来保证线程安全在进行写操作时,会先复制一份数组副本,写操作仅在新的副本上进行,而不对原始数组进行修改。

基本特性:

  • 线程安全:使用 CopyOnWrite 机制,读操作不会加锁,写操作在写副本时加锁,保证线程安全。
  • 写操作开销大:每次写操作都需要复制整个数组,有一定的性能消耗,而且消耗内存。
  • 读操作无锁:由于数组是每次写时才被复制,所以对于大量的读操作是无锁的,性能较高。

适合场景:

适合读多写少的情况。常用于事件通知、事件监停器等场景,能有效减少同步开销,确保线程安全。

你遇到过 ConcurrentModificationException 错误吗?它是如何产生的?

ConcurrentModificationException 它通常出现在多线程环境中,当我们在遍历集合(如 ArrayList)时,如果在遍历的过程中修改了集合的结构(比如添加或删除元素),就会抛出这个异常。这个异常的产生是因为 Java 使用了 fail-fast 机制,它会在检测到结构性修改时立即抛出异常,防止继续执行后续操作,避免不一致的状态。

举例:

比如,如果你在使用 for-each 遍历 ArrayList 时,同时修改了集合的内容,就会触发这个异常。Java 集合类会在 next() 方法调用时检查是否有结构性修改,如果检测到有修改,它会抛出 ConcurrentModificationException。

如何避免:

“为了避免这个异常,通常我们可以使用 Iterator 来遍历集合,因为 Iterator 提供了 remove() 方法来安全地删除元素。此外,在多线程环境下,可以使用像 CopyOnWriteArrayList 这样的线程安全集合,或者使用 synchronized 来同步操作。”