【JAVA】详细分析一遍HashMap以及ConcurrentHashMap

  HashMap作为Java中最常用的数据结构之一,也算是最受欢迎的数据结构之一,我们在用的时候可能只是简单的知道他是用Hash实现的一个数据结构,在查找,删除,等操作上会有很高效的复杂度,所以只要遇到类似的需求就会直接上HashMap(也可能是我太菜了,hh),但是HashMap有我们想象中的那么完美吗,如果只凭表面的工作当然不能下定论,所以我们来详细的看看HashMap的那些事。
(本文把hashmap基本能遇到的问题都讲了一遍,所以如果觉得太长可以点击收藏,分几次看,慢慢理解)

1、HashMap的底层(哈希碰撞)

  我们先来看看一般的hash表,5,12,9插入直接 % 8就可以了
在这里插入图片描述在这里插入图片描述
  如果我来一个,13,我这个时候再哈希一下13%8=5会有和5产生哈希碰撞,然后有问题就会有方法,解决哈希碰撞的方法有下面几种:

   - 线性探测法:这其实就是最简单的方法,公式可以表达为(Key + d)%8,d为0,1,2,…7就是我碰撞到了5,我找一个地址6,如果6没人,我就到6住下了,如果6也有人,我就走下一个,依次类推,这个方法的弊端很明显,如果碰撞的多的话,我一直往下找,大家都连成线了,这个哈希,跟线性的找空位就没啥区别了
   - 二次探测再散列法,跟线性探测法差不多,只是d换成了另外的表达式,(key + f)%8,这里的f分别为+1,-1,+4,-4其实也就是±1的平方,2的平方(有没有一种左右横跳的感觉,hhh),直到找到下一个位置,但是因为步长是1,4,9其实也是固定的,所以跟线性探测法一样,很容易产生聚集,就是所有产生哈希碰撞的元素都聚集在一块。这样一来的话产生碰撞的概率又增加了。

   - 再哈希法:对于易聚集这个问题,再哈希法师可以解决的,再哈希法就是我准备了不止一个哈希函数,在第一个哈希函数产生冲突后,我们进行下一个哈希函数,这个时候的key值不变,只是哈希函数变了,直到找到下一个空位置,这个的弊端就是,要计算很多次,增加了计算时间。

   - 拉链法:解释一下就是拉一个链表的方法,意思就是如果你们都映射到同一个位置,我也不管了,你们全放这吧,但是我一个位置肯定不够,那我就拉个链表把你们全放进去,这个方法是不是就省力多了。HashMap用的就是这个方法,并且这就是他的底层。

   从别人博客偷张图(hh备注一下链接https://blog.csdn.net/lppl010_/article/details/80839160)
在这里插入图片描述

  这样一看,hashmap的底层是不是很简单其实就是一个拉链的数据结构,但是如果思维敏捷的可能已经发现了,看上去确实没啥问题,但是仔细一想,他这个虽然解决了哈希碰撞问题,但是如果碰撞次数过多了,这一条链表非常长怎么办?
  我这个只是一个链表,要是我要去查找其中一个元素,是不是还是逃不过O(n)的命运?没想到也没关系,人家jdk也是在1.8版本才改进了这个问题,1.7之前都一直是链表,所以这样是jdk1.7和jdk1.8的一个区别所在。
  整个哈希表我们可以看成是一个数组,每个数组元素就是一个链表的root,在jdk18.中如果这个链表的长度大于8的时候这个root下面的数据结构会转换成红黑树。

  既然人家都1.8版本了,那我们也就跟着最新版本学习吧

2、HashMap的1.8源码底层实现

在这里插入图片描述
  一个简单的HashMap的继承关系。我们接下来看看在jdk中hashmap的具体实现吧

2.1、HashMap的底层数据结构实现

  在jdk1.7中,hashmap的底层是一个Entry数组,每个元素就是一个Entry<K,V>,这里截取了Entry<K,V>中的一部分,主要是知道这个类有哪些变量就行了。

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的整体其实就是一个Entry类型的数组了。

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

  在jdk1.8,1.7中的Entry<K,V>在1.8中改成了Node<K,V>其实改了个名字,但他们都是实现了Map.Entry<K,V>这个接口,从上面的继承关系我们也能知道。除了改名,内部的结构基本一致,只是数组的类型也变成了Node<K,V>而已。

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
  }
    transient Node<K,V>[] table;

  所以你看,用数据结构实现Hashmap的底层也不是什么难事嘛。

