一、HashCode原理
1、HashCode特性
- HashCode的存在主要是用于查找的快捷性,主要用于HashMap、Hashtable中,用来确定对象的存储地址
- 如果两个对象相同,equals()一定返回true,并且两个对象的HashCode一定相同
- 两个对象的HashCode相同,并不一定表示两个对象就相同,只能说明这两个对象在一个散列存储结构中
- equals()重写也需要同时重写HashCode()
2、Hash算法(散列算法)
- Hash常用于java集合中,Set作为一种无序且无重复元素的集合,如果每次添加元素从头equals遍历,则效率很低,因此采用Hash表存储元素
- Hash算法可以将一个元素直接映射到一个地址上,这样集合要添加新的元素时,通过HashCode可以知道这个元素的物理地址,进行存储:
- 如果这个物理地址没有元素,则直接存储
- 如果已经有元素,就调用equals方法,如果相等就不存储
- 如果equals也不想等,就会产生Hash冲突,即两个不同的元素得到了同一个存储地址
- Hash冲突的解决方法
- 开放定址法
- 当冲突发生时,使用某种探查技术在散列表中形成一个探查序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存入该地址单元)。
- 哈希表越来越满时数据插入非常耗时,因此设计Hash表确保元素数不超过容量的一半,最多不超过2/3
- 开放定址方法按照寻址方法可以分为:
- 1)线性探测:在原来值的基础上往后加一个单位,直至不发生哈希冲突
- 2)在平方探测:在原来的值上先加1的平方个单位,若仍然冲突则减1平方个单位,随之是2的平法,3的平方
- 3)伪随机探测:在原来值基础上加上一个随机函数生成的数,直至不发生哈希冲突
- 链地址法(拉链法),也是目前HashMap使用的方法
- 将具有相同HashCode的值根据插入顺序,形成链表,链表的头节点地址放在Hash表中
- 生成链表需要额外的空间,需要花费精力和时间维护链表,扩容的时候需要reHash
- 开放定址法
二、HashMap原理
- HashMap采用拉链式存储实现,即使用链表处理Hash冲突,但是当同一个Hash值的链表数量过多时,通过key值查询的效率较低,因此JDK1.8中,当链表数量超过8个时,将链表转换为红黑树
1、HashMap的相关属性和构造函数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认的初始化容量16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大的容量2^29
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认的加载因子(填充比)
static final int TREEIFY_THRESHOLD = 8;//链的长度大于8则转换为红黑树
static final int UNTREEIFY_THRESHOLD = 6;//链的长度小于6转换为链表
static final int MIN_TREEIFY_CAPACITY = 64;//当集合中容量大于64时,才会转为树,否则只需要resize
//构造函数1:(initialCapacity:初始容量,loadFactor:填充比)
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);//填充比使用默认填充比然后调用构造函数1
}
//构造函数3:无参构造器
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 填充比赋值,其他也均使用默认值
}
//构造函数4:(使用Map m的元素初始化)
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
2、HashMap的实现原理
1)HashMap添加的元素是一个Entry(Key-Value对),因此首先需要有一个的元素类型的Hash表
2)添加元素时,HashMap会根据Key的值,计算hash值,进一步计算出该元素在Hash表中对应的索引:
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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)//索引
tab[i] = newNode(hash, key, value, null);
else {
………………
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
- 从源码中可以看出,HashMap对于一个Key的索引计算是通过tab[i = (n - 1) & hash]进行的,也就是说该元素的索引是i = (n - 1) & hash,其中n为hash表长度,传递进putVal()中的hash值是在put()方法中的hash(key)得到,hash()方法的源码如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 同时, key.hashCode()调用的是Object类下的hashCode()方法:
public native int hashCode();
- 其返回值是一个int类型的整型数,32位
- 因此若添加的一个元素中Key通过Object类hashCode()方法得到的hash值为h,则该元素在HashMap中的hash表索引为:i = (n - 1) & (h^(h>>>16)),其中n为hash表长度
- 那么,为什么HaspMap中hash值需要重新进行这么多的计算呢?
- 首先分析i = (n - 1) & hash,在一般的hash算法实现中,通常通过对hash表长度取余实现,即hash%n,而对于HashMap,其长度n是2的n次幂,两种计算方式是等同的,并且位运算更快:
假设a=10,b=8=23
a%b:二进制中右移等同于/2,因此对2n取余,就是将原来的二进制数右移n位,剩下的数是做除法后的值,移位出来的值是余数,10%8=1010>>3移位丢弃的数=010
a&(2n-1): 2n-1比 2n少一位,并且全为1,并且一个二进制数&全为1的结果是其本身,因此这个&运算结果就是 a的位于2n-1的位数的二进制数,与取余过程中右移个数也相等, 10&7=10&(2n-1)=1010&0111=010
- 但是,取余和与运算均存在一种缺陷,就是计算结果对高位是无效的,只对低位有效,当i = (n - 1) & hash中的hash只有高位变化时,取余结果不变,如:
0111 0101和0101 0101 的结果是一样的
0111 0101&1111=101
0101 0101&1111=101
- 这种缺陷会导致当所有的key值中,只要后四位一致,其hash索引均是一致的,会带来很大的hash冲突,而这种现象就说明了HashMap为什么要重新计算Object中hashCode()得到的hash:
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
- 前面已经说明key.hashCode()返回的是一个int值,具有32位,本段代码中需要把int值右移16位,将高16位变为低16位,这里使用8位int,右移4位来说明其原理:
假设有两个后四位相同的较大值0111 0101和0101 0101,以及一个较小的值0110来计算hash
右移4位: 0111 0101>>4 -> 0111,0101 0101>>4 -> 0101,0110>>4 -> 0000
与原来的值作异或:0111 0101 ^ 0111 -> 0111 0010,0101 0101 ^ 0101 -> 0101 0000,0110 ^ 0000 -> 0110
- 因此,通过HashMap的重新计算hash,能够将高16位的变化影响到低16位的变化,减少了大量的hash冲突
3)如果hash数组中,该元素的索引位置没有元素,就把该元素放在hash数组中,如果存在元素,则把已存在的元素作为链表的头节点,添加元素依次往后放,这么一条链表上的元素,其hash值是相同的
4)当一个链表长度过长,其检索性能也会降低,新的版本中会把链表长度大于8的链表转换为红黑树,除此之外也会通过将hash数组扩容的方法来提高效率
- hash数组会有一个初始容量,及一个系数(默认是0.75),当hash数组容量超过初始容量的0.75时,会将hash数组扩大两倍(resize()),然后将之前存的元素rehash()到新的hash数组中,这个过程中会改变索引,会把以往较长的链表重新打散存储在新的hash数组中
3、HashMap的resize()
final Node<K,V>[] resize() {
//扩容前的参数复制
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//如果旧表不为空
if (oldCap > 0) {
//旧表长度超出最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新表赋值长度是旧表的两倍(newCap = oldCap << 1)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 容量阈值也同步放大两倍
}
//如果旧表为空,就开始第一次初始化表
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//初始化表赋值
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;//新表长度乘以加载因子
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//构造新表,并把旧表中的所有元素rehash
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//hash数组的遍历
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//旧表中删去元素
//如果旧表中该索引只有一个元素,即链表长度为1
if (e.next == null)
//在新表中重新计算该元素的hash索引
newTab[e.hash & (newCap - 1)] = e;
//如果该hash索引下是否包含多个元素
else if (e instanceof TreeNode)//判断是否是红黑树结构
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 如果是正常的链表结构,则需要将链表遍历,重新放置
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//遍历整个链表
do {
next = e.next;
//新表是旧表的两倍,实例上就把单链表拆成两队
//(e.hash & oldCap) == 0为一队,!=0为一队
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//将两个单链表的头节点放到对应索引处
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
三、HashMap与多线程
1、HashMap的线程不安全场景
1)多线程的put()
- HashMap中put一个(K,V)元素的过程是先根据K的hash值,计算得到在hash数组中的索引位置,然后判断该位置是否已经有元素,如果没有,就放置在该位置,如果有,则放置在该链表的尾部
- 如果两个线程同时put两个元素,这两个元素具有相同的Hash索引,就会产生线程不安全问题
- 假如该Hash对应索引处有一个元素A,并且A.next=null,则线程安全的结果应该是将两个元素均放置在A的链表上,顺序不影响
- 但是如果线程T1和线程T2同时得到了一个信息:这个索引处A.next=null,那么两个线程都会执行A.next=(K,V)这么一步,会导致一个元素的丢失,具体如下图:
2)put和get并发时,可能导致get为null
- 如果线程T1put一个元素,发现需要resize,会在以下代码处产生线程不安全
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
- table重新创建时,是一个空的数组,此时如果其他线程使用get()时,会得到null
3)JDK7中HashMap并发put会造成循环链表,导致get时出现死循环
- JDK8中resize后的rehash是先把旧的链表分为两个单链表,然后把链表头放在对应的hash数组索引位置,避免了这个线程不安全问题
- 但是JDK之前的rehash是通过对整个HashMap的遍历,依次将每个元素rehash到新的hash数组中,会带来这个线程不安全问题
- JDK8以前的rehash方法源码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K, V> e : table) { // table变量即为旧hash表
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;
}
}
}
2、线程安全的HashMap==>ConcurrentHashMap
先埋个坑,脑子有点不够用了