Java容器相关知识点整理

结合一些文章阅读源码后整理的Java容器常见知识点。对于一些代码细节,本文不展开来讲,有兴趣可以自行阅读参考文献。

1. 思维导图

各个容器的知识点比较分散,没有在思维导图上体现,因此看上去右半部分很像类的继承关系。

2. 容器对比

| 类名 | 底层实现 | 特征 | 线程安全性 | 默认迭代器实现(Itr)|
| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| ArrayList | Object数组 | 查询快,增删慢 | 不安全,有modCount | 数组下标 |
| LinkedList | 双向链表 | 查询慢,增删快 | 不安全,有modCount | 当前遍历的节点 |
| Vector | Object数组 | 查询快,增删慢 | 方法使用synchronized确保安全(注1);有modCount | 数组下标 |
| Stack | Vector | 同Vector | 同Vector | 同Vector |
| HashSet | HashMap (使用带特殊参数的构造方法则为LinkedHashMap) | 和HashMap一致 | 和HashMap一致 |和HashMap一致 |
| LinkedHashSet | LinkedHashMap | 和LinkedHashMap一致 | 和LinkedHashMap一致 |和LinkedHashMap一致 |
| TreeSet | TreeMap | 和TreeMap一致 | 和TreeMap一致 | 和TreeMap一致 |
| TreeMap | 红黑树和Comparator(注2) | key和value可以为null(注2),key必须实现Comparable接口 | 非线程安全,有modCount |当前节点在中序遍历的后继 |
| HashMap | 见第3节 | key和value可以为null | 非线程安全,有modCount | HashIterator按数组索引遍历,在此基础上按Node遍历 |
| LinkedHashMap | extends HahsMap (注3), Node有前驱和后继 | 可以按照插入顺序或访问顺序遍历(注4) | 非线程安全,有modCount | 同HshMap |
| ConcurrentHashMap | 见第3节 | key和value不能为null | 线程安全(注1)| 基于Traverser(注5) |
| Hashtable | Entry数组 + Object.hashCode() + 同key的Entry形成链表 | key和value不允许为null | 线程安全, 有modCount | 枚举类或通过KeySet/EntrySet |

操作的时间复杂度

  • ArrayList下标查找O(1),插入O(n)
  • 涉及到树,查找和插入都可以看做log(n)
  • 链表查找O(n),插入O(1)
  • Hash直接查找hash值为 O(1)

注1:关于容器的线程安全

复合操作

无论是Vetcor还是SynchronizedCollection甚至是ConcurrentHashMap,复合操作都不是线程安全的。如下面的代码[1]在并发环境中可能会不符合预期:

if (!vector.contains(element)) 
    vector.add(element); 
    ...
}
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap();
map.put("key", 1);

// 多线程环境下执行
Integer currentVal = map.get("key");
map.put("key", currentVal + 1);

在复合操作的场景下,通用解法是对容器加锁,但这样会大幅降低性能。根据具体的场景来解决效果更好,如第二段代码的场景,可以改写为[1]

ConcurrentHashMap<String, AtomicInteger> map = new ConcurrentHashMap();
// 多线程环境下执行
map.get("key").incrementAndGet();

modCount和迭代器Iterator问题

modCount是大多数容器(比如ConcurrentHashMap就没有)用来检测是否发生了并发操作,从而判断是否需要抛出异常通知程序员去处理的一个简单的变量,也被称为fast-fail。
一开始我注意到,Vector也有modCount这个属性,这个字段用来检测对于容器的操作期间是否并发地进行了其他操作,如果有会抛出并发异常。既然Vector是线程安全的,为什么还会有modCount?顺藤摸瓜,我发现虽然Vector的Iterator()方法是synchronized的,但是迭代器本身的方法并不是synchronized的。这就意味着在使用迭代器操作时,对Vector的增删等操作可能导致并发异常。
为了避免这个问题,应该在使用Iterator时对Vector加锁。
同理可以推广到Collecitons.synchronizedCollection()方法,可以看到这个方法创建的容器,对于迭代器和stream方法,都有一行// Must be manually synched by user!的注释。

注2:TreeMap的comparator和key

comparator是可以为空的,此时使用key的compare接口比较。因此,这种情况下如果key==null会抛NPE。

注3:

JDK8的HashMap中有afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval()三个空方法,在LinkedHashMap中覆盖,用于回调。

注4:LinkedHashMap插入顺序和访问顺序

插入顺序不必解释。访问顺序指的是,每次访问一个节点,都将它插入到双向链表的末尾。

注5:Traverser

其实现类EntryIterator的构造方法实际上是有bug的[5]:它与子类的参数表顺序不一致。
它能确保在扩容期间,每个节点只访问一次。这个原理比较复杂,我没有深入去看,可以参考本小节的参考文献。

3. Hashtable & HashMap & ConcurrentHashMap

这是一个老生常谈的话题了,但是涉及面比较广,本节好好总结一下。
本节不列出具体的源码,大部分直接给出结论,源码部分分析可以参考文献[7][8]。
table表示Map的hash值桶,即每一个元素对应所有同一个hash值的key-value对。

相同点

  • keySet、values、entrySet()首次使用时初始化

差异点