2.2、HashMap链表转红黑树

  首先,我们看在HashMap内定义了这么两个阈值,后面能用上,这个TREEIFY_THRESHOLD也就是如果链表长度大于8就转红黑树的阈值。

	static final int TREEIFY_THRESHOLD = 8;
	
	static final int MIN_TREEIFY_CAPACITY = 64;

  然后我们最常用的HashMap的put函数来了,我们看这个源码就知道,他其实是调用了一个putVal的函数,那我们主要看putVal这个函数就好了,

	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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //这一步是判断如果数量大于8了,那么就进行红黑树转换,转换完了之后,直接break
                        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;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

  而这个函数中

	if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    	treeifyBin(tab, hash);
        break;

  这一句就是判断是否长度超过TREEIFY_THRESHOLD = 8,如果超过了,就转换成红黑树,这个时候调用treeifyBin这个函数

    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

  这个函数里面我们重点看下MIN_TREEIFY_CAPACITY那一句就好了,这一句可以让我们知道,在jdk1.8里面,只有在当前链表长度超过8并且数组大小大于64才会进行红黑树的转换。

  转成红黑树那如果同一个哈希对应的值多了就不怕了,因为查找的复杂度就是O(log(n))了。

  那这里就有问题了,为啥我不一开始就直接把链表作为红黑树,要等到8才把他转成红黑树?而且这个8是怎么来的?我们看看源码里面的注释

/*
     * Implementation notes.
     *
     * This map usually acts as a binned (bucketed) hash table, but
     * when bins get too large, they are transformed into bins of
     * TreeNodes, each structured similarly to those in
     * java.util.TreeMap. Most methods try to use normal bins, but
     * relay to TreeNode methods when applicable (simply by checking
     * instanceof a node).  Bins of TreeNodes may be traversed and
     * used like any others, but additionally support faster lookup
     * when overpopulated. However, since the vast majority of bins in
     * normal use are not overpopulated, checking for existence of
     * tree bins may be delayed in the course of table methods.
     *
     * Tree bins (i.e., bins whose elements are all TreeNodes) are
     * ordered primarily by hashCode, but in the case of ties, if two
     * elements are of the same "class C implements Comparable<C>",
     * type then their compareTo method is used for ordering. (We
     * conservatively check generic types via reflection to validate
     * this -- see method comparableClassFor).  The added complexity
     * of tree bins is worthwhile in providing worst-case O(log n)
     * operations when keys either have distinct hashes or are
     * orderable, Thus, performance degrades gracefully under
     * accidental or malicious usages in which hashCode() methods
     * return values that are poorly distributed, as well as those in
     * which many keys share a hashCode, so long as they are also
     * Comparable. (If neither of these apply, we may waste about a
     * factor of two in time and space compared to taking no
     * precautions. But the only known cases stem from poor user
     * programming practices that are already so slow that this makes
     * little difference.)
     *
     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
*/

  当然,要看懂注释还需要一些水平,hhh,我们看看大神的理解就好了,这一段来自https://blog.csdn.net/qq_39387434/article/details/111152391。

  首先,TreeNodes占用空间是普通Nodes的两倍,如果在node数量不多的情况下,时间复杂度影响不大的前提下如果专用TreeNode的话,空间的代价比时间代价更大,所以只有在一定阈值的时候才会转换成TreeNode

  再者,在理想情况下hashCode的算法是满足泊松分布的,那么根据泊松分布就有后面那一串数字

 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million

  可以看到,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。

3、HashMap的容量及扩容问题

  除去HashMap的底层是很关键的问题,HashMap的扩容问题也是java中的一个技术点,也很关键。

