Java容器相关知识点整理

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

1. 思维导图

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

2. 容器对比

类名 底层实现 特征 线程安全性 默认迭代器实现(Itr)
ArrayList Object数组 查询快,增删慢 不安全 数组下标
LinkedList 双向链表 查询慢,增删快 不安全 当前遍历的节点
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 非线程安全 HashIterator按数组索引遍历,在此基础上按Node遍历
LinkedHashMap extends HashMap 可以按照插入顺序或访问顺序遍历(注4) 非线程安全 同HashMap
ConcurrentHashMap 见第3节 key和value不能为null 线程安全(注1) 基于Traverser (注5)
Hashtable Entry数组 + Object.hashCode() + 同key的Entry形成链表 key和value不允许为null 线程安全 枚举类或通过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!的注释。

fail-fast和fail-safe

以下由kimi生成。

  • Fail-Fast
    fail-fast意味着如果一个线程在迭代过程中发现集合被修改了,它将立即抛出ConcurrentModificationException异常。这种机制可以防止在迭代过程中出现不可预知的行为。

fail-fast是Java集合框架中默认的迭代器行为,例如ArrayList、LinkedList、HashSet和TreeSet等。当使用这些集合的迭代器时,如果在迭代过程中修改了集合(例如添加或删除元素),迭代器会检测到这种修改并抛出异常。

  • Fail-Safe
    与fail-fast不同,fail-safe迭代器不会抛出ConcurrentModificationException异常。这种迭代器通常用于并发集合,如Collections.synchronizedList()或CopyOnWriteArrayList等。

fail-safe迭代器通过创建集合的快照来实现。这意味着迭代器在创建时会记录下集合的状态,并在迭代过程中使用这个状态,即使集合在迭代过程中被修改,迭代器也不会受到影响。

注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  五岳  阅读(590)  评论(0编辑  收藏  举报
回到顶部