前言
三者的比较一直是面试比较热门的问题,如果从源码的角度去分析各自的实现,则很容易对三者做出区分。本文将从以下几点展开论述:
1.什么是HashMap?HashMap的数据结构以及实现原理
2.什么是ConcurrentHashMap?ConcurrentHashMap的数据结构以及实现原理
3.什么是HashTable?HashTable的数据结构以及实现原理
4.总结。(三者的区别)
1.什么是HashMap?HashMap的数据结构以及实现原理
请看此文。或前往我的主页查看文章《Map接口实现——HashMap源码分析》
2.什么是ConcurrentHashMap?ConcurrentHashMap的数据结构以及实现原理
JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的数据结构:
transient volatile HashEntry<K,V>[] table;
final Segment<K,V>[] segments;
如上图所示:
1.一个 ConcurrentHashMap 里包含一个 Segment 对象类型的数组。
2.Segment 的结构和HashMap类似,是一种数组和链表结构(HashEntry 对象类型的数组),segment继承了ReentrantLock。
3.ReentrantLock是可重入锁,是指一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。其实,synchronized 也是可重入锁。ReentrantLock与synchronized 的区别是什么呢?有兴趣的小伙伴可看这里
4.一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
因此:
该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
JDK1.8 对 HashMap 做了改造,当冲突链表长度大于8,并且数组长度大于64时,会将链表转变成红黑树结构。
JDK1.8后,ConcurrentHashMap 类取消了 Segment 分段锁,采用 CAS + synchronized 来保证并发安全,数据结构跟 jdk1.8 中 HashMap 结构类似,都是数组 + 链表(当链表长度大于8时,链表结构转为红黑二叉树)结构。
因此,我们可以大胆猜测一下,ConcurrentHashMap 的默认初始化大小、默认负载因子、数组最大容量、每次扩容是原来的2倍等等应该和JDK1.8后的HashMap 一样。不同的是ConcurrentHashMap 是线程安全的,HashMap 是线程不安全的。
//官方注释:The array of bins. Lazily initialized upon first insertion.
//采用懒加载,只有在第一次插入操作时,才会初始化数组。和HashMap一样
transient volatile Node<K,V>[] table;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//默认初始化容量
private static final int DEFAULT_CAPACITY = 16;
//最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
我们看一下ConcurrentHashMap是如何保证并发的
1.多线程访问共享变量的数据一致性问题:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//value值被volatile 修饰,保证多线程共享变量的一致性
volatile V val;
volatile Node<K,V> next;
方法省略...
}
volatile :
java中的关键字,当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
2.put操作
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;
//容器数组是否为空,如果为空就初始化数组;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//要插入的元素f,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入
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
}
//判断f.hash == -1是否成立,如果成立,说明当前f是ForwardingNode节点,表示有其它线程正在扩容,则一起进行扩容操作;
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
//就是把新的Node节点按链表或红黑树的方式插入到合适的位置;
V oldVal = null;
//注意:锁加在了这里
synchronized (f) {
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)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,value, null);
break;
}
}
}
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) {
//节点插入完成之后,接着判断链表长度是否超过8,如果超过8个,就将链表转化为红黑树结构;
//TREEIFY_THRESHOLD = 8
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//最后,插入完成之后,进行扩容判断。
addCount(1L, binCount);
return null;
}
从面代码我们也可以看到ConcurrentHashMap在初始化时和HashMap采取的机制一样,都是懒加载机制,即在第一次执行put操作向Map中插入数据时,判断table数组是否为空,为空就先初始化。
CAS(Compare And Swap):它是一种乐观锁,认为对于同一个数据的并发操作不一定会发生修改,在更新数据的时候,尝试去更新数据,如果失败就不断尝试。
懒加载机制的好处:防止用户创建了map不使用,浪费资源。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
使用CAS锁控制只有一个线程初始化tab数组;
sizeCtl在初始化后存储的是扩容门槛;
扩容门槛写死的是tab数组大小的0.75倍,tab数组大小即map的容量,也就是最多存储多少个元素。
总结:
在JDK1.8中,ConcurrentHashMap底层的数据结构实现和HashMap一致。在线程安全上,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
3.什么是HashTable?HashTable的数据结构以及实现原理
Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的即线程安全,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
hashtable和jdk1.8之前的hashmap的底层数据机构类似都是采用数组+链表的形式,数组是hashmap的主体,链表则是主要为了解决哈希冲突而存在的。
private transient Entry<?,?>[] table;
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//Hashtable的构造方法
public Hashtable() {
//无参构造方法,默认初始化容量为11,负载因子是0.75
this(11, 0.75f);
}
//有参构造方法
public Hashtable(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal Load: "+loadFactor);
if (initialCapacity==0)
initialCapacity = 1;
this.loadFactor = loadFactor;
//从这里可以看出,Hashtable的初始化不是懒加载机制
table = new Entry<?,?>[initialCapacity];
threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}
//方法被synchronized 修饰,是线程安全的
public synchronized boolean containsKey(Object key){省略....}
public synchronized V get(Object key){省略...}
Hashtable的扩容机制
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
// overflow-conscious code
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
// Keep running with MAX_ARRAY_SIZE buckets
return;
newCapacity = MAX_ARRAY_SIZE;
}
Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
modCount++;
threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
table = newMap;
for (int i = oldCapacity ; i-- > 0 ;) {
for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
Entry<K,V> e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (Entry<K,V>)newMap[index];
newMap[index] = e;
}
}
}
在put操作时并没有判断table数组是否为空!证实了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;
}
4.总结。(三者的区别)
4.1 Hashtable、HashMap的区别
HashMap:
1.JDK1.8之后,数据结构的实现是数组+链表+红黑树
2.默认初始化容量是16,负载因子是0.75
3.发生冲突时,且链表长度大于8时:
数组长度小于64,选择扩容;
数组长度大于64,链表转换为红黑树。
4.初始化是懒加载机制,即在第一次往Map中添加数据时初始化。
5.HashMap 不是同步的,支持 null 键和值
6.等等(大家底层实现明白后自己便能总结 )
HashTable:
1.数据结构的实现是数组+链表。
2.默认初始化容量是11,负载因子是0.75
3.发生冲突时,直接将结点插入到链表中
4.直接初始化(创建出却暂时不使用时,浪费资源)
5.方法是同步的(线程安全),不支持 null 键和值(看上面put方法的源码)
4.2 HashMap和ConcurrentHashMap 的区别
JDK1.8之后,两者的数据结构实现是一样的;
默认初始化容量、负载因子一样的
初始化 都是懒加载机制。
HashMap的键值对允许有null,但是ConCurrentHashMap不允许。
HashMap线程不安全,ConcurrentHashMap 线程安全
4.3 ConcurrentHashMap 和 Hashtable的区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在底层数据结构和实现线程安全的方式上不同。
1.Hashtable和HashMap的区别(除了线程安全以及不支持键值对的null)
底层数据结构:
JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
1.在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
2.Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈,效率越低。
两者的对比图: (大佬的图,来自技术人之路)
HashTable:
JDK1.7的ConcurrentHashMap:
JDK1.8的ConcurrentHashMap(TreeBin: 红黑树节点 Node: 链表节点):
ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。ConcurrentHashMap 锁的方式是稍微细粒度的。
写在最后:
拥抱开源,成就自己,侵权必删。