3.1、HashMap的容量问题

  想到容量,那么最简单的问题那就是初始化了,任意一个有大小的数据结构在初始化的时候肯定会初始化一个大小。当然我们一般在使用的时候,都是直接HashMap<K,V> hash = new HashMap<>();,这就是采用最简单的构造函数去new对象了,那么除了这个构造函数,hashmap还提供了哪些构造函数呢?

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

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

  具体的怎么实现的,大家可以自己看看代码,这里其实给我们最重要的信息就是两个参数,一个是initialCapacity,一个是loadFactor。

	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
	
	static final float DEFAULT_LOAD_FACTOR = 0.75f;

  如果我们是常用的 HashMap()构造函数,那么初始的容量就是DEFAULT_INITIAL_CAPACITY = 16了,并且这个loadFactor的值也是0.75。

  但是我们不用这个简单的构造函数,我们自己定义一个initialCapacity,并且用函数HashMap(int initialCapacity, float loadFactor)这个构造函数呢?这里有一个tableSizeFor函数非常关键,因为hashmap的容量规定要是2的次方,所以当我们initialCapacity的大小不是2的次方的时候,那么这个函数可以找到第一个比initialCapacity大的2的次方的数,然后将此时的threshold设为这个数,这个算法还是很有意思的。

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

  这个怎么理解呢,| 或,无符号右移>>>:左边位用0补充,右边丢弃。这个算法看起来很难理解,很多人可能只是知道可以这样算,但是怎么来的呢,我来讲讲我的理解,接下来非常关键,好好看好好学:
  首先,int在java里面是32位的,所以假设每个数在内存中都是32位的二进制存着。然后我们想想这个问题本身,我们是要找第一个比自身大的2次幂数,那么在二进制上,如果一个数是123,00000000 00000000 00000000 01111011,比他大的,是不是就是最左边的1的前一位为1,其余都为0?00000000 00000000 00000000 10000000,所以我们如果把左边数第一个1开始的右边位数全置为1,return的时候再+1,是不是就得到了这个数?

  好了,那么现在的问题就是怎么将所有位置为1了,jdk中的这些位运算其实就是为了实现这个。

  我们想想如果一个数为01000000 00000000 00000000 00000001,比他大的第一个二次幂是不是10000000 00000000 00000000 00000000,那么我们就要在return之前把这个数变成01111111 11111111 11111111 11111111,那么这个时候或运算和无符号右移就来了。

  第一个,因为我们最左边只有一个1,所以会有左移一位n >>> 1得到00100000 00000000 00000000 00000000,这个时候两个数或一下,是不是就有
01100000 00000000 00000000 00000000了,这样我们是不是就利用之前最左边的一个1得到了两个1?然后我们再想想这个时候再右移两位,然后再或一下是不是就由两个1得到四个1?01111000 00000000 00000000 00000000。然后得到8个1,16个1,01111111 11111111 10000000 00000000,这个时候再右移16位,是不是就得到15个1?然后或一下不就31个1了?然后return的时候+1,不就得到10000000 00000000 00000000 00000000了?

  当然上面的例子是个特例,因为这就是为什么从右移一位开始,右移16位结束的原因,如果中间某个位置有1的话,那也不影响,1|1也是1,能把中间的位置填为1就行。

  懂了吗?没懂评论区可以说一下,hhhh我们再讨论讨论。

  那这个0.75是用来干嘛的呢,我们先看这四个变量

    transient int size;
    //size很简单,我们的hashmap.size()返回的就是这个值,表示现在的hashmap里面有多少个键值对
    transient int modCount;
    //modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
    int threshold;
   
    final float loadFactor;

  loadFactoer就是默认为0.75的值了,这个loadFactor * Length(Node初始化数组长度) = threshold,而当Node数组中的数量超过threshold的时候,那么就是要扩容了。扩容为了保证容量依旧是2的幂次。每次扩容都是容量x2。

3.2、HashMap的扩容问题

  最简单的问题,什么时候需要进行扩容呢?
  1、众所周知当HashMap的使用的桶数达到总桶数*加载因子的时候会触发扩容;
  2、当某个桶中的链表长度达到8进行链表扭转为红黑树的时候,会检查总桶数是否小于64,如果总桶数小于64也会进行扩容;
  3、当new完HashMap之后,第一次往HashMap进行put操作的时候,首先会进行扩容,这个其实就是,如果只是new了一个hashmap,他并不会给他分配内存,初始化大小,只有在第一次put的时候才会进行扩容,分配。

