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

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

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

posted @ 2020-10-14 23:11  雨中遐想  阅读(161)  评论(0编辑  收藏  举报