码迷,mamicode.com
首页 > 其他好文 > 详细

JUC之ConcurrentHashMap

时间:2020-10-07 20:32:22      阅读:19      评论:0      收藏:0      [点我收藏+]

标签:唤醒   重复执行   有一个   导致   bat   head   count   动态   als   

一、Hash表 

          1. 什么是Hash表

                 hash函数就是根据key计算出应该存储地址的位置,而哈希表是基于哈希函数建立的一种查找表

          2.  hash函数设计的考虑因素

  •   计算散列地址所需要的时间(即hash函数本身不要太复杂)
  •   关键字的长度
  •   表长
  •   关键字分布是否均匀,是否有规律可循
  •   设计的hash函数在满足以上条件的情况下尽量减少冲突

           3.哈希冲突的解决方案

              不管hash函数设计的如何巧妙,总会有特殊的key导致hash冲突,特别是对动态查找表来说。hash函数解决冲突的方法有以下几个常用的方法

                    A.开放定制法(线性探索)
                    B.链地址法(HashMap)
                    C.公共溢出区法建立一个特殊存储空间,专门存放冲突的数据。此种方法适用于数据和冲突较少的情况。
                    D.再散列法(布隆过滤器)准备若干个hash函数,如果使用第一个hash函数发生了冲突,就使用第二个hash函数,第二个也冲突,使用第三个……     

   开放定址法

       当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。基本公式为:hash(key) = (hash(key)+di)mod TableSize。其中di为增量序列,TableSize为表长。根据di的不同我们又可以分为线性探测,平方(二次)探测,双散列探测。 

 1)线性探测 
以增量序列 1,2,……,(TableSize -1)循环试探下一个存储地址,即di = i。如果table[index+di]为空则进行插入,反之试探下一个增量。但是线性探测也有弊端,就是会造成元素聚集现象,降低查找效率。具体例子如下图: 

 技术图片

 

特别对于开放定址法的删除操作,不能简单的进行物理删除,因为对于同义词来说,这个地址可能在其查找路径上,若物理删除的话,会中断查找路径,故只能设置删除标志。

//插入函数,利用线性探测法 
bool Insert_Linear_Probing(int num){
    //哈希表已经被装满,则不在填入 
    if(this->size == this->length){
        return false;
    }
    int index = this->hash(num);
    if(this->data[index] == MAX){
        this->data[index] = num;
    }else{
        int i = 1;
        //寻找合适位置 
        while(this->data[(index+i)%this->length] != MAX){
            i++;
        }
        index = (index+i)%this->length; 
        this->data[index] = num;
    }
    if(this->delete_flag[index] == 1){//之前设置为删除 
        this->delete_flag[index] = 0; 
    }
    this->size++;
    return true;
}

链地址法

 HashMap即是采用了链地址法,也就是数组+链表的方式,HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。  

//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类。代码如下

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
        int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        } 

所以,HashMap的整体结构如下  

 技术图片

 

 

 

 

 

 

 简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

二.ConcurrentHashMap

      ConcurrentHashMap是Java并发包中提供的一个线程安全且高效的HashMap实现,ConcurrentHashMap在并发编程的场景中使用频率非常之高,下面我们来分析下ConcurrentHashMap的实现原理,并对其实现原理进行分析 。

     众所周知,哈希表是种非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。

    HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(多线程扩容时可能造成),导致get操作时,cpu空转,  所以,在并发环境中使用HashMap是非常危险的。

  HashTable : HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

 

      HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据比喻[11],这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。java1.7后的CHM中把每个数组叫Segment,每个segment下面存的是默认16段的Hashhenery,Hashhenery解决充突是在Hashhenery下面挂载链表,我们就画图说明下分段锁

技术图片

 

 

ConcurrentHashMap初始化时,计算出Segment数组的大小ssize和每个SegmentHashEntry数组的大小cap,并初始化Segment数组的第一个元素;其中ssize大小为2的幂次方默认为16cap大小也是2的幂次方最小值为2,最终结果根据初始化容量initialCapacity进行计算,计算过程如下

if (c * ssize < initialCapacity)
    ++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
    cap <<= 1;

因为Segment继承了ReentrantLock,所有segment是线程安全的,但是在1.8中放弃了Segment分段锁的设计,使用的是Node+CAS+Synchronized来保证线程安全性,而且这样设计的好处是层级降低了,锁的粒度更小了,可以说是一种优化,比喻锁的是2,那么他锁的就只是发生冲突的2下面的链表,而不像1.7样,是锁整个HashEntry;而且1.8中对链表的长度进行了优化,在1.7的链表中链表查询的复杂度是O(n),但是在1.8中为了解决这问题引入了红黑树,在1.8中当我们链表长度大于8时并且数组长度大于64时,就会发生一个链表的转换,会把单向链表转换成红黑树。

技术图片

 

 

  put操作

     在1.7 中当执行put方法插入数据的时候,根据key的hash值,在Segment数组中找到对应的位置如果当前位置没有值,则通过CAS进行赋值,接着执行Segmentput方法通过加锁机制插入数据;假如有线程AB同时执行相同Segmentput方法

线程A 执行tryLock方法成功获取锁,然后把HashEntry对象插入到相应位置

线程B 尝试获取锁失败,则执行scanAndLockForPut()方法,通过重复执行tryLock()方法尝试获取锁

在多处理器环境重复64次,单处理器环境重复1次,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B
 
当线程A执行完插入操作时,会通过unlock方法施放锁,接着唤醒线程B继续执行 

但在1.8 中执行put方法插入数据的时候,根据key的hash值在Node数组中找到相应的位置如果当前位置的 Node还没有初始化,则通过CAS插入数据

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    //如果当前位置的`Node`还没有初始化,则通过CAS插入数据
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;                   // no lock when adding to empty bin
}

如果当前位置的Node已经有值,则对该节点加synchronized锁,然后从该节点开始遍历,直到插入新的节点或者更新新的节点  

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;
        }
    }
}

如果当前节点是TreeBin类型,说明该节点下的链表已经进化成红黑树结构,则通过putTreeVal方法向红黑树中插入新的节点  

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;
    }
}

如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的节点个数达到了8个,则通过treeifyBin方法将链表转化为红黑树  

 

JUC之ConcurrentHashMap

标签:唤醒   重复执行   有一个   导致   bat   head   count   动态   als   

原文地址:https://www.cnblogs.com/xing1/p/13775782.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!