Skip to content

集合与泛型 ​

HashMap 的 Javadoc 写得很干:基本 get/put 均摊常量时间,前提是哈希把元素打散。迭代时间和「容量 + 实际条目」成正比,所以初始容量开太大,迭代会变慢。负载因子默认 0.75。允许 null key、null value。非线程安全。

这些不是经验之谈,是类文档的承诺。Hashtable 不允许 null,方法带 synchronized,迭代 fail-fast。ConcurrentHashMap 不允许 null(null 被用来表示「现在没有」),迭代 weakly consistent。三种 Map 不能互相当「就是个 map」换着用。

HashMap 桶、链表、树化


一、接口怎么长 ​

Iterable
  Collection          Map
    List                HashMap / LinkedHashMap / TreeMap / EnumMap
    Set                 ConcurrentHashMap
    Queue / Deque

Collection 是「一堆元素」;Map 不是 Collection。keySet()/values()/entrySet() 是视图,改视图就是改 map。keySet().remove(k) 等于 map.remove(k)。迭代 entrySet 时 setValue 改的是 map 里那条,不是副本。

1、List ​

有下标,允许重复。

ArrayList 是可变数组。OpenJDK 21:

  • DEFAULT_CAPACITY = 10
  • 无参构造把 elementData 指到共享的 DEFAULTCAPACITY_EMPTY_ELEMENTDATA(空数组)。第一次 add 才扩到 10。new ArrayList<>(0) 用的是另一份 EMPTY_ELEMENTDATA,第一次 add 按 minCapacity 长,不会直接跳到 10。两份空数组就是为了区分「默认容量还没inflate」和「用户就是要 0」。
  • 再往后 grow:ArraysSupport.newLength(old, minGrowth, old >> 1),也就是大约 1.5 倍,不是 HashMap 那种翻倍。
  • 中间 add(index, e) 要 System.arraycopy 搬元素。随机读 O(1),中间插 O(n)。

LinkedList 是双向链表,按下标访问是走链表,不是数组。随机读用 ArrayList。LinkedList 实现 Deque,当队列用可以,当 List 按下标切不要。

List.of(...)(9)不可变、不允许 null、不允许 add。Arrays.asList 是数组视图,set 可以,add 不行。两者经常被混。

2、Set ​

equals 意义下不重复。HashSet 是包着 HashMap,value 是一份共享的 PRESENT 哨兵。LinkedHashSet 保插入序。TreeSet 靠 compareTo/Comparator,不是 equals——两者不一致就会出现「Set 里两个 equals 为 true 的元素」这种违反 Set 契约的状态。

EnumSet 是位图,只装同一个 enum。比 HashSet 快、更省。

3、Queue / Deque ​

ArrayDeque 作栈/队列都比 Stack(继承 Vector)和 LinkedList 干净。环形数组,两头都能进。迭代无 ConcurrentModification 以外的惊喜,但仍然 fail-fast。

PriorityQueue 是堆,迭代无序,只保证堆顶最小(或比较器最小)。poll 才是按顺序出。offer(null) 抛 NPE。

不要用 Vector、Hashtable、Stack 写新代码。要同步,用 Collections.synchronizedX 或并发容器,不要靠 1.0 那套每个方法一把锁。Collections.synchronizedList 迭代仍要自己 synchronized (list) 包住,文档写了,漏了就不是线程安全。


二、HashMap 实际怎么放 ​

1、常量,OpenJDK 21 源码原文 ​

java
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

源码注释写明:MIN_TREEIFY_CAPACITY 至少是 4 * TREEIFY_THRESHOLD,避免扩容阈值和树化阈值打架。

2、hash 和下标 ​

java
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

下标:tab[(n - 1) & hash]。n 保持 2 的幂,位与代替取模。高 16 位异或进低位,避免只低位变化时全撞一个桶。null key 的 hash 是 0,落在桶 0。

