Java并发突击-HashMap

629 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

HashMap在面试中也是一个比较高频的问题,一般都会问JDK1.7与JDK1.8中的区别,这时候如果我们只是粗略的说下那很大概率会回家等通知了。今天我们就分别从1.7跟1.8中HashMap底层实现来一起探讨下,看看他俩有何不同。

HashMap底层实现

先看两行基础代码:

HashMap map = new HashMap();
map.put(key1,value1);

上边这两行代码我们再熟悉不过了,那我们就拿这两行代码开刀,分别从JDK1.7与JDK1.8中的底层实现看看我们是真的熟悉还是只是最熟悉的陌生人。

JDK1.7

在map实例化之后,底层为我们创建了一个长度为16的一维数组Entry[] table。然后我们进行put操作时:

  1. 调用key1所在类的hashCode()方法计算key1的hash值,此hash值再经过计算后,得到在Entry中存放的位置
    • 如果此位置上的数据为空,此时key1-value1添加成功
    • 如果此位置上的数据不为空(此位置存在一个或多个数据(链表形式)),然后再比较key1与该位置数据的hash值
      • 如果key1的hash值与已存在的数据的hash值都不相同,此时key1-value1添加成功 ①
      • 如果key1的hash值与已存在的某一数据(此处用key2表示)的hash值相同,会继续调用key1所在类的key1.equals(key2)进行比较,此时会有两种情况:
        • 返回false:此时key1-vale1添加成功②
        • 返回true:value1覆盖已存在的value2。

①、②场景下key1-value1与原来的数据以链表的方式存储

  1. HashMap底层使用数组进行存储,我们都知道,数组的长度是固定,我们不断的调用map.put()添加数据的话,还会涉及到扩容的问题,而hashMap的默认扩容方式是扩容为原来得2倍,并将原来的数据复制到新数组中。

JDK1.8

JDK1.8中在实例化后,不会创建数组,而是在首次调用map.put()方法时,创建一个长度为16的Node[] 数组,看清楚啊不是Entry[] 数组,并且数据结构在数组加链表的基础上增加了红黑树。

  • jdk1.7:数组+链表
  • jdk1.8:数组+链表+红黑树 红黑树进化条件:当数组的某一个索引位置上的数据以链表的形式存在的数据个数>8且当前数组长度>64时,此时此索引位置上的所有数据改为使用红黑树存储。

HashMap源码分析

JDK1.7

存储结构: 在jdk1.7中,Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。 Entry是HashMap中的一个静态内部类。

static class Entry<K,V> implements Map.Entry<K,V> {  
    final K key;  
    V value;  
    Entry<K,V> next; //存储指向下一个Entry的引用,单链表结构  
    int hash; //对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算  
  
    /**  
     * Creates new entry.     
     **/    
     Entry(int h, K k, V v, Entry<K,V> n) {  
        value = v;  
        next = n;  
        key = k;  
        hash = h;  
    }  
    //...  
}

默认构造方法

/**  
 * Constructs an empty <tt>HashMap</tt> with the default initial capacity  
 * (16) and the default load factor (0.75). 
 **/
 public HashMap() {  
	 //DEFAULT_INITIAL_CAPACITY = 1 << 4 默认的初始容量16
	 //DEFAULT_LOAD_FACTOR = 0.75f 默认的加载因子0.75
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);  //此处调用有参构造方法
}

有参构造方法

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);
    // HashMap参数赋值
  this.loadFactor = loadFactor;
  threshold = initialCapacity;
  // 空方法,在其子类如 LinkedHashMap 中就会有对应实现
  init();
}

put方法

public V put(K key, V value) {
  // 如果table数组为空数组{},进行数组初始化
  if (table == EMPTY_TABLE) { // EMPTY_TABLE = {} 空数组
    // 分配数组空间
    // 入参为threshold,此时threshold为initialCapacity 默认是1<<4(=16)
    inflateTable(threshold);
  }
  // 如果key为null,存储位置为table[0]的数组和链表上
  if (key == null)
    return putForNullKey(value);
  // 对key的hashcode进一步计算,通过异或运算确保散列均匀
  int hash = hash(key);
  // 获取在table中的实际位置下标
  int i = indexFor(hash, table.length);
  for (Entry<K,V> e = table[i]; e != null; e = e.next) {
    Object k;
    // 如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
    if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
      V oldValue = e.value;
      e.value = value;
      e.recordAccess(this); //调用value的回调函数,这个函数也为空实现
      return oldValue;
    }
  }
    // 记录修改次数,保证并发访问时,若HashMap内部结构发生变化,快速响应失败
  modCount++;
  // 新增一个entry
  addEntry(hash, key, value, i);
  return null;
}

inflateTable方法: inflateTable方法用于对数组进行初始化操作。

