# Java 面试模拟系列 🧠📘
该系列文档基于真实面试准备过程,通过 ChatGPT 辅助问答,对高频 Java 面试题进行整理与答疑。每篇文档聚焦一个主题,覆盖基础、集合、JVM、并发、网络等模块。
序号 文章标题 内容简介 01 Java 模拟面试题(基础篇01) Java 基础知识模拟问答 02 Java 模拟面试题(1) 集合框架、JMM 内存模型等基础进阶题 03 Java 模拟面试题(2) Java 四种引用类型等进阶题 ... 🔄 持续更新中 JVM、并发、Spring 等模块
📌 推荐使用场景:
- 准备面试前的快速复盘
- 学习 Java 各模块的知识要点
- 与 ChatGPT 配合自测提问训练
🚀 持续优化中,欢迎 Star & 分享!
# 💡Java 基础与进阶
# ❓问题 1:Java 中 == 和 equals() 的区别?请举例说明。
详解可参考: 深入理解 Java 中的 == 和 equals()
# ✨分析:可从如下角度进行回答
- 对于基本类型 vs 引用类型
- equals 默认行为及如何重写
- 常见面试陷阱,如 String 比较
# ✅答案:
在 Java 中, == 和 equals() 的区别主要体现在基本数据类型与引用类型的处理上:
# 🧩 对于基本类型:
==比较的是值是否相等。equals()❌ 不适用于基本类型(编译报错)
# 🧩 对于引用类型:
==比较的是引用地址,也就是是否指向同一个对象。equals()默认继承自Object类,也是比较引用地址;
但许多类(如String、Integer、List)都重写了equals()方法,用来比较对象的内容是否相等。
# ✅ 示例代码:
String a = new String("hello"); | |
String b = new String("hello"); | |
System.out.println(a == b); //false(不同对象) | |
System.out.println(a.equals(b)); //true(内容相同) |
# 📌 追问: String.intern() 方法的作用是什么?它与 == 有什么关系?
String.intern() 是一个 native 方法,它的作用是:
如果常量池中已经存在与当前字符串内容相同的字符串,则返回该字符串的引用;否则将当前字符串添加到常量池,并返回这个常量池中的引用。
因此,调用 intern() 后,我们可以使用 == 来比较是否为同一对象引用(即是否引用了常量池中的同一字符串)。
# 🔍 示例代码:
String s1 = new String("hello"); | |
String s2 = "hello"; | |
System.out.println(s1 == s2); //false(s1 是堆中对象,s2 是常量池) | |
System.out.println(s1.intern() == s2); //true(s1.intern () 返回常量池引用) |
# 🧾 总结:
==比较的是引用地址;equals()(在重写后)比较的是对象内容;intern()用于将字符串放入常量池,从而可以使用==来比较字符串内容是否相同(前提是都来自常量池)。
# ❓问题 2: Java 的八种基本数据类型是什么?它们各自的默认值是什么?
Java 有 8 种基本数据类型,分为四类:
| 类型 | 占用字节 | 默认值 | 描述 |
|---|---|---|---|
| byte | 1 byte | 0 | 整型,-128~127 |
| short | 2 bytes | 0 | 整型 |
| int | 4 bytes | 0 | 整型,默认整型 |
| long | 8 bytes | 0L | 长整型 |
| float | 4 bytes | 0.0f | 单精度浮点型 |
| double | 8 bytes | 0.0d | 双精度浮点型 |
| char | 2 bytes | '\u0000' | 字符类型 |
| boolean | 1 bit* | false | 布尔类型 |
说明:boolean 实际上是由 JVM 实现决定占用空间,通常是 1 byte,而不是真正的 1 bit。
# 📌 追问:为什么 char 占两个字节?Java 中的 char 能表示哪些字符?你对 Unicode 的理解?
# ✨ 分析:
请从以下几个方面作答:
char在 Java 中占多少字节,为什么?- Java 使用哪种字符集?
- Unicode 和 UTF-8 有什么关系?
- 是否能表示中文、表情符等字符?
# 🔹 为什么 char 占两个字节?
在 Java 中, char 类型是 无符号的 16 位整数(2 字节),用于表示一个 Unicode 编码的字符(Unicode 编号范围从 \u0000 到 \uFFFF ,即 0~65535)。
当时设计这样是为了支持国际化,尤其是中文、日文、韩文等字符。
# 🔹 Java 中使用什么字符编码?
Java 使用的是 Unicode 编码体系,内部使用 UTF-16 编码表示字符。
char表示的是一个 UTF-16 单元(16-bit),所以最多只能直接表示 BMP(Basic Multilingual Plane,基本多文种平面)内的字符(即\u0000~\uFFFF);- 如果是扩展字符(如 emoji 表情 😊,Unicode 超过 0xFFFF),则需要两个
char表示一个字符(称为 代理对 Surrogate Pair)。
# 🔹 Unicode vs UTF-8 vs UTF-16
| 编码 | 特点 |
|---|---|
| Unicode | 一种字符集,给每个字符分配唯一编码编号 |
| UTF-8 | 可变长编码(1~4 字节),常用于网络和文件存储 |
| UTF-16 | 可变长编码(2 或 4 字节),Java 内部使用该编码方式 |
| char | Java 中使用 UTF-16 单元,每个 char 占 2 字节 |
# 🔍 举个例子:
char c = '中'; // 占 2 字节,对应 Unicode \u4E2D | |
String emoji = "😊"; // 实际占用两个 char,因为 Unicode 超过 0xFFFF | |
System.out.println(emoji.length()); // 输出 2 |
# 📌 小结:
char是 2 字节,因为使用 UTF-16 单元。- Java 使用 Unicode 编码,支持中文。
- 一个
char并不等于一个字符,尤其是在 emoji 表情或特殊语言字符时。
# ❓问题 3:String、StringBuilder 和 StringBuffer 有什么区别?它们各自的适用场景?
# 🔹 String(不可变对象)
- 定义:
final类,内容不可变,每次修改都会创建新对象。 - 特点:线程安全、效率较低(频繁拼接时容易产生大量中间对象)。
- 场景:适用于字符串内容不会频繁变化的场景,如常量、配置参数等。
# 🔹 StringBuilder(可变,非线程安全)
- 定义:可变字符串容器,底层使用
char[]实现。 - 特点:非线程安全,但性能高。
- 场景:单线程环境下频繁修改字符串的操作(如拼接、插入等)。
# 🔹 StringBuffer(可变,线程安全)
- 定义:与
StringBuilder类似,但方法加了 synchronized,保证线程安全。 - 特点:性能比
StringBuilder略低,但在多线程下更安全。 - 场景:多线程环境下操作字符串。
# ✅ 性能对比:
| 操作对象 | 线程安全 | 性能 | 适用场景 |
|---|---|---|---|
| String | ✅ 是 | ❌ 低 | 不变字符串 |
| StringBuilder | ❌ 否 | ✅ 高 | 单线程字符串拼接 |
| StringBuffer | ✅ 是 | ⚠ 中等 | 多线程字符串拼接 |
# 📌 追问(进阶):你知道 String 为什么是不可变的吗?它不可变的好处是什么?
# 🔹 为什么 String 是不可变的?
- 底层结构
String内部使用final char[] value来存储字符;final保证引用不可变,整个String对象内容不能更改。
- 类被 final 修饰
String是一个final类,不能被继承;- 防止子类修改其不可变语义(如重写
hashCode()等)。
# 🔒 不可变的好处:
- 安全性
- 比如将字符串用于文件路径、网络地址、数据库连接等敏感操作,内容不可更改能避免安全漏洞。
- 线程安全
- 不需要额外同步,在多线程下天然安全。
- 缓存优化
- 可以使用字符串常量池(String Pool),提升性能,减少内存开销。
- 可作为 HashMap 的 key
- 不可变性保证
hashCode一致性,HashMap 等容器可安全使用。
- 不可变性保证
String s1 = "abc"; | |
String s2 = s1.replace("a", "x"); | |
System.out.println(s1); //abc(未变) | |
System.out.println(s2); //xbc(新对象) |
# ✅ 小结:
String不可变,底层是final char[];- 类本身是
final,不能被继承; - 不可变带来线程安全、性能优化、可安全作为 key 等多重优势。
# ❓问题 4:Java 中是值传递还是引用传递?请解释 String、基本类型、对象类型在方法中的传参行为。
🔹 Java 中只有值传递(pass-by-value),但根据类型不同表现略有差异:
# 一、基本数据类型(如 int , double , boolean )
- 方法调用时,传的是值的副本。
- 在方法内修改,不会影响原始变量。
void change(int x) { | |
x = 10; | |
} | |
int a = 5; | |
change(a); | |
System.out.println(a); // 输出 5 |
# 二、引用类型(如 String , 自定义对象等)
- 方法调用时,传的是对象引用的副本(仍然是值传递)。
- 在方法内通过该引用可以修改对象的属性,但不能改变原始引用本身的指向。
class Person { | |
String name; | |
} | |
void change(Person p) { | |
p.name = "Jack"; // ✅ 修改了属性 | |
p = new Person(); // ❌ 改变了指向,仅对方法内有效 | |
p.name = "Tom"; | |
} | |
Person person = new Person(); | |
person.name = "Lucy"; | |
change(person); | |
System.out.println(person.name); // 输出 Jack(不是 Tom) |
# 三、特殊说明: String 是不可变对象
即使是引用类型,但由于不可变( final + char[] ),在方法中重新赋值是无效的:
void change(String s) { | |
s = "World"; | |
} | |
String str = "Hello"; | |
change(str); | |
System.out.println(str); // 输出 Hello |
# ✅ 结论口诀(可以背):
Java 全是值传递,
基本类型传数值,
引用类型传地址的副本,
改属性能改,改引用没用!
# ❓问题 4:请说一下 Java 中的继承和多态是如何实现的?能举例说明一下吗?
# 🔷 继承(Inheritance)
Java 使用 extends 关键字实现类的继承。
- 子类继承父类的非私有成员(字段、方法)。
- 支持单继承,但类可以实现多个接口。
class Animal { | |
void sound() { | |
System.out.println("动物叫"); | |
} | |
} | |
class Dog extends Animal { | |
void sound() { | |
System.out.println("狗叫"); | |
} | |
} |
# 🔷 多态(Polymorphism)
多态分为两类:
# ✅ 1. 编译时多态(方法重载 Overload):
- 同一个类中,方法名相同,参数列表不同(类型或数量不同);
- 与返回值无关;
- 属于静态绑定,在编译期间确定调用哪个方法。
void print(int a) {} | |
void print(String s) {} |
# ✅ 2. 运行时多态(方法重写 Override):
- 子类重写父类方法(方法签名必须相同);
- 调用时根据对象的实际类型决定执行哪个方法;
- 属于动态绑定,在运行时确定。
Animal a = new Dog(); // 向上转型 | |
a.sound(); // 输出 “狗叫”,即使 a 是 Animal 类型 |
✔ 运行时多态的核心:父类引用指向子类对象 + 方法重写 + 动态绑定
# ✅ 结论口诀:
继承用 extends,多态靠 override,引用指向父类,执行看子类。
# ❗ 补充说明:
- 方法重载(Overload) 其实不是多态的体现,它是编译时的多态(静态绑定);
- 真正的运行时多态 是通过方法重写 + 向上转型 + 动态绑定实现的。
# 📌 追问(进阶):
你知道 Java 中方法重写时,访问修饰符和异常可以变动吗?比如父类方法抛出异常,子类能否不抛或抛其他的?
# 🔹1. 访问修饰符可以改变,但有限制:
- 子类重写方法的访问级别不能更严格;
- 但可以更宽松。
| 父类方法修饰符 | 子类允许的修饰符 |
|---|---|
public |
public |
protected |
protected , public |
| 默认(包访问) | 默认、 protected 、 public |
private |
❌ 不能被重写(不是重写,是新方法) |
# 🔹2. 异常处理:可以改变,但有限制:
- 父类方法没有抛出异常:子类方法不能抛出检查型异常(Checked Exception),可以抛运行时异常(Unchecked);
- 父类方法抛出某个检查型异常:子类方法只能抛出相同或更小(子类)范围的异常;
- 子类方法可以不抛出异常,即使父类抛出了异常。
# ✅ 举个例子说明:
class Parent { | |
protected void doSomething() throws IOException {} | |
} | |
class Child extends Parent { | |
@Override | |
public void doSomething() throws FileNotFoundException {} // OK(FileNotFoundException 是 IOException 子类) | |
} |
class Parent { | |
public void action() {} | |
} | |
class Child extends Parent { | |
@Override | |
public void action() throws RuntimeException {} // OK(抛运行时异常) | |
} |
class Parent { | |
public void process() {} | |
} | |
class Child extends Parent { | |
@Override | |
public void process() throws IOException {} // ❌ 编译错误(父类不抛,子类不能新增 Checked 异常) | |
} |
# ✅ 小结口诀:
- 访问控制:只能放宽不能收紧;
- 异常抛出:只可少不可多,Checked 异常需兼容;
private方法不能重写,它是子类自己的新方法。
# ❓问题 5:Java 中的异常分为哪两类?如何处理它们?分别举个例子。
# 🔷 Java 中的异常分为两类:
| 类型 | 分类别名 | 特点 |
|---|---|---|
| CheckedException | 编译时异常(受检异常) | 必须在代码中显式处理(try-catch 或 throws) |
| UncheckedException | 运行时异常(非受检异常) | 编译器不会强制处理,程序运行时可能抛出 |
# ✅ 一、Checked Exception(受检异常)
- 继承自
Exception,但不包括 RuntimeException; - 编译器强制要求处理;
- 例如:
IOExceptionSQLExceptionParseException
public void readFile() throws IOException { | |
FileInputStream fis = new FileInputStream("a.txt"); // 可能抛 IOException | |
} |
# ✅ 二、Unchecked Exception(非受检异常)
- 继承自
RuntimeException; - 编译器不要求强制捕获;
- 常见如:
NullPointerExceptionArrayIndexOutOfBoundsExceptionIllegalArgumentException
public void test() { | |
String s = null; | |
System.out.println(s.length()); // 运行时抛出 NullPointerException | |
} |
# ✅ 三、异常处理方式
# 1. try-catch-finally
try { | |
// 有风险的代码 | |
} catch (IOException e) { | |
// 异常处理 | |
} finally { | |
// 无论是否异常,最终执行 | |
} |
# 2. throws 关键字(方法签名抛出异常)
public void doSomething() throws IOException { | |
// 把异常继续往外抛,由调用者处理 | |
} |
# 3. throw 抛出异常对象
throw new IllegalArgumentException("参数非法"); |
# ✅ 小结口诀:
- Checked 必须处理,Unchecked 看你愿不愿意;
- try-catch 用于捕,throws 把锅往外推;
- finally 永远执行(除非
System.exit(0))。
# 📌 追问:
你知道 finally 中 return 会覆盖 try 或 catch 中的 return 吗?是否继续?
# 🔹 finally 的执行原则:
- 无论是否发生异常,
finally块都会执行; - 如果
try或catch中有return,而finally也有return,最终返回的是finally中的值; - 如果
finally中没有return,则执行完finally后仍返回try或catch中的值; System.exit(0)会直接终止 JVM,finally不会被执行。
# ✅ 示例代码:
# 🎯 情况 1:finally 无 return,不影响返回值
public int test1() { | |
try { | |
return 1; | |
} finally { | |
System.out.println("finally 执行"); | |
} | |
} | |
// 输出:finally 执行 | |
// 返回:1 |
# 🎯 情况 2:finally 有 return,会覆盖 try 的 return
public int test2() { | |
try { | |
return 1; | |
} finally { | |
return 2; | |
} | |
} | |
// 返回:2(覆盖了 try 的 return) |
# 🎯 情况 3:System.exit ()
public int test3() { | |
try { | |
System.exit(0); // JVM 终止 | |
return 1; | |
} finally { | |
System.out.println("finally"); // 不执行 | |
} | |
} |
# ✅ 小结口诀:
finally 永远会执行,
有 return 就把结果替,
try catch 返回全作废,
exit 一出谁都不配。
# ❓问题 6:ArrayList 和 LinkedList 有哪些区别?它们各自的适用场景是什么?
# 🔹 1. ArrayList
- 底层结构:动态数组(Object [])
- 优点:
- 支持快速随机访问(O (1))
- 查询性能高
- 缺点:
- 插入 / 删除效率低(中间位置会整体移动元素)
- 扩容有一定性能开销(每次扩容为原来的 1.5 倍)
# 🔹 2. LinkedList
- 底层结构:双向链表(每个节点有 prev/next 指针)
- 优点:
- 插入 / 删除性能高(只需改变前后指针)
- 缺点:
- 不支持随机访问,查询效率低(O (n))
- 占用内存较大(需要额外存储指针)
# ✅ 3. 适用场景对比
| 场景类型 | ArrayList | LinkedList |
|---|---|---|
| 频繁读 / 查询 | ✅ 非常适合(O (1)) | ❌ 慢(O (n)) |
| 频繁增删(中间) | ❌ 需要元素移动 | ✅ 只改指针 |
| 随机访问 | ✅ 快 | ❌ 慢 |
| 内存占用 | 更省 | 更高(额外指针) |
# ✅ 举个例子:
List<String> arrayList = new ArrayList<>(); | |
List<String> linkedList = new LinkedList<>(); | |
arrayList.get(10000); // 快 | |
linkedList.get(10000); // 慢(要从头或尾一个个遍历) |
# 📌 补充细节:
- 线程安全性:两者都不是线程安全的,可以使用
Collections.synchronizedList()包装; - 插入性能对比:ArrayList 在尾部添加性能也很高,但在中间插入时就不如 LinkedList。
# ✅ 小结口诀:
ArrayList 查找快,LinkedList 插入佳,
查询随机选 Array,增删频繁用 Link,
两者都不线程安,需包装才万全。
# 📌 追问:ArrayList 的扩容机制具体是怎么实现的?初始容量是多少?
# 🔹 1. 默认初始容量是多少?
-
JDK 1.8 开始,
ArrayList的初始容量为 0,但第一次添加元素时自动扩容为 10; -
这是为了避免创建不必要的数组,提高性能;
-
相关源码字段:
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
# 🔹 2. 扩容触发条件
当调用 add() 方法插入元素时,如果当前元素个数已经达到数组容量上限,就会触发扩容。
# 🔹 3. 扩容机制(增长规则)
-
从 JDK 1.8 开始,扩容策略为原容量的 1.5 倍:
newCapacity = oldCapacity + (oldCapacity >> 1); // 即 oldCapacity * 1.5
-
比如:
- 容量 10 -> 扩容后为 15
- 容量 15 -> 扩容后为 22
-
源码位置:
ArrayList.grow(int minCapacity)
# 🔹 4. 扩容代价(性能影响)
扩容时会:
- 创建一个更大的新数组;
- 将原数组中的所有元素 使用
System.arraycopy()拷贝 到新数组; - 所以扩容操作的时间复杂度是 O (n),在大数据量下性能开销较大;
- 实际项目中如果可预估数据量,建议提前指定初始容量以避免频繁扩容。
# ✅ 小结口诀:
初始容量默认十,
满了扩容乘一五;
grow 方法来扩张,
拷贝迁移代价高。
# ❓问题 7:HashMap 的底层实现原理是什么?JDK 1.8 做了哪些优化?
# 🔷 一、HashMap 的底层结构变化
# JDK 1.7 及以前:
- 底层结构:数组 + 链表
- 每个桶(bucket)是一个链表,链表中的每个节点是一个 Entry 对象,包含
key,value,hash,next - 发生哈希冲突时,多个 Entry 以链表形式挂载到同一个数组索引下
# JDK 1.8 之后:
- 底层结构:数组 + 链表 + 红黑树
- 当单个桶中的链表长度超过阈值(默认是 8),并且数组长度 ≥ 64 时,链表会被转换为 红黑树
- 红黑树查询效率为 O (log n),提高了性能
# 🔷 二、核心成员结构(源码层面)
transient Node<K,V>[] table; // 数组,默认初始容量 16 | |
static final int TREEIFY_THRESHOLD = 8; // 链表转红黑树的阈值 |
-
Node 是 Entry 的替代类,结构基本一致;
-
put 时先计算 hash 值,定位数组索引;
-
如果当前位置为空,直接插入;
-
如果不为空(发生冲突),则:
- 链表中遍历 key 是否已存在;
- 如果链表长度超过 TREEIFY_THRESHOLD(8),并且 table.length ≥ 64,则转为红黑树。
-
# 🔷 三、HashMap 的主要优化(JDK 1.8)
| 优化点 | 说明 |
|---|---|
| 红黑树引入 | 冲突严重时,链表结构变为红黑树,查询更快(O (n)→O (logn)) |
| 延迟初始化 | table 数组不在构造函数中立即分配,而是在首次使用时初始化(节省内存) |
| hash 计算优化 | 使用扰动函数 hash(key.hashCode()) 减少冲突 |
| 节点结构变化 | Entry 改为 Node ,更通用、清晰 |
# ✅ 小结口诀:
HashMap 数组链表挂,
冲突一多树来刷;
链长八、容量满,
红黑上阵效率大。
七改八,一变多,
JDK8 才是红黑家。
# 📌追问:HashMap 是线程安全的吗?并发场景下会出现什么问题?如何解决?
# 🔷 1. HashMap 是线程安全的吗?
❌ 不是线程安全的。
- 多线程环境下同时调用
put()、resize()等方法时会引发数据不一致、死循环等问题; - JDK 1.7 曾出现链表形成环形结构,导致死循环(CPU 100%);
# 🔷 2. 并发情况下的问题:
| 问题 | 说明 |
|---|---|
| 数据丢失 | 多个线程同时 put,后写入的会覆盖先写的值 |
| 数据覆盖 | Hash 冲突时覆盖已有节点 |
| 死循环(JDK 1.7) | 多线程扩容时链表节点移动顺序不一致,可能形成环链表,导致 get () 死循环 |
# 🔷 3. 解决方案:
# ✅ 方式一:使用 Collections.synchronizedMap(Map)
Map<String, String> map = Collections.synchronizedMap(new HashMap<>()); |
- 简单线程安全封装,但效率较低,所有操作加锁
# ✅ 方式二:使用 ConcurrentHashMap
- 是 JDK 提供的线程安全的哈希表
- 适用于高并发读写场景
# 🔷 4. ConcurrentHashMap 的底层原理(以 JDK 1.8 为例):
| 特性 | 说明 |
|---|---|
| 分段锁机制废弃 | 不再使用 JDK 1.7 的 Segment 分段锁结构 |
| 采用 CAS + synchronized | 保证线程安全, putVal() 用 synchronized 锁住桶节点 |
| 红黑树优化 | 与 HashMap 一样,链表长度超过 8 转为红黑树 |
| 并发扩容机制 | 多线程协作扩容,提高效率 |
# ✅ 小结口诀:
HashMap 并发不保险,死循环卡 CPU;
覆盖丢失都可能,线程安全用 Concurrent;
CAS 再加 S 锁,性能线程都保障。
# 📌 衍生追问(高级面试):你知道 ConcurrentHashMap 为什么不能用来做 count++ 累加操作吗?它是线程安全的,为什么 ++ 还是不安全?
虽然 ConcurrentHashMap 的 put/get 操作是线程安全的,但 count++ 并不是一个原子操作,依然存在竞态条件。
# 🔷 原因: count++ 并不是原子操作,它分三步:
map.put("key", map.get("key") + 1); |
等价于:
- 读取旧值:
int old = map.get("key") - 计算新值:
int newVal = old + 1 - 写入新值:
map.put("key", newVal)
如果多个线程同时执行,会发生如下问题:
- 都读取到相同的旧值;
- 分别加 1 得到相同的新值;
- 最终结果被覆盖,增加的次数比实际少,即出现 “丢更新”。
# 🔷 正确做法 ✅:
方式 1:使用 AtomicInteger
ConcurrentHashMap<String, AtomicInteger> map = new ConcurrentHashMap<>(); | |
map.putIfAbsent("key", new AtomicInteger(0)); | |
map.get("key").incrementAndGet(); // 原子自增,线程安全 |
✅ 这是最推荐的做法,原子性强、性能好。
方式 2:使用 compute() 方法(JDK8+)
map.compute("key", (k, v) -> v == null ? 1 : v + 1); |
- compute 内部加锁处理,保证原子性;
- 更适合做复杂的聚合操作。
# ✅ 小结口诀:
Map 是线程安全,操作不等于原子;
三步变一步,才是并发王道;
要加就用 Atomic,要聚就用 compute。
# ❓问题 8:Set 是如何保证元素不重复的?HashSet 的底层实现原理是什么?
# 🔷 1. Set 是如何保证元素不重复的?
在 Java 中:
Set是一个接口,其常见实现类如HashSet、TreeSet、LinkedHashSet;- 它的核心特性是:不允许元素重复;
- 实现去重的关键,是依赖元素的
hashCode()和equals()方法。
# 🔷 2. HashSet 的底层原理:
# ✅ HashSet 底层其实就是一个 HashMap
private transient HashMap<E,Object> map; | |
private static final Object PRESENT = new Object(); // 仅用作占位符 |
-
当你执行
hashSet.add(e)时,其实等价于:map.put(e, PRESENT);
-
所以元素是否重复,取决于:
e.hashCode()是否一样;- 如果一样,再调用
e.equals()判断是否相等。
# ⚠ 注意:
- 如果你自定义对象放入
HashSet,一定要重写equals()和hashCode()方法; - 否则可能出现 “逻辑重复但实际不重复” 的情况。
# 🔷 3. HashSet 插入过程简述:
hashSet.add(element) → | |
element.hashCode() → | |
定位桶 → | |
equals() 比较 → | |
不存在:加入 | |
存在:忽略(Set 不允许重复) |
# ✅ 举个例子:
Set<String> set = new HashSet<>(); | |
set.add("abc"); | |
set.add("abc"); // 第二次添加会被忽略 | |
System.out.println(set.size()); // 输出 1 |
# ✅ 小结口诀:
HashSet 本质 Map,值用哨兵 PRESENT;
判重复靠 hash + equals,
自定义对象别忘了重写方法!
# 📌 追问:TreeSet 是如何实现去重和排序的?底层用的是什么结构?
# 🔷 1. TreeSet 是什么?
TreeSet是Set接口的实现类;- 它的特点是:
- 元素自动排序(默认升序)
- 不允许重复元素
- 查询、插入、删除都是 O(log n) 的效率
# 🔷 2. TreeSet 的底层结构:
TreeSet 的底层是基于 TreeMap 实现的红黑树(Red-Black Tree)
private transient NavigableMap<E,Object> m; | |
public TreeSet() { | |
this.m = new TreeMap<>(); | |
} |
-
添加元素:
add(e)实际上调用的是:map.put(e, PRESENT);
-
因此,TreeSet 的本质是:
TreeMap<E, Object>
# 🔷 3. 排序 & 去重的依据
# 默认排序:
- 元素必须实现
Comparable接口,重写compareTo()方法; - 如果元素无法比较(没有实现 Comparable),则会抛出
ClassCastException。
# 自定义排序:
-
可以在构造 TreeSet 时传入
Comparator比较器:new TreeSet<>(Comparator.comparing(User::getAge));
# 去重的依据:
- 不是
equals()和hashCode(),而是compareTo()或Comparator.compare()的返回值是否为 0; - 如果返回 0,则认为是重复元素,不会插入。
# ✅ 举个例子:
TreeSet<Integer> set = new TreeSet<>(); | |
set.add(3); | |
set.add(1); | |
set.add(2); | |
set.add(3); // 不会加入 | |
System.out.println(set); // [1, 2, 3],自动排序 + 去重 |
# ✅ 小结口诀:
TreeSet 底层树,红黑排序取;
可比必实现 Comparable,自定就传 Comparator;
判重复靠 compare,不是 equals 或 hashCode。
# 📌 衍生追问:你能说说 HashSet 、 LinkedHashSet 和 TreeSet 的区别吗?三者对比适用场景是什么?
# ✅ HashSet vs LinkedHashSet vs TreeSet
| 特性 | HashSet |
LinkedHashSet |
TreeSet |
|---|---|---|---|
| 底层结构 | HashMap |
HashMap + 双向链表 |
TreeMap(红黑树) |
| 是否有序 | ❌ 无序 | ✅ 插入顺序 | ✅ 自动排序(自然排序或自定义 Comparator) |
| 是否允许重复 | ❌ 不允许 | ❌ 不允许 | ❌ 不允许 |
| 排序规则 | 无 | 按插入顺序 | 默认按 compareTo() ,也可指定 Comparator |
| 查询效率 | ✅ 快(O (1)) | ✅ 快(O (1)) | ⚠ 稍慢(O (log n)) |
| 插入 / 删除效率 | 快 | 较快(略低于 HashSet) | 相对较慢 |
| 适用场景 | 一般场景,追求查询性能 | 需要保持插入顺序的场景 | 需要排序(如字典、排行榜) |
# ✅ 使用建议:
- HashSet:性能最好,用于不关心顺序、只需去重的场景;
- LinkedHashSet:插入顺序敏感,比如缓存、历史记录;
- TreeSet:需要排序输出、快速范围查询的场景,如自然排序、分段查找。
# ✅ 举个小例子:
Set<Integer> hashSet = new HashSet<>(List.of(3, 2, 1)); // [1, 2, 3](无序) | |
Set<Integer> linkedSet = new LinkedHashSet<>(List.of(3, 2, 1)); // [3, 2, 1](插入顺序) | |
Set<Integer> treeSet = new TreeSet<>(List.of(3, 2, 1)); // [1, 2, 3](自然排序) |
# ✅ 小结口诀:
HashSet 去重快,顺序我不管;
LinkedHashSet 不仅快,顺序我记得;
TreeSet 排序最在行,查找范围选它强。