讨论基于JDK 1.8,HashTable产生于JDK 1.1,而HashMap产生于JDK 1.2。
1.Null Key & Null Value
HashMap是支持null键和null值的,允许一个null键和多个null值,而HashTable不允许null键值,在遇到null时,会抛出NullPointerException异常。HashMap在实现时对null做了特殊处理,将null的hashCode值定为了0,从而将其存放在哈希表的第0个bucket中。
以下代码及注释来自java.util.HashTable
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
以下代码及注释来自java.util.HasMap
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2.实现原理
2.1数据结构
HashMap和HashTable都使用哈希表来存储键值对。在数据结构上是基本相同的,都创建了一个继承自Map.Entry的私有的内部类Entry,每一个Entry对象表示存储在哈希表中的一个键值对。
Entry对象唯一表示一个键值对,有四个属性:
-K key 键对象
-V value 值对象
-int hash 键对象的hash值
-Entry entry 指向链表中下一个Entry对象,可为null,表示当前Entry对象在链表尾部
采用的是“桶位”,即一个Node数组实现
HashMap的源码
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
HashTable的源码
private transient Entry<?,?>[] table;
private static class Entry<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Entry<K,V> next;
...
}

上图画出的是一个桶数量为8,存有5个键值对的HashMap/HashTable的内存布局情况。可以看到HashMap/HashTable内部创建有一个Entry类型的引用数组,用来表示哈希表,数组的长度,即是哈希桶的数量。而数组的每一个元素都是一个Entry引用,从Entry对象的属性里,也可以看出其是链表的节点,每一个Entry对象内部又含有另一个Entry对象的引用。
这样就可以得出结论,HashMap/HashTable内部用Entry数组实现哈希表,而对于映射到同一个哈希桶(数组的同一个位置)的键值对,使用Entry链表来存储(解决hash冲突)。
2.2算法
HashMap的相关参数
默认的数组初始容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
默认的最大容量:2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
默认的负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
默认的链表转红黑树的节点数阈值:8
static final int TREEIFY_THRESHOLD = 8;
红黑树转链表的阀值,当链表长度<=6时转为链表(扩容时)。
static final int UNTREEIFY_THRESHOLD = 6;
Hashmap扩容的节点数阈值threshold=capacity*load factor://就是容量乘以负载因子。
通过size记录hashmap中元素的个数,如果size>threshold,则利用resize()对table数组进行扩容。
扩容过程为直接将table的capacity容量乘以2,并且阈值threshold也乘2。
哈希碰撞
当两个key通过hashCode计算相同时,则发生了hash冲突(碰撞),HashMap解决hash冲突的方式是用链表。
当发生hash冲突时,则将存放在数组中的Entry设置为新值的next(这里要注意的是,比如A和B都hash后都映射到下标i中,之前已经有A了,当map.put(B)时,将B放到下标i中,A则为B的next,所以新值存放在数组中,旧值在新值的链表上)
hashmap.put的过程
当向 HashMap 中 put 一对键值时,它会根据 key的 hashCode 值计算出一个位置, 如果该位置没有对象存在,就将此对象直接放进数组当中;如果该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是否是否值相同,map 不允许键值对重复), 如果此链上有对象的话,再去使用 equals方法进行比较,如果对此链上的每个对象的 equals 方法比较都为 false,则将该对象放到数组当中,然后将数组中该位置以前存在的那个对象链接到此对象的后面。
jidk1.8后,当链表的size大于等于8后,就会使用红黑树储存Entry对象。

HashTable的相关参数
默认的数组初始容量:11
负载因子:0.75