JDK1.7

  我们看看1.7的resize方法:

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
 
        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

  这段代码比较简单,就是如果table的长度到了上限了,就不再扩容了,如果还未到上线,则创建一个新的table,然后调用transfer方法,这个transfer方法才是关键。

    /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;              //注释1
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); //注释2
                e.next = newTable[i];                  //注释3
                newTable[i] = e;                       //注释4
                e = next;                              //注释5
            }
        }
    }

  transfer方法的作用就是将原来table的node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表的是相反的(在HashMap线程不安全的情况下,这种头插法可能会导致环状节点,所以1.8把这个改成了尾插法)。
  其实过程比较简单,假设现在的链表为3->5->7,e表示当前节点,next表示下一个节点。
  第一步e指向3,next指向5,然后将3放入newtable,newtable变成3->null,table变成5->7,
  第二步e指向5,next指向7,然后e的next指向newtable的第一个节点3,然后此时将e赋给newtable[i](注释4),就相当于newtable的链表变成了5->3
依次类推、、
在这里插入图片描述

  头插法怎么导致的环形链表呢,我们看如果有两个线程对同一个hashmap进行处理,线程A在分配给e = 3,next=5的时候,这个时候线程B抢占了CPU,对hashmap进行处理,并且完成了对hashmap扩容的过程,得到了下面的结果
在这里插入图片描述
  在线程A中e=A,next=B,但是当线程B执行完之后会有一个什么结果?因为A,B是对同一个hashmap进行处理,所以线程A所对应的hashmap其实也变成了B的样子,在这个时候A得到了CPU的使用权,但是我们看到的是虽然e=A,next=B,但是这个时候5.next = 3,3.next=null(这个是环形产生的关键)
在这里插入图片描述
  当线程A继续操作的时候,按照管理,先把3放入第三个位置,得到,然后e=next=5。
在这里插入图片描述
  按照正常操作,

				int i = indexFor(e.hash, newCapacity); //注释2
                e.next = newTable[i];                  //注释3
                newTable[i] = e; 

  下一步就是把5插到3前面,得到
在这里插入图片描述
  到了这个时候,是最关键的一步,在线程B执行之前,5.next = 7,这个时候我们再进行e=next的时候e应该等于7吧。但是因为线程B执行了之后,现在5.next=3,所以线程A下一个循环应该是e=3,然后e.next = newTable[i] = 5;是不是就有了3.next = 5?那么环形链表就成了
  这里有一个indexFor函数是JDK1.7为table链表中的元素计算新下标的函数其实也可以看成是rehash,h为当前元素的哈希值,length为新table的长度。

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

  因为length-1其实就是length/2位上的所有位都变成了1,比如扩容前为16,扩容后为32,31就是11111,也就是对应的后五位为1,前面的位数全是0,这样的结果就是任何数跟11111与的话,得到的结果都在0-31的范围内,并且因为全是1,比有0存在的情况,更容易将原来的hash值散列化。

  为什么全是1与更容易散列?如果11111的话 ,只有后五位全相同的情况下,才可能会产生哈希冲突,但是如果有一个0的话,假设11011,我现在10100和10000哈希得到的结果都是100000,这样就11011就比11111会多产生一次哈希冲突。虽然在解决散列问题又不错的效果,但每个hash值都要重新计算,还是很费时间,所以1.8就把这个给改掉了。

