zoukankan      html  css  js  c++  java
  • ConcurrentHashMap实现原理

      LZ一直想弄懂ConcurrentHashMap的分段锁到底是怎么一种技术,是不是能对现有编码有所启示,话不多说,进入正题。

    一、什么是哈希表

      在讨论哈希表之前,我们先大概了解一下其他数据结构在新增,查找等操作上的性能。

      数组:采用一段连续的存储单元存储数据。查询快,增删慢。大小固定,不方便动态扩容。

      链表:采用非连续存储单元存储数据。增删快,查询慢,可动态扩容。

      说白了就是:

      1、在访问方式上

      数组可以随机访问其中的元素

      链表则必须是顺序访问,不能随机访问

      2、空间使用上

      链表可以随意扩大

      数组则不能

      LZ一直有个疑问,是不是链表只能顺序访问,所以链表的查询就比组数慢呢?那是不是数组和链表在查询下标为0的元素的时候会一样快?其实不是这样的,理解这个问题,要站在CPU的角度来看。

      CPU 寄存器 – immediate access (0-1个CPU时钟周期) 

      CPU L1 缓存 – fast access (3个CPU时钟周期) 

      CPU L2 缓存 – slightly slower access (10个CPU时钟周期) 

      内存 (RAM) – slow access (100个CPU时钟周期) 

      硬盘 (file system) – very slow (10,000,000个CPU时钟周期)

      各级别的存储器速度差异非常大,CPU寄存器速度是内存速度的100倍! 这就是为什么CPU产商发明了CPU缓存。 而这个CPU缓存,就是数组和链表的区别的关键所在。

      CPU缓存会把一片连续的内存空间读入,因为数组结构是连续的内存地址,所以数组全部或者部分元素被连续存在CPU缓存里面,平均读取 每个元素的时间只要3个CPU时钟时间。而链表的节点分散在堆空间里面,这时候CPU缓存帮不上忙,只能是去读取内存,平均读取时间需要100个CPU时钟周期。 这样算下来,数组访问的速度比链表快33倍! (这里只是介绍概念,具体的数字因CPU而异)

      总结一下, 各种存储器的速度差异很大,在编程中绝对有必要考虑这个因素。 比如,内存速度比硬盘快1万倍,所以程序中应该尽量避免频繁的硬盘读写;CPU缓存比内存快几十倍,在程序中尽量多加利用。

      哈希表

      哈希表(hash table)也叫散列表,我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构,而在上面提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

      比如我们要新增或查找某个元素,通常是通过哈希函数映射到数组中的某个位置,通过数组下标一次丁文就可以完成操作。哈希函数的设计好坏直接影响哈希表的优劣,举个例子,比如我们要再哈希表中执行插入操作:(从上往下看)

      查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

      哈希冲突

      然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,

     二、HashMap实现原理

      HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。

    //HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{}。
    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实现原理

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

      HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(扩容时可能造成,具体原因自行百度google或查看源码分析),导致get操作时,cpu空转,所以,在并发环境中使用HashMap是非常危险的。

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

     HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。

    ConcurrentHashMap源码分析

      ConcurrentHashMap采用了非常精妙的"分段锁"策略,ConcurrentHashMap的主干是个Segment数组。

     final Segment<K,V>[] segments; 

      Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在ConcurrentHashMap,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,并发环境下,对于不同Segment的数据进行操作是不用考虑锁竞争的。(就按默认的ConcurrentLeve为16来讲,理论上就允许16个线程并发执行,有木有很酷)

      所以,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑。

    Segment类似于HashMap,一个Segment维护着一个HashEntry数组。

    transient volatile HashEntry<K,V>[] table;

      HashEntry是目前我们提到的最小的逻辑处理单元了。一个ConcurrentHashMap维护一个Segment数组,一个Segment维护一个HashEntry数组。

    static final class HashEntry<K,V> {
            final int hash;
            final K key;
            volatile V value;
            volatile HashEntry<K,V> next;
            //其他省略
    }

      我们说Segment类似哈希表,那么一些属性就跟我们之前提到的HashMap差不离,比如负载因子loadFactor,比如阈值threshold等等,看下Segment的构造方法

    Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
                this.loadFactor = lf;//负载因子
                this.threshold = threshold;//阈值
                this.table = tab;//主干数组即HashEntry数组
            }

      我们来看下ConcurrentHashMap的构造方法

    public ConcurrentHashMap(int initialCapacity,
                                   float loadFactor, int concurrencyLevel) {
              if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
                  throw new IllegalArgumentException();
              //MAX_SEGMENTS 为1<<16=65536,也就是最大并发数为65536
              if (concurrencyLevel > MAX_SEGMENTS)
                  concurrencyLevel = MAX_SEGMENTS;
              //2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
             int sshift = 0;
             //ssize 为segments数组长度,根据concurrentLevel计算得出
             int ssize = 1;
             while (ssize < concurrencyLevel) {
                 ++sshift;
                 ssize <<= 1;
             }
             //segmentShift和segmentMask这两个变量在定位segment时会用到,后面会详细讲
             this.segmentShift = 32 - sshift;
             this.segmentMask = ssize - 1;
             if (initialCapacity > MAXIMUM_CAPACITY)
                 initialCapacity = MAXIMUM_CAPACITY;
             //计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方.
             int c = initialCapacity / ssize;
             if (c * ssize < initialCapacity)
                 ++c;
             int cap = MIN_SEGMENT_TABLE_CAPACITY;
             while (cap < c)
                 cap <<= 1;
             //创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
             Segment<K,V> s0 =
                 new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                                  (HashEntry<K,V>[])new HashEntry[cap]);
             Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
             UNSAFE.putOrderedObject(ss, SBASE, s0); 
             this.segments = ss;
         }

      初始化方法有三个参数,如果用户不指定则会使用默认值,initialCapacity为16,loadFactor为0.75(负载因子,扩容时需要参考),concurrentLevel为16。

      接下来,我们来看看put方法

    public V put(K key, V value) {
            Segment<K,V> s;
            //concurrentHashMap不允许key/value为空
            if (value == null)
                throw new NullPointerException();
            //hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
            int hash = hash(key);
            //返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment
            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);
        }

      从源码看出,put的主要逻辑也就两步:1.定位segment并确保定位的Segment已初始化 2.调用Segment的put方法。看下get方法。

    public V get(Object key) {
            Segment<K,V> s; 
            HashEntry<K,V>[] tab;
            int h = hash(key);
            long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
            //先定位Segment,再定位HashEntry
            if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
                (tab = s.table) != null) {
                for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                         (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
                     e != null; e = e.next) {
                    K k;
                    if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                        return e.value;
                }
            }
            return null;
        }

      get方法无需加锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据。

      来看下concurrentHashMap代理到Segment上的put方法,Segment中的put方法是要加锁的。只不过是锁粒度细了而已。

    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
                HashEntry<K,V> node = tryLock() ? null :
                    scanAndLockForPut(key, hash, value);//tryLock不成功时会遍历定位到的HashEnry位置的链表(遍历主要是为了使CPU缓存链表),若找不到,则创建HashEntry。tryLock一定次数后(MAX_SCAN_RETRIES变量决定),则lock。若遍历过程中,由于其他线程的操作导致链表头结点变化,则需要重新遍历。
                V oldValue;
                try {
                    HashEntry<K,V>[] tab = table;
                    int index = (tab.length - 1) & hash;//定位HashEntry,可以看到,这个hash值在定位Segment时和在Segment中定位HashEntry都会用到,只不过定位Segment时只用到高几位。
                    HashEntry<K,V> first = entryAt(tab, index);
                    for (HashEntry<K,V> e = first;;) {
                        if (e != null) {
                            K k;
                            if ((k = e.key) == key ||
                                (e.hash == hash && key.equals(k))) {
                                oldValue = e.value;
                                if (!onlyIfAbsent) {
                                    e.value = value;
                                    ++modCount;
                                }
                                break;
                            }
                            e = e.next;
                        }
                        else {
                            if (node != null)
                                node.setNext(first);
                            else
                                node = new HashEntry<K,V>(hash, key, value, first);
                            int c = count + 1;
                  //若c超出阈值threshold,需要扩容并rehash。扩容后的容量是当前容量的2倍。这样可以最大程度避免之前散列好的entry重新散列,具体在另一篇文章中有详细分析,不赘述。扩容并rehash的这个过程是比较消耗资源的。
                            if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                                rehash(node);
                            else
                                setEntryAt(tab, index, node);
                            ++modCount;
                            count = c;
                            oldValue = null;
                            break;
                        }
                    }
                } finally {
                    unlock();
                }
                return oldValue;
            }

      至此,我们应该知道ConcurrentHashMap大概是怎么回事了,所谓的分段锁,就是一个锁保护一部分数据,在原来锁机制上细化了,这个过程,就是在性能和安全中不断衡量的过程。

  • 相关阅读:
    信息论
    学习抓包
    深入学习垃圾kafka
    share data
    【转载】计算图像相似度——《Python也可以》之一
    聊聊java list的使用特性
    log4j多线程以及分文件输出日志
    【转载】JDBC的连接参数的设置导致rowid自动添加到sql
    背包问题
    【转】【动态规划】01背包问题
  • 原文地址:https://www.cnblogs.com/peterxiao/p/10142328.html
Copyright © 2011-2022 走看看