一、开篇:为什么要懂底层物理状态?
先抛一个痛点:很多人学集合只知道add、get、remove,但一被问到“ArrayList 和 LinkedList 有什么区别”、“HashSet 为什么查询快”,就答不上来。根本原因是不理解它们在内存中的物理形态。
二、一切的基石:数组(Array)
1. 核心特点:
内存连续,像一排紧挨着的火车座位。
有索引,可以通过
arr[2]瞬间定位,查找数据时间复杂度O(1)。但是内置没有增删改查方法,例如删除元素代码需要自己来写循环。
长度固定,一旦创建长度定死,不能扩容。
存储类型L:Array 能存基本类型和对象
2. Java代码:
// 1. Array 可以存 int、double、char 等基本类型 int[] arr = new int[5]; // ✅ 存的是真正的 int 值(4 字节) double[] arr2 = new double[5]; // ✅ 存的是真正的 double 值 // 2. 创建时必须定死长度 int[] arr = new int[5]; // 2. 按下标赋值 arr[0] = 10; arr[1] = 20; arr[2] = 30; // arr[3] = 40; // ❌ 报错!ArrayIndexOutOfBoundsException(数组越界) // arr.add(40); // ❌ 报错!没有add()方法 // 3. 获取长度用属性 System.out.println(arr.length); // 输出 33. 内存图
三、List 体系(有序、有索引、可重复)
1. List 接口(规范)
合同内容:必须按插入顺序排列,必须给每个元素发索引号,允许重复元素。
2. ArrayList(动态数组)
底层:
Object[] elementData(连续数组)。存储类型:ArrayList 只能存对象不能存基本类型
特点:查询快(O(1)),增删慢(需要移动元素),但是有内置方法remove()。不仅如此,它还可以动态扩容。
内存:连续。
3. Java代码
// 1. 用法 ArrayList 只能存对象(泛型必须是引用类型) ArrayList<Integer> list = new ArrayList<>(); // ✅ 只能写 Integer,不能写 int // ArrayList<int> list = new ArrayList<>(); // ❌ 编译报错! // 2. 插入、删除数据有对应的方法 list.add(1); list.add(2); // 插入元素 list.remove(0); // 删除元素 移除索引为 0 的元素4. 内存图
四、Set 体系(无索引、不可重复)
1. Set 接口(规范)
合同内容:不提供索引,不允许重复元素。Java严格按照数学中的集合思想。
2. HashSet(底层是 HashMap)
底层:
HashMap<E, Object>,只用了 Key,Value 固定为PRESENT。特点:查询极快(O(1)),无序。
内存:数组连续 + 节点分散(哈希表结构)。
3. Java极简版代码
public class SimpleHashSetDemo { // ========== 1. 节点:数组里挂的东西 ========== static class Node { String key; // 元素(比如 "A") Node next; // 指向下一个节点(链表) Node(String key) { this.key = key; } } // ========== 2. 模拟 HashSet ========== static class HashSet { Node[] table = new Node[16]; // 底层数组,长度16 // 存元素 public void add(String key) { // ① 算哈希值,取模得到数组索引 int index = key.hashCode() % 16; // ② 如果这个索引是空的,直接放进去 if (table[index] == null) { table[index] = new Node(key); return; } // ③ 如果这个索引已经有东西了(哈希冲突),挂到链表后面 Node current = table[index]; while (current.next != null) { current = current.next; } current.next = new Node(key); } } }4. 内存图
5. 解析说明
HashSet 的底层其实与 HashMap 用的是同一套代码逻辑,这也是 Java 设计时的一种“懒惰”体现——说白了,HashSet 本质上就是 HashMap 的 Key 集合,Value 统一使用一个默认的静态常量对象PRESENT(private static final Object PRESENT = new Object();)。因此,HashSet 自身的泛型是<E>,而它内部持有的 HashMap 则使用了<E, Object>泛型,其中 E 代表任意元素类型。
HashSet 在存入元素时,会先根据元素的hashCode()计算出哈希值,再与数组长度进行取模运算(实际源码中使用位运算,效果等价),从而定位到数组的某个索引。假设字符'A'的哈希值为 66,数组长度为 16,那么66 % 16 = 2,因此它会被放到数组索引 2 的位置。如果此时再存入字符'Q',其哈希值若为 82,则82 % 16 = 2,同样得到索引 2,但该位置已经被'A'占据,这就发生了哈希冲突。Java 采用拉链法来解决冲突:将新节点挂在已有节点后面,形成链表(当链表长度超过 8 时转为红黑树)。所以索引 2 的位置会挂着一串节点,'A'在前,'Q'在后,通过next指针相连。
五、Map 体系(键值对)
1. HashMap(最常用)
底层:
Node<K,V>[] table(数组 + 链表/红黑树)。特点:Key 唯一,查询快。
内存:数组连续,节点分散。
2. 解析说明
HashMap 的底层内存结构与 HashSet 所依赖的底层结构完全一致——因为 HashSet 本身就是基于 HashMap 实现的。它们都采用“数组 + 链表/红黑树”的哈希表结构,通过哈希值定位数组索引,用链表或红黑树解决哈希冲突。
唯一的区别在于:HashSet 只使用了 Key,Value 被固定为一个默认的占位对象PRESENT;而 HashMap 的 Value 是由开发者指定的,因此它拥有自己的一套泛型HashMap<K, V>。其中K代表键(Key),可以是任意类型;V代表值(Value),也可以是任意类型。
文章最后,我会带大家复习一下泛型中常见字符的含义,帮助大家彻底记住T、E、K、V这些符号的用法。
六、总结与复习
1. Array和ArrayList的区别
| 对比项 | Array | ArrayList<E> |
|---|---|---|
| 长度 | 固定 | 动态扩容 |
| 存储类型 | 基本类型 + 对象 | 只能存对象 |
| 性能 | 更快 | 稍慢(装箱拆箱 + 方法调用) |
| 类型检查 | 运行时 | 编译时(泛型) |
| 长度获取 | arr.length(属性) | list.size()(方法) |
| 方法丰富度 | 几乎没有 | 一大堆现成方法 |
| 协变 | 支持 | 不支持 |
| 多维 | 原生支持 | 需要嵌套 |
| 适用场景 | 性能敏感、长度固定 | 日常开发、频繁增删 |
2. 内存连续性大对比(核心表格)
| 集合类 | 底层结构 | 内存是否连续 | 有索引? | 允许重复? |
|---|---|---|---|---|
| Array | 原生数组 | ✅ 连续 | ✅ 有 | ✅ 允许 |
| ArrayList | Object[] | ✅ 连续 | ✅ 有 | ✅ 允许 |
| LinkedList | 双向链表 | ❌ 分散 | ✅ 有(但慢) | ✅ 允许 |
| HashSet | HashMap | ⚠️ 数组连续 + 节点分散 | ❌ 无 | ❌ 不允许 |
| HashMap | 数组 + 链表/红黑树 | ⚠️ 数组连续 + 节点分散 | ❌ 无(按 Key 查) | ❌ Key 不可重复 |
3. 常见的泛型符号及含义(约定俗成)
| 符号 | 全称 | 含义 | 典型使用场景 |
|---|---|---|---|
T | Type | 通用类型(最常用) | class Box<T>,表示“某个类型” |
E | Element | 元素类型 | List<E>、Set<E>,表示“集合里的元素” |
K | Key | 键 | Map<K, V>的键 |
V | Value | 值 | Map<K, V>的值 |
N | Number | 数字类型 | class Calculator<N extends Number> |
R | Return | 返回值类型 | interface Function<T, R> |
? | 通配符 | 未知类型 | List<?>,表示“任意类型的 List” |
S、U | (无全称) | 第二、第三类型参数 | class Triple<T, S, U> |
泛型符号说明:
其实T、E、K、V这些占位符本质上都代表同一个意思——任意类型。那为什么ArrayList<E>的源码用E,而不用T或K、V呢?
其实不是不行,而是会大大降低代码可读性。E是Element(元素)的首字母,因为集合里存的就是元素,所以用E让人一看就懂。
那又有人要问了:为什么HashMap<K, V>不写成HashMap<T, T>呢?
对,T确实代表任意类型,但如果你写成HashMap<T, T>,就等于强制规定键和值的类型必须完全一致。比如HashMap<String, String>两个都必须是String,这就大大限制了 Map 存储数据的灵活性。
举个例子,如果你想存HashMap<String, Object>(键是 String,值是任意对象),用HashMap<T, T>就做不到了。所以源码中必须使用不同的占位符来区分键和值。
但为了代码可读性,又不能随便乱起名,于是采用了约定俗成的K和V:
K= Key(键)V= Value(值)
这样一来,既保证了键和值可以是不同类型,又让人一眼就能看懂谁是谁。
4. Java中常见的数组、集合接口和实现类的关系
| 名称 | 是接口还是类 | 继承/实现关系 |
|---|---|---|
| Array(数组) | 语言内置类型 | 无(不是类也不是接口) |
| List | 接口 | 继承Collection |
| ArrayList | 类 | 实现List |
| LinkedList | 类 | 实现List、Deque |
| Set | 接口 | 继承Collection |
| HashSet | 类 | 实现Set |
| LinkedHashSet | 类 | 继承HashSet |
| TreeSet | 类 | 实现NavigableSet |
| Map | 接口 | 不继承Collection |
| HashMap | 类 | 实现Map |
| LinkedHashMap | 类 | 继承HashMap |
| TreeMap | 类 | 实现NavigableMap |