下载地址(已将图片传到云端,md文件方便浏览更改):https://download.csdn.net/download/hancoder/12318377
对应视频地址:https://www.bilibili.com/video/BV1FE411t7M7
在线预览地址:https://blog.csdn.net/hancoder/article/details/105424922
一 HashMap(源码级解读)
1.HashMap简介
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null,但是key只能有一个为null。此外,HashMap中的映射不是有序的。
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突**(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)**而存在的(“拉链法”解决冲突)。
JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8,或者红黑树的边界值)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。
如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间,具体可以参考 treeifyBin
方法。
这样做的目的是因为数组比较小,尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡 。同时数组长度小于64时,搜索时间相对要快些。所以综上所述为了提高性能和减少搜索时间,底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树。具体可以参考 treeifyBin
方法。
当然虽然增了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变的更高效。
2.HashMap底层的数据结构
2.1数据结构概念
在JDK1.8 之前 HashMap 由 数组+链表 数据结构组成的。
在JDK1.8 之后 HashMap 由 数组+链表 +红黑树数据结构组成的。
2.2HashMap底层的数据结构存储数据的过程
测试:
public class Demo01 {
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
map.put("刘德华", 53);
map.put("柳岩", 35);
map.put("张学友", 55);
map.put("郭富城", 52);
map.put("黎明", 51);
map.put("林青霞", 55);
map.put("刘德华", 50);
}
}
存储过程如下所示:
说明:
1.面试题1:HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
对于key的hashCode做hash操作,无符号右移16位然后做异或运算。
还有伪随机数法和取余数法。这2种效率都比较低。而无符号右移16位和异或运算效率是最高的。至于底层是如何计算的我们下面看源码时给大家讲解。
2.面试题2:当两个对象的hashCode相等时会怎么样?
会产生哈希碰撞,若key值内容相同则替换旧的value.不然连接到链表后面,链表长度超过阈值8并且数组长度大于等于64就转换为红黑树存储。
3.面试题3:何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?
只要两个元素的key计算的哈希码值相同就会发生哈希碰撞。jdk8前使用链表解决哈希碰撞。jdk8之后使用链表+红黑树解决哈希碰撞。
4.面试题4:如果两个键的hashcode相同,如何存储键值对?
hashcode相同,通过equals比较内容是否相同。
相同:则新的value覆盖之前的value
不相同:则将新的键值对添加到哈希表中
5.在不断的添加数据的过程中,会涉及到扩容问题,当超出临界值(且要存放的位置非空)时,扩容。默认的扩容方式:扩容为原来容量的2倍,并将原有的数据复制过来。
6.通过上述描述,当位于一个链表中的元素较多,即hash值相等但是内容不相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阈值)超过 8 时且当前数组的长度 > 64时,将链表转换为红黑树,这样大大减少了查找时间。jdk8在哈希表中引入红黑树的原因只是为了查找效率更高。
简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。如下图所示。
但是这样的话问题来了,传统hashMap的缺点,1.8为什么引入红黑树?这样结构的话不是更麻烦了吗,为何阈值大于8换成红黑树?
JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。 当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
至于为什么阈值是8,我想,去源码中找寻答案应该是最可靠的途径。 下面我们在分析源码的时候会介绍。
7.总结:
上述我们大概阐述了HashMap底层存储数据的方式。为了方便大家更好的理解,我们结合一个存储流程图来进一步说明一下:(jdk8存储过程)
说明:
1.size表示 HashMap中K-V的实时数量 , 注意这个不等于数组的长度 。
2.threshold( 临界值) =capacity(容量) * loadFactor( 加载因子 )。这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize(扩容),扩容后的 HashMap 容量是之前容量的两倍 。
3.HashMap继承关系
HashMap继承关系如下图所示:
说明:
- Cloneable 空接口,表示可以克隆。 创建并返回HashMap对象的一个副本。
- Serializable 序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
- AbstractMap 父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。
补充:通过上述继承关系我们发现一个很奇怪的现象, 就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。
据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来了。
4.HashMap类成员+方法
4.1成员变量
//JDK8
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
/*
transient修饰符的作用是使该变量在序列化的时候不会被储存。
但是hashmap中的变量table是储存了容器中所有的元素,在序列化中不被储存,那么反序列化后hashmap对象中岂不是个空容器?
后来通过细想,table里存的只是引用,就算在序列化中储存到硬盘里,反序列化后table变量里的引用已经没有意义了。
至于hashmap是如何在序列化中储存元素呢?原来是它通过重写Serializable接口中的writeObject方法和readObject方法实现的。下面贴出两个方法的源代码。
*/
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。数组不会放满的
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 加载因子
final float loadFactor;
}
Node内部类
Node节点类源码:
// 继承自 Map.Entry<K,V>
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素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; }
// 重写hashCode()方法,他的hash值是key和value的结合,只要有一个不一样,node的hash就不等(大概率)
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 重写 equals() 方法
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;
}
}
树节点类源码:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父
TreeNode<K,V> left; // 左
TreeNode<K,V> right; // 右
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red; // 判断颜色
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
// 返回根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
}
DEFAULT_INITIAL_CAPACITY初始大小
1.集合的初始化容量( 必须是二的n次幂 )
//默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
问题: 为什么必须是2的n次幂?如果输入值不是2的幂比如10会怎么样?
HashMap构造方法还可以指定集合的初始化容量大小:
HashMap(int initialCapacity) 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体位置。 HashMap为了存取高效,要尽量减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法。
这个算法实际就是取模,hash%length,但是计算机中直接求余效率不如位运算(这点上述已经讲解)。所以源码中做了优化,使用 hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂。
为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;
举例:
说明:按位与运算:相同的二进制数位上,都是1的时候,结果为1,否则为零。
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;
例如长度length为8时候,8是2的3次幂。二进制是:1000
length-1 二进制运算:
1000
- 1
---------------------
111
如下所示:当hash为3时
hash&(length-1)
3 &(8 - 1)=3
00000011 3 hash
& 00000111 7 length-1
---------------------
00000011-----》3 数组下标
hash&(length-1) 当hash为2时
2 & (8 - 1) = 2
00000010 2 hash
& 00000111 7 length-1
---------------------
00000010-----》2 数组下标
说明:上述计算结果是不同位置上,不碰撞;
例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
例如长度length为9时候,9不是2的n次幂。二进制是:00001001
length-1 二进制运算:
1001
- 1
---------------------
1000
如下所示:
hash&(length-1) 当hash为3时
3 &(9 - 1)=0
00000011 3 hash
& 00001000 8 length-1
---------------------
00000000-----》0 数组下标
hash&(length-1) 当hash为2时
2 & (9 - 1) = 2
00000010 2 hash
& 00001000 8 length-1
---------------------
00000000-----》0 数组下标
说明:上述计算结果都在0上,碰撞了;
注意: 当然如果不考虑效率直接求余即可(就不需要要求长度必须是2的n次方了)
小结:
1.由上面可以看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
2.另一方面,一般我们可能会想通过 % 求余来确定位置,这样也可以,只不过性能不如 & 运算。而且当n是2的幂次方时:hash & (length - 1) == hash % length
3.因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能
4.如果创建HashMap对象时,输入的数组长度是10,不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是大于且离那个数最近的数字。
tableSizeFor()向上取整2次幂
JDK8
//创建HashMap集合的对象,指定数组长度是10,不是2的幂
HashMap hashMap = new HashMap(10);
public HashMap(int initialCapacity) {
//initialCapacity=10
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
//initialCapacity=10
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);
}
static final int tableSizeFor(int cap) {
//int cap = 10 //把输入值变成2的次幂
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//+1
}
说明:
由此可以看到,当在实例化HashMap实例时,如果给定了initialCapacity(假设是10),由于HashMap的capacity必须都是2的幂,因此tableSizeFor()这个方法用于找到大于等于initialCapacity(假设是10)的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。
下面分析这个算法:
1)、首先,为什么要对cap做减1操作。int n = cap - 1;
这是为了防止,cap已经是2的幂。如果cap已经是2的幂, 又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。如果不懂,要看完后面的几个无符号右移之后再回来看看。
下面看看这几个无符号右移操作:
2)、如果n这时为0了(经过了cap-1之后),则经过后面的几次无符号右移依然是0,最后返回的capacity是 1(最后有个(n < 0) ? 1的操作)。
这里只讨论n不等于0的情况。
3)、注意:|(按位或运算):运算规则:相同的二进制数位上,都是0的时候,结果为0,否则为1。
只要是1就是1
//关于移位的说明:
>> :按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补符号位,即正数补零,负数补1。符号位不变。
>>>:按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。对于正数来说和带符号右移相同,对于负数来说不同。
-1在32位二进制中表示为:
11111111 11111111 11111111 11111111
-1>>1:按位右移,符号位不变,仍旧得到
11111111 11111111 11111111 11111111
因此值仍为-1
而-1>>>1的结果为 01111111 11111111 11111111 11111111
第一次右移 :
int n = cap - 1;//cap=10 n=9
n |= n >>> 1;
00000000 00000000 00000000 00001001 //9
|
00000000 00000000 00000000 00000100 //9右移之后变为4
-------------------------------------------------
00000000 00000000 00000000 00001101 //按位异或之后是13
由于n不等于0,则n的二进制表示中总会有一bit为1,这时考虑最高位的1。通过无符号右移1位,则将最高位的1右移了1位,再做或操作,使得n的二进制表示中与最高位的1紧邻的右边一位也为1,如:
00000000 00000000 00000000 00001101
第二次右移 :
n |= n >>> 2;//n通过第一次右移变为了:n=13
00000000 00000000 00000000 00001101 // 13
|
00000000 00000000 00000000 00000011 //13右移之后变为3
-------------------------------------------------
00000000 00000000 00000000 00001111 //按位异或之后是15
注意,这个n已经经过了n |= n >>> 1;
操作。假设此时n为00000000 00000000 00000000 00001101 ,则n无符号右移两位,会将最高位两个连续的1右移两位,然后再与原来的n做或操作,这样n的二进制表示的高位中会有4个连续的1。如:
00000000 00000000 00000000 00001111 //按位异或之后是15
第三次右移 :
n |= n >>> 4;//n通过第一、二次右移变为了:n=15
00000000 00000000 00000000 00001111 // 15
|
00000000 00000000 00000000 00000000 //15右移之后变为0
-------------------------------------------------
00000000 00000000 00000000 00001111 //按位异或之后是15
这次把已经有的高位中的连续的4个1,右移4位,再做或操作,这样n的二进制表示的高位中正常会有8个连续的1。如00001111 1111xxxxxx 。
以此类推
注意,容量最大也就是32bit的正数,因此最后n |= n >>> 16; ,最多也就32个1(但是这已经是负数了。在执行tableSizeFor之前,对initialCapacity做了判断,如果大于MAXIMUM_CAPACITY(2 ^ 30),则取MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY(2 ^ 30),会执行移位操作。所以这里面的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY。30个1,加1之后得2 ^ 30) 。
请看下面的一个完整例子:
注意,得到的这个capacity却被赋值给了threshold。
this.threshold = tableSizeFor(initialCapacity);//initialCapacity=10
2.默认的负载因子,默认值是0.75 。达到这个值后就扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
3.集合最大容量
//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
4.当链表的值超过8则会转红黑树(1.8新增)
//当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 红黑树是JDK8开始的。要求是桶长度超过8.且数组大小大于64
问题:为什么Map桶中节点个数超过8才转为红黑树?
8这个阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是bucket(桶))从链表转成树的阈值,但是并没有说明为什么是8:
在HashMap中有一段注释说明: 我们继续往下看 :
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5)*pow(0.5, k)/factorial(k)).
The first values are:
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布
(http://en.wikipedia.org/wiki/Poisson_distribution),默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5, k)/factorial(k))。
第一个值是:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
这样就解释了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是权衡,空间和时间的权衡。
这段内容还说到:当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。由此可见,发展将近30年的java每一项改动和优化都是非常严谨和科学的。
也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数字。
补充:
1).
Poisson分布(泊松分布),是一种统计与概率学里常见到的离散[概率分布]。
泊松分布的概率函数为:
泊松分布的参数λ是单位时间(或单位面积)内随机事件的平均发生次数。 泊松分布适合于描述单位时间内随机事件发生的次数。
2).以下是我在研究这个问题时,在一些资料上面翻看的解释:供大家参考:
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,而log(6)=2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
5.当链表的值小于6则会从红黑树转回链表
//当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
6.当Map里面的数量超过这个值时,表中的桶才能进行树形化 ,否则桶内元素太多时会扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD (8)
//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
7、table用来初始化(必须是二的n次幂)(重点)
//存储元素的数组
transient Node<K,V>[] table;
table在JDK1.8中我们了解到HashMap是由数组加链表加红黑树来组成的结构其中table就是HashMap中的数组,jdk8之前数组类型是Entry<K,V>类型。从jdk1.8之后是Node<K,V>类型。只是换了个名字,都实现了一样的接口:Map.Entry<K,V>。负责存储键值对数据的。
8、 HashMap中存放元素的个数(重点)
//存放元素的个数,注意这个不等于数组的长度。
transient int size;
size为HashMap中K-V的实时数量,不是数组table的长度。
9、 用来记录HashMap的修改次数
// 每次扩容和更改map结构的计数器
transient int modCount;
10、 用来调整大小下一个容量的值计算方式为(容量*负载因子)
// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;
jdk7
private static int roundUpToPowerOf2(int number) {
//number >= 0,不能为负数,
//(1)number >= 最大容量:就返回最大容量
//(2)0 =< number <= 1:返回1
//(3)1 < number < 最大容量:
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;//-1
}
//该方法和jdk8中的tabSizeFor实现基本差不多,只不过这里求的是小于该数的最大2次幂
public static int Integer::highestOneBit(int i) {
//只保留最大的位,如果后面都是0还好我没就是想要这个值,但后面不全为0我没就想求更大的幂,所以我们代入此方法时先-1
//因为传入的i>0,所以i的高位还是0,这样使用>>运算符就相当于>>>了,高位0。
//还是举个例子,假设i=5=0101
i |= (i >> 1); //(1)i>>1=0010;(2)i= 0101 | 0010 = 0111
i |= (i >> 2); //(1)i>>2=0011;(2)i= 0111 | 0011 = 0111
i |= (i >> 4); //(1)i>>4=0000;(2)i= 0111 | 0000 = 0111
i |= (i >> 8); //(1)i>>8=0000;(2)i= 0111 | 0000 = 0111
i |= (i >> 16); //(1)i>>16=0000;(2)i= 0111 | 0000 = 0111
return i - (i >>> 1); //(1)0111>>>1=0011(2)0111-0011=0100=4
//所以这里返回4。
//而在上面的roundUpToPowerOf2方法中,最后会将highestOneBit的返回值进行 << 1 操作,即最后的结果为4<<1=8.就是返回大于number的最小2次幂
}
loadFactor加载因子
// 加载因子
final float loadFactor;
说明:
1.loadFactor加载因子,默认0.75,是用来衡量 HashMap 满的程度,表示HashMap的数组存放数据疏密程度,影响hash操作到同一个数组位置的概率,计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。
loadFactor太大导致查找元素效率低,而太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。,所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免。
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
如何传入加载因子;构造haspMap时构造传入
构造方法:
HashMap(int initialCapacity, float loadFactor) 构造一个带指定初始容量和加载因子的空 HashMap。
2.为什么加载因子设置为0.75,初始化临界值是12?
loadFactor越趋近于1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。
如果希望链表尽可能少些。要提前扩容,有的数组空间有可能一直没有存储数据。加载因子尽可能小一些。
举例:
例如:加载因子是0.4。 那么16*0.4--->6 如果数组中满6个空间就扩容会造成数组利用率太低了。
加载因子是0.9。 那么16*0.9---->14 那么这样就会导致链表有点多了。导致查找元素效率低。
所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。
- threshold计算公式:capacity(数组长度默认16) * loadFactor(负载因子默认0.75)。这个值是当前已占用数组长度的最大值。当Size>=threshold的时候,那么就要考虑对数组的resize(扩容),也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。 扩容后的 HashMap 容量是之前容量的两倍.
哈希种子:默认为0
final boolean initHashSeedAsNeeded(int capacity) {
//通过上面的过程,我们知道了currentAltHashing =false
boolean currentAltHashing = hashSeed != 0;
//useAltHashing = false
//我们想让useAl
tHashing为true
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);//容量大于//ALTERNATIVE_HASHING_THRESHOLD是JVM中配置的参数
// false ^ false 结果为false,switching为false
boolean switching = currentAltHashing ^ useAltHashing;//两个不相等返回true
if (switching) {
//true了后种子才可能不是0
hashSeed = useAltHashing//只有在这个地方会改变种子
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
//返回false
return switching;
}
4.2构造方法
HashMap 中重要的构造方法,它们分别如下:
1、构造一个空的 HashMap
,默认初始容量(16)和默认负载因子(0.75)。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的加载因子0.75赋值给loadFactor,并没有创建数组
}
2、 构造一个具有指定的初始容量和默认负载因子(0.75) HashMap
。
// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);//重载
}
3、传入另一个Map
// 包含另一个“Map”的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);//下面会分析到这个方法
}
4、 构造一个具有指定的初始容量和负载因子的 HashMap
。
HashMap(initialCapacity,loadFactor)
public HashMap(int initialCapacity, float loadFactor) {
//初试容量,加载因子
//判断初始化容量initialCapacity是否小于0
if (initialCapacity < 0)
//如果小于0,则抛出非法的参数异常IllegalArgumentException
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//判断初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY-》2的30次幂
if (initialCapacity > MAXIMUM_CAPACITY)
//如果超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity
initialCapacity = MAXIMUM_CAPACITY;
//判断负载因子loadFactor是否小于等于0或者是否是一个非数值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
//如果满足上述其中之一,则抛出非法的参数异常IllegalArgumentException
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//将指定的加载因子赋值给HashMap成员变量的负载因子loadFactor
this.loadFactor = loadFactor;
/*
tableSizeFor(initialCapacity) 判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量大的最小的2的n次幂。这点上述已经讲解过。
但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,并且直接赋值给threshold边界值了。有些人会觉得这里是一个bug,应该这样书写:
this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)。
但是,请注意,在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算,put方法的具体实现我们下面会进行讲解
*/
this.threshold = tableSizeFor(initialCapacity);
}
求2的次幂:我们可能输入了一个10,而HashMap会让他初始化为16大小。
//-----JDK8--------
static final int tableSizeFor(int cap) {
//给容量返回一个2的幂大小的数
//思路:把最高位1后面的位全变成1,然后+1
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//-----JDK7--------
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
public static int Integer.highestOneBit(int i) {
//最高位为1,其余为0
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
return i - (i >>> 1);
}
4.3成员方法
计算索引思路:
由key到hashCode:key有个key.hashCode()方法
由hashCode()得到hash:利用hash()函数。JDK7和8主要是hash()不同,但思想都是移位后按位异或。
hash值得到坐标:直接 i = (length - 1) & hash
;
hash()
流程:key–>key.hashCode()–>hash=hash(key.hashCode())–>hash移位、求异或–>取余得到坐标i
JDK8的hash():
这个哈希方法首先计算出key的hashCode赋值给h,然后与h无符号右移16位后的二进制进行按位异或得到最后的hash值。计算过程如下所示:
//JDK8
static final int hash(Object key) {
int h;
/* 1)如果key等于null:null也是有哈希值的,返回的是0.
2)如果key不等于null:
首先计算出key的hashCode赋值给h,然后与h【无符号右移16位】后的二进制进行【按位异或】得到最后的hash值
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//>>>无符号右移后按位异或
}
//------------------
//JDK7
static int hash(int h) {
//h是k.hashCode();与hash种子的结合,初学认为是k.hashCode();即可
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}//性能会稍差一点,因为扰动了4次hashCode
从上面可以得知HashMap是支持Key为空的,而HashTable是直接用Key来获取HashCode所以key为空会抛异常。
{其实上面就已经解释了为什么HashMap的长度为什么要是2的幂因为HashMap 使用的方法很巧妙,它通过 hash & (table.length -1)来得到该对象的保存位,前面说过 HashMap 底层数组的长度总是2的n次方,这是HashMap在速度上的优化,位操作比取模操作速度快。
当 length 总是2的n次方时,hash & (length-1)运算等价于对 length 取模,也就是hash%length,但是&比%具有更高的效率。比如 n % 32 = n & (32 -1)。}
在putVal函数中使用到了上述hash函数计算的哈希值:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
。。。。。。。。。;
if ((p = tab[i = (n - 1) & hash]) == null){
...;}//这里的n表示数组长度16
。。。。。。。。。;
}
hash()的按位处理演示:
- key.hashCode();返回散列值也就是hashcode。假设随便生成的一个值。
- n:数组初始化的长度是16
- &(按位与运算):运算规则:相同的二进制数位上,都是1的时候,结果为1,否则为零。
- ^(按位异或运算):运算规则:相同的二进制数位上,数字相同,结果为0,不同为1。
>>>
:按位无符号右移。无符号右移无论正负,左面都填充0;而有符号右移>>是正补0负补1
1)key.hashCode();返回散列值也就是hashcode。假设随便生成的一个值。
简单来说就是:
-
高16 bit 不变,低16 bit 和高16 bit 做了一个异或(得到的 hashcode 转化为32位二进制,前16位和后16位低16 bit和高16 bit做了一个异或)
问题:为什么要这样操作呢?
如果当n即数组长度很小,假设是16的话,那么n-1即为 —》1111 ,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把hashCode的高低位都利用起来,从而解决了这个问题。
例如上述:hashCode()的异或结果为h
h: 1111 1111 1111 1111 1111 0000 1110 1010
&
n-1即16-1--》15: 。。。。。。。。。。。。。。。。。....1111
-------------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 1010 ----》10作为索引
其实就是将hashCode值作为数组索引,那么如果下个高位hashCode不一致,低位一致的话,就会造成计算的索引还是10,从而造成了哈希冲突了。降低性能。
-
(n-1) & hash = -> 得到下标 (n-1) n表示数组长度16,n-1就是15
-
取余数本质是不断做除法,把剩余的数减去,运算效率要比位运算低。
put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);//final V putVal()方法缺省权限修饰符,对外不可见,用户无法调用,只能通过put()
}
putVal()
主要参数:
- hash: key的hash值
- key: 原始Key
- value: 要存放的值
- onlyIfAbsent: true代表只插入新值。如果有旧值,不修改。
- evict: 如果为false表示table为创建状态
putVal()方法流程:
-
if坐标为null先resize()然后直接赋值
-
else原来有值
- if判断第一个key对不对。key同则拿到e结点
- elif判断是不是树,是树就按照树的方法处理
- else往后遍历for
- 到了尾结点,顺便判断超没超8,树化或者直接插入。加入后e还是为null。break
- 没到尾结点判断key是否等,同则拿到e后break for,不同则往后遍历for binCount++
- if(e!=null),e为null代表是新插入的,跳过if;不为null代表是修改,修改并给调用者return旧值;
-
执行至此代表e是新插入的,++size判断是否需要resize
与JDK1.7区别:JDK1.7采用头插,1.8采用尾插
JDK8 put():尾插
//JDK8
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);//第一个false为覆盖旧值
}
//JDK8
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/*
1)transient Node<K,V>[] table; 表示存储Map集合中元素的数组。
2)(tab = table) == null 表示将table赋值给tab,然后判断tab是否等于null,空构造函数第一次执行到这肯定是null
3)(n = tab.length) == 0 表示将数组的长度赋值给n,然后判断n是否等于0,n等于0
由于if判断使用双或,满足一个即可,则执行代码 n = (tab = resize()).length; 进行数组初始化。
并将初始化好的数组长度赋值给n.
4)执行完n = (tab = resize()).length,数组tab每个空间都是null
*/
if ((tab = table) == null || (n = tab.length) == 0)//Node数组为空的话就resize
n = (tab = resize()).length;
/*
1)i = (n - 1) & hash 表示计算数组的索引赋值给i,即确定元素存放在哪个桶(数组坐标)中
2)p = tab[i = (n - 1) & hash]表示获取计算出的位置的数据赋值给节点p
3) (p = tab[i = (n - 1) & hash]) == null 判断节点位置是否等于null,如果为null,则执行代码:tab[i] = newNode(hash, key, value, null);直接根据键值对创建新的节点放入该位置的桶中
小结:如果当前桶没有哈希碰撞冲突,则直接把键值对插入空间位置
*/
if ((p = tab[i = (n - 1) & hash]) == null)//该索引上没有Node
//p为该位置"链表",也是一个Node结点
//创建一个新的节点存入到桶中
tab[i] = newNode(hash, key, value, null);//还不return,一会做些记录后再return
else {
// tab[i]!=null,表示这个位置已经有值了。下面基本都是在“位置上有值”的基础上进行操作的
Node<K,V> e; K k;//e标识要修改的那个结点,先查出来那个结点(不存在就创建),最后再改
/*
比较桶中第一个元素(数组中的结点)的hash值和key是否相等
1)p.hash == hash :p.hash表示原来存在数据的hash值,hash表示后添加数据的hash值,比较两个hash值是否相等。不等就直接跳过if
2)(k = p.key) == key :p.key获取原来数据的key赋值给k,key表示后添加数据的key,比较两个key的地址值是否相等
3)key != null && key.equals(k):能够执行到这里说明两个key的地址值不相等,那么先判断后添加的key是否等于null,如果不等于null再调用equals方法判断两个key的内容是否相等
//把首节点与后面的for分离是为了拿到
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))){
//k为该链表的key
e = p;//将旧的元素整体对象赋值给e,用e来记录
}else if (p instanceof TreeNode)// key不同;判断p是否为红黑树结点
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 不是红黑树,说明是链表节点//不是首结点,往后遍历
/*
1)如果是链表的话需要遍历到最后节点然后插入
2)采用循环遍历的方式,判断链表中是否有重复的key
*/
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//说明到达该位置尾Node,没有重复key//p为当前结点,e为下一结点
p.next = newNode(hash, key, value, null);//传入Node的4个成员(hash,k,v,next)
//这里没连接上p e,但连接上了结点,我们所想要的e就是上面new的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //binCount为0时,e为第2个结点。所以bin为7时>=8-1,此时e为第9个>8开始变成红黑树。即是存在链表长度为8的链表的
//转换为红黑树//传入tab与对应的hash,就可知道位置
//treeifyBin()里不一定会把链表转成红黑树。如果长度小于64,会去调用resize。如果长度>=64,则会执行真正的红黑树变形
treeifyBin(tab, hash);
break;//到达了尾结点,结束for遍历"链表"
}//endif key==p.key//下面即!=
/*
执行到这里说明(e = p.next)!=null,即还有元素可以遍历。继续判断链表中结点的key值与插入的元素的key值是否相等
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 有相等的key,跳出循环,但没更改值,一会再更改值
/*
要添加的元素和链表中的存在的元素的key相等了,则跳出for循环。不用再继续比较了
直接执行下面的if语句去替换去 if (e != null)
*/
break;
/*
说明新添加的元素和当前节点不相等,继续查找下一个节点。
用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
*/
p = e;//更新p为下一结点,for binCount++
}//end for
}//endif key等不等
// 在这里更改值;而添加值不在这里操作,在后面++modCount
if (e != null) {
//在上面for里新建结点的时候,e还是为null;而原来有值更改值的时候,e是对应的那个Node。//如果是更改值,返回旧值,如果是添加值,去后面进行++modCount
// 记录e的旧的value
V oldValue = e.value;
// onlyIfAbsent为true代表如果有旧值,并不覆盖旧值,直接返回旧值
if (!onlyIfAbsent || // 可以覆盖旧值。onlyIfAbsent=false代表不是只有没值的时候才覆盖,换个说法就是可以覆盖
oldValue == null)// 不可以覆盖旧值但是旧值为null
//用新值替换旧值
//e.value 表示旧值 value表示新值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}//endif tab[i]!=null
// 能运行到这里,表示这次进行的是插入操作,而不是修改
// map变更性操作计数器
// 比如map结构化的变更 像内容增减或者rehash,这将直接导致外部map的并发
// 迭代引起fail-fast问题,该值就是比较的基础
++modCount;//添加记录次数+1//修改不计入数量//结构性修改次数
// 判断实际大小是否大于threshold阈值,如果超过则扩容
// size即map中包括k-v数量的多少
// 当map中的内容大小已经触及到扩容阈值时,则需要扩容了
// 注:我曾有很长一段时间以外只有数组原来对应位置元素为空的时候才size++,后来才发现是map中总个数。那不得不思考,为什么不直接用数组存Node呢?我想原因大概是因为碰撞不好处理?但仔细想想,原来hashMap的本意就是这样的,元素个数永远不能多于数组,否则数组存不下,但hash碰撞了不是存到下一个位置,因为下一个位置可能有值,而且查的时候不方便,所以直接用链表解决hash碰撞。
if (++size > threshold)//第一次阈值为16,后面resize会操作。
resize();//扩容或者//如果多线程的时候 会出现循环链表的情况,造成CPU升高,值错乱
// 插入后回调//空函数
afterNodeInsertion(evict);
return null;
}
JDK7 put():头插
//jdk7
public V put(K key, V value) {
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//key存在,覆盖旧值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
//key不存在,添加值
modCount++;
addEntry(hash, key, value, i);
return null;
}
//原来没有这个key,需要添加
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果元素的个数达到 threshold 的扩容阈值且数组下标位置已经存在元素,则进行扩容
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);
}
//1.7采用头插,对于链表,头插法快
void createEntry(int hash. K key, V value, int bucketIndex){
//不管原来的数组对应的下标是否为 null ,都作为 Entry 的 BucketIndex 的 next值
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);//这个函数里有头部连接的过程
size++;
}
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
Entry(int h,K k,V v,Entry<K,V> n){
//n为旧索引上结点
value=v;
next=n;//由此可见是头插
key=k;
hash=h;
}
}
static class Node<K,V> implements Map.Entry<K,V> {
//JDK8
final int hash; // 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;//由此可见是尾插
}
}
inal int hash(Object k) {
int h = hashSeed;//种子让更散列
h ^= k.hashCode();//相当于直接赋值h
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
resize()扩容
扩容原理
1.什么时候才需要扩容
- 当前数组一个元素都没有的时候,初始长度为0,添加第一个元素后要改成16
- 当数组中的元素超过一定比例。当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)。loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75。那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
- 当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先用扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表。
2.HashMap的扩容是什么
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来的数组长度n计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。那么多的这一位怎么判断是0还是1呢?:e.hash & oldCap原容量,然后判断等不等于0即可。等0即新位是0,不等0即新位是1。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
&(按位与运算):运算规则:相同的二进制数位上,都是1的时候,结果为1,否则为零。
n-1=15:0000 0000 0000 0000 0000 0000 000【0】 1111
hash1: 1111 1111 1111 1111 0000 1111 000【0】 0101
hash2: 1111 1111 1111 1111 0000 1111 000【1】 0101
(n-1)&hash
5 : 0000 0000 0000 0000 0000 0000 000【0】 0101
16 : 0000 0000 0000 0000 0000 0000 000【1】 0101
n=16 : 0000 0000 0000 0000 0000 0000 000【1】 0000
n & hash
5 : 0000 0000 0000 0000 0000 0000 000【0】 0000 ==0
16 : 0000 0000 0000 0000 0000 0000 000【1】 0000 !=0
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的标记范围在高位多1bit(红色),因此新的index就会发生这样的变化:
说明:5是假设计算出来的原来的索引。这样就验证了上述所描述的:扩容之后所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成“原索引+oldCap(原位置+旧容量)”。为1还是为0可以通过这个式子判断:(e.hash & oldCap) == 0,代表为0。可以看看下图为16扩充为32的resize示意图:
正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。
流程:
调用resize肯定是在求变,容量一定变化
- 拿到Node[] table,计算原来的数组长度oldCap
- if(oldCap>0),把阈值设置为oldCap<<1
- else(oldCap==0),设置容量为16,设置阈值为12
- if (oldTab != null)
- for(oldCap),循环每个坐标位置
- if ((e = oldTab[j]) != null),先清空原坐标位置的链表,原来链表拿e保存。
- if如果只有一个结点,直接计算该结点新的坐标
- elif如果是树结点,变形
- else如果是链表不只一个结点,构造两个链表,while原来的链表把结点重新定位到两个链表上。最后把两个链表接到坐标上。
- if ((e = oldTab[j]) != null),先清空原坐标位置的链表,原来链表拿e保存。
- for(oldCap),循环每个坐标位置
resize-JDK8
resize的时候是尾插的。构造两个链表,一下转移过去
//jdk8
final Node<K,V>[] resize() {
//得到当前数组
Node<K,V>[] oldTab = table;//Node<K,V>[] table
//原容量:如果当前数组等于null长度返回0,否则返回当前数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//当前阈值点 默认是12(16*0.75)
int oldThr = threshold;//容量为0时,threshold
//HashMap构造函数中有这么一句:this.threshold = tableSizeFor(initialCapacity);//即第一次扩容(包括0->16)前阈值==容量
int newCap, newThr = 0;//新的容量和阈值
//如果老的数组长度大于0,开始计算扩容后的大小
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
//2^30
//修改阈值为int的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
/*
没超过最大值,就扩充为原来的2倍
1)(newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量
2)oldCap >= DEFAULT_INITIAL_CAPACITY 原数组长度大于等于数组初始化长度16
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//阈值扩大一倍
newThr = oldThr << 1; // double threshold
}
//老阈值点大于0 直接赋值
else if (oldThr > 0) // oldCap==0,老阈值赋值给新的数组长度
newCap = oldThr;
else {
// oldCap==0 && oldThr==0,直接使用默认值16与0.75
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//重新计算阈值16*0.75
}
// 计算新的resize最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//新的阈值 默认原来是12 乘以2之后变为24
threshold = newThr;
//创建新的哈希表
@SuppressWarnings({
"rawtypes","unchecked"})
// newCap是新的数组长度--》32
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//创建空Node数组
table = newTab;
//判断旧数组是否等于空
if (oldTab != null) {
//原来有值则复制到新Node数组里
// 把每个bucket都移动到新的buckets中
//遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//原来的数据赋值为null 便于GC回收
oldTab[j] = null;
//如果数组当前位置只有一个元素
if (e.next == null)
//没有下一个引用,说明不是链表,当前桶上只有一个键值对,直接插入
newTab[e.hash & (newCap - 1)] = e;
//判断是否是红黑树
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;
//这里来判断如果等于true e这个节点在resize之后不需要移动位置
if ((e.hash & oldCap) == 0) {
// hash最高位为0,放到小的索引位置
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;//更新尾节点
}
// 原索引+oldCap
else {
//hash最高位为1
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到索引为j的bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到索引为j+oldCap的bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize-JDK7
resize的时候才有头插到新地方
//jdk7
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
//扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE;//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity];//新Entry数组
transfer(newTable, initHashSeedAsNeeded(newCapacity));//转移数据
table = newTable; //这句要放在transfer之后
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阈值
}
void transfer(Entry[] newTable, boolean rehash) {
//JDK7
//新table的容量
int newCapacity = newTable.length;
//遍历原table
for (Entry<K,V> e : table) {
while(null != e) {
//保存下一次循环的 Entry<K,V>
Entry<K,V> next = e.next;//分析线程不安全:这个位置线程暂停
if (rehash) {
//通过e的key值计算e的hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
//得到e在新table中的插入位置
int i = indexFor(e.hash, newCapacity);
//采用头插法将e插入i位置,最后得到的链表相对于原table正好是头尾相反的
e.next = newTable[i];
newTable[i] = e;
e = next;//更新e
}
}
}
JDK7是一个一个resize转移过去,JDK8是先组成两个链表再贴上去
resize线程安全问题
为什么线程不安全:
JDK7中,对于一个旧链表
while(null != e) {
//保存下一次循环的 Entry<K,V>
Entry<K,V> next = e.next;//分析线程不安全:这个位置线程暂停
if (rehash) {
//通过e的key值计算e的hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
//得到e在新table中的插入位置
int i = indexFor(e.hash, newCapacity);
//采用头插法将e插入i位置,最后得到的链表相对于原table正好是头尾相反的
e.next = newTable[i];
newTable[i] = e;
e = next;//更新e
}
流程:先把当前结点e的下一个结点保存起来,然后把当前结点保存到新索引处,然后更新下一结点为当前结点。
首先两个线程都有new,可能会生成2个数组。在这期间可能会丢元素。另外jdk7中这个过程多个线程操作会形成死循环。
刚开始:
线程1中的e指向key(0),next指向key(4),此时线程1挂起。(next是局部变量,每个线程都有自己的next和e。但那些key锁对象的结点是不变的,只有那些,因为他们在堆中,存的是地址引用)
while(null != e) {
Entry<K,V> next = e.next;
// 线程1在这里被挂起,线程1指向的e是下图的key0,next是下图的key4
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已经执行完了,结果为:
线程1继续执行,回顾一下他暂停的地方
while(null != e) {
Entry<K,V> next = e.next;
// 线程1在这里被挂起,线程1指向的e是下图的key0,next是下图的key4
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;
}
因为他还没把key0放到新的位置,所以先放key0到新的table上,此时新table那个地方的第一个元素是key0
线程1然后再更新e为key4,next为key0,如下所示。开始挪动key4(现在key4在线程2的新数组上。但是因为使用的是对象的索引,所以next指向的key是还是唯一的key4。)
- 保存新的next=e.next。next为key0
- 把e即key4移动到新table上。新的table上第一个元素为key4
- 更新新e为最新一次的next。即e=next,e为key0
马上就要出问题了
- e为key0,
- 保存新的next=e.next=key4
- 把e即key0移动到新table上,此时还要利用头插法,让key0.next指向key4。
- next为key4,造成了死循环
总结版:HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。
get()
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 数组元素相等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 桶中不止一个节点
if ((e = first.next) != null) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中遍历get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;//找到
} while ((e = e.next) != null);
}
}
return null;
}
遍历map
Set<String> keys = map.keySet();
for (String key : keys) {
System.out.print(key+" ");
}
Collection<String> values = map.values();
for (String value : values) {
System.out.print(value+" ");
}
Set<java.util.Map.Entry<String, String>> entrys = map.entrySet();
for (java.util.Map.Entry<String, String> entry : entrys) {
System.out.println(entry.getKey() + "--" + entry.getValue());
}
remove
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) //key等
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
// 再次判断key等
node = e;// 要删除的结点node
break;
}
p = e;// p为要删除结点前一个结点
} while ((e = e.next) != null);
}
}
// node为要删除的结点,p为要删除的前一个结点
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
// 内部类HashIterator的remove
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
红黑树
树结点:
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
...();//其他方法
}
// 因为继承了LinkedHashMap.Entry<K,V> ,所以有next指针
treeifyBin()单向链表转双向链表
功能:将链表转换为红黑树
红黑树知识可阅读:https://blog.csdn.net/hancoder/article/details/107805459
从哪里调用的这个函数:putVal()里判断添加节点后链表节点个数是否大于TREEIFY_THRESHOLD临界值8,如果大于则将链表转换为红黑树,调用 treeifyBin()。但是在它里面也不一定执行红黑树化,如果数组长度小于64,是进行resize的,而不是红黑树化。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//转换为红黑树 tab表示数组名 hash表示哈希值
treeifyBin(tab, hash);
treeifyBin方法如下所示:
首先根据原来Node结点的顺序转成一个双向链表,即增加一个pre指针,然后再根据这个双向链表从上到下依次插入到一颗红黑树中,转成一棵树
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
替换指定哈希表的索引处桶中的所有链接节点,除非表太小,否则将修改大小。
Node<K,V>[] tab = tab 数组名
int hash = hash表示哈希值
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果小于 64只会进行扩容;长度大于64才会树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();//只是扩容tab[]
else if ((e = tab[index = (n - 1) & hash]) != null) {
/*
1)执行到这里说明哈希表中的数组长度大于阈值64,开始进行树形化
2)e = tab[index = (n - 1) & hash]表示将数组中的元素取出赋值给e
*/
// hd:红黑树的头结点head tl :红黑树的尾结点tail
TreeNode<K,V> hd = null, tl = null;
do {
//把链表的当前结点转成一个树结点。//
TreeNode<K,V> p = replacementTreeNode(e, null);//如何转成红黑树:首先根据原来Node结点的顺序转成一个双向链表,即增加一个pre指针,然后再根据这个双向链表从上到下依次插入到一颗红黑树中,转成一棵树// return new TreeNode<>(p.hash, p.key, p.value, next);
if (tl == null)
//将新创键的p节点赋值给红黑树链表的头结点 //注意这里只是链表,一会我们再根据链表树化,但这里的链表的结点已经变成树结点了
hd = p;
else {
//不为头结点,先尾插法连接成链表,一会再树化 //链表的顺序没有变,原来在前面的还在前面
p.prev = tl;
tl.next = p;
}
tl = p;//更新tail
} while ((e = e.next) != null);//更新结点 且 判断是否继续查找
/*
让桶中的第一个元素即数组中的元素指向新建的红黑树的节点,以后这个桶里的元素就是红黑树
而不是链表数据结构了
*/
if ((tab[index] = hd) != null)
hd.treeify(tab); //在这里把双线链表转成红黑树
}
}
treeify()双向链表转成红黑树
final void treeify(Node<K,V>[] tab) {
//head调用的//但没有传入什么,我们已经把head放到对应位置了,
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this,
next; x != null;
x = next) {
// 把x设置为当前要插入的结点
next = (TreeNode<K,V>)x.next; // 保存原顺序链表的下一节点
x.left = x.right = null;
if (root == null) {
//第一个结点直接作为红黑树根节点
x.parent = null;
x.red = false;
root = x;
}
else {
// 不是根节点
K k = x.key;
int h = x.hash;
Class<?> kc = null;//key的class类型
for (TreeNode<K,V> p = root;;) {
// 遍历现有红黑树,二分法查找该插入的位置
int dir, ph;
K pk = p.key;//p是红黑树里的当前结点,x是我们要插入的结点
//要插入得先比较,但先比较的是哈希值,
if ((ph = p.hash) > h)//如果红黑树里当前结点p的哈希值大于要插入的结点x哈希值
dir = -1;//往左子树查 direction
else if (ph < h)//p的哈希值小于x的哈希值
dir = 1;//往右子树查
//等于
else if ((kc == null && (kc = comparableClassFor(k)) == null) ||
//如果kc为空就先赋值kc//如果该key实现了Comparable<C>可进行比较,就返回Class对象。没实现就返回null //这里应该这是插入根节点的时候或的前半部分才为true,从第二个开始或语句的前半句就不满足了,直接执行或的后半句
(dir = compareComparables(kc, k, pk)) == 0)//前面只是比较哈希值,哈希值相对未必就key相等,所以进一步比较p的key和x的key,并且把结果给dir
//如果上面的dir==0满足,就进行插入,否则继续进行再查找
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;//待插入节点的父节点
// 根据左右值连接
if ((p = (dir <= 0) ? p.left : p.right) == null) {
//看向左还是向右查找,更新红黑树的当前结点 //如果进入if,代表没有x对应的这个key值,要进行插入了
x.parent = xp;// 设置父节点
if (dir <= 0)
xp.left = x;
else
xp.right = x;//先连接上,再调整
//当前结点插入到红黑树中
root = balanceInsertion(root, x); // 把root节点更新,然后让root节点作为新节点去插入到上层!!!
break;// 跳出for
}
}
}
}
moveRootToFront(tab, root);//把根节点赋值给HashMap的table[i]//树结点同时也是以链表形式存在的
//红黑树生成的过程中只是改变了left和right,而原来的next和prev还在,所以还是可以拿双线链表查出来原来后红黑树前的顺序的。我们的结点同时是链表里的一个结点,同时也是红黑树里的一个节点。
//但这里还是做了一些变化的,他让红黑树里根节点那个结点移动到了双向链表的头部,即单独单出来放到头部,其他结点再双向链表中的相对顺序不变,包括因拿出来根节点断掉的那个地方,也自动粘合拼接了。
}
balanceInsertion()红黑树插入代码
下面的局面要结合红黑树里的5个局面
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true;//插入的结点直接给红色
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
//为什么要for:因为调整完子树后可能导致上面的红黑树失衡,需要向上调整
// 变量解释:p:parent
// xp:父节点
// xpp:祖父
// xppl:左面的叔结点(指的是xpp的left左结点,而不是xpp左面的兄弟结点)
// xppr:右面的叔结点
if ((xp = x.parent) == null) {
//局面1:作为根节点
x.red = false;//根节点为黑色
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)//局面2:父是黑//或指的是原来只有一个黑结点,没有第2个结点了
return root;
// 走到这里代表父节点是红色的
if (xp == (xppl = xpp.left)) {
//父节点是祖父结点的左结点
if ((xppr = xpp.right) != null && xppr.red) {
//局面3:3红
xppr.red = false;//叔变黑
xp.red = false; // 父变黑
xpp.red = true; // 祖父变红
x = xpp;// 更新x为祖父节点
}
else {
//叔结点为空和叔结点为黑都会走到这里
if (x == xp.right) {
//2红右左//局面4
root = rotateLeft(root, x = xp);//先左旋 // 更新x为父节点 // 输入是父节点,但我们可以看认为是插入节点为轴
xpp = (xp = x.parent) == null ? null : xp.parent;
}//局面4还没处理完,转成局面5了,扔给局面5处理
if (xp != null) {
//2红左左//局面5
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);//右旋 // 输入祖父节点,但我们可以认为是父节点为轴
}
}
}
}
else {
//父节点是祖父结点的右结点
if (xppl != null && xppl.red) {
//局面3:3红
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
//叔结点为空和叔结点为黑都会走到这里
if (x == xp.left) {
//2红左右 //局面4的镜像
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}//局面4(的镜像)还没处理完,转成局面5(的镜像)了,扔给局面5(的镜像)处理
if (xp != null) {
//2红右右//局面5的镜像
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
// 继续去向上调整
}
}
生成红黑树后,原来的双向链表并没有变,我们还是可以用双向链表查到原来的顺序。
红黑树删除代码
static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root,
TreeNode<K,V> x) {
for (TreeNode<K,V> xp, xpl, xpr;;) {
if (x == null || x == root)
return root;
else if ((xp = x.parent) == null) {
x.red = false;
return x;
}
else if (x.red) {
x.red = false;
return root;
}
else if ((xpl = xp.left) == x) {
if ((xpr = xp.right) != null && xpr.red) {
xpr.red = false;
xp.red = true;
root = rotateLeft(root, xp);
xpr = (xp = x.parent) == null ? null : xp.right;
}
if (xpr == null)
x = xp;
else {
TreeNode<K,V> sl = xpr.left, sr = xpr.right;
if ((sr == null || !sr.red) &&
(sl == null || !sl.red)) {
xpr.red = true;
x = xp;
}
else {
if (sr == null || !sr.red) {
if (sl != null)
sl.red = false;
xpr.red = true;
root = rotateRight(root, xpr);
xpr = (xp = x.parent) == null ?
null : xp.right;
}
if (xpr != null) {
xpr.red = (xp == null) ? false : xp.red;
if ((sr = xpr.right) != null)
sr.red = false;
}
if (xp != null) {
xp.red = false;
root = rotateLeft(root, xp);
}
x = root;
}
}
}
else {
// symmetric
if (xpl != null && xpl.red) {
xpl.red = false;
xp.red = true;
root = rotateRight(root, xp);
xpl = (xp = x.parent) == null ? null : xp.left;
}
if (xpl == null)
x = xp;
else {
TreeNode<K,V> sl = xpl.left, sr = xpl.right;
if ((sl == null || !sl.red) &&
(sr == null || !sr.red)) {
xpl.red = true;
x = xp;
}
else {
if (sl == null || !sl.red) {
if (sr != null)
sr.red = false;
xpl.red = true;
root = rotateLeft(root, xpl);
xpl = (xp = x.parent) == null ?
null : xp.left;
}
if (xpl != null) {
xpl.red = (xp == null) ? false : xp.red;
if ((sl = xpl.left) != null)
sl.red = false;
}
if (xp != null) {
xp.red = false;
root = rotateRight(root, xp);
}
x = root;
}
}
}
}
}
1.5 其他开发问题
容量问题
建议:如果我们确切的知道我们有多少键值对需要存储,那么我们在初始化HashMap的时候就应该指定它的容量,以防止HashMap自动扩容,影响使用效率。
默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(3->4、7->8、9->16) .这点我们在上述已经进行过讲解。
《阿里巴巴java开发手册》中建议我们设置HashMap的初始化容量。
10.【推荐】 集合初始化时,指定集合初始值大小。
说明:HashMap使用HashMap(int initialCapacacity)初始化
那么,为什么要这么建议?你有想过没有。
当然,以上建议也是有理论支撑的。我们上面介绍过,HashMap的扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。
所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会有可能发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。
容量多少合适?
在《阿里巴巴java开发手册》有以下建议:
正例:initialCapacity=(需要存储的元素个数/负载因子)+1。注意负载因子(即loader factor)默认为0.75
也就是说,如果我们设置的默认值是7,经过Jdk处理之后,会被设置成8,但是,这个HashMap在元素个数达到 8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望见到的。我们应该尽量减少扩容。原因也已经分析过。
如果我们通过initialCapacity/ 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过Jdk处理之后,会被设置成16,这就大大的减少了扩容的几率。
当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成initialCapacity/0.75 + 1的话,可以有效的减少冲突也可以减小误差。
所以,我可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成initialCapacity/ 0.75F + 1.0F是一个在性能上相对好的选择,但是,同时也会牺牲些内存。
我们想要在代码中创建一个HashMap的时候,如果我们已知这个Map中即将存放的元素个数,给HashMap设置初始容量可以在一定程度上提升效率。
但是,JDK并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个2的幂。原因也已经分析过。
但是,为了最大程度的避免扩容带来的性能消耗,我们建议可以把默认容量的数字设置成initialCapacity/ 0.75F + 1.0F。
二 HashMap补充知识点
2.1 快速失败fail-fast
https://blog.csdn.net/zymx14/article/details/78394464
简介:fail-fast 机制是java集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。例如:当某一个线程A通过 iterator 去遍历某集合的过程中,若该集合的内容被其他线程所改变了;那么线程A访问集合时,就会抛 ConcurrentModificationException 异常,产生 fail-fast 事件
当然,不仅是多个线程,单个线程也会出现 fail-fast 机制,包括 ArrayList、HashMap 无论在单线程和多线程状态下,都会出现 fail-fast 机制,即上面提到的异常
先记住一句话:
刚开始得到迭代器时候会同步一下modCount 和expectedModCount ,每当next会先校验modCount 和expectedModCount 是否相等,而list.remove会修改modCount
①单线程fail-fast
1.1ArrayList发生fail-fast例子
public class ArrayListTest {
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
arrayList.add(10);
arrayList.add(11);
Iterator<Integer> iterator = arrayList.iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
if (next == 11) {
arrayList.remove(next);//list的remove方法,不是iterator的remove方法
}
}
}
}
/*
10
11
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
at java.util.ArrayList$Itr.next(ArrayList.java:831)
at edu.just.failfast.ArrayListTest.main(ArrayListTest.java:15)
*/
从结果看出,在单线程下,在使用迭代器进行遍历的情况下,如果调用 ArrayList 的 remove 方法,此时会报 ConcurrentModificationException 的错误,从而产生 fail-fast 机制
错误信息告诉我们,发生在 iterator.next() 这一行,继续点进去,定位到 checkForComodification() 这一行
public E next() {
//iterator.next()
checkForComodification();//这里会校验,报错在这
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
继续点进去,可以在 ArrayList 的 Itr 这个内部类中找到该方法的详细定义,这里涉及到两个变量,modCount 和 expectedModCount,
- modCount 是在 ArrayList 的父类 AbstractList 中进行定义的,初始值为 0,
- expectedModCount 则是在 ArrayList 的 内部类中进行定义的,
在执行 arrayList.iterator() 的时候,首先会实例化 Itr 这个内部类,在实例化的同时也会对 expectedModCount 进行初始化,将 modCount 的值赋给 expectedModCount
AbstractList源码
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
// modCount 初始值为 0//抽象类的成员变量
protected transient int modCount = 0;
}
//ArrayList+Iterator源码
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
public Iterator<E> iterator() {
//迭代器
// 实例化内部类 Itr
return new Itr();
}
/* An optimized version of AbstractList.Itr */
private class Itr implements Iterator<E> {
//即将遍历的元素的索引
int cursor; // index of next element to return
//刚刚遍历过的元素的索引。lastRet=cursor-1,默认为1,即不存在上一个时,为-1.
int lastRet = -1; // index of last element returned; -1 if no such//
//迭代器记录的修改次数,一般线程不共享
int expectedModCount = modCount;//初始赋值
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();//校验//即如果从得到iter到遍历完iter过程中如果list里的元素改变了就可能会报错(通过iter更改的不报错)
int i = cursor;//当前要遍历的
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;//下个要遍历的
return (E) elementData[lastRet = i];//上个遍历的
}
public void remove() {
if (lastRet < 0)//没有遍历过
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);// 调用类的remove方法,里面有modCount++
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;//这个是关键,只有iter的remove会更新两值
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
/*
可以看到,该remove方法并不会修改modCount的值,并且不会对后面的遍历造成影响,**因为该方法remove不能指定元素,只能remove当前遍历过的那个元素**,所以调用该方法并不会发生fail-fast现象。该方法有局限性。
*/
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
}
知道了这两个变量是从何而来之后,我们来看 checkForComodification() 这个方法,如果 modCount 和 expectedModCount 不等,就会抛出 ConcurrentModificationException 这个异常,换句话说,一般情况下,这两个变量是相等的,那么啥时候这两个变量会不等呢?
经过观察,发现 ArrayList 在增加、删除(根据对象删除集合元素)、清除等操作中,都有 modCount++ 这一步骤,即代表着,每次执行完相应的方法,modCount 这一变量就会加 1
public boolean add(E e) {
//ArrayList
ensureCapacityInternal(size + 1); // 里面有modCount++
elementData[size++] = e;
return true;
}
// 根据传入的对象来删除,而不是根据位置
public boolean remove(Object o) {
//ArrayList
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index); // 里面有modCount++
return true;
}
}
return false;
}
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
}
public void clear() {
//ArrayList
modCount++;//++
// clear to let GC do its work
for (int i = 0; i < size; i++)
elementData[i] = null;
size = 0;
}
// 而set方法里没有modCount++;
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
分析到这儿,似乎有些明白了,我们来完整的分析一下整个过程,在没有执行删除操作之前,ArrayList 中的 modCount 变量和迭代器中的 expectedModCount 的值一直都是相同的。在迭代的过程中,调用了 ArrayList 的 remove(Object o) 方法,使得 ArrayList 的 modCount 这个变量发生变化(删除成功一次加1),一开始和 modCount 相等的 expectedModCount 是属于内部类的,它直到迭代结束都没能发生变化。在迭代器执行下一次迭代的时候,因为这两个变量不等,所以便会抛出 ConcurrentModificationException 异常,即产生了 fail-fast 异常。
要点:modCount 共享,expectedModCount 不共享
1.2HashMap发生fail-fast:
public class HashMapTest {
public static void main(String[] args) {
HashMap<Integer, String> hashMap = new HashMap<>();
hashMap.put(1, "QQQ");
hashMap.put(2, "JJJ");
hashMap.put(3, "EEE");
Set<Map.Entry<Integer, String>> entries = hashMap.entrySet();
Iterator<Map.Entry<Integer, String>> iterator = entries.iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> next = iterator.next();
if (next.getKey() == 2) {
hashMap.remove(next.getKey());
}
}
System.out.println(hashMap);
}
}
/*
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextEntry(HashMap.java:922)
at java.util.HashMap$EntryIterator.next(HashMap.java:962)
at java.util.HashMap$EntryIterator.next(HashMap.java:960)
at edu.just.failfast.HashMapTest.main(HashMapTest.java:20)
*/
根据错误的提示,找到出错的位置,也是在 Map.Entry next = iterator.next()
这一行,继续寻找源头,定位到了 HashMap 中的内部类 EntryIterator 下的 next() 方法
private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
public Map.Entry<K,V> next() {
return nextEntry();
}
}
继续往下找,来到了 HashMap 下的另一个私有内部类 HashIterator,该内部类也有 expectedModCount,modCount 是直接定义在 HashMap 中的,初始值为 0,expectedModCount 直接定义在 HashMap 的内部类中,当执行 arrayList.iterator() 这段代码的时候,便会初始化 HashIterator 这个内部类,同时调用构造函数 HashIterator(),将 modCount 的值赋给 expectedModCount
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable{
// 初始值为 0
transient int modCount;
// HashMap 的内部类 HashIterator
private abstract class HashIterator<E> implements Iterator<E> {
Entry<K,V> next; // next entry to return
// 期待改变的值,初始值为 0
int expectedModCount; // For fast-fail
int index; // current slot
Entry<K,V> current; // current entry
HashIterator() {
// expectedModCount 和 modCount 一样,初始值为 0
expectedModCount = modCount;
if (size > 0) {
// advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
}
public final boolean hasNext() {
return next != null;
}
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
...
}
}
来看抛出异常的 nextEntry() 这个方法,只要 modCount 和 expectedModCount 不等,便会抛出 ConcurrentModificationException 这个异常,即产生 fast-fail 错误
同样,我们看一下 modCount 这个变量在 HashMap 的哪些方法中使用到了,和 ArrayList 类似,也是在添加、删除和清空等方法中,对 modCount 这个变量进行了加 1 操作
// 成员方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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 加 1
modCount++;
addEntry(hash, key, value, i);
return null;
}
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);//该方法里面,如果删除成功,则将 modCount++
return (e == null ? null : e.value);
}
public void clear() {
// 将 modCount 加 1
modCount++;
Arrays.fill(table, null);
size = 0;
}
我们来捋一下整个过程,在对 HashMap 和 Iterator 进行初始化之后,没有执行 remove 方法之前,HashMap 中的 modCount 和内部类 HashIterator 中的 expectedModCount 一直是相同的。在 HashMap 调用 remove(Object key) 方法时,如果删除成功,则会将 modCount 这个变量加 1,而 expectedModCount 是在内部类中的,一直没有发生变化,当进行到下一次迭代的时候(执行 next 方法),因为 modCount 和 expectedModCount 不同,所以抛出 ConcurrentModificationException 这个异常
在 HashMap 添加了三个键不同的元素,且 Iterator 完成初始化之后,modCount 和 expectedModCount 的值都为 3,直到 HashMap 执行 remove(Object key) 方法,modCount 加 1 变成 4,而 expectedModCount 依然为 3,在下一次循环执行 next() 方法的时候,会检查这两个值,如果不同,则会抛出 ConcurrentModificationException 异常,即产生 fail-fast 机制
②多线程failt-fast
2.1ArrayList
public class ArrayListThreadTest {
public static void main(String[] args) throws InterruptedException {
final ArrayList<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.add(5);
Thread thread = new Thread("线程1") {
@Override
public void run() {
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1: " + next);
}
}
};
Thread thread1 = new Thread("线程2") {
@Override
public void run() {
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer next = iterator.next();
if (next == 2) {
iterator.remove();
}
System.out.println("线程2: " + next);
}
}
};
thread.start();
thread1.start();
}
}
/*
线程2: 1
线程2: 2
线程2: 3
线程2: 4
线程2: 5
线程1: 1
Exception in thread "线程1" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
at java.util.ArrayList$Itr.next(ArrayList.java:831)
at edu.just.failfast.ArrayListThreadTest$1.run(ArrayListThreadTest.java:21)
*/
同样,在 next 处,抛出了 ConcurrentModificationException 这个异常。
这里和单线程中不同的是,在删除的时候,调用的是 Iterator 对象的 remove() 方法,这是个内部类 Itr 中的方法。该内部类下的 remove 方法,其实还是调用 ArrayList 下的 remove(int index) 方法,但是,删除完之后,会将修改后的 modCount 赋值给 expectedModCount,相当于将这两个变量进行同步了
// ArrayList 中的内部类 Itr
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
public boolean hasNext() {
return cursor != size;
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
// 将 modCount 赋给 expectedModCount
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
那么既然已经同步了,为什么还是会抛出这样的异常呢?通过输出的结果,大概可以分析出两个线程执行的流程
线程1先获得处理器的资源,进入运行状态,即执行 run() 方法里的代码,执行完 Iterator iterator = list.iterator() 这段代码之后,线程1因为执行了 sleep 方法,线程1进入阻塞状态
线程2获取处理器的资源,开始执行 run() 方法里面的代码,当迭代到 key 等于 2 的时候,执行 iterator.remove(),同时,ArrayList 下的 modCount 加 1,同时线程2迭代器下的 expectedModCount 的值和 modCount 一样,需要注意的是,modCount 是个共享的变量,即两个线程都可以同时对其进行操作,而 expectedModCount 则是各个线程各自拥有的,这一点很重要。最终,线程1下的 modCount 和 expectedModCount 都变成了 6
当线程2执行完毕,线程1重新获得处理器资源,开始执行,第一次循环没问题,第二次循环时,当执行到 Integer next = iterator.next() 的时候,因为共享变量 modCount 已经变成了 6,而线程 1 的 expectedModCount 依然是 5,两个变量不等,此时抛出 ConcurrentModificationException 异常,即产生 fail-fast 机制
简单的说,因为 modCount 是共享变量,expectedModCount 则是各自独有的变量,这就导致了,一个线程更新了 modCount,同时更新了自己拥有的 expectedModCount,当另一个线程执行的时候,发现 modCount 更新了,但是自己的 expectedModCount 并没有更新,便会产生这样的错误
2.2HashMap
public class HashMapThreadTest {
public static void main(String[] args) {
final HashMap<Integer, String> hashMap = new HashMap<>();
hashMap.put(1, "AAA");
hashMap.put(2, "BBB");
Thread thread = new Thread("线程1") {
@Override
public void run() {
Iterator<Map.Entry<Integer, String>> iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> next = iterator.next();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1: " + next);
}
}
};
Thread thread1 = new Thread() {
@Override
public void run() {
Iterator<Map.Entry<Integer, String>> iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> next = iterator.next();
if (next.getKey() == 2) {
hashMap.remove(next.getKey());
}
System.out.println("线程2: " + next);
}
}
};
thread.start();
thread1.start();
}
}
/*
线程2: 1=AAA
线程2: 2=BBB
线程1: 1=AAA
Exception in thread "Thread-0" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextEntry(HashMap.java:922)
at java.util.HashMap$EntryIterator.next(HashMap.java:962)
at java.util.HashMap$EntryIterator.next(HashMap.java:960)
at edu.just.failfast.HashMapThreadTest$1.run(HashMapThreadTest.java:19)
*/
HashMap 的过程和上面的 ArrayList 类似,因为 modCount 是共享变量,expectedModCount 是每个线程独有的变量,线程2的 HashMap 执行了 remove(),导致 modCount 和expectedModCount 同时加 1,而线程1的 expectedModCount 变量的值并没有修改,导致了 modCount 和 expectedModCount 这两个变量的值不同,因此抛出异常
③避免fail-fast
3.1对于单线程
对应单线程来说,我们执行删除操作的时候,不要使用集合自身的删除方法,而使用集合中迭代器的删除方法
因为无论是 ArrayList 还是 HashMap,他们对应的迭代器中的 remove 方法中,都有这么一句代码,expectedModCount = modCount,这就意味着,即使删除了,这两个变量也是一直同步的,不会发生 modCount 加 1,而 expectedModCount 不变的情况
3.2对于多线程
使用java并发包JUC(java.util.concurrent)中的类来代替ArrayList 和hashMap。
如使用 CopyOnWriterArrayList代替ArrayList,CopyOnWriterArrayList在是使用上跟ArrayList几乎一样,CopyOnWriter是写时复制的容器(COW),在读写时是线程安全的。该容器在对add和remove等操作时,并不是在原数组上进行修改,而是将原数组拷贝一份,在新数组上进行修改,待完成后,才将指向旧数组的引用指向新数组,所以对于CopyOnWriterArrayList在迭代过程并不会发生fail-fast现象。但 CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。
对于HashMap,可以使用ConcurrentHashMap,ConcurrentHashMap采用了锁机制,是线程安全的。在迭代方面,ConcurrentHashMap使用了一种不同的迭代方式。在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据 ,iterator完成后再将头指针替换为新的数据 ,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。即迭代不会发生fail-fast,但不保证获取的是最新的数据。
参考链接:
http://www.jb51.net/article/84468.htm
http://www.cnblogs.com/ccgjava/p/6347425.html
https://blog.csdn.net/babycan5/article/details/89004482
我们可以发现在put方法中modCount的增加有一个前提,就是只有在key不存在的情况下才会进行自增操作,在进行覆盖时并不会出现自增。也就是说,如果是覆盖HashMap中原有的key的话并不会触发ConcurrentModificationException。例如下面所示代码,在遍历中对Map进行了修改 但是并无异常抛出。
仔细分析不难发现唯一的区别在于key的覆盖并没有更改Map的结构。无论此key的存储方式是链表还是树,key的覆盖都只是简单的替换。遍历下去都可以正常的获取到所有值。但是涉及到Map结构修改的操作都有可能导致遍历无法遍历到所有值,因此才会触发ConcurrentModificationException。
Unsafe
阅读:https://blog.csdn.net/hancoder/article/details/107805260
红黑树
阅读:https://blog.csdn.net/hancoder/article/details/107805459
三 ConcurrentHashMap
我们先了解HashTable的做法:
//直接在方法上加synchronized锁住this,这样效率很低
public synchronized V put(k key,V value){
}//效率低//put不同索引的时候是没有线程安全的,但也是得等待
3.1 JDK1.7
3.1.1 属性:
Segments[] segments
ConcurrentHashMap有个Segment<K,V>[] segments;
每个segment[i]维护一个HashEntry<K,V>[] table
HashEntry[] table
每个ConcurrentHashMap下有一个Segments[]数组,数组的每一个元素Segments[i]下面有一个table[]数组,每个元素table[i]下有一个链表。因为java中只要一个对象有next指针,那么这个对象代码的也是一个链表。所以table[i]对应的是一个HashEntry元素,同时也是一个链表
构造
ConcurrentHashMap(无参)
public ConcurrentHashMap(){
return this(DEFAULT_INITIAL_CAPACITY,//segment[].length×table[].length,每个table大小为segment[i]=DEFAULT_INITIAL_CAPACITY/DEFAULT_CONCURRENCY_LEVEL
DEFAULT_LOAD_FACTOR,//
DEFAULT_CONCURRENCY_LEVEL)//16//并发级别,segment[]数组长度//segments也是2的幂//
}
ConcurrentHashMap(有参)
//JDK8
public ConcurrentHashMap(int initialCapacity,
float loadFactor,
int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
- concurrrentLevel/segment size一定为2的幂
- cap/c至少为2,initialCapacity / ssize向上取整、再向上取幂。扩容扩的是这个,而不是扩容segment
- capacity:这个等式是不成立的capacity=cap×concurrrentLevel,因为他刚开始只创建ss[0]的table[],别的索引位置还是为null,而是扩容的时候只是扩容对应的table[],即各个table[]的不一样大小的。
//JDK7
public ConcurrentHashMap(int initialCapacity,
float loadFactor,
int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)//1^16
concurrencyLevel = MAX_SEGMENTS;//最大并发级别
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;//sigment Size
while (ssize < concurrencyLevel) {
//如果并发级别输入的是15,那么我们就可以通过while转成16
++sshift;//移位次数
ssize <<= 1;
}//用ssize表示并发级别,肯定为2的幂
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;//并发级别-1,掩码,即1111..用作位运算
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;//总共节点数//1^30
int c = initialCapacity / ssize;//c=segment[i]下链表长度
if (c * ssize < initialCapacity)//上面有余数造成的
++c;
// cap也是2的幂
int cap = MIN_SEGMENT_TABLE_CAPACITY;//2链表长度至少2
while (cap < c)
cap <<= 1;//我们后面用的是cap,而不是c。即链表长度最少为2
// 先创建segments[]数组和segments[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor,
(int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);//cap先只用于s0 //segment[0]
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];//创建Segments[]
UNSAFE.putOrderedObject(ss, SBASE, s0); // 用unsafe类的cas操作:segments[0]=s0
this.segments = ss;
}
扩容是扩segment[i],即tab[]。除了segment[0],别的地方刚开始都是空的,要放的时候先new ,如果没有s0,那么每个地方都需要除一下,很麻烦,根据s0 就可以快速算出来。原型。
不够了rehash
//内部类
static final class Segment<K,V> extends ReentrantLock implements Serializable {
put
Concurrent.put()
首先了解ConcurrentHashMap类中有如下的关于unsafe的逻辑,对Unsafe类一点不了解的同学去后面先了解下Unsafe:https://blog.csdn.net/hancoder/article/details/107805260
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class tc = HashEntry[].class;//segment[i]
Class sc = Segment[].class;//concurrentHashMap.segments[]并发数组
// base
TBASE = UNSAFE.arrayBaseOffset(tc);
SBASE = UNSAFE.arrayBaseOffset(sc);
// 数组每个元素的偏移
ts = UNSAFE.arrayIndexScale(tc);
ss = UNSAFE.arrayIndexScale(sc);
SSHIFT = 31 - Integer.numberOfLeadingZeros(ss);
TSHIFT = 31 - Integer.numberOfLeadingZeros(ts);
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);//获取key的hash值
// 求完key的hash值后以前我们是用类似于取余的操作求得索引位置
// 但是在chashmap中取余之前还有个移位的操作hash >>> segmentShift,即无符号右移20多位,得到前几位,位数与和原来hashmap取余位数一样,保留原来hash值的高位
int j = (hash >>> segmentShift) & segmentMask;//先取前面的位,然后掩码前面的位。效果为取高位的掩码//segmentMask就是我们的segment.length-1
//通过unsafe的getObject方法获取数组segment[]中某个位置的元素
// 其实下面就是确保求得segments[j],各个参数去前面的unsafe博客中学习
// 求得的segments[j]赋值给s
if ((s = (Segment<K,V>)UNSAFE.getObject (segments, (j << SSHIFT) + SBASE)) == null) //如果当前索引位置为null,则创建一个segment对象 // nonvolatile; recheck
// 创建数组放到segments[j]这个位置上
s = ensureSegment(j);
// segments[j].put()
return s.put(key, hash, value, false);//调用segment的put方法
}
//下面介绍一下为什么j << SSHIFT就是我们想要的 ss 乘 索引,==
//而 SSHIFT=31-Integer.numberOfLeadingZeros(ss);//numberOfLeadingZeros二进制时ss前面有多少个0,SSHIFT表示ss表示的二进制的1后面有多少个0//我们不妨再转换回来,用i表示1后面有多少个
//验证:j<<31-Integer.numberOfLeadingZeros(ss) == ss×j
//ss为2的次幂,假设为2的i次幂,那么==左式就为j<<i,而==右式为2^i ×j,结合计算机组成原理知识,显然相等
// 这个索引位置为null,这个函数用cas确保多个线程都使用的一个数组
private Segment<K,V> ensureSegment(int k) {
//参数为segments[j]
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // u为数组的偏移量
Segment<K,V> seg;
// 如果执行完前面的代码还是为空的话,使用cas去new数组放到segments[j] // 可以懒汉模式
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment<K,V> proto = ss[0]; // 使用segment[0]作为原型
int cap = proto.table.length;//查看ss[0]所对应数组的长度
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 创建和ss[0]所对应数组的长度相同的大小
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) {
// recheck
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 用cas去设置数组
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
segment[]是我们的并发数组,HashEntry[] tablle是每个segment[i]下的数组
Segment.put()
// 内部类Segment
static final class Segment<K,V> extends ReentrantLock implements Serializable {
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//tryLock()尝试加锁//与lock()的区别在于:tryLock()不阻塞,如果能获取到锁就取得锁返回true,如果获取不到就返回false
//尝试能否一下就获取到lock锁
HashEntry<K,V> node = tryLock() ? null ://如果获取到锁就返回null给node
scanAndLockForPut(key, hash, value);//没有获取到锁就调用scanAndLockForPut()方法 //while (!tryLock()) { 获取不到锁的时候就去做点别的事情
//执行到这里的时候,已经拿到锁了。scanAndLockForPut里也会最终拿到锁
//如果是通过scanAndLockForPut得到的node,我们此时还顺便拿到了头结点
V oldValue;//如果是修改,则返回旧值
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;和原来方法一样,取低位
HashEntry<K,V> first = entryAt(tab, index);//用UNSAFE.getObjectVolatile方法获取tab[index]
for (HashEntry<K,V> e = first;;) {
if (e != null) {
//如果tab[i]下有链表
K k;
if ((k = e.key) == key || //如果两个new String那么此时不是同一个引用地址
(e.hash == hash && key.equals(k))) {
//equals能判断key值的内置是否相等,而不是比较引用地址
oldValue = e.value;//保存旧值
if (!onlyIfAbsent) {
//如果onlyIfAbsent==true,就代表map只能添加删除,不能修改
e.value = value;
++modCount;
}
break;
}
e = e.next;//更新e
}
else {
//当前tab[i]下没有链表。或者说遍历到链表了最后了还是没找到key对应的值,那么就new Node
// 如果node!=null,代表第一时间没有获取到锁,在等锁的过程中做了点别的事情,想去利用时间如果遍历到末尾都没有对应的key,就new个结点。但是也可能虽然没有对应的key,但是其他拿到锁了,就先不创建结点先处理正经事了 // 即如果等锁过程中每创建node,那么node还是==null
if (node != null) // 在等锁的过程中已经创建了个node结点了
node.setNext(first);//调用unsafe赋值给first
else // 还没创建node结点,先创建一个node,头插法连接原来的链表
node = new HashEntry<K,V>(hash, key, value, first);//在这里同时赋值给first
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)//tab[]里元素到达阈值,扩容table[]
rehash(node);
else
setEntryAt(tab, index, node);//没有超过阈值直接赋值给tab[index]=node
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();//解锁
}
return oldValue;
}
等锁中Segment.scanAndLockForPut()
先解读下tryLock再解读scanAndLockForPut。scanAndLockForPut是想在等锁的过程中先去做点别的事情,做完事情再去拿锁。
tryLock()和lock()的区别:
//学习测试用例
import java.util.Collections;
import java.util.Scanner;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Test123123 {
public static Lock lock = new ReentrantLock();
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
// lock.lock();
while(!lock.tryLock()){
System.out.println("没获取到锁,可以去做一些其他的事情");
//应用到concurrentHashMap中就是获取锁的过程中可以去做一些其他的事情,比如new HashEntry()
}
System.out.println("线程1");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.unlock();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
// lock.lock();
while(!lock.tryLock()){
System.out.println("没获取到锁,可以去做一些其他的事情");
}
System.out.println("线程2");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
lock.unlock();
}
}).start();
}
}
scanAndLockForPut():
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);//取得table[i]对应的链表
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // 初始值为负。negative while locating node
while (!tryLock()) {
//如果获取不到锁就先去做点别的事情
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
//进入的几个条件://1 第一次时候进入//2 key不等时也进入//退出if条件://1 直到找到最后一个节点还没找打key,所以new了个新node//2 找到对应的key了
if (e == null) {
//table[i]上没有链表,或者该链表遍历到最后一个节点了还没有对应的key,就新建
if (node == null) //new一个node
node = new HashEntry<K,V>(hash, key, value, null);// 这个地方没有拼接
retries = 0;//改为0
}
else if (key.equals(e.key))//是否为当前节点
retries = 0;//改为0
else
e = e.next;//更新为下一节点
}//后面的操作都代码的前提:已经找到key对应的node了
else if (++retries > MAX_SCAN_RETRIES) {
//++retries//细节:找到key对应的node时才开始累加
lock();//重试次数大于64了,直接阻塞等锁吧//如果只有一个CPU的话就只重试一次
break;
}
else if ((retries & 1) == 0 && //偶数次 //拿到key对应的node之后的,偶数次重试时,判断table[i]对应的first有没有被其他线程改变
(f = entryForHash(this, hash)) != first) {
//在while (!tryLock())的过程中,很可能其他进程已经改了该数组。//JDK7头插法新增//头结点发生了变化
e = first = f; // re-traverse if entry changed//被其他线程改变了,重新给当前线程赋值其他线程改了的first
retries = -1;//如果头结点发生了变化,重新设置为-1//重新去获取node,万一node被别的线程删除了呢?//所以其实源码这里有点错误,应该把node再重置为null,否则还是会返回删除的结点
}
}
return node;//可能我们在等待锁的过程中已经找到key对应的node了。也可能还没找到对应的node
}
//segment[s]下的取tab[i]
static final <K,V> HashEntry<K,V> entryForHash(Segment<K,V> seg, int h) {
//h:hash
HashEntry<K,V>[] tab;
//先判断seg == null,否则seg未必有table属性
return (seg == null || (tab = seg.table) == null) ? null :
(HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);//如果seg不为空,就返回tab[i]
}
扩容Segment.rehash()
解释:即resize(),只不过是resize的HashEntry[] table。在jdk7里没有resize这个函数
关键点:一次挪1-X个
private void rehash(HashEntry<K,V> node) {
//resize table[]//即rehash是相当于我们HahsMap里的resize()
//问题:如何保证resize时没有线程安全:resize操作是在put函数内的,而put获取了锁才调用resize
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;//原长度×2
threshold = (int)(newCapacity * loadFactor);
//新建新的table[]
HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;// 新容量的mask
for (int i = 0; i < oldCapacity ; i++) {
//进行元素转移
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;// 记录链表上第二个entry
// 链表上头结点的新索引
int idx = e.hash & sizeMask;
if (next == null) // Single node on list
newTable[idx] = e;
else {
// Reuse consecutive sequence at same slot
// 记录首结点
HashEntry<K,V> lastRun = e;
// 记录首结点的索引
int lastIdx = idx;
// 往下遍历
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
//一次转移多个结点,所以顺序并非只是倒序了
// 当前结点的新索引位置
int k = last.hash & sizeMask;
// 如果当前结点的新索引位置和上个结点不一样,
if (k != lastIdx) {
lastIdx = k;// 更新为当前结点的另外一个索引位置
lastRun = last;// 更新为当前结点
}
}/* 执行完for到这的时候,肯定是遍历完了这个链表,但是并没有改变链表的结构,只是拿到了
链表最后的一些元素,这些元素将来新索引相同,即这些元素有可能只是一个,有可能多个*/
// 先把链表最后的元素挪到新的位置
newTable[lastIdx] = lastRun;
// 把链表前面的元素移动到新的索引位置
for (HashEntry<K,V> p = e; // e还是头结点,还是从头结点开始遍历
p != lastRun; // 排除刚才挪完的一个或一串元素
p = p.next) {
// 还是采用头插法
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);//让node连接上原来的链表
newTable[nodeIndex] = node;//放在头结点
table = newTable;
}
remove()
final V remove(Object key, int hash, Object value) {
if (!tryLock())//也添加了锁
scanAndLock(key, hash);
V oldValue = null;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> e = entryAt(tab, index);
HashEntry<K,V> pred = null;
while (e != null) {
K k;
HashEntry<K,V> next = e.next;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
V v = e.value;
if (value == null || value == v || value.equals(v)) {
if (pred == null)
setEntryAt(tab, index, next);
else
pred.setNext(next);
++modCount;
--count;
oldValue = v;
}
break;
}
pred = e;
e = next;
}
} finally {
unlock();
}
return oldValue;
}
3.2 JDK8
JDK8里面没有了Segment[],那拿什么实现的呢?
JDK8只有一个数组,每个数组位置包含一个链表(一个node结点)
put()
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();//如果没空抛异常
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)//如果整个table[]为空
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//f为tab[i]对应的node
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null))) //如果tab[i]为空,就通过CAS创建node
//如果CAS失败就进行下一次循环,失败的原因可能是其他线程已经new了,所以下一次循环可能不进入这个else if了
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)//把tab[i]头结点的hash赋值给fh,看这个node值的hash值是否为-1,代表有其他线程在对整个tab[]进行扩容
tab = helpTransfer(tab, f);//帮助进行扩容
else {
V oldVal = null;
synchronized (f) {
//同步//拿到当前tab[i]的头结点node 同步锁
//我们这里只是拿了根节点的同步锁,万一经过旋转后根节点变化了呢?那其他线程岂不是有自己的根节点锁从而也能执行?所以有下一句的判断
if (tabAt(tab, i) == f) {
//重新拿一下头结点,防止这个头结点被其他线程删除了
if (fh >= 0) {
//头结点hash值不为负,代表不是树结点
binCount = 1;//记录链表上有多少个元素
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
//当前结点是红黑树
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}//End Of if (fh >= 0) {
}//end of if (tabAt(tab, i) == f)
}//synchronized同步结束
if (binCount != 0) {
//这里没锁,所以在里面得加锁 //==0代表put没生效,
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);//转为为红黑树//里面有锁
if (oldVal != null)//修改后返回旧值
return oldVal;
break;//没有转为红黑树,同时也是新建结点插入,而不是修改
}
}
}//for
addCount(1L, binCount);
return null;
}
初始化
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;//sizeCtrl
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin //一个线程初始化就行 //yield代表放弃cpu拥有权,重新去排队,而重新配对再进入的时候别的线程已经初始化好了
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//只有一个线程能够减一,如果多个线程都减了,会进入上面的if
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;//16
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);//0.75
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}