HashMap与ConcurrentHashMap的底层原理

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.csdn.net/u011240877/article/details/53351188

           [3]https://zhuanlan.zhihu.com/p/21673805

  • 5
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值