【源码分析】HashMap源码探索(一)

233 阅读9分钟

一、HashMap简介

(1)hash的概念

Hash就是把任意长度的输入(又叫做预映射,pre-image),通过哈希算法,变换成固定长度的输出(通常是整型),该输出就是哈希值。这种转换是一种压缩映射,也就是说,散列值的空间通常远小于输入的空间。不同的输入可能会散列成相同的输出,从而不可能从散列值来唯一的确定输入值。简单的说,hash函数就是将一种将任意长度的消息压缩到某一固定长度的信息摘要函数。

(2)JDK1.7的HashMap

JDK1.7的HashMap基于哈希表实现,每一个元素是一个key-value对,通过单链表存储相同key的元素的方式解决hash值冲突问题;容量不足(超过了阀值)时,数组会自动增长。

HashMap是非线程安全的,只是用于单线程环境下,多线程环境下可以采用concurrent并发包下的concurrentHashMap

HashMap实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆。

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

(3)JDK1.8的HashMap

JDK1.8 对HashMap进行了比较大的优化,底层实现由之前的“数组+链表” 改为“数组+链表+红黑树”。而当链表长度太长时(也就是相同hash值的元素很多时,查找效率就会降低),故链表就转换为红黑树,这样大大提高了查找的效率。

(4)讲解思路

  • HashMap如何定位插入元素的数组的索引位置
  • ② 分析putget方法

二、HashMap的常用方法

jdk1.7下

(1)构造方法

一、几个重要的成员变量

// 初始化桶大小,因为底层是数组,所以这是数组默认的大小
static final int DEFAULT_INITIAL_CAPACITY = 16;

// 桶最大值 2^30
static final int MAXIMUM_CAPACITY = 1 << 30; 

// 默认的负载因子(0.75)
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 真正存放数据的数组
transient Entry<K,V>[] table;

// Map存放数量的大小
transient int size;

// 桶大小,可在初始化时显式指定
int threshold;

// 负载因子,可在初始化时显式指定
final float loadFactor;

二、有参构造方法

  • 由上可知给定的默认容量为16,负载因子为0.75。当Map中的元素数量达到了16*0.75=12时,就需要扩容,而扩容过程涉及到rehash、复制数据等操作,故扩容是非常消耗性能的
  • 所以推荐事先根据估计HashMap的需要的初始容量,来尽量避免扩容带来的性能损耗
public HashMap(int initialCapacity, float loadFactor) {
    // 初始容量要>0 
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    // 不能大于最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 负载因子要是整数且不能小于0
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    init(); // HashMap的init方法是空的,实现的是LinkedHashMap
}

threshold = initialCapacity;这里将初始容量赋值给阈值,为什么呢?下面会将

(2)put()

这里可以看到,HashMap其实是在put操作执行时,才会去初始化数组,并且把阈值(threshold,也就是初始容量)作为一个供计算最终容量的值去初始化数组。

public V put(K key, V value) {
    // 1.当数组是空的时候,会先去初始化数组
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    // 2.根据key计算哈希值
    int hash = hash(key);
    // 根据哈希值和数据长度计算数据下标
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 3.哈希值相同再比较key是否相同,相同的话值替换,否则将这个槽转成链表
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    // fast-fail,迭代时响应快速失败,还未添加元素就进行modCount++,将为后续留下很多隐患
    modCount++; 
    // 添加元素,最后一个参数i是table数组的下标
    addEntry(hash, key, value, i);  
    return null;
}

1. inflateTable(int toSize)

当数组是空的时候,首先调用以下方法来初始化数组

作用:寻找>=toSize的最小的2的次幂数,如果toSize=13,则capacity=16(2^4);若toSize=16,capacity=16

private void inflateTable(int toSize) {
    // 返回小于(toSize- 1) * 2的最接近的2的次幂
    int capacity = roundUpToPowerOf2(toSize); // 使用阈值计算
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}

也就是说,当你设置了HashMap的初始容量initCapacity时,并不是存储的数据达到设置的初始容量initCapacity * loadFactor时就扩容,而是到了roundUpToPowerOf2(initCapacity) * loadFactor时才会扩容。

如果toSize=1,则计算后的capacity=1,所以如果将initcapacity设为1的话,第一次put不会扩容

为什么capacity一定要是>=toSize最小的2的次幂数? -->参考本博客

2. hashCode(Object K)

int hash = hash(key); 然后根据key计算对应的hash值。

3. indexFor(int hash, int length)

int i = indexFor(hash, table.length);根据hash值和数组大小计算当前key要存放的具体数组位置。

static int indexFor(int h, int length) {
  // 翻译:断言Integer.bitCount(长度)=1:“长度必须是2的非零幂”;
  return h & (length-1);
}

这段代码要好好研究,当length==16时,如果哈希值h==1010 1010,那么计算出来的数组下标i就是

  0000 1111
