【hashMap系列】数据结构源码分析

目录

  • 为什么容量必须是2的幂?
  • 为什么树化容量是8,还原链表容量是6?
  • 为什么容量>64才能树化
  • tableSizeFor是如何把用户传入的初始值,转化成最近(大)的一个2的幂的数?
  • HashMap底层数据结构(为什么是红黑树)
  • HashMap合适扩容(何时变红黑树/链表)

先看HashMap的类之间的关系图,全局了解它的位置。

1)hashMap底层数据结构原理

          介绍hashmap类中的几个关键属性字段(注意看注释,加以思考)

    /**
     * The default initial capacity - MUST be a power of two.
     */
    //默认初始容量 16  Capacity指桶的数量(数组中的容量,非整体k v个数)
    //容量必须是2的幂
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    //最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    //默认负载因子,当size值大于capacity*loadfactor时进行扩容
    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    

    /**
     * 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.
     */
    // bin指的是每一个KV 
    //树化容量:当一个桶中的KV个数超过(>)8的时候,则use a tree rather than list for a bin
    static final int TREEIFY_THRESHOLD = 8;

   
    //由树还原成链表的容量,为什么是6   (和8??需要思考一下)
    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;


    // 容量(数组桶个数)>64的时候,才允许将链表变成红黑树
    //否则:直接扩容,而非树化
    //为了避免扩容,树化方式的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;


 

         Q:为什么容量必须是2的幂?

               在查询一个key对应的位置index时,源码中是这样计算的:i = (n - 1) & hash   n是数组的长度。因为要避免碰撞,所以在取值,元素的位置时要尽量分散不重复,所以这个i(也就是index)的取值规则很重要。hash值是Object类生成的一个hash值(int类型),本来就是随机的,所以与前面的(n-1)按位与(&)的话 ,只有2的幂-1的情况下,化成二进制都是1111111...,故再与hash按位与,则取决于hash的后几位,便均匀的分布在了0到n之间了。

               举例:加入数组容量是n=16(默认值),那么15 & (随机n),则出现的值,一定是在0到15之间。则平均分布到了16个桶中,避免了冲突碰撞。

               补充:& 是代表按位与,都是1才是1,否则为0

         Q:为什么默认初始容量 16 ?

               避免碰撞,取折中值。因为是8或者4的话很容易导致map扩容影响性能,如果分配的太大的话又会浪费资源,所以就使用16作为初始大小

         Q:为什么树化容量是8,还原链表容量是6?

              首先明白,树化是由链表转化成红黑树,因为链表太长,查找效率低,才会转化成红黑树,链表的查询时间复杂度 n/2,计算:

              bin(kV值)长度为6:链表时间复杂度  6/2 =3 。

                                      若变成红黑树,则时间复杂度 log(6)=2.58  (时间复杂度的计算,log默认是2为底)

                                      则链表为6的时候,链表更好。

             bin长度为7  时:链表时间复杂度:7/2=3.5

                                        红黑树:log7=2.8

             bin长度为8时: 链表时间复杂度  8/2=4

                                       红黑树:log8=3 (更优!)

             为什么不是7呢?这里应该是把7当成一个过度的点,不然元素增减频繁,转化也频繁,会耗费性能。            

         Q:为什么容量>64才能数化,并且防止冲突要至少大>4 * TREEIFY_THRESHOLD?

                这个值的含义是,如果数组(table)长度小于这个值的话,则没有必要进行结构转化(链表变树)反而扩容方式更好。

                当一个数组位置碰撞集中了很多个键值对,因为这些对象的 key的hash 值和(length-1) 相与 (&)得到的结果(index) 相同,但并不是hash相同的概率高,(可能仅仅是因为length太小了,所以会一直冲突碰撞),所以这种情况,扩容就好了,冲突会小一些),没有必要去转化成红黑树。                 

          介绍HashMap的几种构造函数

          1、传参 初始容量和负载因子

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

          主要方法:tableSizeFor(initialCapcity) 如何将传入的initialCapcity变成2的幂呢?文章后面会有讲解。

           2、传参初始容量   

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

                其中this方法调用的是第一种构造函数,默认负载因子是static final float DEFAULT_LOAD_FACTOR = 0.75f; 

           3、无参构造

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

              同样是默认负载因子,0.75。其他的都是默认值。

              这里注意,在第一次使用这个map的时候,才会把整个数据结构(数组)初始化出来--first use

