jdk源码分析——HashMap

Posted 自由水鸟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了jdk源码分析——HashMap相关的知识,希望对你有一定的参考价值。

一.基础概念

散列表:也叫哈希表,是我们常用的一种数据结构,它可以根据key值直接访问数据,从而在O(1)的时间复杂度内实现数据的写入和查找。
散列函数:将key值映射到散列表中的一个位置的函数。
碰撞:有时,不同的key值被映射到同一个散列表位置,这种情况叫做“碰撞”。
装载因子:散列表中已经添加的元素个数/散列表长度。它是衡量散列表可以被装满程度的一个参数。

当发生碰撞时,意味着多个key的散列函数值相同,需要花额外的开销去查找最终的位置。因此,理想的散列函数需要将key值均匀的分布到散列表中。

另外,散列表不能被装的太慢,如果装的太满,则发生碰撞的概率就会大大增加,但是如果装的太松散,则又太浪费空间,因此装载因子需要取一个合适的值,从而提高散列表的效率。

常见的碰撞解决方法有:

  • 开放定址法:当冲突发生时,使用特定的公式计算新的位置,直到不发生冲突为止。例如:

H=(f(key) + d) % m

其中,f是散列函数,m是哈希表最大长度,d是增量,可以固定的值,也可以取随机值。

  • 再哈希法:预先设定一组散列函数,当碰撞发生时,使用其他散列函数再计算,直到不发生碰撞。

二.类定义

HashMap的类定义如下:

public class HashMap<K,V>
   extends AbstractMap<K,V>
   implements Map<K,V>, Cloneable, Serializable

Cloneable接口和Serializable接口我们之前有提到过,都是声明式接口,本身没有任何方法。

Map接口定义了 形式的数据结构所需要具备的基础方法,主要包括存储,查询,判断元素个数,删除等。

AbstractMap也实现了Map接口,因此HashMap其实完全可以不再实现Map接口了,这里依然实现Map接口,个人理解可能有以下原因:

  • 强制HashMap实现Map中声明的方法。

  • 起到一种声明的作用,显示声明HashMapMap家族的一员。

三.存储结构

transient Entry[] table;

Entry可以理解为是链表的节点,它的基础数据结构如下:

static class Entry<K,V>  {
   final K key; // key值
   V value; // value值
   Entry<K,V> next; // 指向链表下一个元素的引用
   final int hash; // 元素的hash值

这里有两点需要注意:

  • Entry被声明为static

