jdk源码分析(五)——HashMap

jdk源码分析(五)——HashMap_第1张图片

一.基础概念

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

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

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

常见的碰撞解决方法有:

  • 开放定址法:当冲突发生时,使用特定的公式计算新的位置,直到不发生冲突为止。例如:
H=(f(key) + d) % m 

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

  • 链地址法:将冲突的元素使用链表连接起来。查找时,在链表中逐一查找。如下图所示:
jdk源码分析(五)——HashMap_第2张图片
  • 再哈希法:预先设定一组散列函数,当碰撞发生时,使用其他散列函数再计算,直到不发生碰撞。

二.类定义

HashMap的类定义如下:

public class HashMap
    extends AbstractMap
    implements Map, Cloneable, Serializable
jdk源码分析(五)——HashMap_第3张图片

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

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

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

  • 强制HashMap实现Map中声明的方法。
  • 起到一种声明的作用,显示声明HashMapMap家族的一员。

三.存储结构

HashMap是基于数组来进行元素存储的,当有碰撞发生时,使用链地址法解决。因此HashMap的存储结构同第一节中链地址法中的结构。

transient Entry[] table;

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

static class Entry  {
    final K key; // key值
    V value; // value值
    Entry 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 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 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 e = table[bucketIndex];
    table[bucketIndex] = new Entry(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 e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry 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 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 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 方法原理是什么?

本文已迁移至我的博客:http://ipenge.com/27033.html

你可能感兴趣的:(jdk源码分析(五)——HashMap)