hashCode 差的 key(很多哈希只低位变)全挤一个桶,树化是后路,不是借口。自己写 key 的 hashCode 要把字段搅开,Objects.hash 可以,别只 return 一个低位变化的 id。

3、会错的程序:hash 恒 1,连续 put ​

java
final class Bad {
    final String name;
    Bad(String name) { this.name = name; }
    @Override public int hashCode() { return 1; }
    @Override public boolean equals(Object o) {
        return o instanceof Bad b && name.equals(b.name);
    }
}

HashMap<Bad, Integer> m = new HashMap<>();
for (int i = 0; i < 11; i++) {
    m.put(new Bad("k" + i), i);
}

hash(key) = 1 ^ (1 >>> 16) = 1。下标 (n - 1) & 1,n 是 2 的幂时永远是桶 1。lo/hi 那一位 1 & oldCap:16、32 都是 0,扩容 不拆链,整串仍在新表的桶 1。这才能把「第几次才 treeifyBin、表多长才真树化」逼到台面上。均匀哈希几乎走不到下面这张表。

hash 恒 1:第 1 / 第 9 / 第 11 次 put

4、putVal / treeifyBin 源码(OpenJDK 21) ​

链表尾插时 binCount 从 0 起。把 putVal 里这段循环按「桶里已有几个、这次加第几个」展开。p 是当前走到的节点,一开始是桶头。

java
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

桶里已有 1 个(只有头),加第 2 个:第一次循环 p.next==null,binCount 还是 0,0 ≥ 7?否。已有 8 个,加第 9 个:循环走到头的后继 7 次,binCount 加到 7,7 ≥ 7,才 treeifyBin。注释 -1 for 1st 指的就是这个:阈值按「加上之后至少 8 个」,所以 第 8 次 put 还不调。

treeifyBin 第一句不是树化:

java
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)  // 64
    resize();
else { /* 链表换成 TreeNode,hd.treeify(tab) */ }

resize 翻倍后,每个旧桶按 hash & oldCap 拆成 lo(下标 j)和 hi(j + oldCap)。hashCode 恒 1 时 1 & 16 == 0、1 & 32 == 0,整串跟着 lo 走,扩容不拆链。1.8 尾插不再成环,多线程仍会丢数据。

5、逐步表:下一行执行完 table 是什么 ​

new HashMap<>() 之后 table==null,threshold 还不是 12,第一次 putVal 才 resize() 出 16 格、threshold=12。

步骤刚执行完n桶 1 形态sizethresholdtreeifyBin?
put k0table 从 null 分配 16 格,桶 1 一个节点16k0112否
put k1 … k7尾插。加 k7(第 8 个)时 binCount=6,6≥7?否16k0→…→k7(8 个)812否
put k8binCount=7≥7,调 treeifyBin。16<64,只 resize32仍链表 9 个(1&16=0,不拆)924调了,没树化
put k9再调 treeifyBin。32<64,resize64仍链表 10 个(1&32=0)1048调了,没树化
put k10treeifyBin,64≥64,replacementTreeNode + treeify64红黑树 11 个,仍留 next1148真树化

size > threshold 这条扩容,在这串 put 里一次都没先开火:树化检查比它更早撞上。哈希均匀时 #9 那次 resize 会把链拆到两个桶,binCount 掉下去,永远到不了最后一行。

改一行:把 hashCode 改成 return name.hashCode();。k0 和 k1 多半不在同一桶,#9 的 treeifyBin 不会发生。表只剩「第一次 resize 到 16、size 过 12 再翻倍」——那是正常路径,不是树化路径。

new HashMap<>(100) 不是容量 100,向上取到 128。要装 N 个还不扩容,初始容量至少 N / 0.75 再取 2 的幂。

6、LinkedHashMap、IdentityHashMap、WeakHashMap ​

LinkedHashMap 多一条双向链表,保插入序或访问序(accessOrder=true 时 get 也会把节点搬到链尾)。重写 removeEldestEntry 可当 LRU:return size() > MAX;。它继承 HashMap,非线程安全。

