HashMap、Hashtable、ConcurrentHashMap是日常开发中使用频率较高的数据结构,它们都是以key-value的形式来存储数据,且都实现了Map接口,日常开发中很多人对其三者之间的区别并没有十分清晰的概念。本文将剖析部分源码,以及从线程安全性、速度等方面来分析三者之间的区别。首先讲下三者的一些区别:
1.HashMap与Hashtable基本上等价,区别在于Hashtable的大部分方法都是被synchronized修饰,并且键值都不能为null(HashMap则可以);
2.由于Hashtable大部分方法被synchronized修饰,因此是线程安全的,HashMap则是非线程安全的,大量的线程存取可能会出现异常;
3.hashMap效率相对比Hashtable高,因为synchronized修饰方法,获取锁会耗费时间,导致效率相对较低。
HashMap在jdk 1.7和jdk 1.8的版本在设计思想上有所改变,1.7主要是数组+链表,1.8是数组+链表+红黑树,红黑树也是一种链表数据结构。红黑树具有二叉树的优势,在查找方面具有一定优势,弥补jdk 1.7中HashMap因数据量较大导致链表过长、查询缓慢的问题。本文对于HashMap的源码分析主要基于jdk 1.7,因为jdk 1.7中源码相对容易理解。
这是因为hashMap在对键值为空的时候做了特殊处理,具体见下面源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
由上述代码可知,当key为空时,哈希时会直接被赋值为0。
// 初始化容量大小
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75F;
//table:数据存储区
transient HashMap.Entry<K, V>[] table;
//已存数据的大小
transient int size;
//table数组需要扩容的临界值,等于table的长度*loadFactor
int threshold;
//装载因子
final float loadFactor;
//table结构修改的次数
transient int modCount;
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
由上述代码可知,tableSizeFor方法就是扩容方法,所以你看得懂上述这个方法的操作么?稍微举个例子讲述一下:
1.当传入的cap值为35时;
2.首先执行第一步 cap-1,得到n的值为34;
3.执行第二部:n或(n右移一位),34 二进制为:100010,n右移一位变成:010001,或操作结果为:110011;
4.n右移两位变成:001100,再与110011进行或操作,变成111111,此时n为111111;
5.n右移四位变成:000000,再与111111进行或操作,结果n仍为:111111;
6.n右移16为,再与111111进行或操作,n仍为111111;
7.n+1变成1000000,变成64;
其实要看懂上述代码,首先要理解什么是或操作(或操作是一种由高位决定位值得运算,只要对应位有一个1,则进行与操作时该位便为1,只有对应位同时为0时,结果才为0)。所以上述代码每右移多少位,就把最高位右边的第x位设置为1。
首先看一下Hashtable的put方法源码:
public synchronized V put(K key, V value) {
// 确保value不为空。这句代码过滤掉了所有value为null的键值对
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
//在此处计算key的hash值,如果此处key为null,则直接抛出空指针异常。
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;
}
从上图源码可以看出,hashtable的大部分方法都是由关键字synchronized修饰,因此是线程安全的。
ConcurrentHashMap底层是基于数组+链表,而在jdk1.7和jdk1.8中稍有不同,jdk1.7中的数据结构采用分段式设计,segment数组 + HashEntry数组 + 链表实现,hash冲突采用拉链法处理。而在jdk1.8中,借鉴了jdk1.8中HashMap的设计思想,采用数组 + 链表 + 红黑树的数据结构,并且有原来的分段式锁换成了CAS + Synchronized锁,其它的地方并没有改变。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry<K,V>[] table;
transient int count;
//table结构修改的次数
transient int modCount;
//阈值
transient int threshold;
//加载因子
final float loadFactor;
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
}
上述Segment类是ConcurrentHashMap的一个内部类,它是ConcurrentHashMap分段锁实现的基础,在生成一个ConcurrentHashMap 对象时,内部会维护一个Segment数组,这个Segment数组会将一个大的table分割成多个小的table来进行加锁。从理论上说,segment数组的数量是多少,并发量就是多少。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
对于ConcurrentHashMap的数据插入,这里要进行两次Hash去定位数据的存储位置。当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。
1.使用Collections.synchronizedMap(Map)创建线程安全的map集合;
2.使用Hashtable替代hashMap;
3.使用ConcurrentHashMap来代替hashMap;
1.hashMap、hashtable的主要区别在于安全性、同步性、速度,要根据场景来选择不同的数据结构;
2.hashMap的键合值都可以为空,但是hashtable、ConcurrentHashMap的键值都不可以为空;
3.hashtable、ConcurrentHashMap都是线程安全的,hashMap是非线程安全的;
4.hashtable是一个过时的类,使用线程安全的ConcurrentHashMap。
1.https://juejin.cn/post/6844904023003250701
2.https://developer.aliyun.com/article/38213?spm=a2c6h.14164896.0.0.13f64a13sROcRL
3.https://zhuanlan.zhihu.com/p/69284871