教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?

拉克丝小声的在嘀咕: 天苍苍海茫茫,面试我很忙...

" 今天看你早早地准备去面试,又这么沮丧的回来,面试的不好? "

拉克丝十分生气地说:" 我学了这么多的知识,为啥面试以前的问题还问,现在都没人用了,他问我你对 JDK1.7版本的HashMap有什么了解,现在不是都用 1.8了,他居然还问我这个问题(深深叹了一口气)。

"来我给你讲一下JDK 1.7HashMap 去给我买杯 奈雪的茶"

" 你怎么还不去买呀"

"你给我掏钱呀?" 

(我居然自己搬石头扎了自己脚),过了几分钟,拉克丝高兴地拿着奶茶回来了给我,我拿着手中的奶茶,奶茶再也不香了前言

  • 这篇文章是基于JDK1.7 来深入了解HashMap 的底层数组+链表
  • 你通过这篇文章可以学到 在1.8版本更新前 HashMap的工作原理,怎么填充数据 和获取数据已经1.7版本HashMap存在的几个问题。

正文

接下来我们用 思维导图来分析一下HashMap

教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第1张图片

假如,我们现在已经把刘备放进了这个数组,这是我们再放进去一个 变量如下

     教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第2张图片

 

  • 例如,我们又有一个问题,如果我们再添加一个元素.索引和上面一样都是相同的,那我们是添加上边呢还是下边呢?

 

教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第3张图片

  1. 这里为啥 刘备2会放在头部呢,
  2. 因为这样是基于链表的效率来说,放在链表的头部是最快的,那为啥不是放在 刘备1 的下面呢?
  3. 因为 如果你放在尾部的话 你需要遍历整个链表的尾节点是谁,所以是放在链表头部是最快的

但是这样也出现了一个问题

  • 我们现在是模拟put 添加数据,但是如果我们 想要获取 get("刘备2")来获取它的value,他返回的也是 数组刘备的下标,所以我们就无法获取到刘备2的value值,这个问题我们已经怎么解决呢?
  • 解决思路: 当我们在刘备的 头部添加刘备2 的同时应该在做一步操作就是向下移动一位如下

            教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第4张图片

  1. 这样链表的所有数据我们都可以通过 get 来获取到了

教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第5张图片

"你说的好复杂呀,怎么思维导图都这么复杂呢"

"其实也不是很复杂,来接下来带你看看源码,你就知道了"

 

源码

1),首先我们先来看一下它的属性

//默认数组的容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//默认数组最大的限制
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认的加载因子是 3/4=0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//空的数组
static final Entry[] EMPTY_TABLE = {};

//table 存的就是Entry 放的就是 k ,v
transient Entry[] table = (Entry[]) EMPTY_TABLE;

//每次put 添加数据的时候 +1  size()方法返回的就是size
transient int size;

//扩容界限 默认是 16*0.75=12
int threshold;


final float loadFactor;

//这个也很重要,这个与 ConcurrentModificationException 异常有关
transient int modCount;

static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

2),接下来我们看下他的构造方法

    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;
        //为什么把容值赋值给一个域值( threshold ) 呢?
        threshold = initialCapacity;
        //init() 方法是空的,实际上是在 LinkedHashMap上用,这里我主要说hashmap 这里不在继续深入了
        init();
    }

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


    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

3),接下来我们来分析一下 put() 方法

    public V put(K key, V value) {
        //判断当前数组是否已经初始化,类似于懒加载/延迟加载,当你put存储元素的时候,才进行初始化
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

我们具体来看下inflateTable是怎么进行初始化的。

    inflateTable(threshold);

方法为
    private void inflateTable(int toSize) {
        //翻译为: 找到一个 toSize的2的幂次函数
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        //创建了一个 Entry对象长度为 capacty  
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

注意:
    1),不知道大家发现没有 inflateTable()传入的参数 toSize 就是 threshold 
    2),threshold 就是我们再构造器自己指定数组容量 ,如果没有指定那就是默认的容量16

问题:
    1),为什么不是直接使用 toSize 来作为新数组的长度?
    2),为什么要根据 toSize 来获取它的2的幂次函数来作为新数组的长度呢?
    

我们来看下 roundUpToPowerOf2(int toSize) 的源码 ,我们假设在创建数组的时候,指定容量为10,toSize = threshold =10;

    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    } 

注意:
    1),number >= MAXIMUM_CAPACITY 10<最大容量,索引会直接走三目表达式的的false
    2), Integer.highestOneBit((number - 1) << 1) 这个方法是roundUpToPowerOf2()方法的关键所在

