1.HashMap结构
HashMap基于数组,通过散列函数在O(1)时间内来访问记录。首先HashMap里面实现一个静态内部类Entry,Map里面的内容都保存在Entry[]里面。然后,通过对key的hashcode & 数组长度得到在数组中位置,如有冲突,则在冲突位置形成链表,即拉链法。
2.HashMap字段属性
2.1数组table和初始容量
//初始化使用,长度总是 2的幂
transient Node<K,V>[] table;
//默认 HashMap 集合初始容量为16(必须是 2 的倍数)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//集合的最大容量,如果通过带参构造指定的最大容量超过此数,默认还是使用此数
static final int MAXIMUM_CAPACITY = 1 << 30;
HashMap是由数组+链表+红黑树组成,这里的数组就是 table 字段。其进行初始化长度默认是 DEFAULT_INITIAL_CAPACITY= 16。
而且由于哈希算法为了避免冲突都要求数组长度是质数,所以JDK 声明数组的长度总是 2的n次方(合数)。
2.2装载因子loadFactor
装载因子,是用来衡量 HashMap 满的程度,计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。
默认的负载因子0.75 是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子loadFactor 的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子 loadFactor 的值,这个值可以大于1。
//默认的装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//调整大小的下一个大小值(容量*加载因子)。capacity * load factor
int threshold;
//散列表的加载因子。
final float loadFactor;
//此映射中包含的键值映射的数量。(集合存储键值对的数量)
transient int size;
2.3其他
//(JDK1.8新增)当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
//(JDK1.8新增)当桶(bucket)上的节点数小于这个值时会转成链表
static final int UNTREEIFY_THRESHOLD = 6;
//(JDK1.8新增)当集合中的容量大于这个值时,表中的桶才能进行树形化 ,否则桶内元素太多时会扩容,而不是树形化 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
3.HashMap的存取实现
3.1hashcode
public int hashCode():HashCode是根类Obeject中的方法。默认情况下,Object中的hashCode() 返回对象的32位jvm内存地址。也就是说如果对象不重写该方法,则返回相应对象的32为JVM内存地址。
3.2hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
i = (table.length - 1) & hash;//这一步是在后面添加元素putVal()方法中进行位置的确定
一共分三步:
(1)取 hashCode 值: key.hashCode()
(2)高位参与运算:h>>>16
hashCode() 在很多时候下低位是相同的,这将导致冲突(碰撞),因此 1.8 以后做了个移位操作:将元素的 hashCode() 和自己右移 16 位后的结果求异或。
这样就使得 hashCode()是由高位和低位共同决定,在数组长度较小时,降低冲突。
(3)取模运算:(n-1) & hash
为降低取模运算( hash%length)的开销,HashMap 通过 hash & (table.length -1)来得到该对象的保存位,HashMap 底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当 length 总是2的n次方时,hash & (length-1)运算等价于对 length 取模,也就是 hash%length,因为&比%具有更高的效率。比如 n % 32 = n & (32 -1)。
3.3put
//hash(key)就是上面讲的hash方法,对其进行了第一步和第二步处理
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//(1)如果table为null或者长度为0,则进行初始化:resize()方法本来是用于扩容,由于初始化没有实际分配空间,这里用该方法进行空间分配
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(2)这里用到了前面讲解获得key的hash码的第三步,取模运算
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//(3)----------------------------------
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//(4)该链是红黑树--------------------------------
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//(5)该链是链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//链表长度大于8,转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//用作修改和新增快速失败
//(6)超过最大容量,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);// 这都是一个空的方法实现,LinkedHashMap 是继承的 HashMap,并且重写了该方法,相当于一个模板模式里的一个钩子,当需要的时候再调用。
return null;
}
(1)初始化:如果哈希表数组table为空,进行扩容(resize);
(2)数组寻址:根据键值key计算hash值得到插入的数组索引i,
如果table[i]==null,直接新建节点添加,转向(6);
如果table[i]不为空,转向(3);
(3)添加value(首元素判定):判断table[i]的首个元素是否和key相同,(需重写hashCode以及equals)
如果相同直接覆盖value;
否则转向(4);
(4)添加value(红黑树判定):判断table[i] 是否为treeNode(红黑树),
如果是红黑树,则直接在树中插入键值对;
否则转向(5);
(5) 添加value(链表判定):遍历table[i],判断链表长度是否大于8,
如果是,则把链表转换为红黑树,在红黑树中执行插入操作;
否则进行链表的插入操作;
(6)扩容:插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过,进行扩容。
3.3.1 resize
由于 HashMap 扩容开销很大,因此与扩容相关的两个因素:
- 容量capacity:数组长度
- 加载因子loadFactor:决定了 HashMap 中的元素占有多少比例时扩容,size/capacity
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//原数组如果为null,则长度赋值0
int oldThr = threshold;
int newCap, newThr = 0;
//(1)
if (oldCap > 0) {//如果原数组长度大于0
if (oldCap >= MAXIMUM_CAPACITY) {//数组大小如果已经大于等于最大值(2^30)
threshold = Integer.MAX_VALUE;//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return oldTab;
}
//原数组长度大于等于初始化长度16,并且原数组长度扩大1倍也小于2^30次方
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阀值扩大1倍
}
else if (oldThr > 0) //旧阀值大于0,则将新容量直接等于就阀值
newCap = oldThr;
else {//阀值等于0,oldCap也等于0(集合未进行初始化)
newCap = DEFAULT_INITIAL_CAPACITY;//数组长度初始化为16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//阀值等于16*0.75=12
}
//计算新的阀值上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//(2)
if (oldTab != null) {
//把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//元数据j位置置为null,便于垃圾回收
if (e.next == null)//数组没有下一个引用(不是链表)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容分为两部分:
(1)首先是计算新桶数组的容量 newCap 和新阈值 newThr;
(2)然后将原集合的元素重新映射到新集合中;
(3)若table[i]的首个元素为空,直接插入;
(4)若table[i] 为红黑树,split()方法重新分配;如果是红黑树,则直接在树中插入键值对;否则转向(5);
(5)若table[i] 为链表,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。原因如下:
经过观测可以发现,我们使用的是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不会倒置。
4.线程安全
4.1HashMap不是线程安全的
请参考[3]
4.2Hashtable
与HashMap区别:
- Hashtable不允许键和值是null,而HashMap不允许;
- Hashtable是同步的,而HashMap不允许。Hashtable通过在put方法上使用synchronized关键字实现同步。效率低的原因是所有访问Hashtable的线程必须竞争同一把锁。
4.3ConcurrentHashMap
4.3.1ConcurrentHashMap结构
ConcurrentHashMap由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁。一个ConcurrentHashMap里包含一个Segment数组,一个Segment包含一个HashEntry数组,当对HashEntry里的数据修改时,必须先获得对应的Segment锁。
4.3.2ConcurrentHashMap初始化
分为三步:
(1)初始化segments数组:为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组长度是2的N次方。
(2)初始化段偏移量(segmentShift)和段掩码(segmentMask)
(3)初始化每个segment:与初始化HashMap数组相同
4.3.2定位Segment
为了使用分段锁Segment,需进行一次再散列。同3.2节。
4.3.3ConcurrentHashMap操作
1.get
get整个过程不需要加锁,除非读到null才会加锁重读。这是因为ConcurrentHashMap的共享变量都是volatile类型,所以可以保证多线程环境下的可见性,能保证多线程的读(但是只能单线程写,但是get不需要写)。
另外,为防止冲突,定位Segment和定位HashEntry的散列算法不一致。
2.put
分为三步:
(1)定位到Segment
(2)判断是否对Segment里的HashEntry扩容,
如果需要扩容,则重新定位,插入;
如果不需要扩容,则直接插入。
为了高效,ConcurrentHashMap不会对整个容器扩容,只对某个Segment扩容。
3.size
最安全但也是最低效的做法:把put、remove和clean全部锁住再进行size。
ConcurrentHashMap的做法是先尝试两次不锁住Segment的方式来统计size,如果在统计的过程中,size发生变化,再采用加锁的方式。
参考:[1]https://www.cnblogs.com/ysocean/p/8711071.html#_label5
[2]https://blog.youkuaiyun.com/u011240877/article/details/53351188