& 1010 1010
———————————
  0000 1010   
  
不妨再试试length==31,得到的数组下标还是哈希值h

  1111 1111
& 1010 1010
———————————
  1010 1010

那如果数组长度length不是2的次方数,结果会怎样,不妨试一下,假设length==17

  0001 0000
& 1010 1010
———————————
  0000 0000

原来在数组大小是2的次方数的情况下,比如当table长度为 16 时,table.length - 1 = 15,用二进制来看,此时低 4 位全是 1,高 28 位全是 0,与 0 进行 & 运算必然为 0,因此此时 hashCode 与 "table.length - 1"的 & 运算结果只取决于hashCode的低 4 位

在这种情况下,由于hash结果只取决于hashCode的低 4 位,hash 冲突的概率也会增加。因此,在 JDK 1.8 中,将高位也参与计算,目的是为了降低 hash 冲突的概率。

4. for循环

for循环的作用就是,遍历数组下标i对应的链表,判断

if (e.hash == hash && ((k = e.key) == key || key.equals(k)))

  • 成立,则覆盖key对应的value,并返回原值oldValue
  • 否则使用头插法,将元素插入到链表中,下面会讲到。

5. addEntry()

addEntry(hash, key, value, i); 进入该方法后,首先判断当前HashMap是否需要扩容,扩容的条件有2:

  • size >= thresholdHashMap元素总数是否大于阈值threshold(capacity*0.75)
  • null != table[bucketIndex],位于当前下标的数组元素是否为空
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 写入Entry时需要判断是否需要扩容,如果需要就进行两倍扩充,并将当前的key重新hash定位
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}

5.1 resize()

简单看一下resize()方法

void resize(int newCapacity) {  
    // 引用扩容前的Entry数组  
    Entry[] oldTable = table; 
    int oldCapacity = oldTable.length;  
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        // 扩容前的数组大小如果已经达到最大(2^30)了
        threshold = Integer.MAX_VALUE; 
        // 修改阈值为int的最大值(2^31-1),这样以后就不会扩容了 
        return;  
    }  
    // 初始化一个大小为2*capacity的新Entry数组
    Entry[] newTable = new Entry[newCapacity];    
    // 将数据转移到新Entry数组里      
    transfer(newTable);     
    // HashMap的table属性引用新Entry数组
    table = newTable;                             
    threshold = (int) (newCapacity * loadFactor);//修改阈值  
}  

5.1.1 transfer()

transfer()把原来数组中的链表上的元素迁移到新数组中

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    // 遍历旧的Entry数组
    for (Entry<K,V> e : table) {
        // 遍历数组上的每一个位置上的链表
        while (null != e) {
            // 访问下一个Entry链上的元素  
            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;
        }
    }
}

一、主要的迁移思路:假如数组上某个元素的链表结构为[1]->[2]->[3],那么

  • Entry<K,V> next = e.next;相当于指向元素[2]
  • e.next = newTable[i];``[1]->指向新数组下标为i的第一个元素
  • newTable[i] = e;新数组下标为i的第一个元素指向[1]
  • e = next;e指向e的next,第1点的作用是先记录e.next的位置,避免e在第2点中迷失掉
  • 重复一下以上逻辑,其实最后相应的链表结构会被逆序[3]->[2]->[1]

二、这里重新计算元素在新数组中的位置的算法如下:

假如原数组的大小capacity为16,某个元素的hash值为1011 1010
  0000 1111
& 1011 1010
———————————
  0000 1010  ==10
 
扩容后,capacity=32
  0001 1111
& 1011 1010
———————————
  0001 1010  ==26(10+原数组容量)

得到的规律就是,新数组的新下标 == 旧数组的旧下标 + 原数组容量

接下来看createEntry()方法

// 把当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表(采用头插法)
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];  // 得到
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

看一下Entry的构造方法就知道头插法在这里的应用了。

这里的逻辑就是:得到数组下标bucketIndex对应的链表头指针e,将头指针改为新增结点new Entry,其中新增结点的next指针指向原头结点e,最后HashMap元素总数size自增1。

Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}

(3)get()

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);
    return null == entry ? null : entry.getValue();
}

final Entry<K,V> getEntry(Object key) {
    // 根据key计算出hashcode,然后定位到具体的桶中
    int hash = (key == null) ? 0 : hash(key);
    // 判断该位置是否是链表,不是链表就根据key的hashcode是否相等来返回值,
    // 是链表则需要遍历直到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 != null && key.equals(k))))
            return e;
    }
    return null;
}

jdk1.8

//构造函数1
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);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);//新的扩容临界值
}
 
//构造函数2
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
 
//构造函数3
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
 
//构造函数4:用m的元素初始化散列映射
public HashMap(Map<!--? extends K, ? extends V--> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}