容器类型 底层实现(见说明4) key的hash方法 table下标计算 扩容后table容量(见说明1、5) 插入 clone hash桶的最大容量
Hashtable hash值桶数组 + 链表 hashCode() (hashCode & MAX_INT) % table.length origin*2+1 头部插入 浅拷贝 MAXINT- 8
HashMap(1.7) hash值桶数组 + 链表 String使用sun.misc.Hashing.stringHash32,其他用hashCode()后多次异或折叠(见说明2) (length-1) & hashCode origin*2 头部插入(见说明6) 浅拷贝 2^30
HashMap(1.8) hash值桶数组 + 链表/红黑树(见说明3) hashCode()高低16位异或 (length-1) & hashCode origin*2(见说明7) 尾部插入 浅拷贝 2^30
ConcurrentHashMap(1.7) hash值桶数组 + Segment extends ReentrantLock(见说明9) + 数组 String使用sun.misc.Hashing.stringHash32,其他用hashCode()后多次异或折叠和加法操作(见说明8) (length-1) & hashCode origin*2 头部插入 不支持 2^30
ConcurrentHashMap(1.8) hash值桶数组 + 链表/红黑树(见说明10) hashCode()高低16位异或 % MAX_INT (length-1) & hashCode origin*2 尾部插入 不支持 2^30

说明

  1. HashMap和ConcurrentHashMap的key桶大小都是2的幂,便于将计算下标的取模操作转化为按位与操作
  2. Map的key建议使用不可变类如String、Integer等包装类型,其值是final的,这样可以防止key的hash发生变化
  3. 1.8以后,链表转红黑树的阈值为8,红黑树转回链表的阈值位6。8是链表和红黑树平均查找时间(n/2和logn)的阈值,不在7转回是为了防止反复转换。
  4. 1.7的HashMap的Entry和1.8中的Node几乎是一样的,区别在于:后者的equals()使用了Objects.equals()做了封装,而不是对象本身的equals()。另外链表节点Node和红黑树节点TreeNode没有关系,后者是extends LinkedHashMap的Node,通过红黑树查找算法找value。1.7的ConcurrentHashMap的Node中value、next是用volatile修饰的。但是,1.8的ConcurrentHashMap有TreeNode<K,V> extends Node<K,V>,遍历查找值时是用Node的next进行的。
  5. 扩容的依据是k-v容量>=扩容阈值threshold,而threshold= table数组大小 * 装载因子。扩容前后hash值没有变,但是取模(^length)变了,所以在新的table中所在桶的下标可能会变
  6. HashMap1.7的头插法在并发场景下reszie()容易导致链表循环,具体的执行场景见文献[7][9]。这一步不太好理解,我个人是用[9]的示意图自己完整在纸上推演了一遍才理解。关键点在于,被中断的线程,对同一个节点遍历了两次。虽然1.8改用了尾插法,仍然有循环引用的可能[10][11]
  7. 1.8的HashMap在resize()时,要将节点分开,根据扩容后多计算hash的那一位是0还是1来决定放在原来的桶[i]还是桶[i+原始length]中。
  8. 1.7中计算出hash值后,还会使用它计算所在的Segement
  9. put(key,value)时锁定分段锁,先用非阻塞tryLock()自旋,超过次数上限后升级为阻塞Lock()。
  10. 1.8的ConcurrentHashMap抛弃了Segement,使用synchronized+CAS(使用tabAt()计算所在桶的下标,实际是用UNSAFE类计算内存偏移量)[12]进行写入。具体来说,当桶[i]为空时,CAS写值;非空则对桶[i]加锁[13]

ConcurrentHashMap的死锁问题

1.7场景

对于跨段操作,如size()、containsValue(),是需要按Segement的下标递增逐段加锁、统计,然后按原先顺序解锁的。这样就有一个很严重的隐患:如果线程A在跨段操作时,中间的Segement[i]被
线程B锁定,B又要去锁定Segement[j] (i>j),此时就发生了死锁。

1.8场景

由于没有段,也就没有了跨段。但是size()还是要统计各个桶的数目,仍然有跨桶的可能。如何计算?如果没有冲突发生,只将 size 的变化写入 baseCount。一旦发生冲突,就用一个数组(counterCells)来存储后续所有 size 的变化[14]
而containsValue()则借助了Traverser(见第2节注5及参考文献[15]),但是返回值不是最新的

参考文献

没有在文中特殊标注的文章,是参考了其结构或部分内容,进行了重新组织。

  1. Vector 是线程安全的?
  2. 使用ConcurrentHashMap一定线程安全?
  3. TreeMap原理实现及常用方法
  4. Java容器常见面试题
  5. Java高级程序员必备ConcurrentHashMap实现原理:扩容遍历与计数
  6. Java容器面试总结
  7. Java:手把手带你源码分析 HashMap 1.7
  8. Java源码分析:关于 HashMap 1.8 的重大更新 注:本篇的resize()源码和我本地JDK8的不一致!
  9. HashMap底层详解-003-resize、并发下的安全问题
  10. JDK8中HashMap依然会死循环!
  11. HashMap在jdk1.8中也会死循环
  12. ConcurrentHashMap中tabAt方法分析
  13. HashMap?ConcurrentHashMap?相信看完这篇没人能难住你!
  14. ConcurrentHashMap 1.8 计算 size 的方式
  15. Java集合类框架学习 5.3—— ConcurrentHashMap(JDK1.8)
posted @ 2020-06-18 01:42  五岳  阅读(576)  评论(0编辑  收藏  举报
回到顶部