private void inflateTable(int toSize) {
   // Find a power of 2 >= toSize
   // capacity一定是2的次幂,比如toSize=13,则capacity=16
   int capacity = roundUpToPowerOf2(toSize);
     // 依据加载因子为HashMap的扩容阈值赋值
   threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
   // 分配空间
   table = new Entry[capacity];
   // 选择合适的Hash因子
   initHashSeedAsNeeded(capacity);
 }

threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1) 通过默认值计算得出threshold=12,也就是不会等到长度达到16是扩容,而是到12的时候会进行扩容

hash方法

final int hash(Object k) {
  int h = hashSeed;
  // 这里针对String优化了Hash函数,是否使用新的Hash函数和Hash因子有关
  if (0 != h && k instanceof String) {
    return sun.misc.Hashing.stringHash32((String) k);
  }
 
  h ^= k.hashCode();
 
  // This function ensures that hashCodes that differ only by
  // constant multiples at each bit position have a bounded
  // number of collisions (approximately 8 at default load factor).
  h ^= (h >>> 20) ^ (h >>> 12);
  return h ^ (h >>> 7) ^ (h >>> 4);
}

indexFor方法: 通过indexFor进一步处理来获取实际的存储位置。h &(length-1)保证获取的index一定在数组范围内

static int indexFor(int h, int length) {
  // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
  return h & (length-1);
}

addEntry方法:

void addEntry(int hash, K key, V value, int bucketIndex) {
  if ((size >= threshold) && (null != table[bucketIndex])) {
    // 扩容,新容量为旧容量的2倍
    resize(2 * table.length);
    hash = (null != key) ? hash(key) : 0;
    // 扩容后,计算当前元素的插入位置下标
    bucketIndex = indexFor(hash, table.length);
  }
    // 把元素放入HashMap的桶的对应位置
  createEntry(hash, key, value, bucketIndex);
}

createEntry方法:

//头插法
void createEntry(int hash, K key, V value, int bucketIndex) {
  // 获取待插入位置元素
  Entry<K,V> e = table[bucketIndex];
  // 新插入的元素指向原有元素,并将新的Entry放在数组的第一个位置
  table[bucketIndex] = new Entry<>(hash, key, value, e);
  // 元素个数+1  
  size++;
}

resize方法(扩容):

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));
  // 修改HashMap的底层数组 
  table = newTable;
  // 修改扩容阀值 
  threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer(将老的表中的数据拷贝到新的数组中):

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);
      }
      // 定位新的Hash桶位置下标(并没有重新计算hash值,而是与新的容量进行&操作,只会在两个位置中选择)
      int i = indexFor(e.hash, newCapacity);
      // 元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
      e.next = newTable[i];
      // newTable[i]的值总是最新插入的值
      newTable[i] = e;
      // 继续遍历下一个元素
      e = next;
    }
  }
}

JDK1.8

put方法

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // Node数组非Entry
    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)
      	// 通过 hash 找到对应的数组下标,如果当前下标中的内容为null,直接将数据放进去
        tab[i] = newNode(hash, key, value, null);
    else {
      	// 如果通过 hash 找到对应下标的位置有数据,会发生 hash 碰撞
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
          	// 如果要插入 key 和当前数组对应的下标的 key 一致,就把当前节点赋值给临时节点e
            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) // 链表达到树化阈值,转红黑树
                        treeifyBin(tab, hash);
                    break;
                }
              	// 链表中找到和要插入的节点 key 一致,将该节点赋值给临时节点e
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
      	// 如果临时节点e不为 null,说明要插入的数据已经存在当前 HashMap 中,更新该节点的值
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e); 
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)// 如果 HashMap 中的存储的元素个数大于阈值,会触发扩容
        resize();
    afterNodeInsertion(evict);
    return null;
}

总结:

  • 数据结构不同:1.7中的hashMap基于数组+链表 1.8中的hashMa基于数组+链表+红黑树(链表长度>8时,转化为红黑树)
  • 插入方式不同:1.7是头插法,1.8是尾部插入
  • hash计算方式不同:1.7是9次扰动处理(4次位运算+5次异或);1.8是两次扰动处理(1次位运算+1次异或)
  • 扩展策略不同:1.7是插入前扩展;1.8是插入成功后扩容
  • 1.7中的resize()方法负责扩容,inflateTable()负责创建表,而1.8中的resize()表为空时创建表,表有值时扩容表。
  • 1.7中hashMap在遍历时如果不存在,会调用addEntity()将节点添加到链表的头部,而1.8中元素处于链表的情况下遍历时如果不存在,会直接将节点在插入到尾部
  • 1.7中新增节点采用头部插入法,1.8中新增节点采用尾部插入法,这也是1.8不容易出现循环链接的原因
  • 1.7通过hashSeed值修改节点hash值从而达到rehash时的链表分散;1.8中键hash值不会改变,rehash时根据(hash&oldCap) == 0将链表分散。
  • 1.8rehash时保证原链表的顺序,而1.7 rehash时有可能改变原链表的顺序(头插法导致的)