IdentityHashMap 用 == 和 System.identityHashCode,违反 Map 的 equals 契约,只给框架内部用(序列化、注解处理)。业务代码别用。

WeakHashMap 的 key 是弱引用,GC 后条目消失,适合「谁还握着这个 key 对象,条目就还在」的缓存。value 是强引用,value 如果反向引用 key,弱引用失效,条目永远清不掉——这是它最常见的坑。value 用 WeakReference 包一层,或干脆别用它当业务缓存。


三、ConcurrentHashMap 不是「加了锁的 HashMap」 ​

1、文档承诺 ​

和 Hashtable 一样 不允许 null key/value。get 读到的非 null 值,与当初的插入/更新有 happens-before。批量操作(forEach、reduce)反映的是这些单元素关系的组合,不保证对整张表原子。迭代器 weakly consistent:不抛 ConcurrentModificationException,可能看见也可能看不见并发插入,不会对你抛结构损坏。

Java 8 起不是 1.7 那种 Segment 分段锁。实现是桶数组 + 链表/红黑树,扩容时桶可以逐个转移,未转移的桶仍可读写。sizeCtl 控制初始化、扩容协助。面试还答「Segment 默认 16」是在答 1.7。

size() 在并发下是估计。计数用类似 LongAdder 的分段,不精确。要精确快照,没有无锁的免费午餐。mappingCount() 返回 long,避免 size 超过 Integer.MAX_VALUE 时截断。

2、复合操作 ​

不要用 chm.get(k); if (null) chm.put(k,v)。用 putIfAbsent / computeIfAbsent。文档给的频率统计写法是:

java
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

compute* 系列对这条 key 串行。计算函数里不要再进同一张 map,否则可能重入、IllegalStateException,文档写了 The remapping function must not modify this map during computation。

computeIfAbsent 的 mapping function 返回 null,表示不插入。CHM 不允许 null value,返回 null 就是「算了」。HashMap 的 computeIfAbsent 同样:返回 null 不插入。

putIfAbsent 返回旧值(没有则 null)。replace(k, old, neu) 按 Objects.equals 比旧值。record 当 value、状态机用 replace 做 CAS,实战篇会用。

3、putVal 走一遍:CAS 空桶、锁桶头、协助扩容 ​

CHM putVal:锁桶头,协助扩容

OpenJDK 21 的 spread:(h ^ (h >>> 16)) & 0x7fffffff。比 HashMap 多一个 & HASH_BITS——最高位清零,因为负 hash 被 MOVED(-1)、TREEBIN(-2)、RESERVED(-3) 占了当标记。

putVal 主循环(简化):

java
for (Node<K,V>[] tab = table;;) {
    Node<K,V> f; int n, i, fh;
    if (tab == null || (n = tab.length) == 0)
        tab = initTable();                          // CAS sizeCtl 抢初始化
    else if ((f = tabAt(tab, i = (n - 1) & h)) == null) {
        if (casTabAt(tab, i, null, new Node<>(h, key, value)))
            break;                                  // 空桶:CAS 挂上,无锁
    }
    else if ((fh = f.hash) == MOVED)
        tab = helpTransfer(tab, f);                 // 撞上 ForwardingNode,来帮搬
    else {
        synchronized (f) {                          // 只锁这条桶的头结点
            if (tabAt(tab, i) == f) {
                // fh >= 0 走链表尾插 / 覆盖;f instanceof TreeBin 走红黑树
            }
        }
    }
}
addCount(1L, binCount);                             // 分段计数,顺便看要不要扩容

逐格看「下一步状态」:

撞到的桶做什么锁
空casTabAt(null → 新节点)无锁,CAS
链表头(fh >= 0)synchronized (f) 后尾插或覆盖只锁这一条桶
fh == MOVED(ForwardingNode)helpTransfer 去搬其它桶协助扩容
fh == TREEBINsynchronized (f) 走红黑树只锁这一条桶

