zoukankan      html  css  js  c++  java
  • HashMap源码分析--jdk1.7

    1 简介

    Jdk1.7的HashMap是使用数组+链表实现了。
    Jdk1.8的HashMap是使用数组+链表+红黑树实现了。
    源码中采用了很多的位运算,里面的逻辑也是令人拍案叫绝~~

    在Jdk1.7中HashMap的结构大概长如下图的样子:

    2 存储过程

    当需要保存数据时,集合内部初始化一个数组,存入的key和value先封装很一个Entry对象,再根据传入的key计算该Entry对象应该挂在数组的哪个位置。如果数组的某个位置已经有元素了,则该位置由新元素替代,新元素的指向的下一个结点为原来的旧元素。如上图的[key1,value1],[key2,value2],[key3,value3]...[key7,value7]是按照顺序插入的,最后可能会形成的结构。

    3 几个重要的变量

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    

    默认的初始数组容量16,采用的是1左移4位得到。那你为啥不直接写16呢?

    transient int size;
    

    集合的容量

    static final int MAXIMUM_CAPACITY = 1 << 30;
    

    集合的最大容量1073741824,10亿多,应该是不会用完吧。。

    final float loadFactor;
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    

    加载因子,默认0.75,用来判断集合是否需要扩容会用到。

    int threshold;
    

    阈值。用来判断集合是否需要扩容,是根据数组大小和加载因子计算得来的 阈值=数组大小*加载因子。如数组大小为16,加载因子为0.75,阈值就是16*0.75=12,当然不是集合容量达到12就要扩容,还需要一个条件,后面会说明。

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

    我们说的HashMap的数组指的就是这个变量table,存放的是Entry对象的引用。这个Entry对象就是我们说的链表的结点。

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

    HashMap的链表结点,记录key,value,下个结点索引next,当前结点key的hash值。

    transient int modCount;
    

    记录集合操作的次数。比如增、删、改。

    4 深入源码

    4.1 新建集合

    我们先看一行代码,HashMap内部做了什么?

    HashMap<String,String> hashMap = new HashMap<>();
    

    该方法调用了HashMap的构造方法。

    public HashMap() {
            this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); // 默认数组大小16, 默认加载因子0.75
    }
    
    public HashMap(int initialCapacity, float loadFactor) { //16,0.75
        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; //加载因子修改为默认的0.75
        threshold = initialCapacity;  //阈值赋值为16,最后应该是12的,我们先不着急
        init();
    }
    

    我们的第一行代码主要是确定了加载因子和阈值。这个阈值其实是错误的,为什么呢?前面我们说过,阈值=数组大小*加载因子,这里它直接等于的数组大小16,这个16为了后面新建数组使用的。

    4.2 添加第一笔数据

    我们再看第二行代码,往集合中put数据。

    String s1 = hashMap.put("key1", "我是value1");
    

    调用集合的put方法。

    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> 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;
    }
    

    在put方法中,我们首先看到了下面的语句。别忘了我们的数组还没有初始化呢,数组就是在这个地方初始化的。

    if (table == EMPTY_TABLE) {
        inflateTable(threshold); //传入我们不太完美的阈值16,来初始化数组
    }
    

    4.2.1 数组初始化

    private void inflateTable(int toSize) { //16
    
        int capacity = roundUpToPowerOf2(toSize);//找到大于等于传入数的最小2次幂,我们传入的是16,这里返回的也是16
    
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//你看到我们的阈值变完美了吗? 16*0.75=12
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    
    

    roundUpToPowerOf2方法很有趣,作用是找到大于等于传入数的最小2次幂,我们传入的是16,这里返回的也是16,也就是找到我们数组的应该新建的大小。

    Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1)就是计算阈值了,我们的阈值现在变为12了,记住这个数字。

    initHashSeedAsNeeded方法的作用不是太清楚,没看出来干嘛的。听有的老师说这个也没用??

    4.2.2 计算key的hash值

    我们接着回到put方法中。

    int hash = hash(key);//我们的"key1"计算的结果为3237927
    

    可以看到,通过我们的key,计算除了一个hash值,过程太复杂,我没有认值看。具体的计算方法如下:

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

    4.2.3 计算应该存放在数组中的位置

    我们再次回到put方法中,可以看到如下代码:

    int i = indexFor(hash, table.length);//indexFor(3237927,16)计算结果为7
    

    该代码是根据我们刚才计算的hash值和新建数组的长度,得到了一个该hash值应该在数组中存放的位置。
    如果知道hash算法的话应该清除,即使是相似的元素,计算出来的hash值也可能千差万别。那怎么保证计算出来的位置是在table数组的范围内[0~15]呢,这就是下面算法的绝妙之处了。

    static int indexFor(int h, int length) {
        return h & (length-1);
    }
    

    这个方法是让两个数相与运算。我们的length为16,length-1的二进制形式为0000 1111,与任何数相与运算结果的范围都在0000 0000~0000 1111,也就是范围总在[0~15],刚好是table数组的下标范围。
    我们刚才计算的"key1"的hash值为3237927,二进制的后4位为0111,所以最后计算出来的结果为7。

    4.2.4 添加元素

    我们还是回到put方法中。

    for (Entry<K,V> 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);//3237927,"key1","我是value1",7
      return null;
    

    首先是一个for循环,从table数组中取出要放入位置的元素,判断是否为null,由于我们是第一次存入数据,当然为null,for循环里面的逻辑是不会执行的。for循环里面的逻辑其实是如果已经有相同的key在集合中存在了,就把存入新的value,把原来的value返回。

    modCount++;我们要往集合中添加元素了,操作步骤要加1了。在addEntry方法执行后返回null。下面我们重点介绍addEntry方法。

    void addEntry(int hash, K key, V value, int bucketIndex) { //3237927,"key1","我是value1",7
        if ((size >= threshold) && (null != table[bucketIndex])) {// (0 >= 12)&&(null != table[7])
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
    
        createEntry(hash, key, value, bucketIndex);//3237927,"key1","我是value1",7
    }
    

    addEntry方法传入4个参数,分别是key计算出来的hash值key,value,table下标
    下面的if判断是判断是否需要扩容。扩容的话,我们的table长度变为原来的2倍,之前存入元素key的hash值有的也会跟着改变。扩容部分下面再说,我们接着往下走。

    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex]; //取出原本table数组该位置上指向的元素,没有元素为null
        table[bucketIndex] = new Entry<>(hash, key, value, e); //3237927,"key1","我是value1",null
        size++; //集合的元素加1 ,现在集合元素数量为1了
    }
    
    Entry(int h, K k, V v, Entry<K,V> n) {//Entry的构造方法
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    

    首先取出原本table数组该位置table[7]上指向的元素,由于之前没有元素,所以取出的为null。
    接着创建一个Entry对象,放入我们的table[7]的位置上。
    集合大小增加1。

    至此,我们的第一行添加代码String s1 = hashMap.put("key1", "我是value1");执行完毕,当然返回的是null。

    4.2.5 添加一个元素后,集合结构

    我们有没有办法看到集合再添加完一个元素后的结构呢?答案是可以的。
    在put方法要返回的地方执行一段代码,查看集合的结构。

    for(int i = 0 ; i< table.length;i++){
        System.out.print(i+"-->");
        Entry<K, V> e = table[i];
        while (null != e){
            Object key = e.key;
            Object value = e.value;
            System.out.print("["+key+","+value+"] -->");
            e = e.next;
        }
        System.out.println("null");
    }
    

    给你一个图,让你看看现在HashMap内部是什么样子。

    4.3 添加第二笔数据

    刚才我们执行了下面的代码,添加了一笔数据。如果我们再添加一笔数据,集合会发生什么呢?

    String s1 = hashMap.put("key1", "我是value1");
    

    再添加第二笔数据。

    String s2 = hashMap.put("key2", "我是value2");
    

    如果进入put方法中我们会发现,除了不用进行数组初始化操作,其他的步骤和添加第一笔是时一样的。添加第二笔数据后集合的结构如下:

    0-->null
    1-->null
    2-->null
    3-->null
    4-->null
    5-->null
    6-->[key2,我是value2] -->null
    7-->[key1,我是value1] -->null
    8-->null
    9-->null
    10-->null
    11-->null
    12-->null
    13-->null
    14-->null
    15-->null
    

    4.4 添加重复的key

    现在我么的集合中已经有两个元素了,但是现在如果我再添加一个key2会发生什么呢?

        HashMap<String,String> hashMap = new HashMap<>();
        String s1 = hashMap.put("key1", "我是value1");
        String s2 = hashMap.put("key2", "我是value2");
        String s3 = hashMap.put("key2", "我是value3"); //我们要执行这一行代码了
    

    通过调试我们发现原先的我是value2被替换成了我是value3。查看HashMap的结构,也印证了这一点。

    4.5 解决存放位置冲突

    通过上面的分析我们知道,元素存放的位置需要根据key的hash来计算的,但是不同的has值计算后,得出的要存放数组的同一个位置那应该怎么办呢?

    我么接着上面的插入操作,插入了11个元素,现在要插入第12个元素了。

          HashMap<String,String> hashMap = new HashMap<>();
          String s1 = hashMap.put("key1", "我是value1");
          String s2 = hashMap.put("key2", "我是value2");
          String s3 = hashMap.put("key2", "我是value3");
          hashMap.put("key3", "我是value3");
          hashMap.put("key4", "我是value4");
          hashMap.put("key5", "我是value5");
          hashMap.put("key6", "我是value6");
          hashMap.put("key7", "我是value7");
          hashMap.put("key8", "我是value9");
          hashMap.put("key9", "我是value9");
          hashMap.put("key10", "我是value10");
          hashMap.put("key11", "我是value11");
          hashMap.put("key12", "我是value12");//我们要执行这一行了
    

    插入前先看一下现在集合的状态。

    0-->[key4,我是value4] -->null
    1-->[key3,我是value3] -->null
    2-->[key6,我是value6] -->null
    3-->[key5,我是value5] -->null
    4-->null
    5-->null
    6-->[key2,我是value3] -->null
    7-->[key1,我是value1] -->null
    8-->null
    9-->null
    10-->[key10,我是value10] -->null
    11-->[key11,我是value11] -->null
    12-->[key8,我是value9] -->null
    13-->[key7,我是value7] -->null
    14-->null
    15-->[key9,我是value9] -->null
    

    可以看到,数组位置快要被占满了。我们接着插入第12个元素。

    在执行到createEntry方法的时候,发现要插入的位置为[3],但是这个位置已经被[key5,我是value5]所占用。
    做法就是新建一个Entry存放[key12,我是value12],并把它的下一个元素指向[key5,我是value5],
    而原来数组table[3]的指向由[key5,我是value5]转为了[key12,我是value12]。

    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    

    这种插入方式称为头插入

    插入前后集合状态对比:

    4.6 如何扩容

    我们刚才的数组大小是16,那什么时候数组的容量会扩容呢?

    接着插入数据。

          HashMap<String,String> hashMap = new HashMap<>();
          String s1 = hashMap.put("key1", "我是value1"); 
          String s2 = hashMap.put("key2", "我是value2");
          String s3 = hashMap.put("key2", "我是value3");
          hashMap.put("key3", "我是value3");
          hashMap.put("key4", "我是value4");
          hashMap.put("key5", "我是value5");
          hashMap.put("key6", "我是value6");
          hashMap.put("key7", "我是value7");
          hashMap.put("key8", "我是value9");
          hashMap.put("key9", "我是value9");
          hashMap.put("key10", "我是value10");
          hashMap.put("key11", "我是value11");
          hashMap.put("key12", "我是value12");
          hashMap.put("key13", "我是value13");
    

    再执行到addEntry方法的时候,我们看到了如下的代码:

      if ((size >= threshold) && (null != table[bucketIndex])) { //(12>=12) && (null != table[3])
          resize(2 * table.length);
          hash = (null != key) ? hash(key) : 0;
          bucketIndex = indexFor(hash, table.length);
      }
    

    上面的代码是扩容时会执行到的,if判断是什么意思呢?

    size >= threshold :集合的容量大于等于阈值。

    我们现在的集合容量为12,阈值也为12,这个条件是满足的。

    null != table[bucketIndex] : 要插入的元素位置指向非空。

    我们的key为"key13",计算出来的backetIndex为3,而table[3]上刚刚好已经有元素了,不为空,这个条件也是满足的。

    接下来是执行数组扩容代码了,标准是数组扩容为原来的2倍

     resize(2 * table.length); //resize(2*16)
    
      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);
      }
    

    resize方法的功能是:

    • 新建一个2倍大的新数组;

    • 将旧元素挂到新数组下面;

    • 重新计算一个阈值;

    其中最有意思的是将旧元素挂到新数组下面这个方法。

      transfer(newTable, initHashSeedAsNeeded(newCapacity));
    
      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;
                  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;
              }
          }
      }
    

    transfer方法接收一个新数组,一个布尔值(此值是为了重新计算key的hash值,与jvm启动时的一个参数有关,如果没有配置的话默认为false)。rehash的情况请参考java中HashMap的另一面-Djdk.map.althashing.threshold

    transfer方法会遍历旧的数组,计算原来元素在新的数组上的位置。这是一个非常耗费资源的操作。最后将旧的元素放置在新数组下面,完成扩容操作。

    扩容前后新旧数组的对比:

    可以看到扩容后原先元素的位置可能会发生变化。

    --------------- 我每一次回头,都感觉自己不够努力,所以我不再回头。 ---------------
  • 相关阅读:
    poptest老李谈动态口令原理
    poptest老李谈数据库优化总结
    poptest老李谈jvm的GC
    poptest老李谈分布式与集群 2
    poptest老李谈分布式与集群 1
    POPTEST老李推荐:互联网时代100本必读书,来自100位业界大咖推荐 3
    POPTEST老李推荐:互联网时代100本必读书,来自100位业界大咖推荐 2
    POPTEST老李推荐:互联网时代100本必读书,来自100位业界大咖推荐 1
    老李谈JVM内存模型
    Oracle常见语法错误
  • 原文地址:https://www.cnblogs.com/zjw-blog/p/13817995.html
Copyright © 2011-2022 走看看