# 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 类,也是比较引用地址
    但许多类(如 StringIntegerList )都重写了 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 是不可变的?
  1. 底层结构
    • String 内部使用 final char[] value 来存储字符;
    • final 保证引用不可变,整个 String 对象内容不能更改。
  2. 类被 final 修饰
    • String 是一个 final 类,不能被继承;
    • 防止子类修改其不可变语义(如重写 hashCode() 等)。

# 🔒 不可变的好处:
  1. 安全性
    • 比如将字符串用于文件路径、网络地址、数据库连接等敏感操作,内容不可更改能避免安全漏洞。
  2. 线程安全
    • 不需要额外同步,在多线程下天然安全。
  3. 缓存优化
    • 可以使用字符串常量池(String Pool),提升性能,减少内存开销。
  4. 可作为 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,引用指向父类,执行看子类。

# ❗ 补充说明:

  1. 方法重载(Overload) 其实不是多态的体现,它是编译时的多态(静态绑定);
  2. 真正的运行时多态 是通过方法重写 + 向上转型 + 动态绑定实现的。

# 📌 追问(进阶):

你知道 Java 中方法重写时,访问修饰符和异常可以变动吗?比如父类方法抛出异常,子类能否不抛或抛其他的?

# 🔹1. 访问修饰符可以改变,但有限制:
  • 子类重写方法的访问级别不能更严格
  • 可以更宽松
父类方法修饰符 子类允许的修饰符
public public
protected protected , public
默认(包访问) 默认、 protectedpublic
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
  • 编译器强制要求处理;
  • 例如:
    • IOException
    • SQLException
    • ParseException
public void readFile() throws IOException {
    FileInputStream fis = new FileInputStream("a.txt"); // 可能抛 IOException
}

# ✅ 二、Unchecked Exception(非受检异常)

  • 继承自 RuntimeException
  • 编译器不要求强制捕获;
  • 常见如:
    • NullPointerException
    • ArrayIndexOutOfBoundsException
    • IllegalArgumentException
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 块都会执行;
  • 如果 trycatch 中有 return ,而 finally 也有 return最终返回的是 finally 中的值
  • 如果 finally 中没有 return ,则执行完 finally 后仍返回 trycatch 中的值;
  • 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);

等价于:

  1. 读取旧值: int old = map.get("key")
  2. 计算新值: int newVal = old + 1
  3. 写入新值: 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 是一个接口,其常见实现类如 HashSetTreeSetLinkedHashSet
  • 它的核心特性是:不允许元素重复
  • 实现去重的关键,是依赖元素的 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 是什么?
  • TreeSetSet 接口的实现类;
  • 它的特点是:
    • 元素自动排序(默认升序)
    • 不允许重复元素
    • 查询、插入、删除都是 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。

# 📌 衍生追问:你能说说 HashSetLinkedHashSetTreeSet 的区别吗?三者对比适用场景是什么?
# ✅ 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 排序最在行,查找范围选它强。