三、HashMap原理
1、概述
HashMap在底层数据结构上采用了数组+链表+红黑树,通过Hash算法获取元素索引保存,hash值相同且key值相等的元素视为相同,则用新值替换旧值并返回旧值。HashMap中null总是放在数组的第一个链表中,最多允许一对键值对的Key为Null,允许多对键值对的value为Null。它是非线程安全的,在排序上面是无序的。JDK1.8中元素是插在链表的尾部,而1.7中新元素是插在链表的头部。
2、参数概念
(1)、桶
数组中的每一个元素(Node<K,V>),桶中连的链表或者红黑树中的每一个元素称为bin
(2)、capacity
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
HashMap中所有桶的总容量(默认值为2^4=16),容量必须为2^n,扩容是按照原容量的2倍进行扩容。如果在构造函数中指定了Map的大小,那么进行put操作时,初始化时通过tableSizeFor() 函数将Map大小调整为离传入值最近的2的整数幂。
(3)、maximun_capacity
static final int MAXIMUM_CAPACITY = 1 << 30;
HashMap的最大数组容量2^30。
(4)、loadFactor
static final float DEFAULT_LOAD_FACTOR = 0.75f;
负载因子(默认值为0.75f)反应了HashMap桶数组的使用情况(假设键值对节点均匀分布在桶数组中),当我们调低负载因子时,HashMap所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键与键之间产生的碰撞会下降,链表长度变短。HashMap的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。相反,如果增加负载因子(负载因子可以大于1),HashMap所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。链表长度变长,效率也随之降低,这种情况是拿时间换空间。负载因子0.75是对空间和时间效率的一个平衡选择,一般情况下,使默认值就可以。
(5)、threshold
int threshold;
计算公式:threshold = capacity*loadFactor
临界值,当HashMap的size大于threshold时会执行resize操作。
(6)、treeify_threshold
static final int TREEIFY_THRESHOLD = 8
树化阈值默认值为8,当单个链表的容量超过阈值时,将链表转化为红黑树。
(7)、untreeify_threshold
static final int UNTREEIFY_THRESHOLD = 6;
红黑树转链表的默认阈值为6,当resize后或者删除操作后单个链表的容量低于阈值时,将红黑树转化为链表。
(8)、min_treeify_capacity
static final int MIN_TREEIFY_CAPACITY = 64;
最小树化桶容量(tab.length)默认值为64,当桶中的bin被树化时最小的桶容量(tab.length),低于该容量时不会树化。
注意:树化的条件是链表的数量大于并且数组长度大于64(binCount > 8 && tab.length > 64)。如果只是链表的长度大于8,但是数组的长度没有大于64,就会对HashMap进行扩容操作,重新散列这些元素,直到两个条件都符合。
(9)、Node<K,V>类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
...
}
Node<K,V>是HashMap的内部类实现Map.Entry<K,V>接口,HashMap的哈希桶数组中存放的键值对对象就是Node<K,V>。类中维护了一个next指针指向链表中的下一个元素。值得注意的是,当链表中的元素数量超过TREEIFY_THRESHOLD后会HashMap会将链表转换为红黑树,此时该下标的元素将成为TreeNode<K,V>,继承于LinkedHashMap.Entry<K,V>,而LinkedHashMap.Entry<K,V>是Node<K,V>的子类,因此HashMap的底层数组数据类型即为Node<K,V>。
3、Hash算法
Hash,一般翻译做“散列”,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,输出的就是散列值。根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
HashMap元素索引算法:
-
取key的hashCode
-
高位运算
-
取模运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(1)、h = key.hashCode():取key的hashCode
(2)、h^h >>> 16:高位运算
通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),目的是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。原因是,之前提到了当数组的长度为2^n ,而取模(hash & (len-1))只取低位,会造成大量碰撞,因此为了更好地反应哈希码的全局属性,Java8运用了代码中的扰动函数,可能出于性能考虑,相较Java7,Java8只做了一次高低位混合。
tab[i = (n - 1) & hash]
(3)、i = (n - 1) & hash:该运算等价于key的hash值对数组的长度length取模,也就是h%length,但是&比%具有更高的效率。
4、数据结构
JDK 1.8以前HashMap的实现:数组+链表
JDK 1.8当前HashMap的实现:数组+链表+红黑树
(1)、比较
JDK1.8之前即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,极端情况HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。但JDK1.8当链表的长度达到一定值(默认是8)时,将链表转换成红黑树(时间复杂度为O(logN),可以极大提高查询效率。
5、源码解析
(1)、put方法
/**
* HashMap的put方法
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* put详细操作实现
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 当底层数组==null,初始化数组获取数组长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根据hash值获取桶下标中当前元素,如果为null,说明之前没有存放过与key相对应的value,直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 处理hash碰撞情况
Node<K,V> e; K k;
// hash碰撞,并且当前桶中的第一个元素即为相同key,e!=null, 见注【1】
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 链表结构,遍历链表找到需要插入的位置
for (int binCount = 0; ; ++binCount) {
// 遍历至链表尾部,无相同key的元素,插入链表尾部, e=null
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 插入后,如果链表长度大于等于树化阈值,将链表转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 链表中找到相同key的元素,退出遍历,(e=p.next)!=null
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// p指向p.next,继续检查链表中下一个元素
p = e;
}
}
// e!=null时,说明遍历链表或树过程中找到了key相同的元素
// 根据onlyIfAbsent或者旧value是否为null来判断是否要覆盖value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 用于LinkedHashMap的回调方法,HashMap为空实现
afterNodeAccess(e);
// 返回替换之前的旧元素, 见注【3】
return oldValue;
}
}
// 新键值对的添加属于"Structural modifications", modCount要自增,见注【2】
++modCount;
// 当前键值对数超过threshold时,对桶数组进行扩容
if (++size > threshold)
resize();
// 用于LinkedHashMap的回调方法,HashMap为空实现
afterNodeInsertion(evict);
// 新添加键值对,返回null, 见注【3】
return null;
}
注:
【1】
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))这个判断在put方法里出现了2次,都是为了校验新加的元素和当前元素是不是拥有相同的key,如果是的话将会覆盖旧的value。这里需要注意的是,当两个对象相等,hashCode()方法必须返回相同的hash,但反过来不一定。这句判断主要有2个维度,第一,对于相同的对象key,他们的hash必须相等,对于null,2.1中的代码我们也看到了,null对应的hash等于0,所以两个key=null,他们也拥有相同的hash,也可以推断,如果一对键值对,key=null,那么它永远存放在HashMap底层数组的第一个桶中。第二,如果key!=null,我们应该用equals方法来判断对象是否相等,所以(k = p.key) == key这句话是为了短路当key为同一个对象(反复put覆盖旧value)或者null的时候的情况。
【2】
在许多非线程安全的集合类中都会看到modCount成员变量,简单地讲,这个变量的用途是当迭代器在做集合遍历的时候能够快速识别其他线程对当前对象的结构性修改,从而抛出java.util.ConcurrentModificationException实现fail-fast机制。一般我们在做集合迭代时会用Iterator iterator = map.entrySet().iterator();获取迭代器,调用iterator.next()方法后获取迭代器中的下一个元素。在HashMap中,每一次iterator()将返回一个新的java.util.HashMap.EntryIterator<K, V>对象,EntryIterator是java.util.HashMap.HashIterator<K, V>的子类,无参构造时会初始化expectedModCount=modCount成员变量,该变量就是用来校验当前的迭代器与其所属的map对象是否保持同步。每一次java.util.HashMap.EntryIterator.next()被调用,都会调用父类的java.util.HashMap.HashIterator.nextNode()方法,方法中,当检测到expectedModCount!=modCount就会抛出ConcurrentModificationException,说明所属的map对象发生了所谓的
“Structural modifications”从而实现fail-fast机制。
【3】
根据HashMap中Javadoc的描述,put方法会返回覆盖的旧键值对的value,当返回为null时表示,map中不存在对应的key,键值对为新添加的项,或者map中对应key的value本身为null。
(2)、get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
a、通过hashcode找到散列桶的具体位置
b、通过key的equlas方法在链表中找到key对应的value
(3)、resize()方法
resize()就是把原来的数据重新hash,然后给到扩容后的数组。HashMap中所有桶的总容量(默认值为2^4=16),容量必须为2^n,扩容是按照原容量的2倍进行扩容,这样可以减少碰撞的几率,让数据元素更均匀的分布,提高查询的效率,减少空间的浪费。
final HashMap.Node<K, V>[] resize() {
HashMap.Node[] var1 = this.table;
int var2 = var1 == null ? 0 : var1.length;
int var3 = this.threshold;
int var5 = 0;
int var4;
...
}
扩容为2的n次幂的原因:
①、因为2的幂-1都是11111结尾的,所以碰撞几率小。
16转化成二进制为10000
15转化成二进制为01111
注:按位与运算:按位与运算符"&"是双目运算符。其功能是参与运算的两数各对应的二进位相与。只有对应的两个二进位均为1时,结果位才为1 ,否则为0。 例:1101 & 1011=1001
②、当HashMap的数组长度是2的整次幂时,(n - 1) & hash与 hash % n计算的结果是等价的,而后者是更容易想到的办法,前者是更有效率的办法。
③、JDK1.7在HashMap扩容时有do while操作,可能会出现环,因为两个线程同时操作一个链表的元素指向,会导致混乱。JDK8是等链表整个while循环结束后,才给数组赋值。
源码解析原地址:JDK1.8源码解析-HashMap(一)_斯文的张扬的博客-CSDN博客
6、HashMap和Hashtable的区别
(1)、HashMap允许key/value为null,Hashtable不允许;
(2)、HashMap没有考虑同步,是线程不安全的。HashTable是线程安全的,给api套上了一层synchronized修饰,每一个方法上都有一个synchronized;
(3)、HashMap继承于AbstractMap类,HashTable继承与Dictionary类;
(4)、HashMap的Iterator迭代器是fail-fast迭代器,而Hashtable的Enumeration迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException;
(5)、容量的初始值和增加方式都不一样:HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。Hashtable默认的容量大小是11;增加容量时,每次将容量变为"原始容量x2 + 1";
(6)、添加key-value时的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode();
7、ConcurrentHashMap原理
(1)、概述
a、HashMap在高并发的环境下,执行put操作会导致HashMap的Entry链表形成环形数据结构,从而导致Entry的next节点始终不为空,因此产生死循环获取Entry。
b、HashTable虽然是线程安全的,但是效率低下,由于get/put所有相关操作都是使用了synchronized来实现锁机制,当一个线程访问HashTable的同步方法时,其他线程如果也访问HashTable的同步方法,那么会进入阻塞或者轮训状态。
c、数据结构:数组+链表+红黑树的结构。
(2)、CAS无锁算法
ConcurrentHashMap底层CAS的实现也是依赖Unsafe类进行实现的。
比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。这是作为单个原子操作完成的。原子性保证新值基于最新信息计算;如果该值在同一时间被另一个线程更新,则写入将失败。操作结果必须说明是否进行替换;这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成。
(3)、JDK1.8和JDK1.7的区别
a、在JDK1.7中ConcurrentHashMap使用锁分段技术提高并发访问效率。首先将数据分成一段一段地存储,然后给每一段数据配一个锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,虽然说是没有阻塞整个哈希表,但是会阻塞某个分段锁。然而在JDK1.8中的实现已经抛弃了Segment分段锁机制,利用CAS + Synchronized来保证并发更新的安全。
b、JDK1.8底层采用数组+链表+红黑树的存储结构,JDK1.7采用数组 + 链表。
(4)、源码分析
①、put方法
/**
* ConcurrentHashMap的put方法
*/
public V put(K key, V value) {
return putVal(key, value, false);
}
/**
* put详细操作实现
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
//判断存储的key、value是否为空,若为空,则抛出异常
if (key == null || value == null) throw new NullPointerException();
//计算hash值
int hash = spread(key.hashCode());
int binCount = 0;
//进入无限循环,该无限循环可以确保成功插入数据
for (ConcurrentHashMap.Node<K,V>[] tab = table;;) {
ConcurrentHashMap.Node<K,V> f; int n, i, fh;
//若table表为空或者长度为0,则初始化table表
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//根据key的hash值取出table表中的结点元素,判断Node是否为空
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//若取出的结点为空(该桶为空),则利用CAS将key、value、hash值生成的结点放入桶中,失败则自旋保证成功
if (casTabAt(tab, i, null,
new ConcurrentHashMap.Node<K,V>(hash, key, value, null)));
break; // no lock when adding to empty bin
}
//如果当前位置的 hashCode == MOVED == -1
else if ((fh = f.hash) == MOVED)
//则进行扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//如果都不满足,则利用synchronized锁结点上锁,写入数据,这里的结点可以理解为hash值相同组成的链表的头结点
synchronized (f) {
if (tabAt(tab, i) == f) {
//fh > 0 说明这个节点是一个链表的节点,不是树的节点
if (fh >= 0) {
binCount = 1;
//遍历链表所有的结点
for (ConcurrentHashMap.Node<K,V> e = f;; ++binCount) {
K ek;
//如果hash值和key值相同,则修改对应结点的value值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
ConcurrentHashMap.Node<K,V> pred = e;
//遍历到了最后一个结点,那么就证明新的节点需要插入,就把它插入在链表尾部
if ((e = e.next) == null) {
pred.next = new ConcurrentHashMap.Node<K,V>(hash, key,
value, null);
break;
}
}
}
//如果这个节点是树节点,就按照树的方式插入值
else if (f instanceof ConcurrentHashMap.TreeBin) {
ConcurrentHashMap.Node<K,V> p;
binCount = 2;
if ((p = ((ConcurrentHashMap.TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
//将hash、key、value放入红黑树
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//如果链表长度已经达到临界值8,就需要把链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
②、get方法
/**
* ConcurrentHashMap的get方法
*/
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算hash值
int h = spread(key.hashCode());
//根据hash值确定节点位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果搜索到的节点key与传入的key相同且不为null,直接返回这个节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果eh<0 说明这个节点在树上 直接寻找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//否则遍历链表 找到对应的值并返回
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
8、LinkedHashMap原理
LinkedHashMap = HashMap + 双向链表
(1)、LinkedHashMap源码
LinkedHashMap构造函数,主要就是调用HashMap构造函数初始化了一个Entry[] table,然后调用自身的init初始化了一个只有头结点的双向链表。header是一个Entry类型的双向链表表头,本身不存储数据。当你插入元素时它会将节点插入双向链表的链尾,如果key重复,则也会将节点移动至链尾,当用get()方法获取value时也会将节点移动至链尾。
//LinkedHashMap继承了HashMap
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
{
//构造方法:初始容量、负载因子、是否按照访问顺序排序
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
super(initialCapacity, loadFactor);
//accessOrder设置为false按照插入顺序排序(默认);为true按照访问顺序排序;
this.accessOrder = accessOrder;
}
//Entry对象多了before、after属性供双向链表使用
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
}
(2)、put方法
LinkedHashMap没有重写put方法,直接调用的是HashMap的put方法。
①、调用HashMap—>put(K key, V value)方法。
②、HashMap—>put(K key, V value)方法调用自己的putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)方法
③、putVal(...)方法调用了newNode(int hash, K key, V value, Node<K,V> next)方法,LinkedHashMap重写了该方法,创建了 Entry。
④、LinkedHashMap—>newNode(int hash, K key, V value, Node<K,V> next)方法在最后调用自己的linkNodeLast(LinkedHashMap.Entry<K,V> p)在这个方法中LinkedHashMap将新创建的Entry接在双向链表的尾部,实现了双向链表的建立。双向链表建立之后,我们就可以按照插入顺序去遍历 LinkedHashMap。
(3)、get方法
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
//accessOder为true时,被访问的节点被置于双向链表尾部
afterNodeAccess(e);
return e.value;
}