/**
     * 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;

           小补充:transient的作用是,该字段(仅可修饰字段)在序列化的时候,不会被传递,参考作用:节省空间,无需传递。

 

           4、传入Map           

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

           此时这个新构造的map中会把参数中的map初始化进去。

          

      看下我们如何使用hashMap,刚才说table只有在第一次使用的时候,才会初始化整个map的数据结构,那么我们来看下源码,如何初始化一个hashmap。

 

     PUT方法     

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    其中hash方法如下

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

     hashCode()表示的是JVM虚拟机为这个Object对象分配的一个int类型的数值。为什么要(h = key.hashCode()) ^ (h >>> 16)呢?参考 此文章,简而言之,让hash更均匀。

    其中 putVal方法如下

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)
            //如果是第一次使用,则 初始化table,主要使用  resize() 方法来初始化
            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);
                        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;
        //如果map中的数据大于 threshold=capacity(桶数量:数组数量)*loadFactor
        //则进行扩容操作
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

 

    Q:tableSizeFor是如何把用户传入的初始值,转化成最近(大)的一个2的幂的数?

          先来看下tableSizeFor()的源码

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

            讲解这段算法之前,先介绍几个运算符号:

&按位与运算符
两位同时为“1”,结果才为“1”,否则为0
按位或运算只要有一个为1,其值为1
^异或运算符两个相应位为“异”(值不同),则该位结果为1,否则为0
~取反运算符对一个二进制数按位取反,即将0变1,1变0
<<左移运算符将一个运算对象的各二进制位全部左移若干位(左边的二进制位丢弃,右边补0)
>>右移运算符将一个数的各二进制位全部右移若干位,正数左补0,负数左补1,右边丢弃
>>>无符号右移运算符右移 expression2 指定的位数。右移后左边空出的位用零来填充。移出右边的位被丢弃

          查看上述n |=n>>>1 可以类比n+=1的计算方式来计算。

          为什么是  n>>>1 .....n>>>16呢?

           首先,1“n |=n>>>1”的意思是对一个二进制数依次向右移位,然后与原值取或。为了得到比传进来的参数(cap)   大的最近一个2的幂(2的幂的表现形式都是:1000......) ,所以,我们最后得到的n一定是111111....,最后再+1便得到了10000...这种2的幂的数。

            接下来就要各位一定动手写一写,一下就明白了。举个例子:

            假如我们传进来一个参数为17的容量cap,那么经过tableSizeFor算法计算的话,步骤如下:

             细心的同学看到这里,应该就能明白了,为什么tableSizeFor(int cap),要先-1,再做位或运算。因为,如果参数传入的是16,也就是本来就是2的幂,那么,经过运算之后,会变成32,更大的数字。            

            另外:容量是一个int类型的,int占4字节,一共32位。并且最大容量是1>>>30。(所以,n |=n>>>1一直到16,能把30位的最大的数字都覆盖上)      

    Q: resize()的作用有两个  ①初始化  ②扩容

            第一次使用的时候,才会初始化整个table,也是用的resize()方法,然后等容量大于threshold的时候,就会再次扩容,容量变成原来的2倍。        

          

在谈数据结构 JDK1.8

      【链表/红黑树】

      与1.7的主要区别是

  • TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。

  • HashEntry 修改为 Node。

   变树的条件:①链表上的长度>8  &  ②容量>64  两者缺一不可。上文中已经讲过原因。那为什么是红黑树呢?下面来讲一下各种树。

       如果不了解树结构的,可以参考:此文章

 

为什么是红黑树?

推荐文章:红黑树特性    红黑树对比AVL

红黑树和AVL树都是最常用的平衡二叉搜索树,他们支持插入,删除和查找。logN  

红黑树AVL
适合插入,删除密集场景平衡条件更加严格,适合查找
更通用(添加,删除,查找)查找速度快,但添加删除慢
 旋转难实现和调试

    

     

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值