hashMap和currentHashMap
我觉得这算是顺势了解一下
从源码的角度在熟悉一遍
- 什么hash算法
- 如何解决hash碰撞
- java8中加入了红黑树,怎么变化的
先从这三个方面了解一波
- but 我想到之前有个问题 并发修改异常的问题
- 于是乎我写了一段代码 关于hashMap的
代码如下
Map map = new HashMap()
map.put("1", "1");
map.put("2", "2");
Iterator iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry next = (Map.Entry) iterator.next();
iterator.remove();
break;
}
System.out.println(map.size());
}
-
其实我比较难理解这一句
Map.Entry next = (Map.Entry) iterator.next();
-
为什么拿了下一个值才有当前值,我试着去除这段代码 抛出了一个异常 如下: java.lang.IllegalStateException
-
我跟着这个代码进入错误源头,发现还是在hashMap的代码里面
如下
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;
}
- 发现代码中 current 为空 ,为什么呢? Node<K,V> p = current 代码中写,当前的current 为空,也即是P为空,p是链表的头节点,头结点为空,也就是数组中Entry为空,头结点head为空,找不到链表的下一个节点了,肯定是不对的,但是为什么 加了一句这个 Map.Entry next = (Map.Entry) iterator.next() 它就能找到
- 在我执行 iterator.next()的时候,hashMap 实现了iterator的next的接口,并拿到当前的节点初始化了current的数值
看完上面的代码,还是看一下HashMap的工作原理吧
为什么使用hashMap?
- HashMap 是一个散列桶bucket(数组和链表),它存储的内容是键值对 key-value 映射
- HashMap 采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
- HashMap 是非 synchronized,所以 HashMap 很快
- HashMap 可以接受 null 键和值,而 Hashtable 则不能(原因就是 equlas() 方法需要对象,因为 HashMap 是后出的 API 经过处理才可以)
### HashMap 的工作原理是什么?
- 我们使用 put(key, value) 存储对象到 HashMap 中,使用 get(key) 从 HashMap 中获取对象。当我们给 put() 方法传递键和值时,我们先对键调用 hashCode() 方法,计算并返回的 hashCode 是用于找到 Map 数组的 bucket 位置来储存 Node 对象。
JDK1.8的put过程
- 对 Key 求 Hash 值,然后再计算下标
- 如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的 Hash 值相同,需要放到同一个 bucket 中)
- 如果碰撞了,以链表的方式链接到后面
- 如果链表长度超过阀值(TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表
- 如果节点已经存在就替换旧值
- 如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)
get过程
- 考虑特殊情况:如果两个键的 hashcode 相同,你如何获取值对象?
- 当我们调用 get() 方法,HashMap 会使用键对象的 hashcode 找到 bucket 位置,找到 bucket 位置之后,会调用 keys.equals() 方法去找到链表中正确的节点,最终找到要找的值对象。
关于 keys.equals() 看代码
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) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
- 两个hashCode一样的对象equals比较对象时不一定一样,然而equals一样,hashCode一定一样
hashMap中的hash算法
- 牛逼在哪里?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
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); .......
- 从代码看到,在计算hash值时,使用到了位与异或运算,实际上,对数组长度进行取模运算应该也是可以,这样使得元素相对分布比较均匀,但对于计算机而言,位与运算速度更快,消耗更小
- 上面的符号的意思
^ :按位异或 运算规则:0^0=0; 0^1=1; 1^0=1; 1^1=0;
>>>:无符号右移,忽略符号位,空位都以0补齐
无符号右移>>>(不论正负,高位均补0)
正数:例如4>>>2
与4>>2的运算相同,结果也为1
负数:例如-4>>>2
首先写出-4的二进制数,因为是负数所以最高位为1
1000 0000 0000 0000 0000 0000 0000 0100
然后写出-4补码:保证符号位不变,其余位置取反加1(从右往左遇到第一个1,然后剩下的全部取反就是了)
1111 1111 1111 1111 1111 1111 1111 1100(补码)
右移2位: 在高位补0
0011 1111 1111 1111 1111 1111 1111 1111
结果为:1073741823
-
顺便熟悉一下其他的运算
-
与运算(&)运算规则: 0&0=0; 0&1=0; 1&0=0; 1&1=1;
-
或运算(|)运算规则:0|0=0; 0|1=1; 1|0=1; 1|1=1;
-
所以我们看到,在计算一个hash值的时候,先做 h>>>16 无符号右移16位,在与或 h^h>>>16
-
最后在putVal的时候还需进行与数组链表长度的n-1进行与&运算 即 (n-1)&hash
jdk8为什么不用二叉查找树,为什么不一直用红黑树,什么是红黑树?
- 二叉查找树固然可以,但是在一定情况下,二叉查找树会退化成链表,造成层次过深,导致遍历过慢,二红黑树由于可以自身的左旋,右旋,变色,成为一个稳定的平衡二叉树,为了保持平衡需要代价,所以在链表长度大于8的时候才使用红黑树,不然红黑树很短的话,需要消耗时间去平衡,却得不到非常好的效果
- 红黑树是啥样的:根节点为黑色;每个红色节点下面两个子节点为黑色,反之不一样,从任何一个叶子节点到根节点的黑色节点个数一样;**每个叶子节点都是黑色的空节点(NIL节点)?**这个要去看一下的
说道这里 似乎要引出更多的概念了,比如 hashTable, CocurrentHashMap
- 首先说说他们之间的区别
- 1.hasHtable线程安全的 不允许key为空,是线程安全的,是对整个table 进行同步操作的
- 2.CocurrentHashMap是对table中的某个segment进行操作的,所以当迭代很大的时候,他的性能是优与hashtable
- jdk1.8中 CocurrentHashMap 放弃了 segment分段锁,采用了 CAS + synchronized 来保证并发安全性
于是我们了解一下什么是CAS吧
-
CAS Compare And Swap 意为比较交换
-
执行函数名为 CAS(V,E,N)
-
V 表示要更新的变量 Variables
-
E 表示预期值 expected value
-
N 表示新值 new value
-
CAS逻辑(乐观锁): 通常说当前线程运行过程中我们提供过一个期望值 E,判断当前的要更新的变量V是否等于当前期望值E,如果相等,更新V设置为N ,可以反复重试 一个自旋操作,若不相等,直接不更新V并退出
乐观锁实现方式
实现方式1 这种方式同时可以解决 从CAS算法中导致的ABA问题
-
假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
-
操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
-
在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
-
操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
-
操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
实现方式2
- CAS算法
这里有一个常见的问题 ABA
什么情况下出现ABA
- 比如说,线程A 正在准备将要更新的变量V=10更新为新值N=20,此时,有其他线程进入将N改为10,最后线程A读取的新值是被认为是没有修改过的 也就是
A 的需要更新变量 v=10->v=20->v=10的过程也就是ABA问题
乐观锁的ABA问题的解决办法:
- 方法1:对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。
- 方法2:AtomicStampedReference原子类是一个带有时间戳的对象引用,在每次修改后,AtomicStampedReference不仅会设置新值而且还会记录更改的时间。当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值才能写入成功,这也就解决了反复读写时,无法预知值是否已被修改的窘境
CAS与synchronized的使用情景
- 单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- -对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;
- 而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
接下来再看看CocurrentHashMap的操作
-
最大特点是引入了 CAS
借助 Unsafe 来实现 native code。CAS有3个操作数,内存值 V、旧的预期值 A、要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值V修改为 B,否则什么都不做。Unsafe 借助 CPU 指令 cmpxchg 来实现。 -
CAS 使用实例
对 sizeCtl 的控制都是用 CAS 来实现的: -
-1 代表 table 正在初始化
N 表示有 -N-1 个线程正在进行扩容操作
如果 table 未初始化,表示table需要初始化的大小
如果 table 初始化完成,表示table的容量,默认是table大小的0.75倍,用这个公式算 0.75(n – (n >>> 2)) -
put 过程
根据 key 计算出 hashcode
判断是否需要进行初始化
通过 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功
如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
如果都不满足,则利用 synchronized 锁写入数据
如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树 -
get 过程
根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值
如果是红黑树那就按照树的方式获取值
就不满足那就按照链表的方式遍历获取值
看到这里我发现还有一个关键的字段是很重要的volatile
- 首先它干了什么
- Volatile 变量协调读写线程间的内存可见性
- 重新看了之前小灰的一篇文章,我发现这个关键字很难理解啊 https://mp.weixin.qq.com/s/DZkGRTan2qSzJoDAx7QJag
- 所以一步一步来吧
从代码的角度比较
- 线程A,线程B存在于工作内存 ,s=0存在于主内存 ,当线程A从主内存中读取s=0并将s置为3,此时,线程B从主内存中读取的s的值可能是0,也可能是3,主要原因是因为工作内存中修改的数据不会立马同步到主内存中
- 解决该问题有很多办法,使用synchronized字段同步线程自然可以,但是会严重影响程序性能,这里有一种轻量级的解决办法
- 使用volatile关键字来保证对所有线程的可见性 什么是可见性?可见性就是一个线程改变了工作内存的值,立马同步到主内存,当其他线程读取主内存的数据,会从主内存读取到最新的值
- 为什么volatile会有这样的特性,有益于java的先行发生原则happens-before 也就是线程A的写入主内存的操作会先行于线程B的读取操作,当然我们要记住volatile关键字并不能保证线程安全,因为只能让变量保持可见性,不能保证变量的原子性。
什么时候适合使用这个关键字呢?
-
运行结果并不依赖于当前值,或者保证只有单一线程能修改的值 注:当前情况下只能有一个线程能够修改,也就是保证线程安全的情况下才能使用呢
-
变量不需要于其他变量共同成为约束条件
-
这里需要引入两个新的概念:指令重排和内存屏障 看看小灰的文档吧https://mp.weixin.qq.com/s/DZkGRTan2qSzJoDAx7QJag
-
我们在看CoCurrentHashMap中的volatile字段的用法,看源码
` static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}`
-
volatile关键字分别修饰了V val 和Node<K,V> next 这也就是为什么在CocurrentHashMap中读操作中基本是不加锁的也可以正确的读取数据 https://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/index.html 这个文档详细介绍了。
-
刚刚回头看hashMap源码,思维有点跳跃了 transient是Java语言的关键字,用来表示一个域不是该对象串行化的一部分。当一个对象被串行化的时候,transient型变量的值不包括在串行化的表示中,然而非transient型的变量是被包括进去的。