概述
java.util.concurrent.ArrayBlockingQueue 是一个线程安全的、基于数组、有界的、阻塞的、FIFO队列。试图向已满队列中放入元素会导致操作受阻塞;试图从空队列中提取元素将导致类似阻塞。
此类基于java.util.concurrent.locks.ReentrantLock 来实现线程安全,所以提供了ReentrantLock所能支持的公平性选择
- ArrayBlockingQueue简介
- 源码分析
WeakHashMap,从名字可以看出它是某种Map。它的特殊之处在于WeakHashMap里的entry可能会被GC自动删除,即使程序员没有调用remove()或者clear()方法。
WeakHashMap 的这个特点特别适用于需要缓存的场景。在缓存场景下,由于内存是有限的,不能缓存所有对象;对象缓存命中可以提高系统效率,但缓存MISS也不会造成错误,因为可以通过计算重新得到。 要明白 WeakHashMap 的工作原理,还需要引入一个概念 : 弱引用(WeakReference)。我们都知道Java中内存是通过GC自动管理的,GC会在程序运行过程中自动判断哪些对象是可以被回收的,并在合适的时机进行内存释放。GC判断某个对象是否可被回收的依据是,是否有有效的引用指向该对象。如果没有有效引用指向该对象(基本意味着不存在访问该对象的方式),那么该对象就是可回收的。这里的有效引用 并不包括弱引用。也就是说,虽然弱引用可以用来访问对象,但进行垃圾回收时弱引用并不会被考虑在内,仅有弱引用指向的对象仍然会被GC回收。 WeakHashMap 内部是通过弱引用来管理entry的,弱引用的特性对应到 WeakHashMap 上意味着什么呢?将一对key, value放入到 WeakHashMap 里并不能避免该key值被GC回收,除非在 WeakHashMap 之外还有对该key的强引用。
关于强引用,弱引用等概念可以参考Java Reference详解,这里只需要知道Java中引用也是分种类的,并且不同种类的引用对GC的影响不同就够了。
具体的实现和HashMap基本一致,只是当多个对象的hash冲突时,在hashMap中会形成链表或者红黑树,但是在WeakHashMap只会存在链表的情形。其实个人看来,使用红黑树反而会增加其成本,好不容易维护好了一颗树,但是不知道什么时候这棵树就被删除。因此还不如不维护的好。
TreeMap底层通过红黑树(Red-Black tree)实现,也就意味着containsKey(), get(), put(), remove()都有着log(n)的时间复杂度。其具体算法实现参照了《算法导论》
出于性能原因,TreeMap是非同步的(not synchronized),如果需要在多线程环境使用,需要程序员手动同步;或者通过如下方式将TreeMap包装成(wrapped)同步的: SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...))
红黑树是一种近似平衡的二叉查找树,它能够确保任何一个节点的左右子树的高度差不会超过二者中较低那个的一倍。具体来说,红黑树是满足如下条件的二叉查找树(binary search tree):
在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整使得查找树重新满足红黑树的约束条件。

TreeMap整体结构如下图所示

TreeMap实现了NavigableMap接口,该接口主要是扩展SortedMap接口,用于搜锁离指定接口最近的匹配数据。SortedMap接口主要定义根据key值的排序来定义Map的视图顺序,如果key值实现了Comparable,则会根据返回结果进行排序,如果指定了Comparator,则会根据其进行排序。
因此在创将TreeMap对象的时候,最好指定Comparator,或者插入的key值实现了Comparable接口,否则会抛出异常。
TreeSet是对TreeMap的简单包装,对TreeSet的函数调用都会转换成合适的TreeMap方法,因此TreeSet的实现非常简单。这里不再赘述。
1 | // TreeSet是对TreeMap的简单包装 |
本篇文章首先会对HashTable进行简单的介绍,然后对其源码进行分析,最后总结下HashTable和HashMap之间的区别,
HashTable是一个散列表,和HashMap类似,他存储的内容是键值对(key-value)映射。类图如下

HashTable继承于Dictionary,实现了Map、Cloneable、java.io.Serializable接口。
HashTable的函数都是同步的,这意味着他是线程安全的。
它的key,value都不可以为null。此外HashTable的映射不是有序的。
首先看下重要的属性
1 |
|
Hashtable的实例有两个参数影响其性能:初始容量 和 加载因子。
容量是哈希表中桶的数量,初始容量就是哈希表创建时的容量。注意,哈希表的状态为open:在发生“哈希冲突”的情况下,单个桶会存储多个条目,这些条目必须按顺序搜索。
加载因子是对哈希表在其容量自动增加之前可以达到多满的一个尺度。初始容量和加载因子这两个参数只是对该实现的提示。关于何时以及是否调用rehash方法的具体细节则依赖于该实现。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查找某个条目的时间(在大多数 Hashtable 操作中,包括 get 和 put 操作,都反映了这一点)。
Hashtable有4个构造方法,如下所示:

