HashMap底层原理(详解及源码分析)(转载)

数组:是具有相同数据类型的元素的集合,采用连续的内存空间存储数据。对于指定下标的查找,时间复杂度为 O(1) ;通过给定值进行查找,由于要遍历数组,所以时间复杂度为 O(n) ;如果是一维的有序数组进行二分查找,其时间复杂度就是 O(log n),如果是二维的有序矩阵进行查找,就是 O(n);对于添加和删除操作,要移动数组元素,其平均复杂度也为 O(n)。由此可见,数组查询数据快,但是增删比较慢。
链表:是一种线性表,但是不会按照线性的顺序存储数据,而是在每一个节点里存放了到下一个节点的指针。存储区间离散,占用内存比较宽松,使用链表查询比较慢,因为它需要遍历链表逐一对比,其时间复杂度为 O(n);但是它增删比较快,只需处理结点间的引用就行,不需要移动数据,时间复杂度为 O(1) ;
哈希表:添加、删除、查找等操作都很快,不考虑哈希冲突的情况下,只需要一次定位即可完成,时间复杂度为 O(1) 。哈希表的主干就是数组。

比如插入元素时,会根据key的hash去计算一个index值,这个index就是该元素在数组中的位置,在再根据equals方法决定其在该数组位置上的链表中的存储位置。

哈希冲突
哈希本身就存在概率性,两个不同的元素,通过哈希运算得到的存储地址是相同的怎么办?也就是在进行插入时,发现已经被别的元素占用了,这其实就是所谓的哈希冲突,也叫哈希碰撞。一个好的哈希函数会尽可能保证计算简单和散列地址分布均匀,但是我们都知道,数组长度是有限的,再好的哈希函数也不能保证得到的存储地址不会发生冲突。那冲突如何解决的呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的地址),链地址法,而HashMap就采用了链地址法,也就是 数组+链表 的方式。

HashMap的结构和底层原理

HashMap是基于哈希表的 Map 接口的实现,允许使用 null 值和 null 键。
如图所示,HashMap的主干是数组,数组中的每一项又是一个链表,数组里每个地方都存了 Key-Value 键值对,它持有一个指向下一个元素的引用,这就构成了链表,HashMap底层将key-value当成一个整体来处理,这个整体在Java7 叫 Entry 在Java8 叫Node
在这里插入图片描述

//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂。
//至于为什么是2的次幂,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
1
2
3
Entry是HashMap中的一个静态内部类。我们来看Entry的源码:

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;

} 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
所以,HashMap的总体结构如下:
在这里插入图片描述
总的来说,HashMap是由数组+链表组合成的数据结构,数组是
HashMap的主干,而链表是为了解决哈希冲突存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

既然提到了链表,就有一个问题,新的Entry节点到底是怎样插入到链表中去的?
java8之前都是头插法,即新来的值会取代原有的值,原来的值就顺推到链表中去,因为写这个代码的作者认为后来的值被查找的可能性更大,提升查找效率。
但是,java8之后都是用尾插了。那为什么要改为尾插呢?

首先我们看下HashMap中的扩容机制:

之前我们提到数组长度是有限的,当我们插入元素达到一定数量,就要对数组进行扩容,也就是resize(后面还会详细讲解resize方法)。

什么时候resize呢?

有两个因素决定:

Capacity:HashMap当前长度
loadFactor:负载因子,默认是0.75
举个例子,比如当前容量是16,当你存13个元素时,判断发现需要进行扩容,而扩容不仅仅是扩大容量这么简单的。
扩容可以分两步:

新建一个长度为之前数组2倍的新的数组;
将当前的Entry数组中的元素重新hash过去
这里简单解释一下为什么要重新hash,而不是直接拷贝过去?
因为数组长度发生变化,映射到的index也会发生变化,那么元素在数组中存放的位置就可能不一样,这个时候拷贝过去可能就不对了。