那 Integer.highestOneBit() 这个方法是干什么的呢?

    public static void main(String[] args) {
        int i = Integer.highestOneBit(15);
        int i1 = Integer.highestOneBit(16);
        int i2 = Integer.highestOneBit(17);
        System.out.println(i);
        System.out.println(i1);
        System.out.println(i2);
    }
    //运行结果为
    8
    16
    16

从结果可以看出
    :这个方法会根据你传入的数,得到一个小于等于这个数的2幂次方函数,例如你传入的是10,那么你得到的就是8

拉克丝一脸疑惑地问: 你刚才不是说 roundUpToPowerOf2() 方法返回的是大于等于2的幂次方函数? 然而在这个方法内部实现却是 Integer.highestOneBit() 方法返回却是 小于等于 2的幂次方函数,这样不就发生冲突了?

"这个问题都被你发现了,那为什么会这样呢?让我来给你讲一下 Integer.highestOneBit() 方法是如何实现的你就知道了"

    public static int highestOneBit(int i) {
        // HD, Figure 3-1
        i |= (i >>  1);
        i |= (i >>  2);
        i |= (i >>  4);
        i |= (i >>  8);
        i |= (i >> 16);
        return i - (i >>> 1);
    }

例如我们现在传入的i=10 

扩展:
                                      8421
8421码: 例如 10的8421码,二进制表示为 0000 1010  , 这里结果为 8+2=10,如果高位有1,每次都是2倍递增。

|(或运算): 只要有一个为1,结果就为1。例如 0|0=0; 0|1=1;1|0=1;1|1=1;


方法执行1 =10如下步骤

i |= (i >>  1); 运行如下 >> 右移是从左边添加一个0
第一步右移一位
10的二进制   0000 1010 
右移一位     0000 0101 
|(或运算)
结果为       0000 1111

第二步 右移俩位
拿到第一步结果 0000 1111
右移二位为    0000 0011
|(或运算)
结果为       0000 1111

其实后面的都不用直接运算了,因为右移都是向高位补0,而低位一直都是1,所以结果一直都不会变了

这时,我们来看最后一句return 语句

i - (i >>> 1)

i值为     0000 1111
i >>> 1  0000 0111
-
结果为    0000 1000 即 8

因为我们传入的是10 所以它的最小2的幂次函数就是8 ,这不正是我们需要的结果呢


"你这里是特殊情况吧,比如我要传个很大的值,他的二进制不是应该很长,你这种情况不适用呢?拉克丝问道

"看来你还是不是太懂呢,那我在给你举个栗子你就懂了"

现在我就不举一个具体数字的例子来给你算一下了,我就直接用 * 来表示这是一个很长的数字

第一步左移1位
传入的值   0001 **** 
右移一位后 0000 1***  (注意: 因为 0|1=1;1|0=1;1|1=1,所以取| 结果一定为1)
|(或运算)
运算结果   0001 1***


第二步右移2位
1结果值    0001 1***
右移2位后  0000 011*
|(或运算)
运算结果为 0001 111*

第二步右移4位
2结果值为  0001 111*
右移4位    0000 0001
|(或运算)
运算结果为 0001 1111


发现:
这里你有没有发现 这个算法就是在慢慢地,慢慢地把你的这些低位转化为1,那你为什么还要有右移8位,右移16位呢?
:因为我们是一个int类型,int类型占4个字节,每个字节8位 总共有32位。

拉克丝感叹地说"啊,JDK的作者已经对运算已经到了出神入化的地步了,我想都想不到"

这时我们回到 初始化table的方法中 Integer.highestOneBit((number - 1) << 1)

  • 其实要返回大于等于2的幂次方函数 , 但方法中使用了 找到小于等于2的幂次方函数,其实是  (number - 1) << 1 这段代码起了作用,使其不冲突。
Integer.highestOneBit((number - 1) << 1)

例如 我们传入的是10 这时我们要得到一个2的幂次方数,值为16,按照这样的话 我们肯定要把10变大,取值范围为 16< 值 <32

这时我们不关心它-1,我们直接 把10左移一位

10         0000 1010
左移一位为  0001 0100 即20

例如 我们的后几位全是 0001 1111 后面全是1 结果是 31 也没有超出我们的范围

这里左移一位,直接判断他的值取值范围在 16 < 20 < 32,正是我们要的 


那它为什么要 -1 呢?
: 这里是一种特殊的情况,例如 我们传入的是8,本来就是2的幂次方函数,执行这个方法返回的应该是8,如果不减1会出现下面这种情况

8的二进制 0000 1000
左移一位  0001 0000 结果为 16 与我们想要得到的结果不一致,所以要进行-1操作

8-1=7 的二进制位 0000 0111
左移一位 为      0000 1110 结果为12 

找到最低的2的幂次函数 是8与我们想要的结果一致

 那初始化数组完成后,我们现在继续看这个方法是如何计算索引的

