注:主要记录自己学习过程中的心得,以Java8(jdk1.8)为主,另外在比较过程中会标明jdk1.7
一、定义
1.1 综述
HashMap基于Map接口实现,是一个用于存储Key-Value键值对的集合(允许null键和null值,但key不允许重复,故只能有一个键为null),每一个键值对也叫做Entry。HashMap不能保证放入元素的顺序,是无序的。
1.2 继承关系
public class HashMap<k,v>extends AbstractMap<k,v> implements Map<k,v>,Cloneable,Serializable
1.2基本属性
static final int DEFAULT_INITIAL_CAPACITY =1<<4;//默认初始化大小16
static final float DEFAULT_LOAD_FACTOR = 0.75f; //负载因子0.75
static final Entry<?,?>[] EMPTY_TABLE={}; //初始化默认数组
transient int size; //HashMap中元素的数量
1.3底层实现
Java8之后:HashMap 由数组+链表+红黑树的形式构成
put方法:大致思路为:
1.对key的hashCode()做hash,再计算index;
2.如果没碰撞直接放到bucket里;如果碰撞了,以链表的形式存在buckets后
3,.如果碰撞导致链表过长(>=TREEIFY_THRESHOLD),就把链表转换成红黑树
4.如果节点已经存在就替换old value
5.数据存放后,判断当前存入的对象个数,如果大于阈值(负载因子*HashMap的容量),则进行扩容。
*hash碰撞:hashCode相同,key不同
二、hashCode算法与HashMap的hash算法
2.1.hashCode:产生的依据:哈希码并不是完全唯一的,它是一种算法,让同一个类的对象按照自己不同的特征尽量的有不同的哈希码,
2.2.HashMap中的hash算法(java8):
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
*为什么hash算法要逻辑右移16位,为什么用位异或
原因就在于HashMap是通过tab[(n - 1) & hash]),即数组长度减一 位与 hash值的方式来计算数组下标的,而n的大小一般不会超过2^16(甚至更小) 此时 只有hash值的低16位甚至更低的位置参与位与运算,造成的结果就是冲突加剧,很明显这样不是一个好的散列算法。
但是如果将hashCode的值右移16位,即取int类型的一半,并且使用异或运算,那么hashCode的更多位值参与到运算里,降低了冲突的产生,使结果更加均匀。
至于为什么用位异或而不是位与或者位或,原因是&会使结果偏向0,|使结果偏向1。
hashMap容量为什么建议是2的幂次方
hash算法的目的就是让hash值尽量均匀的分布,再来看hashMap计算index的方法tab[(n - 1) & hash]),当容量为2的幂次方的时候,n-1转换为二进制所有位置都是1,这样进行位与运算时,是0或者是1完全取决于hash值对应位置的数值(位数取决于n-1)。
eg: 当容量为9时,有两个key其hash值分别为
10110010 11110010 11001110 00101011 , 11010101 01000100 00011111 01011101
且(9-1)转换为二进制为1000
则tab[(n - 1) & hash])运算的结果都为1000,因为位与的原因 hash值的低三位完全没起到作用,这背离了设计初衷。
2.3..HashMap中的hash算法(java7):
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
可以看到Java8与7中的hash算法都用到了hashCode值,但对其处理过程却完全不同,Java8的hash算法已经说的很清楚了,在这来说说Java7的hash算法,可以看到在下图中做了好几次逻辑右移和异或,目的是明确的:就是尽量让每一位都参与运算,让相近的数最后通过hash能分散开并减少碰撞,(但是为什么要这么做,为什么选择20,12,7,4,查阅了很多资料,最终还是没得到一个特别有说服力的答案,感兴趣的可以看看https://stackoverflow.com/questions/9335169/understanding-strange-java-hash-function)
在自定义容量的时候最好是多少呢?
HashMap h=new HashMap(n);
由源码知,hashMap的容量大于阈值(负载因子*HashMap的容量)时,会扩容,而扩容会重新计算数据的位置,性能损失严重,故n必须大于预计数据量的1.33333(4/3)倍(默认负载因子0.75) ,另外hashmap并不是用户输入多少,初始化的时候容量就是多少,而是会对用户输入的n值进行判断,取第一个比(n-1)大的2的幂。
三、Java7与Java8扩容
3.1 java7:
扩容需要满足两个条件:1.存放新值之前 当前已有元素的个数必须大于等于阈值
2.发生hash冲突
public V put(K key, V value) {
//省略部分代码。。。。
//计算当前key的哈希值
int hash = hash(key);
//通过哈希值和当前数据长度,算出当前key值对应在数组中的存放位置
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//如果计算的位置有值,且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;
}
modCount++;
//存放值的具体方法
addEntry(hash, key, value, i);
return null;
}
put方法里面调用了addEntry(),很关键的一行代码if ((size >= threshold) && (null != table[bucketIndex]))
void addEntry(int hash, K key, V value, int bucketIndex) {
//(新值插入之前当前个数是否大于等于阈值)(新值将要存放的位置是否有值,即存放是否发生哈希碰撞)
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);
}
通过代码可知,Java7中 HashMap当同时满足两个条件时才会扩容,同时从代码中也能看出:先扩容再执行插入操作。
仔细看这个if条件,会出现一个比较有意思的事,因为扩容必须两个条件都满足,我们假设前11个元素hashcode相同,那么他们会被放入同一个桶里,当put第12个元素的时候,如果hashcode还相同,此时if条件(11>=12)不满足,不会触发扩容。即如果接下来的15个元素每一个占一个桶,那么就会出现容量为16的hashmap存储了27个元素还没有进行扩容(默认负载因子为0.75,容量为16),当然这种极端情况现实中应该不会出现。。。
满足扩容条件之后会调用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));
table = newTable;
//设置新的阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
关键在于resize方法中的transfer()方法。
/**
* Transfers all entries from current table to newTable.
*/
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;
}
}
}
看if(rehash)这行代码,true则重新进行计算key的hash值,false则不用计算,而rehash的来源在resize方法中可以看到rehash=initHashSeedAsNeeded(newCapacity)。
/**
* Initialize the hashing mask value. We defer initialization until we
* really need it.
*/
final boolean initHashSeedAsNeeded(int capacity) {
//如果hashSeed!=0,表示当前正在使用备用哈希
boolean currentAltHashing = hashSeed != 0;
//如果VM启动了且map的容量大于阈值,则使用备用哈希
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
//异或操作
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
//把hashSeed设置为随机值
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
仔细看这个方法: boolean currentAltHashing,我们可以记住一点 ,刚开始hashSeed初始化的时候是赋值0的,也只有下面的if(switching)里面才会更改hashSeed的值,所以currentAltHashing的值为false。再看useAltHashing的值,它是由两个值的逻辑与运算决定的:sun.misc.VM.isBooted一般VM启动的时候他是为true的,而ALTERNATIVE_HASHING_THRESHOLD的值为integer的最大值,所以使用过程中capacity一般是小于后者的,为false,结果就是useAltHashing为false,最终导致的就是switching的值一般是false。
(感兴趣的可以看看这个,从14:25开始看起,讲的很清楚https://www.bilibili.com/video/BV1vE411v7cR?p=4)
回到上面的transfer方法,if(rehash),很明显rehash为false,所以得出结论:
一般情况下,触发扩容的时候,不会去重新计算key的hash值,只会重新计算在数组中的位置。
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;
有意思的点在于扩容的时候需要将旧的元素移到扩容后的newTable里:
同一个链表里最先put的元素会放到链表头,链表翻转。(比如:以前是A->B->C->D,扩容后变为D->C->B->A).扩容一次,翻转一次。
扩容结束之后或者不满足扩容条件之后就会执行插入操作 (区别在于扩容后时会重新计算元素在表中的位置index)。
而当不满足扩容条件时,会去执行插入操作createEntry(int hash,K key,V value,int bucketIndex);
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++;
}
3.2 Java8
java 8扩容条件与7不同,在两种情况下都会扩容,先说结论(先插入再判断是否扩容):
(1)存放新值之后,目前元素(包括新加入的这个值)的个数大于阈值,
(2)发生hash冲突,存入链表长度(不包括新加入的key,虽然此时链尾的next指针已指向新key)>=8并且hash表的数组长度<64
为了说明这两种扩容情况,我们从put方法说起吧:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/**
* 关键从这块开始,
* Java8并不是在new的时候对对象初始化,(jdk7是在new的时候初始化)
* 而是在这里当对象为空的时候才会调用resize进行初始化。
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//插入的新元素没有冲突,则在对象存放位置创建新node,该新节点将作为这个位置链表的头结点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//如果将要插入的位置已有元素
Node<K,V> e; K k;
//并且该元素与将要插入的元素相同(==,equals),则直接覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果不相等,则分两种情况:判断所要存储的位置是否为红黑树结构,是则调用putTreeVal
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不是红黑树,则为链表,进行循环遍历该链表
else {
for (int binCount = 0; ; ++binCount) {
/**
* 如果存入位置所在链表下一个位置为空(从头结点下一个位置开始)
* 则将新节点直接存入(即头结点的next指针指向newNode)
* 接着binCount>=7(即判断这个链表的长度是否>=8,此时链表的长度计算不包括newNode)
* 满足条件,则调用treeifyBin方法(该方法会判断map长度是否<64,小于则触发扩容)
* 存入位置链表下一位置不为空,则判断是否相同,是则覆盖,不是则继续循环
*/
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//在判断处理流程中,如果key相同(==,equals),则直接覆盖,将旧值返回
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//size此时是不包括新元素的,所以++size包括新加入的元素,即目前键值对数>阈值,触发扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
有耐心的可以看上面代码说明,附上如果binCount>7之后调用的treeifyBin方法:
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
/**
* 这里MIN_TREEIFY_CAPACITY=64,
* tab.length等于hashmap的容量,是一个2的幂(而不是有的博主说的map的size)
* 即当满足binCount>7且length<64时,触发扩容,不满足时转红黑树
*/
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//既然都已经进入treeifyBin方法了,这边为什么还要进行一次判断没搞懂,没搞懂
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
附:java7插入元素的方式是头插法,即往HashMap里面put元素时,此时新增的元素在链表上的头部
四、get方法的流程
未完。。。