Hash的计算公式—> index = HashCode(Key) & (Length - 1)

说完扩容我们言归正传,为什么改为尾插?
比如我们向容量为2的容器内用不同线程插入3个值,在没有扩容之前,它可能存储方式如下图。

我们可以看到链表的指向A->B->C
注意:A的下一个指针是指向B的
在这里插入图片描述
因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,而就数组中的元素扩容之后的索引位置有可能发生变化。就可能出现下面的情况:

B的下一个指针是指向A的
在这里插入图片描述
一旦线程都调整完了,就可能出现环形链表
在这里插入图片描述
这个时候去取值,就会无限循环下去。

由此可见,使用头插会改变链表上的顺序,但是用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环。

也就是说,原本是A->B,扩容之后还是A->B
在这里插入图片描述
Java7在多线程操作HashMap时可能会引起死循环,因为是扩容前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。

Java8在多线程操作HashMap时同样的前提下并不会引起死循环,因为是扩容前后链表顺序不变,保持之前节点的引用关系。

那Java8既然不会引起死循环,是不是就可以把HashMap用在多线程中?

即使没有死循环,但是从源码中可以看到put/get方法都没有加同步锁,多线程最容易出现的情况就是:无法保证值下一面还是原值,所以无法保证线程安全。
在这里插入图片描述
HashMap其他几个重要字段

/*实际存储的key-value键值对的个数/
transient int size;

/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacityloadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到/
int threshold;

/**负载因子,代表了table的填充度有多少,默认是0.75
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;

/*HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException
/
transient int modCount;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HashMap有4个构造器,其他构造器如果用户没有传initialCapacity
和loadFactor这两个参数,会使用默认值:

initialCapacity默认为16,loadFactory默认为0.75

我们看下其中一个

public HashMap(int initialCapacity, float loadFactor) {
     //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
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;
    threshold = initialCapacity;

init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组

OK,接下来我们来看看put操作的实现

HashMap put方法逻辑图(JDK1.8)
在这里插入图片描述
put:(key-value)方法是HashMap中最重要的方法,使用HashMap最主要使用的就是put,get两个方法。

判断键值对数组table[i]是否为空或者为null,否则执行resize()进行扩容;
根据键值key计算hash值得到插入的数组索引 i ,如果table[i] == null,直接新建节点添加即可,转入6,如果table[i] 不为空,则转向3;
判断table[i] 的首个元素是否和key一样,如果相同(hashCode和equals)直接覆盖value,否则转向4;
判断table[i] 是否为treeNode,即table[i]是否为红黑树,如果是红黑树,则直接插入键值对,否则转向5;
遍历table[i] ,判断链表长度是否大于8,大于8的话把链表转换成红黑树,进行插入操作,否则进行链表插入操作;便利时遇到相同key直接覆盖value;
插入成功后,判断实际存在的键值对数量size是否超过了threshold,如果超过,则扩容;
看一下源码中put的实现:

public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
//此时threshold为initialCapacity 默认是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//根据key的哈希值、数组长度获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
//判断同一个key,既要判断hash值相等,还要判断key是同一个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++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;
to_size=16,capacity=16;to_size=17,capacity=32.

private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
/**此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,
capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 */
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
1
2
3
4
5
6
7
8
roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值.

private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : “number must be non-negative”;
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
1
2
3
4
5
6
hash函数