put() 方法内部源码
  int hash = hash(key);
  int i = indexFor(hash, table.length);

    //这是根据key计算出它的hashCode
    final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    
    //通过拿到的hashcode 和数组长度 算出数组下标
    static int indexFor(int h, int length) {
        return h & (length-1);
    }


扩展
按位与& :两位全为1,结果才为1,例如 0&0=0;0&1=0;1&0=0;1&1=1
异或 ^ : 两个相应位为“异”(值不同),则该位结果为1,否则为0,例如0^0=0; 0^1=1; 1^0=1; 1^1=0;

疑问: 根据上面的思维导图是 hashcode的值 % 数组的长度来进行计算的,那么这里的这样一句代码是什么意思呢?

例如我们调用 indexFor()这个方法,随便传入一个hashcode 数组长度为16 进行如下计算

16-1 =15 的二进制位 : 0000 1111
随便写一个二进制 :    1010 1010         
进行&操作
结果为               0000  1010 即10 

从这个可以反映出,高位都是0,底位和这个hashcode的底层都是一样的,那么,我这个结果的取值范围就是你这个低四位的取值范围,而我这个hashcode本身就是随机的,是在 0000 -1111本身就是0-15之间的,正好符合我们的约束。

其实这是有一个规律的:
:就是它的高位都是0,低位都是1,如果说我们直接拿16来进行运算,结果将会怎么样呢?

16的二进制位    0001 0000
随机hashcode   1010 1010 
进行&操作
结果为         0000 0000 即 0

注意: 即得到了结果为0,不符合我们的要求

这里我们反推一下,indexFor() 方法的length 一定要是2的幂次函数,才能和 h & (length-1) 进行配套使用,所以这里也解决了我们刚开始那个疑问,为什么要在创建数组长度的时候为什么一定要找一个大于等于2的幂次方函数,而不是直接使用我们指定的长度。

最后我们再来看一个问题

15的二进制: 0000 1111
Hash二进制: 0100 1010 (这里不管我们怎么改变hashcode 的高位,例如 1111 1010 ,1010 1010 等等)
进行&操作
结果为      0000 1010 即 10

得出结论: 不论我们如何改变这个hashcode的高位 ,最终都不会影响它的结果


hash() 计算出它的hashcode方法如下

 h ^= k.hashCode();
 h ^= (h >>> 20) ^ (h >>> 12);
 return h ^ (h >>> 7) ^ (h >>> 4);

例如 一个hashcode 0100 1010 
      向右移动4位 0000 0100
      进行^操作
      结果为      0100 1110

经过^运算,右移运算之后,^运算后的hashcode和原先 k.hashCode() 的方法所返回来的hashcode 值,高位也参加到这个运算中,这也就解答了我们第二个问题,为什么算出来的hashcode 直接拿出来之后为啥要进行右移运算(散列性)。

因为拿到它的hashcode 是根据Object.hashCode()来拿到的,因为他考虑到你可能重写它的hashCode()方法,那么很有可能,你的技术水平不行的话,你重写出来的hashCode()方法,是有问题的,从而导致调用你重写的hashCode()返回的值,非常不均匀,那么它通过右移这些方法,可以去容错,这一点在jdk1.8会有一些优化。

"好了,到这里讲的听懂了?"

"有那么一点点感觉,我已经拿我的小本本给记下了,我这边再问你一个问题,那为HashMap什么要使用链表呢?"

"因为不管你是通过hashcode()算法 还是 indexFor() 方法来最终算出它的数组下标都有可能是重复的,这时就发生了冲突,这被称为Hash冲突,而解决Hash冲突只有俩个办法 1),就是我们这里说的链表法  2),就是再散列法,如果算出来的索引和旧的索引发生冲突,那么我就在次计算一个新的索引,直到没有发生重复为止。"