Hashtable和hashmap的区别总结
1、 hashmap中key和value均可以为null,但是hashtable中key和value均不能为null。
2、 hashmap采用的是数组(桶位)+链表+红黑树结构实现(jdk1.8之后),而hashtable中采用的是数组(桶位)+链表实现。
3、 hashmap中出现hash冲突时,如果链表节点数小于8时是将新元素加入到链表的末尾,而hashtable中出现hash冲突时采用的是将新元素加入到链表的开头。
4、 hashmap中数组容量的大小要求是2的n次方,如果初始化时不符合要求会进行调整,必须为2的n次方,而hashtable中数组容量的大小可以为任意正整数。
5、 hashmap中的寻址方法采用的是位运算按位与,而hashtable中寻址方式采用的是求余数。
6、 hashmap不是线程安全的,而hashtable是线程安全的,hashtable中的get和put方法均采用了synchronized关键字进行了方法同步。
7、 hashmap中默认容量的大小是16,而hashtable中默认数组容量是11。
3.ConcurrentHashMap
大概的意思是,如果一个不需要线程安全的map,建议使用LinkedHashMap代替Hashtable。如果需要一个线程安全的高并发的实现类,然后推荐使用ConcurrentHashMap代替Hashtable。
尽量不要使用HashTable,因为在单线程中,无需做线程控制,运行效率更高;在多线程中,HashTable中的synchronized会造成线程饥饿,死锁,可以用ConcurrentHashMap替代。
3.1ConcurrentHashMap介绍
ConcurrentHashMap在1.8中的实现,相比于1.7的版本基本上都改变了。首先,取消了Segment分段锁的数据结构,取而代之的是数组+链表(红黑树)的结构。而对于锁的粒度,调整为对每个数组元素加锁(Node)。然后是定位节点的hash算法被简化了,这样带来的弊端是Hash冲突会加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。这样一来,查询的时间复杂度就会由原先的O(n)变为O(logN)。HsahMap处理类似,下面是其基本结构:
初始大小为16
private static finalint DEFAULT_CONCURRENCY_LEVEL = 16;
加载因子为0.75
private static final float LOAD_FACTOR = 0.75f;
链表转红黑树的阀值为8(大于等于这个值)
static final int TREEIFY_THRESHOLD = 8;
红黑树转链表的阀值为6(小于等于这个值)
static final int UNTREEIFY_THRESHOLD = 6;
重要的成员变量
table:默认为null,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂 次方。
nextTable:默认为null,扩容时新生成的数组,其大小为原数组的两倍。
sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
-1 代表table正在初始化
-N 表示有N-1个线程正在进行扩容操作
其余情况:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默认是table大小的0.75倍,居然用这个公式算0.75(n - (n >>> 2))。
Node:保存key,value及key的hash值的数据结构。
其中value和next都用volatile修饰,保证并发的可见性。
构造函数仅仅是对Map容量,并发级别等做了赋值操作,并没有有初始化table[]数组。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // 初始化容量至少要为concurrencyLevel
initialCapacity = concurrencyLevel;
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
put()方法;
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 如果table为空,初始化;否则,根据hash值计算得到数组索引i,如果tab[i]为空,直接新建节点Node即可。注:tab[i]实质为链表或者红黑树的首节点。
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {// table[i]后面无节点时,直接创建Node(无锁操作)
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)// 如果当前正在扩容,则帮助扩容并返回最新table[]
tab = helpTransfer(tab, f);
else {// 在链表或者红黑树中追加节点
V oldVal = null;
synchronized (f) {// 这里并没有使用ReentrantLock,说明synchronized已经足够优化了
if (tabAt(tab, i) == f) {
if (fh >= 0) {// 如果为链表结构
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {// 找到key,替换value
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
// 如果没有找到值为key的节点,直接新建Node并加入链表即可。
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {// 如果为红黑树,执行putTreeVal操作。
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) {
if (binCount >= TREEIFY_THRESHOLD)// 如果节点数>=8,到达阀值,变为红黑树结构
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);//执行putTreeVal操作。
return null;
}
从上面代码可以看出,put的步骤大致如下:
1.参数校验。
2.若table[]未创建,则初始化。
3.当table[i]后面无节点时,直接创建Node(无锁操作)。
4.如果当前正在扩容,则帮助扩容并返回最新table[]。
5.然后在链表或者红黑树中追加节点。
6.最后还回去判断是否到达阀值,如到达变为红黑树结构。
除了上述步骤以外,还有一点我们留意到的是,代码中加锁片段用的是synchronized关键字,而不是像1.7中的ReentrantLock。这一点也说明了,synchronized在新版本的JDK中优化的程度和ReentrantLock差不多了。
get()方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());// 定位到table[]中的i
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {// 若table[i]存在
if ((eh = e.hash) == h) {// 比较链表头部
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
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;// 未找到
}
get()方法的流程相对简单一点,从上面代码可以看出以下步骤:
1.首先定位到table[]中的i。
2.若table[i]存在,则继续查找。
3.首先比较链表头部,如果是则返回。
4.然后如果为红黑树,查找树。
5.最后再循环链表查找。
从上面步骤可以看出,ConcurrentHashMap的get操作上面并没有加锁。所以在多线程操作的过程中,并不能完全的保证一致性。这里和1.7当中类似,是弱一致性的体现。
| 项目 | JDK1.7 | JDK1.8 |
|---|---|---|
| 概览 | ![]() | ![]() |
| 同步机制 | 分段锁,每个segment继承ReentrantLock | CAS + synchronized保证并发更新 |
| 存储结构 | 数组+链表 | 数组+链表+红黑树 |
| 键值对 | HashEntry | Node |
| put操作 | 多个线程同时竞争获取同一个segment锁,获取成功的线程更新map;失败的线程尝试多次获取锁仍未成功,则挂起线程,等待释放锁 | 访问相应的bucket时,使用sychronizeded关键字,防止多个线程同时操作同一个bucket,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点;如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;更新了节点数量,还要考虑扩容和链表转红黑树 |
| size实现 | 统计每个Segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果并不一样的准确的。先采用不加锁的方式,连续计算元素的个数,最多计算3次:如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数; | 通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数; |
最后的最后,ConcurrentHashMap能完全替代HashTable吗?
hash table虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,hash table的迭代器是强一致性的,而concurrenthashmap是弱一致的。 ConcurrentHashMap的get,clear,iterator 都是弱一致性的。
下面是大白话的解释:
- Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。
- ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分 数据。
选择哪一个,是在性能与数据一致性之间权衡。ConcurrentHashMap适用于追求性能的场景,大多数线程都只做insert/delete操作,对读取数据的一致性要求较低。
本文详细探讨了HashMap与HashTable在JDK1.8中的实现差异,包括对null的支持、数据结构、实现原理等方面。此外,还对比了ConcurrentHashMap与HashTable的特性与适用场景。


529

被折叠的 条评论
为什么被折叠?