  • 字段keyhash被声明为final

静态内部类和普通内部类的一个重要区别就是:静态内部类中不能引用外部类中的非静态属性,这样对外部类中的属性来说,更加安全。因此,当我们考虑使用内部类时,在可能的情况下,应当尽量使用静态内部类,除非内部类中需要用到外部类中的属性。

keyhash被声明为final的,同样是出于安全考虑,即keyhash值被初始化后不允许被修改。

以上两点值得我们学习。

散列表的默认长度是16,最大长度是2的30次方,默认的装载因子是0.75。

static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

四.核心方法

1.构造方法

我们先来看看最常用的构造方法:

public HashMap() {
   // 将装载因子设置为默认值
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   // 散列表中元素个数的阈值,超过阈值需要进行扩容
   threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
   // 初始化散列表
   table = new Entry[DEFAULT_INITIAL_CAPACITY];
   init();
}

void init() {}

我们看到,init方法其实是空的,它是留给HasMap类的子类去实现的,例如LinkedHashMap等。

我们再来看一个可以指定初始化参数的构造方法:

/**
* 通过初始化参数构造HashMap
* @param initialCapacity 初始容量
* @param loadFactor 装载因子
*/

public HashMap(int initialCapacity, float loadFactor) {
   // 初试容量必须大于等于0
   if (initialCapacity < 0)
       throw new IllegalArgumentException("Illegal initial capacity: " +
               initialCapacity);
   // 初始容量不能超过2的30次方
   if (initialCapacity > MAXIMUM_CAPACITY)
       initialCapacity = MAXIMUM_CAPACITY;
   // 检查装载因子是否合法
   if (loadFactor <= 0 || Float.isNaN(loadFactor))
       throw new IllegalArgumentException("Illegal load factor: " +
               loadFactor);

   // 找到一个值,使得该值是2的次方,并且大于初始化容量
   int capacity = 1;
   while (capacity < initialCapacity)
       capacity <<= 1;

   this.loadFactor = loadFactor;
   threshold = (int)(capacity * loadFactor);
   table = new Entry[capacity];
   init();
}

从上述代码中,我们可以看到初始化容量总是2的次方。为什么散列表的长度一定得是2的次方呢?这个问题的答案我们稍后给出。

2.put方法

// 将key,value存储到散列表中
public V put(K key, V value) {
   // key为空的情况,说明key可以为null
   if (key == null)
       return putForNullKey(value);
   // 注意,这里对key的哈希值又进行了一次哈希
   int hash = hash(key.hashCode());
   // 计算该key需要保存的数组位置
   int i = indexFor(hash, table.length);
   // 该位置上可能已经有很多发生碰撞的元素了,因此需要遍历
   for (Entry<K,V> e = table[i]; e != null; e = e.next) {
       Object k;
       // 即将存入的key和已经存在的key相等,则进行覆盖
       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;
}

// 将null作为key存入散列表
private V putForNullKey(V value) {
   for (Entry<K,V> 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;
}

// 扰动函数,主要目的就是使哈希值更随机的分布到哈希表中
static int hash(int h) {
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}

// 将hash值映射到散列表的某个位置
static int indexFor(int h, int length) {
   return h & (length-1);
}

// 添加新的链表元素到链表头部
void addEntry(int hash, K key, V value, int bucketIndex) {
   Entry<K,V> e = table[bucketIndex];
   table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
   // 添加新的元素后,如果超过长度阈值,则扩容为原来的2倍
   if (size++ >= threshold)
       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);
   table = newTable;
   threshold = (int)(newCapacity * loadFactor);
}

// 将旧表转移到新表
void transfer(Entry[] newTable) {
   Entry[] src = table;
   int newCapacity = newTable.length;
   // 遍历旧表中每一个元素,重新计算在新表中的位置,并添加到新表
   for (int j = 0; j < src.length; j++) {
       Entry<K,V> e = src[j];
       if (e != null) {
           src[j] = null;
           do {
               Entry<K,V> next = e.next;
               int i = indexFor(e.hash, newCapacity);
               e.next = newTable[i];
               newTable[i] = e;
               e = next;
           } while (e != null);
       }
   }
}

这里需要注意的地方有几点:

  • put方法中int hash = hash(key.hashCode());这一句对key的哈希值又进行了一次哈希,这是为了防止有一些key的哈希函数实现的不好,使哈希值分布不够均匀,因此对原有的哈希值又进行了一次“扰动”,使其可以更均匀的分布到散列表中

  • indexFor(计算散列位置)方法,进行了一次按位与,是将哈希值与(length-1)进行按位与,这就是散列表长度是2的次方的原因所在,因为只有散列表的长度是2的次方,(length-1)才会是高位为0,低位都是1,方便进行与运算。

3.get方法

了解了put方法的原理后,我们再看get方法就比较简单了:

public V get(Object key) {
   if (key == null)
       return getForNullKey();
   // 由于put的时候执行过hash,因此get的时候也需要再执行一次,才能对应上
   int hash = hash(key.hashCode());
   // 计算出散列表的对应位置,并遍历链表,如果找到,则返回
   for (Entry<K,V> e = table[indexFor(hash, table.length)];
        e != null;
        e = e.next) {
       Object k;
       if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
           return e.value;
   }
   // 如果未找到,则返回null
   return null;
}

// key为null的元素,如果存在,在table[0]位置
private V getForNullKey() {
   for (Entry<K,V> e = table[0]; e != null; e = e.next) {
       if (e.key == null)
           return e.value;
   }
   return null;
}

参考资料:

1.Static nested class in Java, why?
2.JDK 源码中 HashMap 的 hash 方法原理是什么?


以上是关于jdk源码分析——HashMap的主要内容,如果未能解决你的问题,请参考以下文章

Java中HashMap底层实现原理(JDK1.8)源码分析

Java中HashMap底层实现原理(JDK1.8)源码分析

Java中HashMap底层实现原理(JDK1.8)源码分析

2JDK8中的HashMap实现原理及源码分析

HashMap源码分析--jdk1.8

JDK1.8源码分析之HashMap