锁粒度是「一条桶的头结点」,不是整表。别的桶照常读写。这是 8 相对 7 分段锁的进化:粒度从「16 段」细到「每个桶」。

sizeCtl 一个 int 编码四种状态:-1 正在初始化;负且非 -1 的值高位是 resize 戳、低位是「多少线程在协助扩容」;正数是下次扩容阈值;0 是默认。扩容时把旧表拆成一段段,每个线程 transfer 认领一段桶往新表搬,搬完的旧桶放一个 ForwardingNode(hash=MOVED)。别的线程 put 撞上 ForwardingNode,就 helpTransfer 一起搬——扩容是多线程协作的,不是一个线程闷头搬别人干等。

addCount 用类似 LongAdder 的 CounterCell[] 分段累加,size() 求和是估计。要长整数用 mappingCount()。

面试把 CHM 答成「把 HashMap 的方法都加 synchronized」是错的:锁的是桶头不是方法,扩容是协作的,计数是分段的,读多数时候无锁(get 全程不加锁,靠 volatile 的 Node.val 和 tabAt 的 acquire 语义)。


四、泛型:擦除,不是 C++ 模板 ​

1、运行时没有 List<String> 这个类 ​

java
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true,都是 ArrayList

类型参数被擦成边界(没写就是 Object,写了 T extends Number 就是 Number)。所以:

  • 不能 new T()、不能 new T[](数组要记运行时分量类型,擦掉就没了)
  • 不能写 List<int>,只能 List<Integer>
  • instanceof List<String> 非法,只能 instanceof List<?>
  • 重载 void f(List<String>) 和 void f(List<Integer>) 擦完签名相同,编译失败
  • 不能 catch (T e),不能 class Fail<T> extends Exception

反射能拿到签名里的类型参数(getGenericSuperclass),那是 class 文件里的 Signature 属性,给编译器和工具看的,运行时检查不靠它。ArrayList<String> 的 get(0) 字节码是 checkcast String,这层 cast 是编译器插的。

2、不变、协变、逆变 ​

List<String> 不是 List<Object> 的子类型(不变)。否则 List<Object> 引用指向 List<String>,add(Integer) 会在运行时把整型塞进只该装 String 的结构——数组协变已经犯过这错,泛型不跟。

要「只读」用 List<? extends Number>:能 get 成 Number,不能 add(除了 null)。要「只写」用 List<? super Integer>:能 add(Integer),get 只能当 Object。这是 PECS:Producer Extends, Consumer Super。

java
void copy(List<? extends Number> src, List<? super Number> dst) {
    for (Number n : src) dst.add(n);
}

src 是生产者,dst 是消费者。List<?> 等于 List<? extends Object>,几乎只能读成 Object、加 null。

3、桥方法、堆污染、@SafeVarargs ​

桥方法:class Box implements Comparable<Box> 的 compareTo(Box),编译器再生成一个 compareTo(Object) 转调,满足擦除后的接口。你在反射里会看见两个方法,不是源码写了两份。

堆污染:List 原始类型(raw type)和参数化类型混用,运行时可能把 Integer 塞进「以为是 String」的 List,下次 get 的 checkcast 炸。

java
List raw = new ArrayList<String>();
raw.add(1);                         // 警告,运行时塞进去了
List<String> typed = raw;
String s = typed.get(0);            // ClassCastException

不要用 raw type。新代码看见 List 不写 <> 就是事故。

@SafeVarargs 只抑制警告,不让 varargs 和泛型突然变成具体化。可变参数是数组,数组和泛型叠在一起仍会有堆污染。能不用就不用。方法必须 static 或 final 或 private 才能标这个注解——否则子类覆盖还能把堆污染扩大。

型变、通配符捕获、类型推断、递归泛型、反射拿真实类型参数,单独在 泛型与类型系统 里走读。这里只需记住:容器的类型参数擦成 Object,安全靠 get 处的 checkcast。


