HashMap,HashTable与CurrentHashMap
HashMap简介
HashMap是一个散列表,可以用于储存数据,存储的内容是键值对(key-value)映射,和C++的数组功能相似,但在Java中有很多类都能实现数组的功能,只是存取方式不同,排序方式不同。HashMap就是其中之一。
HashMap有以下特点:
- 存储键值对应的映射
- 实现Map接口,具有很快的访问速度
- 最多允许一条记录的键位null
- 不支持线程同步(在java多线程编程中,这一点尤为重要)
- 无序存储,不会记录插入的顺序
HashTable简介
和HashMap一样,HashTable 也是一个散列表,它存储的内容是键值对(key-value)映射。HashTable 继承于Dictionary,实现了Map、Cloneable、java.io.Serializable接口。HashTable 的函数都是同步的,这意味着它是线程安全的。它的key、value都不可以为null。此外,Hashtable中的映射不是有序的。
HashMap和HashTable的实例有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量就是哈希表创建时的容量。注意,哈希表的状态为 open:在发生“哈希冲突”的情况下,单个桶会存储多个条目,这些条目必须按顺序搜索。加载因子是对哈希表在其容量自动增加之前可以达到多满的一个尺度。初始容量和加载因子这两个参数只是对该实现的提示。关于何时以及是否调用 rehash 方法的具体细节则依赖于该实现。
通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查找某个条目的时间。
HashMap与HashTable的区别
时间
HashTable产生于JDK 1.1,而HashMap产生于JDK 1.2。从时间的维度上来看,HashMap要比HashTable出现得晚一些。
作者
以下是HashTable的作者:
以下代码及注释来自java.util.HashTable
* @author Arthur van Hoff
* @author Josh Bloch
* @author Neal Gafter
以下是HashMap的作者:
以下代码及注释来自java.util.HashMap
* @author Doug Lea
* @author Josh Bloch
* @author Arthur van Hoff
* @author Neal Gafter
可以看到HashMap的作者多了大神Doug Lea。不了解Doug Lea的,可以看这里。
对外的接口(API)
HashMap和HashTable都是基于哈希表来实现键值映射的工具类。讨论他们的不同,我们首先来看一下他们暴露在外的API有什么不同。
public method
下面两张图分别是HashMap和HashTable的继承关系以及对外函数图:
从图中可以看出,两个类的继承体系有些不同。虽然都实现了Map、Cloneable、Serializable三个接口。但是HashMap继承自抽象类AbstractMap,而HashTable继承自抽象类Dictionary。其中Dictionary类是一个已经被废弃的类,这一点我们可以从它代码的注释中看到:
以下代码及注释来自java.util.Dictionary
* <strong>NOTE: This class is obsolete. New implementations should
* implement the Map interface, rather than extending this class.</strong>
同时我们看到HashTable比HashMap多了两个公开方法。一个是elements,这来自于抽象类Dictionary,鉴于该类已经废弃,所以这个方法也就没什么用处了。另一个多出来的方法是contains,这个多出来的方法也没什么用,因为它跟containsValue方法功能是一样的。代码为证:
以下代码及注释来自java.util.HashTable
public synchronized boolean contains(Object value) {
if (value == null) {
throw new NullPointerException();
}
Entry tab[] = table;
for (int i = tab.length ; i-- > 0 ;) {
for (Entry<K,V> e = tab[i] ; e != null ; e = e.next) {
if (e.value.equals(value)) {
return true;
}
}
}
return false;
}
public boolean containsValue(Object value) {
return contains(value);
}
所以从公开的方法上来看,这两个类提供的,是一样的功能。都提供键值映射的服务,可以增、删、查、改键值对,可以对建、值、键值对提供遍历视图。支持浅拷贝,支持序列化。
Null Key & Null Value
HashMap是支持null键和null值的,而HashTable在遇到null时,会抛出NullPointerException异常。这并不是因为HashTable有什么特殊的实现层面的原因导致不能支持null键和null值,这仅仅是因为HashMap在实现时对null做了特殊处理,将null的hashCode值定为了0,从而将其存放在哈希表的第0个bucket中。我们以put方法为例,看一看代码的细节:
以下代码及注释来自java.util.HashTable
public synchronized V put(K key, V value) {
// 如果value为null,抛出NullPointerException
if (value == null) {
throw new NullPointerException();
}
// 如果key为null,在调用key.hashCode()时抛出NullPointerException
// ...
}
以下代码及注释来自java.util.HasMap
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 当key为null时,调用putForNullKey特殊处理
if (key == null)
return putForNullKey(value);
// ...
}
private V putForNullKey(V value) {
// key为null时,放到table[0]也就是第0个bucket中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
实现原理
本节讨论HashMap和HashTable在数据结构和算法层面,有什么不同。
数据结构
HashMap和HashTable都使用哈希表来存储键值对。在数据结构上是基本相同的,都创建了一个继承自Map.Entry的私有的内部类Entry,每一个Entry对象表示存储在哈希表中的一个键值对。
Entry对象唯一表示一个键值对,有四个属性:
- -K key 键对象
- -V value 值对象
- -int hash 键对象的hash值
- -Entry<K, V> entry 指向链表中下一个Entry对象,可为null,表示当前Entry对象在链表尾部
可以说,有多少个键值对,就有多少个Entry对象,那么在HashMap和HashTable中是怎么存储这些Entry对象,以方便我们快速查找和修改的呢?请看下图:
上图画出的是一个桶数量为8,存有5个键值对的HashMap/HashTable的内存布局情况。可以看到HashMap/HashTable内部创建有一个Entry类型的引用数组,用来表示哈希表,数组的长度,即是哈希桶的数量。而数组的每一个元素都是一个Entry引用,从Entry对象的属性里,也可以看出其是链表的节点,每一个Entry对象内部又含有另一个Entry对象的引用。
这样就可以得出结论,HashMap/HashTable内部用Entry数组实现哈希表,而对于映射到同一个哈希桶(数组的同一个位置)的键值对,使用Entry链表来存储(解决hash冲突)。
以下代码及注释来自java.util.HashTable
/**
* The hash table data.
*/
private transient Entry<K,V>[] table;
以下代码及注释来自java.util.HashMap
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
从代码可以看到,对于哈希桶的内部表示,两个类的实现是一致的。
算法
上一小节已经说了用来表示哈希表的内部数据结构。HashMap/HashTable还需要有算法来将给定的键(key),映射到确定的hash桶(数组位置)。需要有算法在哈希桶内的键值对多到一定程度时,扩充哈希表的大小(数组的大小)。本小节比较这两个类在算法层面有哪些不同。
初始容量大小和每次扩充容量大小的不同。先看代码:
以下代码及注释来自java.util.HashTable
// 哈希表默认初始大小为11
public Hashtable() {
this(11, 0.75f);
}
protected void rehash() {
int oldCapacity = table.length;
Entry<K,V>[] oldMap = table;
// 每次扩容为原来的2n+1
int newCapacity = (oldCapacity << 1) + 1;
// ...
}
以下代码及注释来自java.util.HashMap
// 哈希表默认初始大小为2^4=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
void addEntry(int hash, K key, V value, int bucketIndex) {
// 每次扩充为原来的2n
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
}
可以看到HashTable默认的初始大小为11,之后每次扩充为原来的2n+1。HashMap默认的初始化大小为16,之后每次扩充为原来的2倍。还有一点没列出代码,就是如果在创建时给定了初始化大小,那么HashTable会直接使用你给定的大小,而HashMap会将其扩充为2的幂次方大小。
也就是说HashTable会尽量使用素数、奇数,而HashMap则总是使用2的幂作为哈希表的大小。当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀(具体证明,见这篇文章)。所以单从这一点上看,HashTable的哈希表大小选择,似乎更高明些。从另一方面看,在取模计算时,如果模数是2的幂,那么可以直接使用位运算来得到结果,效率要大大高于做除法。所以从hash计算的效率上,又是HashMap更胜一筹。
事实就是HashMap为了加快hash的速度,将哈希表的大小固定为了2的幂。当然这引入了哈希分布不均匀的问题,所以HashMap为解决这问题,又对hash算法做了一些改动。具体情况如何,需要分析在获取了key对象的hashCode之后,HashTable和HashMap分别是怎样将他们的hash确定到哈希桶(Entry数组位置)中的。
以下代码及注释来自java.util.HashTable
// hash 不能超过Integer.MAX_VALUE 所以要取其最小的31个bit
int hash = hash(key);
int index = (hash & 0x7FFFFFFF) % tab.length;
// 直接计算key.hashCode()
private int hash(Object k) {
// hashSeed will be zero if alternative hashing is disabled.
return hashSeed ^ k.hashCode();
}
以下代码及注释来自java.util.HashMap
int hash = hash(key);
int i = indexFor(hash, table.length);
// 在计算了key.hashCode()之后,做了一些位运算来减少哈希冲突
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 取模不再需要做除法
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
正如上文所述,HashMap由于使用了2的幂次方,所以在取模运算时不需要做除法,只需要位的与运算就可以了。但是由于引入的hash冲突加剧问题,HashMap在调用了对象的hashCode方法之后,又做了一些位运算再打散数据。关于这些位计算为什么可以打散数据的问题,本文不再展开了,感兴趣的可以看这里。
线程安全
众所周知HashTable是同步的,HashMap不是,也就是说HashTable在多线程使用的情况下,不需要做额外的同步,而HashMap则不行。那么HashTable是怎么做到的呢?
以下代码及注释来自java.util.HashTable
public synchronized V get(Object key) {
Entry tab[] = table;
int hash = hash(key);
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return e.value;
}
}
return null;
}
public Set<K> keySet() {
if (keySet == null)
keySet = Collections.synchronizedSet(new KeySet(), this);
return keySet;
}
可以看到,也比较简单,就是公开的方法比如get都使用了synchronized描述符。而遍历视图比如keySet都使用了Collections.synchronizedXXX进行了同步包装。
代码风格
HashMap的代码要比HashTable整洁很多。下面这段HashTable的代码,我就觉着有点混乱,不太能接受这种代码复用的方式。
以下代码及注释来自java.util.HashTable
/**
* A hashtable enumerator class. This class implements both the
* Enumeration and Iterator interfaces, but individual instances
* can be created with the Iterator methods disabled. This is necessary
* to avoid unintentionally increasing the capabilities granted a user
* by passing an Enumeration.
*/
private class Enumerator<T> implements Enumeration<T>, Iterator<T> {
Entry[] table = Hashtable.this.table;
int index = table.length;
Entry<K,V> entry = null;
Entry<K,V> lastReturned = null;
int type;
/**
* Indicates whether this Enumerator is serving as an Iterator
* or an Enumeration. (true -> Iterator).
*/
boolean iterator;
/**
* The modCount value that the iterator believes that the backing
* Hashtable should have. If this expectation is violated, the iterator
* has detected concurrent modification.
*/
protected int expectedModCount = modCount;
Enumerator(int type, boolean iterator) {
this.type = type;
this.iterator = iterator;
}
//...
}
HashTable已经被弃用
以下描述来自于HashTable的类注释:
If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable. If a thread-safe highly-concurrent implementation is desired, then it is recommended to use java.util.concurrent.ConcurrentHashMap in place of Hashtable.
简单来说就是,如果不需要线程安全,那么使用HashMap,如果需要线程安全,那么使用ConcurrentHashMap。HashTable已经被淘汰了,不要在新的代码中再使用它。
ConcurrentHashMap
HashMap的数据结构为(数组+链表+红黑树)如下图:
那么在并发的情况下容易出现环形链表,如下图:
要避免 HashMap 的线程安全问题,有多个解决方法,比如改用HashTable或者 Collections.synchronizedMap() 方法。但是这两者都有一个问题,就是性能,无论读还是写,他们两个都会给整个集合加锁,导致同一时间的其他操作阻塞。
ConcurrentHashMap的优势在于兼顾性能和线程安全,一个线程进行写操作时,它会锁住一小部分,其他部分的读写不受影响,其他线程访问没上锁的地方不会被阻塞。
ConcurrentHashMap简介
java.util.concurrent.ConcurrentHashMap属于 JUC 包下的一个集合类,可以实现线程安全。
ConcurrentHashMap由多个 Segment 组合而成。Segment 本身就相当于一个 HashMap 对象。同 HashMap 一样,Segment 包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。
单一的 Segment 结构如下:
ConcurrentHashMap在JDK1.7与JDK1.8的区别
每一版本的JDK,都会对HashMap、HashTable以及ConcurrentHashMap的内部实现做优化。1.8在1.7的基础上对于ConcurrentHashMap的优化主要体现在数据结构,实现并发安全的方式等方面,来解决询遍历链表效率太低的弊端。
数据结构
JDK1.7时,ConcurrentHashMap 为了提高本身的并发能力,在内部采用了一个叫做 Segment 的结构,一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组,如下图。根据下图结构,可以了解到:ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作,第一次 Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。因此,这一种结构的带来的副作用是 Hash 的过程要比普通的HashMap要长;带来的好处是写操作的时候可以只对元素所在的 Segment 进行操作即可,不会影响到其他的 Segment。因此,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
JDK1.8时,如下图所示ConcurrentHashMap和HashMap是很相似的。在结构上,简化JDK1.7时的【segment数组+数组+链表】为【数组+链表+红黑树】的数据结构。去掉segment数组之后,定位元素不用再进行两次hash;采用红黑树之后可以保证查询效率(O(logn))。
加锁方式
JDK1.8ConcurrentHashMap抛弃了原有的Segment分段锁,而采用了CAS + synchronized 来保证并发安全性。也将JDK1.7 中存放数据的HashEntry改为Node,但作用都是相同的。其中的val和next都用了 volatile 修饰,保证了可见性。
//键值输入。 此类永远不会作为用户可变的Map.Entry导出(即,一个支持setValue;请参阅下面的MapEntry),
//但可以用于批量任务中使用的只读遍历。 具有负哈希字段的节点的子类是特殊的,并且包含空键和值(但永远不会导出)。 否则,键和val永远不会为空。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
/**
* 对map.get()的虚拟化支持; 在子类中重写。
*/
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
接着再看看put方法的源码,源码如下:
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//(1)
if (key == null || value == null) throw new NullPointerException();
//(2)
int hash = spread(key.hashCode());
int binCount = 0;
//(3)
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//(4)
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//(5)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//(6)
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//(7)
synchronized (f) {
if (tabAt(tab, i) == f) {
//(8)
if (fh >= 0) {
binCount = 1;
//(9)
for (Node<K,V> e = f;; ++binCount) {
K ek;
//(10)
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//(11)如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//(12)
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//(13)
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//代码(14)
addCount(1L, binCount);
return null;
}
- 代码(1)若为空 抛异常
- 代码(2)计算hash值
- 代码(3)
- 代码(4)判断是否需要进行初始化。
- 代码(5)
f
即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。 - 代码(6)如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 代码(7)如果都不满足,则利用 synchronized 锁写入数据。结点上锁 这里的结点可以理解为hash值相同组成的链表的头结点
- 代码(8)fh〉0 说明这个节点是一个链表的节点 不是树的节点.
- 代码(9)在这里遍历链表所有的结点
- 代码(10)如果hash值和key值相同 则修改对应结点的value值
- 代码(11)如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部
- 代码(12)如果这个节点是树节点,就按照树的方式插入值
- 代码(13)如果链表长度已经达到临界值8 就需要把链表转换为树结构。如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。 - 代码(14)将当前ConcurrentHashMap的元素数量+1
接着再看看JDK1.8中ConcurrentHashMap的get方法源码,源码如下:
// GET方法(JAVA8)
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算hash值
int h = spread(key.hashCode());
//根据hash值确定节点位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果搜索到的节点key与传入的key相同且不为null,直接返回这个节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果eh<0 说明这个节点在树上 直接寻找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//否则遍历链表 找到对应的值并返回
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
最后看一下JDK1.8中ConcurrentHashMap的remove方法源码,源码如下:
// REMOVE OR REPLACE方法(JAVA8)
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 数组不为空,长度不为0,指定hash码值为0
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
// 是一个 forwardNode
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
validated = true;
// 循环寻找
for (Node<K,V> e = f, pred = null;;) {
K ek;
// equal 相同 取出
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
// value为null或value和查到的值相等
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
// 若是树 红黑树高效查找/删除
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
return null;
}
根据上述源码分析,JDK1.8ConcurrentHashMap降低了锁粒度,JDK1.7版本锁的粒度是基于Segment的,一个Segment包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,有以下几点原因:
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了。
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然。
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据。