结合一些文章阅读源码后整理的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 |
说明
- HashMap和ConcurrentHashMap的key桶大小都是2的幂,便于将计算下标的取模操作转化为按位与操作
- Map的key建议使用不可变类如String、Integer等包装类型,其值是final的,这样可以防止key的hash发生变化
- 1.8以后,链表转红黑树的阈值为8,红黑树转回链表的阈值位6。8是链表和红黑树平均查找时间(n/2和logn)的阈值,不在7转回是为了防止反复转换。
- 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进行的。
- 扩容的依据是k-v容量>=扩容阈值threshold,而threshold= table数组大小 * 装载因子。扩容前后hash值没有变,但是取模(^length)变了,所以在新的table中所在桶的下标可能会变
- HashMap1.7的头插法在并发场景下reszie()容易导致链表循环,具体的执行场景见文献[7][9]。这一步不太好理解,我个人是用[9]的示意图自己完整在纸上推演了一遍才理解。关键点在于,被中断的线程,对同一个节点遍历了两次。虽然1.8改用了尾插法,仍然有循环引用的可能[10][11]。
- 1.8的HashMap在resize()时,要将节点分开,根据扩容后多计算hash的那一位是0还是1来决定放在原来的桶[i]还是桶[i+原始length]中。
- 1.7中计算出hash值后,还会使用它计算所在的Segement
- put(key,value)时锁定分段锁,先用非阻塞tryLock()自旋,超过次数上限后升级为阻塞Lock()。
- 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]),但是返回值不是最新的
参考文献
没有在文中特殊标注的文章,是参考了其结构或部分内容,进行了重新组织。
- Vector 是线程安全的?
- 使用ConcurrentHashMap一定线程安全?
- TreeMap原理实现及常用方法
- Java容器常见面试题
- Java高级程序员必备ConcurrentHashMap实现原理:扩容遍历与计数
- Java容器面试总结
- Java:手把手带你源码分析 HashMap 1.7
- Java源码分析:关于 HashMap 1.8 的重大更新 注:本篇的resize()源码和我本地JDK8的不一致!
- HashMap底层详解-003-resize、并发下的安全问题
- JDK8中HashMap依然会死循环!
- HashMap在jdk1.8中也会死循环
- ConcurrentHashMap中tabAt方法分析
- HashMap?ConcurrentHashMap?相信看完这篇没人能难住你!
- ConcurrentHashMap 1.8 计算 size 的方式
- Java集合类框架学习 5.3—— ConcurrentHashMap(JDK1.8)