JDK1.8

  同样先看看resize函数

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)                      //注释1
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        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;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {                                 //注释2
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)                                        //注释3
                        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) {                      //注释4
                                if (loTail == null)                            //注释5
                                    loHead = e;
                                else
                                    loTail.next = e;                           //注释6
                                loTail = e;                                    //注释7
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {                                  /注释8
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

  1.8在1.7的基础上改进了不少。
  第一个把原来的头插法改成了尾插法,这样就避免了产生换新链表,尾插法就很简单了,结合前面的讲解,看个图就好了。
在这里插入图片描述
  第二个改进也就是我们前面聊到的rehash需要每个hash值都重新算一遍的问题,在1.8版本,jdk改进了这个index的过程。

  因为扩容之后,容量多了一倍,就相当于index的范围在二进制的位数上最左边多了一位,所以,可以直接判断oldhash值的前一位是0还是1,比如现在16要扩容为32.对于1 0010和0 0010原位置都为2,jdk1.7需要对两个hash值对11111进行与操作,在jdk1.8中只需要判断,32/2 = 16该二进制位上是1还是0,如果是0的话,就直接保留原位置,如果为1的话就原位置+16,所以10010rehash之后为18,而00010rehash之后还是2。

  1.8这一步就大大减少了rehash的位运算时间,提高了效率。
在这里插入图片描述
  关于hashmap的容量和扩容问题差不多到这就结束了,接下来就是hashmap和hashtable的对接部分,hashmap的安全问题了。

4、HashMap的安全问题

  其实安全问题在上面1.7版本的时候已经说过一个了,就是在不上锁的情况下,扩容的时候会形成环形链表,这个在1.8版本已经用尾插法解决了。

  在JDK1.8中,put函数我们可以看到,其实也是没锁的,所以当两个线程同时对同一个key进行操作的话,后操作的线程会将前操作的线程给覆盖掉。

  所以,即使hashmap在很多技术上都采用了很巧妙的方法,但是因为线程不安全,所以用的时候还是需要注意。

5、HashMap解决线程安全问题

  解决HashMap的线程安全问题第一个就是使用HashTable,但是从JDK1.0HashTable提出,到现在,HashTable已经被弃用了,一方面是HashTable为了线程安全,变得效率比较低下,比不上HashMap,一方面是HashTale继承了Dictionary这个已经被弃用的父类,父类都被弃用了,那子类肯定就不会再用了。
在这里插入图片描述

  所以一般解决HashMap线程安全问题一般都是用第二种方法:用ConcurrentHashMap类。
在这里插入图片描述
  一方面ConcurrentHashMap继承了HashMap性能较好的优势,一方面又跟HashTale一样,线程安全,并且实现线程安全的方法要优于HashTable。所以这里我们主要看看ConcurrentHashMap为什么实现线程安全的方法优于HashTable。

  HashTable实现线程安全得方法比较简单粗暴,每次上锁的时候,直接将整张表锁住,这样就只能每次一个线程独占hash表,这样虽然实现了线程安全,但一定程度上性能还是很差的。ConcurrentHashMap在HashTable的基础上进行了改进,先是把哈希表分成若干个segment,给每个segment上一个锁,这样当某一个segment被独占的时候,其他的segment也能被其他线程并发访问,很显著的提升了效率。
  那么这样一来,ConcurrentHashMap的结构其实就很明显了,ConcurrentHashMap主要数据结构为一个segment数组,每个segment数组元素对应一个HashEntry数组,这个数组就跟HashMap的Node数组基本差不多,每个segment享有一把锁。
在这里插入图片描述
  上面说的都是Java1.7版本的ConcurrentHashMap的实现,在1.8版本,Java通过CAS实现了无锁的处理方法,这个在这里就不细讲,准备在下一篇文章写JAVA多线程JUC和锁的时候详细分析一下CAS。但是我们可以看到,segment是继承了ReetrantLock类,这是JUC中的一个可重入类,具体的可以看看这篇文章

    static class Segment<K,V> extends ReentrantLock implements Serializable {
        private static final long serialVersionUID = 2249069246763182397L;
        final float loadFactor;
        Segment(float lf) { this.loadFactor = lf; }
    }

  ConcurrentHashMap的不足?

  第一个是因为多了一个segment选择, ConcurrentHashMap在hash的时候其实是hash了两次,第一次找到segment,第二次才找到HashEntry,所以如果不是多线程的话,用HashMap的效率一般会更高。

  第二个因为写的时候会锁住segment,但是读的时候是畅通无阻的,随时可读,所以可能读到的一些结果不是最新更新过的结果。

  对于HashMap和ConcurrentHashMap你有个大概的了解了吗?如果有什么问题或者文章又不足的地方,欢迎交流!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值