上图为Hashtable的内部结构,实际上我们通过Entry <?,?>[] table就可以看出,Hashtable内部为一个Entry<?,?>类型的数组,而Entry的结构如下所示,从而可以看出Hashtable是数组连表,既然是一个数组链表就会存在hash冲突的情况,下面就通过Hashtable中的实现细节,来探寻其中的奥秘。
1 | private static class Entry<K,V> implements Map.Entry<K,V> { |
put方法
1 | public synchronized V put(K key, V value) { |
如上为Hashtable中添加key-value的方法,通过源码可以看出主要流程分为以下几步:
1 | private void addEntry(int hash, K key, V value, int index) { |
rehash方法
在上文中提到在当前元素个数大于扩容阈值时,会调用rehash方法进行扩容操作并且重新分布元素的位置,而阈值threshold=capacity * loadFactor,所以当capacity一定时,可以通过负载因子loadFactor去控制阈值的大小,负载因子loadFactor越大则阈值threshold越大,反而
负载因子loadFactor越小则阈值threshold越小,可以根据实际情况调整负载因子的大小从而调节Hashtable的性能。
下面为rehash方法的源码
1 | protected void rehash() { |
从源码中可以看出rehash的过程实际上是扩容并重新分布的过程,主要包括以下几个步骤:
get方法
如下为Hashtable通过key值获取对应的value值的方法,其流程比较简单,和添加中存在部分类似,根据key值定位(此时若key为null,也将会报NullPointerException),然后遍历查找对应的值,若没找到则返回null
1 | public synchronized V get(Object key) { |
其实通过本文的介绍你会发现Hashtable与HashMap存在很多相似的地方,下面来介绍下HashMap与Hashtable的区别:
本文首先对LinkedHashMap整体进行分析,然后在介绍LinkedHashSet,其实它只是针对LinkedHashMap的简单封装。类图如下
LinkedHashMap实现了Map接口,即允许放入key为null的元素,也允许插入value为null的元素。从名字上可以看出该容器是linked list和HashMap的混合体,也就是说它同时满足HashMap和linked list的某些特性。可将LinkedHashMap看作采用linked list增强的HashMap。
存储结构如下

事实上LinkedHashMap是HashMap的直接子类,二者唯一的区别是LinkedHashMap在HashMap的基础上,采用双向链表(doubly-linked list)的形式将所有entry连接起来,这样是为保证元素的迭代顺序跟插入顺序相同。上图给出了LinkedHashMap的结构图,主体部分跟HashMap完全一样,多了header指向双向链表的头部(是一个哑元),该双向链表的迭代顺序就是entry的插入顺序。
除了可以保迭代历顺序,这种结构还有一个好处 : 迭代LinkedHashMap时不需要像HashMap那样遍历整个table,而只需要直接遍历header指向的双向链表即可,也就是说LinkedHashMap的迭代时间就只跟entry的个数相关,而跟table的大小无关。
首先看下LinkedHashMap的重要参数和构造函数,源码如下
1 | /** |
除了加入以上三个属性,还扩展了节点的结构用来支持链表,主要是加入了获取其前节点以及节点的数据,具体代码如下
1 | static class Entry<K,V> extends HashMap.Node<K,V> { |
插入
下面来看下创建过程,整体的创建过程和HashMap一直,只是在创建节点时会有增加了链接其它节点的功能,
首选是覆盖HashMap中的创建修改节点的,从而保证每次添加和修改节点时能够加入LinkedHashMap的链表功能。
1 |
|
接下来的插入操作和HashMap是一致的,有区别的点是在插入的过程中,会根据前面设置的accessOrder来调整LinkedHashMap链表的顺序,原理是扩展了HashMap的俩个函数
1 | // 在插入完成后,判断是否需要移除某些节点, |
从上面可以看出,LinkedHashMap和HashMap的插入基本一致,只是会在插入的每个节点中保持一条链表,链表的顺序可以根据插入或者访问顺序,在创建对象的时候就定义下来。
访问
访问源码如下,主要还是使用了HashMap中定义的函数,在访问完成后,会根据accessOrder来调整链表的顺序
1 | public V get(Object key) { |
移除
在LinkedHashMap中没有定义remove方法,直接使用的是HashMap中的方法,区别是移除后,LinkeHashMap又多了一步,afterNodeRemoval,具体定义如下,其实就是删除节点之间的连接
1 | void afterNodeRemoval(Node<K,V> e) { // unlink |
LinkedHashSet是对LinkedHashMap的简单包装,对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法,因此LinkedHashSet的实现非常简单,这里不再赘述。
1 | public class LinkedHashSet<E> |
LinkedHashMap除了可以保证迭代顺序外,还有一个非常有用的用法: 可以轻松实现一个采用了FIFO替换策略的缓存。具体说来,LinkedHashMap有一个子类方法protected boolean removeEldestEntry(Map.Entry<K,V> eldest),该方法的作用是告诉Map是否要删除最老的Entry,所谓最老就是当前Map中最早插入的Entry,如果该方法返回true,最老的那个元素就会被删除。在每次插入新元素的之后LinkedHashMap会自动询问removeEldestEntry()是否要删除最老的元素。这样只需要在子类中重载该方法,当元素个数超过一定数量时让removeEldestEntry()返回true,就能够实现一个固定大小的FIFO策略的缓存。示例代码如下:
1 | /** 一个固定大小的FIFO替换策略的缓存 */ |
本篇文章主要对HashMap的代码进行分析,会先介绍插入以及扩容的原理,然后在介绍在多线程操作中可能出现的问题,最后简单介绍下JDK7中时如何实现的以及8中为什么要进行更改。
HashMap类图如下:

HashMap是一个散列表,他存储的内容是键值对(key-value),其中key允许为null。这也是他与HashTable的一个重要区别。
HashMap继承于AbstractMap,实现了Map,Cloneable,java.io.Serualizable接口
HashMap的实现不是同步的,这意味着他不是线程安全的,在oracle给出的官方文档中,如果要并发使用,推介使用下面这种方式
1 | Map m = Collections.synchronizedMap(new HashMap(...)); |
HashMap 的实例有两个参数影响其性能:“初始容量” 和 “加载因子”。容量是哈希表中数组的长度,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的数组长度。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。
搞清楚HashMap,首先需要知道HashMap是什么,即它的存储结构-字段;其次弄明白它能干什么,即它的功能实现-方法。下面我们针对这两个方面详细展开讲解。
从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。

这里需要讲明白两个问题:
数据底层具体存储的是什么
从源码可知,HashMap类中有一个非常重要的字段,就是Node[] table,即哈希桶数组,明显它是一个Node的数组,jdk8中源码如下:。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。
HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,其它的解决冲突的方法可以参考解决哈希冲突的常用方法分析。Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。
系统将调用key的hashCode()方法得到其hashCode 值(该方法适用于每个Java对象),然后再通过某种算法来定位该键值对的存储位置,后面会介绍,有时两个key会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。
在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:
1 | int threshold; // 所能容纳的key-value对极限 |
首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下(官方给出的解释),如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。而modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考这篇文章,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。本文不再对红黑树展开讨论,想了解更多红黑树数据结构的工作原理可以参考这篇文章。
HashMap的内部功能实现很多,本文主要从根据key获取哈希桶数组索引位置、put方法的详细执行、扩容过程三个具有代表性的点深入展开讲解。
确定哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。先看看源码的实现(方法一+方法二):
方法一:
1 | static final int hash(Object key) { //jdk1.8 & jdk1.7 |
方法二:
1 | //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的 |
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
下面举例说明下,n为table的长度。

分析HashMap的put方法
HashMap的put方法执行过程可以通过下图来理解,自己有兴趣可以去对比源码更清楚地研究学习。

JDK1.8HashMap的put方法源码如下:
1 | 1 public V put(K key, V value) { |
扩容机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。
1 | 1 void resize(int newCapacity) { //传入新的容量 |
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
1 | 1 void transfer(Entry[] newTable) { |
newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。有兴趣的同学可以研究下JDK1.8的resize源码,写的很赞,如下:
1 | 1 final Node<K,V>[] resize() { |
仔细看看怎么会导致死循环
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。代码例子如下(便于理解,仍然使用JDK1.7的环境):
1 | public class HashMapInfiniteLoop { |
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。

注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。


e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。
jdk1.7和1.8详细对比,JAVA集合:HashMap深度解析(版本对比)
HashSet 前面已经说过HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法,因此HashSet的实现非常简单,只有不到300行代码。这里不再赘述。
1 | //HashSet是对HashMap的简单包装 |
我们先学习Map,然后再学习Set;因为Set的实现类都是基于Map来实现的(如,HashSet是通过HashMap实现的,TreeSet是通过TreeMap实现的)。
首先,我们看看Map架构。

如上图: