一、HashMap的定义和重要成员变量
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
熟悉源码的童鞋会很奇怪,为啥AbstractMap已经实现了Map接口,HashMap还要再实现一遍呢?为啥呢?我™也不知道……
直接看HashMap的重要成员变量:
int DEFAULT_INITIAL_CAPACITY = 16:默认的初始容量为16 ;
int MAXIMUM_CAPACITY = 1 << 30:最大的容量为 2 ^ 30 ;
float DEFAULT_LOAD_FACTOR = 0.75f:默认的加载因子为 0.75f ;
Entry< K,V>[] table:Entry类型的数组,HashMap用这个来维护内部的数据结构,它的长度由容量决定 ;
int size:HashMap的大小 ;
int threshold:HashMap的极限容量,扩容临界点(容量和加载因子的乘积);
这些成员变量灰常灰常重要,在HashMap源码内部经常看到。这里重点介绍一下加载因子这玩意儿:加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
关于这个,再说得细一点,之所以采用Hash散列进行存储,主要就是为了提高检索速度。
众所周知,有序数组存储数据,对数据的检索效率会很高,但是,插入和删除会有瓶颈产生。而链表存储数据,通常只能采用逐个比较的方法来检索数据(查找数据),但是,插入和删除的效率很高。
于是,将两者结合,取长补短,优势互补一下,就产生哈希散列这种存储方式。
具体是怎么样的呢?
我们可以理解成,在链表存储的基础上,对链表结构进行的一项改进。
我们将一个大的链表,拆散成几个或者几十个小的链表。每个链表的表头,存放到一个数组里面。这样,在从大链表拆分成小链表的时候就有讲究了。我们按照什么规则来将一个大链表中的数据,分散存放到不同的链表中呢?在计算机当中,肯定是要将规则数量化的,也就是说,这个规则,一定要是个数字,这样才比较好操作。比如,按照存放时间,每5分钟一个时间段,将相同时间段存放的数据,放到同一个链表里面;或者,将数据排序,每5个数据形成一个链表;等等,等等,还有好多可以想象得到的方法。但是,这些方法都会存在一些不足之处。我们就在想了,如果存放的数据,都是整数就好了。这样,我可以创建一个固定大小的数组,比如50个大小,然后,让数据(整数)对50进行取余运算,然后,这些数据,自然就会被分成50个链表了,每个链表可以是无序的,反正链表要逐个比较进行查询。如果,我一个有200个数据,分组后,平均每组也就4个数据,那么,链表比较,平均也就比较4次就好了。但是,实际上,我们存放的数据,通常都不是整数。所以,我们需要将数据对象映射成整数的一个算法。HashCode方法,应运而生了。每个数据对象,都会对应一个HashCode值,通过HashCode我们可以将对象分组存放到不同的队列里。这样,在检索的时候,就可以减少比较次数。
在实际使用当中,HashCode方法、数组的大小 以及 数据对象的数量,这三者对检索的性能有着巨大的影响。
1.如果数组大小为1,那和链表存储没有什么区别了,而且,还多了一步计算HashCode的时间,所以,数组不能太小,太小查询费时间。
2.如果我只存放1个数据对象,数组又非常大,那么,数组所占的内存空间,就比数据对象占的空间还大,这样,少量数据对象,巨大的数组,虽然能够使检索速度,但是,浪费了很多内存空间。
3.如果所有对象的HashCode值都是相同的数,那么,无论数组有多大,这些数据都会保存到同一个链表里面,一个好的HashCode算法,可以使存放的数据,有较好的分散性,在实际的实现当中,HashSet和HashMap都对数据对象产生的HashCode进行了二次散列处理,使得数据对象具有更好的分散性。
二、HashMap的数据结构
先看代码,主要是两块:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
transient Node<K,V>[] table;
没错,其实HashMap的底层就是一个数组,数组的元素是一个单链表。用图看更清晰:
Node继承自Entry是HashMap的一个内部类,它也是维护着一个key-value映射关系,除了key和value,还有next引用(该引用指向当前table位置的链表),hash值(用来确定每一个Entry链表在table中位置)。
再看下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);
//计算出大于初始容量的最小 2的n次方作为哈希表table的长度,下面会说明为什么要这样
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
//设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建Entry数组
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
这个构造函数主要做的事情就是:
1. 对传入的 容量 和 加载因子进行判断处理
2. 设置HashMap的容量极限
3. 计算出大于初始容量的哈希表table的长度,这个长度一定是2的次方,并且当次方-1时就小于这个初始容量,有点绕,举个例子来说:初始长度为10,那长度就是16,因为2的3次方为8小于10。 然后用该长度创建Entry数组(table),这个是最核心的。为什么一定要是二的次方呢?这里直接说结论:当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。 原因会在下文中进行测试说明。
三、put
public V put(K key, V value) {
//如果key为空的情况
if (key == null)
return putForNullKey(value);
//计算key的hash值
int hash = hash(key);
//计算该hash值在table中的下标
int i = indexFor(hash, table.length);
//对table[i]存放的链表进行遍历
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判断该条链上是否有hash值相同的(key相同)
//若存在相同,则直接覆盖value,返回旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//修改次数+1
modCount++;
//把当前key,value添加到table[i]的链表中
addEntry(hash, key, value, i);
return null;
}
看源码可以知道HashMap是可以把null当做key的,看下putForNullKey方法:
private V putForNullKey(V value) {
//查找链表中是否有null键
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++;
//如果链中查找不到,则把该null键插入
addEntry(0, null, value, 0);
return null;
}
可以看到HashMap默认把null键的Entry放在数组的0位置,因为null无法获得hash值。下面看addEntry方法:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
//这一步就是对null的处理,如果key为null,hash值为0,也就是会插入到哈希表的表头table[0]的位置
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
这里出现了几个非常重要的方法,也是hashMap最核心的原理所在。
1.hash方法和indexFor方法:
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
hash方法没啥可说的,就是对key值进行hash算法获得一个hash值,hash算法请自行百度,这是一个数学方法,没兴趣的同学也可以直接略过,只要记住hash算法的目的就一个,使hash后的那个int值尽量分散。重点看indexFor,其实很简单就是拿当前key的hash值与HashMap的长度-1进行与操作,其本质就是个取模的操作,使用与运算效率要比%高得多。好,我们前面说了HashMap的长度一定是2的次方,那2的次方-1满足什么条件呢?
2^1 - 1 = 0x1;
2^2 - 1 = 0x11;
2^3 - 1 = 0x111;
2^4 - 1 = 0x1111;
……
我们写个代码简单测试一下就知道这个方法的意义何在了:
public static void main(String[] args)
{
int length = 16;
for(int i = 1; i <= 32; i++)
{
System.out.print((i & (length - 1)) + ", ");
}
}
当length = 16时,输出:
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0
当length = 15时,输出:
0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 0,
明显后者碰撞的概率要大得多,同时因为15 - 1 = 0x1110,所以无论hash为多少最后一位都会被与成0,导致最后一位为1的空间永远无法得到利用。
2.resize方法
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建一个新的 Hash 表
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
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);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
还记得HashMap中的一个变量吗,threshold,这是容器的容量极限,还有一个变量size,这是指HashMap中键值对的数量,也就是node的数量
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
什么时候发生扩容?
当不断添加key-value,size大于了容量极限threshold时,会发生扩resize。
resize这里是个大坑,因为会导致死锁,而根本原因来自transfer方法,这个方法干的事情是把原来数组里的1-2-3链表transfer成了3-2-1,不太看得懂的同学可以看我下面的演示代码,本质是一样的:
package com.amuro.studyhashmap;
public class HashMapStudy
{
public static void main(String[] args)
{
Node n1 = new Node();
n1.data = 1;
Node n2 = new Node();
n2.data = 2;
Node n3 = new Node();
n3.data = 3;
n1.next = n2;
n2.next = n3;
printLinkedNode(n1);
System.out.println("------");
Node newHead = mockHashMapTransfer(n1);
printLinkedNode(newHead);
}
static class Node
{
int data;
Node next;
}
static void printLinkedNode(Node head)
{
while(head != null)
{
System.out.println(head.data);
head = head.next;
}
}
static Node mockHashMapTransfer(Node e)
{
Node newHead = null;
while(e != null)
{
Node next = e.next;
e.next = newHead;
newHead = e;
e = next;
System.out.print("");
}
return newHead;
}
}
结果输出:
1
2
3
_ _
3
2
1
transfer方法的本质就是这个,那为什么会导致死锁呢?简单分析一下:
我们假设有两个线程T1、T2,HashMap容量为2,T1线程放入key A、B、C、D、E。在T1线程中A、B、C Hash值相同,于是形成一个链接,假设为A->C->B,而D、E Hash值不同,于是容量不足,需要新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中(refresh)。这时T2进程闯进来了,T1暂时挂起,T2进程也准备放入新的key,这时也发现容量不足,也refresh一把。refresh之后原来的链表结构假设为C->A,之后T1进程继续执行,链接结构为A->C,这时就形成A.next=B,B.next=A的环形链表。一旦取值进入这个环形链表就会陷入死循环。
所以多线程场景下,建议使用ConcurrentHashMap,用到了分段锁的技术,后面有机会再讲。
最后整理一下put的步骤:
1. 传入key和value,判断key是否为null,如果为null,则调用putForNullKey,以null作为key存储到哈希表中;
2. 然后计算key的hash值,根据hash值搜索在哈希表table中的索引位置,若当前索引位置不为null,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束;
3. 否则调用addEntry,用key-value创建一个新的节点,并把该节点插入到该索引对应的链表的头部。
四、get
public V get(Object key) {
//如果key为null,求null键
if (key == null)
return getForNullKey();
// 用该key求得entry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key);
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;
}
put能看懂的同学看get应该毫无压力,调用hash(key)求得key的hash值,然后调用indexFor(hash)求得hash值对应的table的索引位置,然后遍历索引位置的链表,如果存在key,则把key对应的Entry返回,否则返回null。
从HashMap的结构和put原理我们也能理解为什么HashMap在遍历数据时,不能保证插入时的顺序。这时需要使用LinkedHashMap。
最后把java里map的四个实现类做个总结。
1.Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。 HashMap最多只允许一条记录的键为Null;允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap;可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。
2.Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。
3.LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的.也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。
4.TreeMap实现SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。
最后的最后再加个tip:
ConcurrentHashMap提供的线程安全是指他的put和get等操作是原子操作,是线程安全的。但没有提供多个操作(判断-更新)的事务保护,也就是说:
//T1:
concurrent_map.insert(key1,val1);
//T2:
concurrent_map.contains(key1);
是线程安全的。
//T1,T2同时
if (! concurrent_map.contains(key1) {
concurrent_map.insert(key1,val1)
}
线程不安全。