五、迭代与 fail-fast ​

ArrayList / HashMap 的迭代器:结构修改(不是通过迭代器自己的 remove)之后,next 可能抛 ConcurrentModificationException。实现是比较 modCount 和迭代器创建时的 expectedModCount。这是 尽力检测,不是契约保证每次都抛。单线程 for-each 里 list.add 必炸;多线程「没炸」不代表安全。

java
for (String s : list) {
    if (s.isEmpty()) list.remove(s);    // CME
}
list.removeIf(String::isEmpty);         // 对

for (E e : coll) 是 iterator。list.remove(e) 在循环里改结构,走 fail-fast。要删,用 iterator.remove() 或 removeIf。按索引倒着删 for (int i = list.size()-1; i >= 0; i--) 不走迭代器,不会 CME,但可读性差,且只对 List 下标成立。

CopyOnWriteArrayList 迭代的是快照,不会 CME,写入拷贝整组,适合读多写极少。写多了分配会打爆。快照是写那一刻的数组,迭代开始之后的写入看不见——这是它的语义,不是 bug。

增强 for 不能拿来改 List 结构,下标 for 可以,但 ConcurrentModification 管不到你用下标时的并发。并发下标遍历 ArrayList 是 data race。


六、改一行:hash 均匀之后那张表塌掉 ​

上面的表依赖 hashCode 恒 1。改成 return name.hashCode(); 再 put 11 次:k0、k1 几乎不在同一桶,binCount 到不了 7,treeifyBin 一次不调。剩下的只有 size > threshold 的翻倍(8 个左右时 16→32)。面试答「超过 8 就树化」少了两句:① 第 9 个进同一桶才调 treeifyBin;② 表长 < 64 只扩容。两句都在源码里,不在口口相传里。


七、CHM 的 sizeCtl、ArrayList 怎么长、modCount 为什么「尽力」 ​

1、CHM 扩容不是整表一把锁 ​

Java 8 起没有 Segment。sizeCtl 是一个 int,负值表示正在初始化或扩容(高位记「多少 worker 在协助」一类编码),正值是下次扩容阈值。扩容时旧表的桶可以逐个转移到新表:某个线程 put 撞上转发节点,会帮着搬几个桶再干自己的活。面试答「分段锁 16」是 1.7。

size() 走类似 LongAdder 的计数,并发下是估计。mappingCount() 返回 long。精确快照没有无锁免费午餐。

2、ArrayList.grow ​

无参构造:elementData 指向共享空数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA。第一次 add,oldCapacity==0 且是这份空数组,直接 new Object[Math.max(10, minCapacity)]。new ArrayList<>(0) 用另一份 EMPTY_ELEMENTDATA,第一次按 minCapacity 长,不跳到 10。

之后:ArraysSupport.newLength(old, minGrowth, old >> 1),优先涨约 50%。不是 HashMap 翻倍。中间 add(i, e) 要 arraycopy,随机插是 O(n)。ensureCapacity 在默认空表且 minCapacity <= 10 时故意不 inflate——避免 ensureCapacity(8) 把默认表扩到 8 而不是 10。

3、modCount 是尽力,不是契约 ​

结构修改(add/remove 导致容量或 size 变,HashMap 的 put 新 key)++modCount。只改 HashMap 的 value 不加。迭代器创建时记下 expectedModCount,next 发现不一致就 CME。

规范 / Javadoc:best-effort。单线程 for-each 里 list.add 必炸。多线程一个在遍历一个在 add,可能 CME,可能把数组读成错的而不抛——检测靠的是一个不加锁的 int,不是 happens-before。没抛 CME 不等于线程安全。subList 的 fail-fast 看的是父 list 的 modCount,父结构变了子视图也炸。

下一篇把 checked 异常、try-with-resources、NIO.2、ByteBuffer 钉完。容器会抛的那些异常,规则在异常篇,不在这里编。