详细HashMap分析

​ 相信大家都知道,在Java8之前,HashMap采用了数组+链表的结构,在Java8中加入了红黑树,为什么在Java8中决定加入红黑树呢?我们先来看一下HashMap的数据结构

在这里插入图片描述

如图,在Java8之前是没有右边红黑树结构的,只有数组+链表,为什么采用了这两种数据结构呢?

数组:查询效率比较高,增加删除的效率比较低;

链表:增加删除效率较高,查询效率较低;

基于这两种数据结构的特性,所以在Java8之前采用了数组+链表的方式,结合两种数据结构的优点。

  • 那为什么在Java8还要引入红黑树呢?

    大家思考一下链表的存储方式。尽管在数组中加入了链表的结构,那么数据存储多了,链表是不是会变得越来越长,那查找链表末尾的数据时,需要将链表遍历完成才能得到该数据。基于这种情况,所以在Java8中引入了红黑树。

  • HashMap的总体结构就是这样了。不知道大家现在是否会有一个疑惑,如图中,每个方框表示数据存储,HashMap又是以Key-Value的形式存储的,又有数组,链表等结构,HashMap是如何实现存储的?

    其实,按照我们学过的数据结构知识,可以很快想到,Java是基于面向对象的,那么作者可能会在创建一个包含key和value属性的类,假如是Node,那么HashMap就会维护一个Node[]数组和Node类型的链表, 我们可以从源码中得到考证:

    static class Node<K,V> implements Map.Entry<K,V> {     
    		final int hash;
            final K key;
            V value;
            Node<K,V> next;
    
    	/**
         * The table, initialized on first use, and resized as
         * necessary. When allocated, length is always a power of two.
         * (We also tolerate length zero in some operations to allow
         * bootstrapping mechanics that are currently not needed.)
         */
        transient Node<K,V>[] table;
    }
    

从源码中我们可以看到,作者在HashMap中确实维护了一个有泛型的key和value的类,只不过这里多了两个属性:hash和next;还定义了**Node[]**成员变量,所以从这里可以看出,目前我们的想法是正确的。

大家已经了解了Java8中HashMap的总体组成:数组+链表+红黑树

在使用HashMap中,这种组合方式是一个循序渐进的结果,在这种组合中,大家认为哪种结构是最容易实现的?我认为是数组,因为数组只要new Node[size]创建一个就了事了,在源码中我们也可以看到“DEFAULT_INITIAL_CAPACITY”这个属性,为16,这是一个二进制的左移操作。(1的二进制位0001,左移4位变成10000,也就是2^4=16)

注:这里大家请注意一下属性的注释

   /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

  • 那么在这样的一个长度16的数组里,HashMap是如何确定存放位置的呢?

当我们要向数组里存放数据时,应当先确定存放的位置,然后判断位置上是否已有数据,再决定数据的存储方式。

这里我们直接map.put(“key”,“value”)操作,回来到下面的代码中

/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
 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);

可以看到,**tab[i = (n - 1) & hash]**这里计算出数组中的位置,那为什么作者要这么计算存储的位置呢?

我们先不管作者是如何想的。我们自己思考,要在一个长度16的数组里确定存放位置,我们应当将索引确定在

0-15之间,如何确定索引?这一hash值就派上用场了,首先我们看一段代码:

System.out.println("Dawn".hashCode());

/**
*	结果
*/
2122804

我们可以先假设通过hashCode()方法取到一个int整数,那么,作者可以使用这个整数去计算得到一个0-15的整数,我们可以想到,最简单的方法就是直接取余,2122804%16就可以得到一个符合要求的数,那么为什么作者选择了

(n-1)&hash逻辑运算的方式呢?

我们从上面已经得知,数组的默认长度是16,作者采用二进制右移的操作得到,16的二进制位10000.这里判断采用与运算可以看到16-1的结果为15(01111),那么这里就可以得出结果了,这样运算,也保证了结果是在0-15之间的,如下:

10101010010101
         01111 &
-----------------
00000000000101 = 500000-011111

到这里,我们也可以知道,作者处理hash冲突的方法,当计算到相同的索引时,会转化成链表向下顺移存储。那么,这种方法还有什么需要优化的吗?

大家有没有发现,如果这样直接用hashCode去进行与运算,真正决定索引位置的只有后面5位,如果每次计算的索引值都是同一个,那么链表会越来越长,性能上肯定实惠大打折扣的,那么针对这种情况如何进行优化呢?目前能想到的,就是尽可能的让更多的位数来计算决定索引的位置,得出0和1的概率要接近或相等。参考下图:

在这里插入图片描述

可以看出,进行异或运算得出0和1的概率是相同的,我们再去瞅瞅作者的源码:

 /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

这里可以看出,作者将hashCode高16位右移16位与低16位进行异或运算,这里也解答了上面初始长度的注释中为什么会有这样一句了**“The default initial capacity - MUST be a power of two”**

为了保证索引在0-(n-1)之间,那么初始长度只能是2的n次方了,因为作者进行了一次减1操作,会得到01111这样的二进制数。但是,在创建HashMap时我们可以自己设定长度,如果我们偏不按照作者的规则走,那又如何呢?其实作者有自己一套应付方法。

/**
     * Returns a power of two size for the given target capacity.
     */
    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;
    }

如果你传入了不符合规则的长度,作者会将你的长度转化成2的n次方。

对索引的均匀分布已经得到解决,那链表长度过长的问题呢?这个就简单了,当链表的长度达到一个阈值时,会转化成红黑树,从源码中可以得到该阈值是8

/**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

具体大家可以去看看源码。

现在还有一个问题,就是数组的扩容问题

//------------------------------------------------------------------------------
	if (++size > threshold)
            resize();

每次向HashMap中添加值的时候,都会size变量都会自加与threshold进行判断。我们可以数组初始化resize()方法中找到相应的代码

   /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
----------------------------------分界线--------------------------------------------
 final Node<K,V>[] resize() {
    ...
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
	threshold = newThr;
    ...
}

将数组的长度乘以0.75,然后将结果值赋给threshold,所以这个阈值为0.75,如果没有进行长度指定,那么数组的长度为16,当超过16*0.75=12的时候,数组就会扩容。

那么如何扩容呢?其实很简单,就是重新创建一个数组,容量为原来的两倍,将原有的数据转移到新的数组上,但是,数组的位置可能会不同,这里会根据新的数组长度进行hash计算,原数据可能会在原下标的位置上,或者是在原下标+原容量的位置上。

相信大家也知道,HashMap不是线程安全的。

HashTable是对每一个get put等方法加上一个synchronize实现线程安全的,但是这种方法会影响性能。

所以,这里要说的是ConcurrentHashMap,相比HashTable,ConcurrentHashMap的线程安全机制做的比较好,它没有向HashTable那样直接在方法上进行加锁,而是采用了锁分段技术。这里笔者能力有限,还没继续深入研究。

对HashMap的分析就到这里,如果有错的地方,欢迎留言。_

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值