/*这是一个神奇的函数,用了很多的异或,移位等运算
对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
/
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);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

/**
* 返回数组下标
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
1
2
3
4
5
6
h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为index=2。位运算对计算机来说,性能更高一些(HashMap中有大量位运算)

所以最终存储位置的确定流程是这样的:
在这里插入图片描述
再来看看addEntry的实现:
添加新元素前,判断是否需要对map的数组进行扩容,如果需要扩容,则扩容多大?

void addEntry(int hash, K key, V value, int bucketIndex) {
//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
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);
}

1
2
3
4
5
6
7
8
9
10
通过以上代码能够得知,当发生哈希冲突(当前key计算的hash值计算出的数组索引位置已经存在值)并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素重新hash过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。

注意:

HashMap在添加值的时候,它默认能存储16个键值对,直到你使用这个HashMap时,它才会给HashMap分配16个键值对的存储空间,(负载因子为0.75,阈值为12),当16个键值对已经存储满了,我们在添加第17个键值对的时候才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。
HashMap也有可能存储更多的键值对,最多可以存储26个键值对,我们来算一下:存储的前11个值全部发生hash碰撞,存到数组的同一个位置中,(这时元素个数小于阈值12,不会扩容),之后存入15个值全部分散到数组剩下的15个位置中,(这时元素个数大于等于阈值,但是每次存入元素并没有发生hash碰撞,不会扩容),11+15=26,当我们存入第27个值得时候满足以上两个条件,HashMap才会发生扩容;
HashMap的初始化长度为16

/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
1
2
3
4
为什么是16?

是为了实现均匀分布。具体解释请看下面内容。

为何HashMap的数组长度一定是2的次幂?

我们来继续看上面提到的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()把原数组中的元素放到新数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    //设置hashmap扩容后为新的数组引用
    table = newTable;
    //设置hashmap扩容新的阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法

void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
     //for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
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);
}
//通过key的hash值和新数组的大小计算出在当前数组中的存放位置
int i = indexFor(e.hash, newCapacity);
//将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。

HashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)。
在这里插入图片描述
还有,数组长度保持2的次幂,length-1的低位都为1,只要输入的HashCode本身分布均匀,会使得hash算法获得的数组索引index更加均匀。

这是为了实现均匀分布。

在这里插入图片描述
我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。
在这里插入图片描述
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

get方法:

public V get(Object key) {
     //如果key为null,则直接去table[0]处去检索即可。
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
1
2
3
4
5
6
7
get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。我们再看一下getEntry这个方法

final Entry<K,V> getEntry(Object key) {

    if (size == 0) {
        return null;
    }
    //通过key的hashcode值计算hash值
    int hash = (key == null) ? 0 : hash(key);
    //indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
    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;
}    

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
可以看出,get方法的实现相对简单,key(hashcode)–>hash–>indexFor–>最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。要注意的是,有人觉得上面在定位到数组位置之后然后遍历链表的时候,e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null,后面的例子会做出进一步解释。

重写equals方法需同时重写hashCode方法?

我们举个小例子来看看,如果重写了equals而不重写hashcode会发生什么样的问题

public class MyTest {
private static class Person{
int idCard;
String name;

    public Person(int idCard, String name) {
        this.idCard = idCard;
        this.name = name;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()){
            return false;
        }
        Person person = (Person) o;
        //两个对象是否等值,通过idCard来确定
        return this.idCard == person.idCard;
    }

}
public static void main(String []args){
    HashMap<Person,String> map = new HashMap<Person, String>();
    Person person = new Person(1234,"乔峰");
    //put到hashmap中去
    map.put(person,"天龙八部");
    //get取出,从逻辑上讲应该能输出“天龙八部”
    System.out.println("结果:"+map.get(new Person(1234,"萧峰")));
}

}

实际输出结果:null

如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)–>hash–>indexFor–>最终索引位置 ,而通过key取出value的时候 key(hashcode1)–>hash–>indexFor–>最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)

所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。

JDK1.8中HashMap的性能优化

假如一个数组槽位上链上数据过多(即拉链过长的情况)导致性能下降该怎么办?
JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。即当链表超过8时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。关于红黑树这部分,这里就不再做介绍了。

原文链接:https://blog.youkuaiyun.com/sinat_40482939/article/details/107901208?utm_medium=distribute.pc_feed.427267.nonecase&depth_1-utm_source=distribute.pc_feed.427267.nonecase

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值