Java 与数据结构
Java 与数据结构
Java 的集合框架把数据结构课上的抽象类型做成了接口和实现。会用
ArrayList、HashMap只是第一步,知道它们背后是动态数组、双向链表、桶数组 + 红黑树、环形数组和二叉堆,才能解释「为什么扩容是 1.5 倍」「为什么 HashMap 的容量是 2 的幂」「为什么 LinkedList 随机访问慢」。
1. 一张总表
| 抽象数据结构 | Java 载体 | 底层实现 | 关键复杂度 | 典型场景 |
|---|---|---|---|---|
| 动态数组 | ArrayList | Object[] elementData + size,按 1.5 倍扩容 | 索引 O(1),尾部追加均摊 O(1),中间插入/删除 O(n) | 结果集、列表展示 |
| 双向链表 | LinkedList | Node 的 prev/next/item | 两端增删 O(1),按下标访问 O(n) | 双端队列、LRU 的手写实现 |
| 哈希表 | HashMap | 桶数组 + 链表/红黑树 | 查找/插入/删除平均 O(1);树化后 O(log n),未树化时最坏 O(n) | 配置、缓存、计数 |
| 哈希集合 | HashSet | 内部就是一个 HashMap | 成员判断平均 O(1) | 去重、集合运算 |
| 有序哈希表 | LinkedHashMap | HashMap + 双向链表 | 增删查平均 O(1),可保持插入/访问顺序 | LRU、有序输出 |
| 红黑树 | TreeMap | 平衡二叉搜索树 | 查找/插入/删除 O(log n),天然有序 | 排名、区间查询 |
| 双端队列 | ArrayDeque | 环形数组 + head/tail | 两端增删 O(1) | 栈、队列、BFS |
| 优先队列 | PriorityQueue | 数组存储的二叉堆 | 堆顶 O(1),入队/出队 O(log n) | Top K、任务调度 |
| 并发哈希表 | ConcurrentHashMap | CAS + 桶级 synchronized | 读几乎无锁,写只锁单个桶的头节点 | 并发缓存、计数器 |
| 写时复制列表 | CopyOnWriteArrayList | 写时复制整个数组 | 读 O(1),写 O(n) | 读多写少的监听器列表 |
2. ArrayList:动态数组
2.1 内存布局
ArrayList
elementData ──► [ Object[0] ][ Object[1] ][ Object[2] ][ null ][ null ] ...
size = 3ArrayList 内部就是一个 Object[],所以:
get(i)直接算地址,O(1);add(e)在尾部写下一个槽位,容量够就是 O(1),不够才扩容;add(index, e)、remove(index)要把后面的元素整体移动,O(n)。
2.2 扩容:1.5 倍
// JDK 里的核心逻辑(简化)
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
elementData = Arrays.copyOf(elementData, newCapacity);- 默认容量是 10,但第一次 add 才分配(懒加载)。
- 按比例扩容让 n 次追加的总搬移量保持在 O(n),所以追加是均摊 O(1),单次触发扩容仍可能是 O(n)。
- 已知数据量时用
new ArrayList<>(expectedSize);构造器会按给定值精确分配初始数组,后续装满才按 1.5 倍grow()。
List<String> list = new ArrayList<>(1000);
list.add("a");
System.out.println(list.size()); // 1
System.out.println(list.get(0)); // a2.3 常见坑
// 1. Arrays.asList 返回定长列表,add/remove 会抛 UnsupportedOperationException
List<Integer> fixed = Arrays.asList(1, 2, 3);
// fixed.add(4); // 运行时异常
// 2. List.of 是不可变列表
List<Integer> immutable = List.of(1, 2, 3);
// immutable.set(0, 9); // UnsupportedOperationException
// 3. subList 是视图,不是拷贝
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
List<Integer> sub = list.subList(0, 2);
sub.set(0, 99); // 原 list 也会变:list.get(0) == 99需要独立副本时用 new ArrayList<>(list) 或 List.copyOf(list)。
3. LinkedList:双向链表
first ──► [prev|null][item][next] ◄──► [prev][item][next] ◄──► ...
last ────────────────────────────────────────────────────────┘- 每个元素是一个
Node,多存prev、next两个引用,内存开销比ArrayList大。 addFirst/addLast/removeFirst/removeLast是 O(1);get(index)要从头或尾走,O(n);- 实现了
Deque,可以当栈和队列,但性能通常不如ArrayDeque。
内存开销也要算:LinkedList 每个元素是一个 Node 对象(对象头 + item/next/prev 三个引用),ArrayDeque 只维护一个 Object[];元素多时前者通常更费内存。
Deque<Integer> deque = new LinkedList<>();
deque.addFirst(1);
deque.addLast(2);
System.out.println(deque.pollFirst()); // 1LinkedList 适合「频繁在两端增删、很少按下标访问」的场景;Java 里这种场景大多已经被 ArrayDeque 取代。
4. HashMap:桶数组 + 链表/红黑树
4.1 结构
table ──► [ bucket0 ] [ bucket1 ] [ bucket2 ] ... [ bucket15 ]
│
▼
Node(hash, key, value, next) ──► Node ──► ...- 默认容量 16,负载因子 0.75;元素数超过
capacity × 0.75就扩容为 2 倍。 - 容量始终是 2 的幂,这样
(n - 1) & hash等价于取模,但更快。 - 哈希扰动:
(h = key.hashCode()) ^ (h >>> 16),让高位也参与低位索引。 - 冲突用链表;当链表长度 ≥ 8 且 table 长度 ≥ 64 时,转成红黑树;节点数降到 6 个及以下再转回链表。
- Java 8 扩容时会把一条链表拆成
lo/hi两条,保持相对顺序,避免旧版本的头插法成环问题。
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.merge("a", 1, Integer::sum); // 计数:a -> 2
map.computeIfAbsent("b", k -> 0); // 不存在才放 0
System.out.println(map.getOrDefault("c", -1)); // -14.2 equals 与 hashCode 的契约
HashMap 用 hashCode 找桶,用 equals 在桶内比较:
a.equals(b)为 true,则a.hashCode() == b.hashCode()必须成立;- 重写
equals必须重写hashCode; - 作为 key 的对象在放入后不要修改参与哈希的字段,否则再也找不到。
record User(String id) {} // record 自动生成 equals/hashCode
Map<User, String> map = new HashMap<>();
map.put(new User("1"), "alice");
System.out.println(map.get(new User("1"))); // alice4.3 容量与性能
HashMap 的容量是 2 的幂,构造时传的 initialCapacity 会被 tableSizeFor 向上取整。如果预估有 1000 个元素,直接 new HashMap<>(1000) 仍可能扩容,因为阈值是 capacity × 0.75;更稳妥的是 new HashMap<>((int) (1000 / 0.75f) + 1)。
4.4 线程安全
HashMap 不是线程安全的。并发写入可能导致数据丢失、size 不准,旧版本甚至会出现链表成环。并发场景用:
ConcurrentHashMap:读几乎无锁,写只锁桶;Collections.synchronizedMap:全表锁,简单但吞吐低;- 只读场景:发布后不再修改,可以安全共享。
5. HashSet、LinkedHashMap、TreeMap
5.1 HashSet
HashSet 内部就是一个 HashMap,元素作为 key,value 是一个固定的 PRESENT 对象。所以去重、contains 的复杂度和 HashMap 一致。
Set<String> set = new HashSet<>();
set.add("a");
System.out.println(set.contains("a")); // true5.2 LinkedHashMap:插入顺序与 LRU
LinkedHashMap 在 HashMap 的节点上再加 before/after,维护一条双向链表:
accessOrder = false:按插入顺序遍历(默认);accessOrder = true:按访问顺序遍历,get/put会把节点移到尾部。
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
LruCache(int capacity) {
super(capacity, 0.75f, true); // accessOrder = true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}生产环境更常用 Caffeine,但理解 LinkedHashMap 是理解 LRU 的基础。
5.3 TreeMap:红黑树
TreeMap 用红黑树保持 key 有序,查找/插入/删除都是 O(log n),并实现了 NavigableMap:
TreeMap<Integer, String> tm = new TreeMap<>();
tm.put(1, "a");
tm.put(3, "c");
tm.put(5, "e");
System.out.println(tm.floorKey(4)); // 3
System.out.println(tm.ceilingKey(4)); // 5
System.out.println(tm.subMap(1, true, 5, false)); // {1=a, 3=c}key 必须实现 Comparable,或者构造时传 Comparator;不允许 null key(自然排序时)。
6. ArrayDeque:环形数组
ArrayDeque 用环形数组保存元素,head 指向队首,tail 指向下一个空位:
elements: [ ][b][c][d][ ][ ][ ][ ]
^ ^
head tailaddFirst/addLast/pollFirst/pollLast都是 O(1);- 容量不足时按 2 倍扩容;
- 不允许
null元素; - 不是线程安全的。
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
System.out.println(stack.pop()); // 2
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1);
queue.offer(2);
System.out.println(queue.poll()); // 1ArrayDeque 通常比 LinkedList 更快、更省内存,是 Java 里栈和队列的默认选择。
7. PriorityQueue:二叉堆
PriorityQueue 用数组存完全二叉树,默认是小顶堆:
queue[0] 最小
left = 2*i + 1
right = 2*i + 2
parent = (i - 1) / 2offer/poll:O(log n);peek:O(1);- 用集合构造器
new PriorityQueue<>(collection)批量建堆:内部 heapify,O(n); - 迭代顺序不保证有序,只有堆顶最小。
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(9);
System.out.println(minHeap.poll()); // 1
// 大顶堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
// Top K:维护大小为 k 的小顶堆
PriorityQueue<Integer> topK = new PriorityQueue<>();
for (int x : nums) {
topK.offer(x);
if (topK.size() > k) topK.poll();
}元素必须可比较,或者传 Comparator;不允许 null;不是线程安全的,并发场景用 PriorityBlockingQueue。
8. 并发容器
| 容器 | 特点 | 适用 |
|---|---|---|
ConcurrentHashMap | 读无锁,写锁桶;不允许 null key/value | 并发缓存、计数器 |
CopyOnWriteArrayList | 写时复制整个数组,读不加锁 | 读多写少的监听器列表 |
ArrayBlockingQueue | 有界数组队列,支持公平锁 | 线程池任务队列、生产者-消费者 |
LinkedBlockingQueue | 链表队列,可有界 | 高吞吐任务队列 |
SynchronousQueue | 不存储元素,直接移交 | newCachedThreadPool |
PriorityBlockingQueue | 无界优先队列 | 带优先级的任务 |
ConcurrentHashMap 的 computeIfAbsent 是原子的,适合做本地缓存:
ConcurrentHashMap<String, User> cache = new ConcurrentHashMap<>();
User user = cache.computeIfAbsent(id, this::loadUser);注意:computeIfAbsent 的映射函数里不要做耗时 I/O 或递归更新同一个 map,可能长时间占住桶锁。
9. 选型决策
需要按下标随机访问、尾部追加? → ArrayList
需要两端增删、当栈/队列? → ArrayDeque
需要按 key 查找、去重? → HashMap / HashSet
需要保持插入顺序或实现 LRU? → LinkedHashMap
需要按 key 排序、区间查询? → TreeMap
需要每次取最小/最大? → PriorityQueue
需要并发读写? → ConcurrentHashMap
读多写少、事件监听? → CopyOnWriteArrayList
需要阻塞式生产者-消费者? → BlockingQueueJava 21 起,List/Deque 等实现了 SequencedCollection,统一提供 getFirst()、getLast()、reversed();写新代码时可以用这组接口,不必在 list.get(0) 和 list.get(size-1) 之间来回算下标。
10. 常见坑
Arrays.asList是定长列表:add/remove抛UnsupportedOperationException。List.of不可变:set也不行。subList是视图:修改子列表会影响原列表;原列表结构变化后子列表可能失效。HashMap的 key 可变:放入后修改参与哈希的字段,get找不到。- 只重写
equals不重写hashCode:两个相等对象落到不同桶。 - 遍历时
list.remove:抛ConcurrentModificationException;用Iterator.remove()或removeIf。 LinkedList随机访问:get(i)是 O(n),循环里用下标访问会变成 O(n²)。PriorityQueue迭代无序:需要完整排序用Arrays.sort或TreeMap。ConcurrentHashMap不允许 null:get返回 null 不能区分「不存在」和「值为 null」。- 自动装箱的
==:Integer在-128..127有缓存,范围外==比较的是引用。
11. 动手实验
import java.util.*;
public class CollectionLab {
public static void main(String[] args) {
// 1. ArrayList 扩容
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < 20; i++) {
list.add(i);
}
System.out.println(list.size());
// 2. 随机访问:ArrayList vs LinkedList
int n = 100_000;
List<Integer> array = new ArrayList<>();
List<Integer> linked = new LinkedList<>();
for (int i = 0; i < n; i++) {
array.add(i);
linked.add(i);
}
long t0 = System.nanoTime();
long sum = 0;
for (int i = 0; i < n; i++) sum += array.get(i);
long t1 = System.nanoTime();
for (int i = 0; i < n; i++) sum += linked.get(i);
long t2 = System.nanoTime();
System.out.printf("ArrayList get: %.2f ms%n", (t1 - t0) / 1e6);
System.out.printf("LinkedList get: %.2f ms%n", (t2 - t1) / 1e6);
System.out.println(sum);
// 3. HashMap 计数
Map<String, Integer> freq = new HashMap<>();
for (String s : List.of("a", "b", "a")) {
freq.merge(s, 1, Integer::sum);
}
System.out.println(freq);
// 4. 小顶堆 Top K
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int x : new int[]{5, 1, 9, 3, 7}) {
heap.offer(x);
if (heap.size() > 2) heap.poll();
}
System.out.println(heap); // [7, 9](顺序不保证)
}
}想要严肃的微基准,用 JMH,不要用 System.nanoTime() 直接下结论;JIT 预热、逃逸分析、死代码消除都会影响结果。
12. 小结
ArrayList是动态数组,扩容 1.5 倍,尾部快、中间慢。LinkedList是双向链表,两端快、随机访问慢,通常被ArrayDeque取代。HashMap是桶数组 + 链表/红黑树,容量为 2 的幂,负载因子 0.75;key 的equals/hashCode必须一致。LinkedHashMap用双向链表维护顺序,可实现 LRU;TreeMap用红黑树保持有序。ArrayDeque是环形数组,是栈/队列的默认选择;PriorityQueue是二叉堆,只保证堆顶最小。- 选型的本质是先问「数据怎么访问、怎么修改」,再找匹配的底层布局;并发场景优先用
java.util.concurrent里的容器。