参考链接: Java HashMap
一、什么是Map
根据Map源码上的注释可以得到:
1.Map是一个接口,他是key-value的键值对,一个map不能包含重复的key,并且每一个key只能映射一个value;
2.Map接口提供了三个集合视图:key的集合,value的集合,key-value的集合;
3.Map内元素的顺序取决于Iterator的具体实现逻辑,获取集合内的元素实际上是获取一个迭代器,实现对其中元素的遍历;
4.Map接口的具体实现中存在三种Map结构,其中HashMap和TreeMap都允许存在null值,而HashTable的key不允许为空,但是HashMap不能保证遍历元素的顺序,TreeMap能够保证遍历元素的顺序。
二、HashMap的概念
1.什么是哈希表
哈希表(HashTable,散列表)是根据key-value进行访问的数据结构,他是通过把key映射到表中的一个位置来访问记录,加快查找的速度,其中映射的函数叫做散列函数,存放记录的数组叫做散列表,哈希表的主干是数组。
上面的图中就是一个值插入哈希表中的过程,那么存在的问题就是不同的值在经过hash函数之后可能会映射到相同的位置上,当插入一个元素时,发现该位置已经被占用,这时候就会产生冲突,也就是所谓的哈希冲突,因此哈希函数的设计就至关重要,一个好的哈希函数希望尽可能的保证计算方法简单,但是元素能够均匀的分布在数组中,但是数组是一块连续的且是固定长度的内存空间,不管一个哈希函数设计的多好,都无法避免得到的地址不会发生冲突,因此就需要对哈希冲突进行解决。
(1)开放定址法:当插入一个元素时,发生冲突,继续检查散列表的其他项,直到找到一个位置来放置这个元素,至于检查的顺序可以自定义;
(2)再散列法:使用多个hash函数,如果一个发生冲突,使用下一个hash函数,直到找到一个位置,这种方法增加了计算的时间;
(3)链地址法:在数组的位置使用链表,将同一个hashCode的元素放在链表中,HashMap就是使用的这种方法,数组 链表的结构。
2.什么是HashMap
HashMap是基于哈希表的Map接口的实现,提供所有可选的映射操作,允许使用null值和null键,存储的对象时一个键值对对象Entry<K,V>;
是基于数组 链表的结构实现,在内部维护这一个数组table,数组的每个位置保存着每个链表的表头结点,查找元素时,先通过hash函数得到key值对应的hash值,再根据hash值得到在数组中的索引位置,拿到对应的链表的表头,最后去遍历这个链表,得到对应的value值。
三、HashMap类中的主要方法
1.HashMap中的几个重要的变量
//默认初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子,默认为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//数组表,大小可以改变,且大小必须为2的幂
transient Entry[] table;
//当前Map中key-value映射的个数
transient int size;
//下次扩容阈值,当size > capacity * load factor时,开始扩容
int threshold;
//负载因子
final float loadFactor;
//Hash表结构性修改次数,用于实现迭代器快速失败行为
transient volatile int modCount;
2.HashMap的Entry实体
HashMap存储的对象是一个Entry实体,Entry是HashMap中的一个静态内部类,在HashMap类中的源码为:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
//构建一个新的实体
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
.........
}
其中key值定义的为final,因此在定义之后就无法进行修改,key和value就是在调用map时对应的键值对,next存储的是链表中的下一个节点,他是一个单链表,hash是对key的hashcode再次进行哈希运算之后得到的值,存储起来是为了避免重复计算。
3.HashMap的初始化
初始化构造一个hashMap有四种方法,这四种方法都比较好理解,我们主要看一下第一种方法:
在指定了初始化容量和负载因子时,首先会对参数进行检查,符合条件后,会根据初始化容量进行调整,使其必须是2的次幂,因此即使用户在使用时指定了容量,实际初始化的容量不一定等于指定的容量,至于为什么必须得是2的次幂,在后面的过程中在讲,在设置好容量之后,根据当前容量和负载因子计算出下一次扩容时的阈值threshold,也就是map中的元素个数达到这个值之后就要进行扩容。
//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);
//根据初始化的容量对实际的容量进行调整,必须为2的次幂
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
//根据容量和负载因子计算出当前的阈值
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
//2.仅指定容量,使用默认的负载因子,内部实现实际上还是依赖的第一种方法
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//3.使用默认容量和负载因子
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
//4.根据指定的map初始化,根据指定的map的容量和默认容量共同决定初始化容量,使用默认负载因子
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) 1,DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
putAllForCreate(m);
}
4.HashMap的put操作
由于HashMap中允许存在null的key,因此在插入元素的时候,需要对key值进行判断,其主要步骤为:
(1)如果key为null值,单独进行处理,putForNullKey(value);
(2)如果key不是null值,根据key的hashCode再次计算hash值 hash(key.hashCode());
(3)根据hash值和table数组的长度得到该元素在数组中的索引位置i=indexFor(hash,table.length);
(4)取得该位置的链表,如果链表不为空,对链表进行遍历,判断key值在链表中是否存在,已存在用新的value值替代旧值,同时返回旧值;
(5)否则,向该位置的链表的表头插入新的元素addEntry(hash,key,value,i)
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
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;
}
在put操作中,我们也看到了其中包含好几个方法,接下来对这几个方法进行说明:
当key值为null时,默认是将该元素插入到table[0]的位置,首先也是取到table[0]位置的链表,如果链表不为空,对链表进行遍历,判断是否存在key为null的值,如果存在,将新的value替换旧的值,同时返回旧值,如果不存在,就在链表的表头插入一个新的Entry
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;
}
在put操作中,最终决定元素存放的位置还需要以下两个方法,第一个可以看出来是对一个值使用hash方法,使用了各种异或、移位的操作,他是对key的hashCode再一次进行计算以及二进制位操作的过程,目的是为了使最终在数组中存储的位置尽可能的分布均匀,具体移位操作的过程就不讲了
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
indexFor方法是根据得到的hash值和数组的长度得到最终元素在数组中存放的索引位置,主要使用的是与(&)操作,这种方法也是保证获取的index一定是在数组的范围内,例如,如果数组的默认容量时16,h=25,转换为二进制计算得到的是:index=9,也就是该元素在table[9]的位置。
HashMap的的数组长度一定是2的次幂,在这里也可以看出来,比如长度为16的数组,那么length-1=15,他的二进制是01111,长度为32的数组,length-1=31,二进制为011111,可以看出length-1的低位全部都是1,这样在进行hash&(length-1)操作的时候,只要hash对应的最左边的差异位为0,能够保证所有的位置都可能被用到,例如如果长度是15,那么length-1=14,二进制为01110,与任何值进行&的时候,尾数永远都是0,就会导致0001,1001,1101等尾数为1的位置永远不可能有元素放置进去,所以设置长度为2的次幂的原因也是为了尽可能的保证所有的位置都可以被存储元素。
在put操作中,如果一个key值在数组中不存在的时候,需要往map中加入一个节点,其主要的方法是addEntry,他的主要步骤是:首先根据在table数组中的索引值拿到该位置上的链表e,新建一个Entry,使其的key和value是新加入的元素,next是e,也就是将新加入的值作为链表的表头放到table对应的位置上。
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);
if (size >= threshold)
resize(2 * table.length);
}
在HashMap中每插入一个新的元素之后,这里新的是指key在map中之前是不存在的,都需要对table数组的容量进行检查,判断是否需要扩容,当当前map中的元素的个数超过设置的阈值时threshold时,调用resize方法,其中传递的参数是当前数组的长度的2倍。
在使用resize的过程中,首先要判断当前数组的容量是否已经是最大值,如果是最大值,直接将阈值设置为Integer的最大值,然后返回,也就是不对数组进行扩容了。
否则,创建一个新的数组newTable,他的容量时当前数组容量的2倍,然后调用transfer方法,将旧数组中的元素复制到新的数组中,返回新的数组,每次的扩容操作都是将数组翻倍,然后对每一个元素重新计算位置,因此扩容操作是一个耗费时间和资源的操作。
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);
}
接下来看一下transfer方法,他是对老的数组进行遍历,取出table中每一个索引位置上的链表,然后对链表进行遍历,取出链表中的每一个Entry,根据其中的hash值和新的数组的length重新计算新的索引位置,然后将元素插入新的链表中,这一步会使得原有数组中的所有元素的位置被重新计算。
//复制老的键值对到新的数组中
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);
}
}
}
5.HashMap的get操作
HashMap的get操作相比put操作就简单很多,首先是判断key值是否为null,如果是null,则直接调用getForNummKey()方法去table[0]的位置查找元素,拿到table[0]处的链表进行遍历,这里只需要判断链表中的key值是否是null,返回对应的value值。
如果key值不是null,则需要对key.hashCode再次计算hash值,调用indexFor方法拿到在table中的索引位置,获取到对应的链表。拿到链表后,需要对链表进行遍历,判断hashCode值是否相等以及key值是否相等,共同判断key值是否在HashMap中存在,从而拿到对应的value值。在这里也就说明了为什么把一个对象放入到HashMap的时候,最好是重写hashCode()方法和equals方法,hashCode()可以确定元素在数组中的位置,而equals方法在链表比较的时候会用到。
public V get(Object key) {
if (key == null)
return getForNullKey();
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;
}
return null;
}
private V getForNullKey() {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
四、HashMap的多线程不安全的体现
1.resize扩容时死循环
在扩容操作是,是对链表进行循环操作,如果同时有两个线程在对同一个链表进行transfer操作,线程A在transfer的时候会修改为value2.next=value1, 线程B操作时,根据原始链表拿到的是value1.next=value2,而由于线程A已经修改为value2.next=value1,那么就会存在死循环的问题。
2.数据不一致
当一个线程在对HashMap进行resize操作,而另一个线程在进行get操作时,比如原始数组长度时16,扩容之后是32,在进行get操作根据扩容之前的length拿到index1,而这个时候另一个线程正好对index1的链表做好扩容操作,那么从index1的位置取出来的元素肯定是null,这时候可以使用ConcurrentHashMap(ConcurrentHashMap在之后再讲)
五、HashMap总结
1.HashMap是由数组和链表组成的,数组是HashMap的主体,链表是为了解决哈希冲突的问题,对于HashMap的插入问题,如果插入位置不含有链表,那么直接插入到链表的表头即可,如果包含链表,需要先遍历链表,判断key是否已经在链表中存在,存在则替换value值,不存在则插入到链表的表头。
2.HashMap中是根据hashCode和equals方法来决定存放的位置,因此不允许一个map将自己作为key值,但是可以将自己作为value值。
3.HashMap是线程不安全的,对于多线程环境下,无法保证数据的正确性,使用的时候需要注意。
4.如果在使用时能评估到HashMap中元素的个数,建议指定初始化容量的大小,在HashMap中默认容量时16,在插入元素的时候,会对元素进行检查是否需要扩容,每次扩容就是新建一个原来2倍容量的数组,对原有的元素全部重新哈希,因此如果不断插入元素,那么会需要不断的扩容,不断的哈希,这时候产生的内存开销和cpu开销在某些情况下可能是致命的。