当我们的key为null的时候,put()方法是如何做的呢?

      if (key == null)
         return putForNullKey(value);



    private V putForNullKey(V value) {
        for (Entry e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }
  • 可以看出 当hashmap 处理 key == null 所有的元素,都会存储在数组的第一个元素, 在for循环中增加了 e.key == null的判断我们可以知道 数组只能存储一个 key 为null 的键值对

 

"我接下来讲的也很重要,来我们再看下put方法是如何解决下面这个问题的"

    public static void main(String[] args) {
        
        HashMap map = new HashMap();
        map.put("1", "1");
        String value = map.put("1", "2");
        System.out.println(value);
        System.out.println(map.get("1"));
    }

运行结果为:
1
2
  • 我们继续看 put 源码如下
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
  • HashMap中只能存一个 相同的key 
  • 通过运行结果我们可以发现,当我们在数组中在插入一个 字符串1 的时候返回的 value结果为 1 然后我们通过 get来获取1 则返回的是 2 ,我们可以看出来 2 覆盖了之前的1 ,并且当第二次put添加key为1的时候返回的是第一个 结果1
  • 当我们把相同的key放进数组的时候,他会循环整个数组,判断数组 hash值 key的地址值 字符串是否相同,如果相同 则返回 旧的value,然后把旧的value改为新的value。

 

我们接下来在put()方法往下面看具体代码

    //把k,v放进数组中去
    addEntry(hash, key, value, i);

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //判断是否扩容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
           
        createEntry(hash, key, value, bucketIndex);
    }

    //具体添加到HashMap 的方法
    void createEntry(int hash, K key, V value, int bucketIndex) {    
        
        // 获取链表节点的 Entry        
        Entry e = table[bucketIndex];
        //把新的节点插在了链表的头部
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        //+1 表示数组个数
        size++;
    }
  • 当size >= capacity * load factor 的时候,有可能进行扩容的另一个条件是 null != table[bucketIndex] bucketIndex就是计算出来的那个索引

"那为什么hashMap需要进行扩容呢?"

  • 因为对于hashmap来说,数组是一个连续的空间,在初始化数组的时候,就已经固定好了数组的长度,我也不能像链表一样自动扩充它的长度,在对数组进行扩容的时候,必须新创建一个数组这才是数组的扩容
  • 主要原因是如果不进行数组扩容,当hashmap存储很多个数据的时候,因为数组的长度是不变的,所以一直数组上链表的长度会非常长,这样当hashmap 通过get来获取数据的时候,获取效率会非常低,所以我们要扩容数组的长度,使它能够保存更多的数据,这样链表就变短了,效率也增加了。
addEntry() 方法

    resize(2 * table.length);

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

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry e : table) {
            while(null != e) {
                Entry next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
  1. resize() 方法默认扩容长度是原数组的2倍
  2. transfer() 方法是把旧数组的数据 移动到新数组中,我们接下来主要看这个方法到底是怎么进行数据转移的。

 

教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第6张图片

  1. 其实hashmap 是按单线程来进行转移的,就算你数据掉头也是没有关系的。
  2. 我们现在在想一个问题,那么多线程情况下,就是俩个线程同时调用你这个 hashmap 对象,去调用你的 put() 方法,俩个线程都同时都会走到  resize() 方法,都会创建俩个新的数组,俩个线程会公用这一份旧的数据,如果又一个线程已经都执行完之后,第二个线程执行到 e.next() 就会被阻塞到。

教拉克丝去面试(二) 1.7版本HashMap源码你知道多少?_第7张图片

 那我们应该怎么在多线程情况下,避免hashmap的死循环链表呢?

  • :我们再使用HashMap的时候,尽量不让它进行扩容,如这个判断 size >= threshold ,这里假如我们这里有32个要存储的数据,这里我们通过配置 加载因子和 数组容量如 new HashMap(32,1); 这里我们算出来的扩容 threshold也是32,这样的话就可以避免我们的扩容,这样也是一种解决情况。

 

  1. transfer() 数组转移 还有,一个判断我一直没有说如下
              if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
  1. initHashSeedAsNeeded(newCapacity) 布尔类型的 rehash 主要是通过这个方法来生成的,如下
 //值默认为 0
 transient int hashSeed = 0;  

 
 //   capacity 值就是新数组容量的长度
 final boolean initHashSeedAsNeeded(int capacity) {

        //currentAltHashing 默认就是false
        boolean currentAltHashing = hashSeed != 0;

        //sun.misc.VM.isBooted() 判断JVM是否启动 当然一般都是启动的呢 true
        //Holder.ALTERNATIVE_HASHING_THRESHOLD 值为 Integer.MAX_VALUE
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);

        //switching  因为 currentAltHashing 为false 
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                ? sun.misc.Hashing.randomHashSeed(this)
                : 0;
        }
        return switching;
    }

这里我们可以看出来
: switching  = false ^ useAltHashing 当useAltHashing  我们可以知道 只有当capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD 时useAltHashing   才为true 才能 运行

     if (rehash) {
         e.hash = null == e.key ? 0 : hash(e.key);
     }
但是我们有知道了 Holder.ALTERNATIVE_HASHING_THRESHOLD 是Integer 的最大值 ,所以我们怎么用都不会出现 为true的情况,所以 if(rehash) 方法里面的代码一直都不会执行

"可学到了,给你讲了这么多有点饿了,我去找点吃的,你先理解一下,故事还没有结束哦"

"HashMap 数组 , 链表" 拉克丝看起了笔记默默地念叨

 

 

你可能感兴趣的:(java面试,HashMap,1.7版本HashMap,java,底层)