Java专题-HashMap

249 阅读7分钟

HashMap

参数

结构

数组 + 链表 Entry - Node

负载因子 0.75

初始大小 16

扩容大小 原始2倍

树化阈值 8 长度大于8的概率非常小

链表转树 6

MIN_TREEIFY_CAPACITY = 64 最小树化阈值,当Table所有元素超过改值,才会进行树化(为了防止前期阶段频繁扩容和树化过程冲突)

链表长度大于8且数组长度大于64

1.为什么用数组

链表也可以满足要求,数组的随机访问速度比LinkedList快,ArrayList的扩容机制是1.5倍,不方便扩容

2.为什么不直接使用红黑树

链表元素大于8的概率非常小,新增节点速度变慢了

6个元素退化为链表,防止频繁转换

2.如何计算hash

为了分散的更加均匀,与(length - 1) & hash值,例如开始长度是16,每次hash值和15也就是1111做与运算,可以得到桶的位置

3.put方法的过程

1.对key的hashCode()做hash运算,计算index; 2.如果没碰撞直接放到bucket⾥; 3.如果碰撞了,以链表的形式存在buckets后; 4.如果碰撞导致链表过⻓(⼤于等于TREEIFY_THRESHOLD),就把链表转换成红⿊树(JDK1.8中的改动); 5.如果节点已经存在就替换old value(保证key的唯⼀性) 6.如果bucket满了(超过load factor*current capacity),就要resize

4.resize

扩容为原始的两倍,并移动元素,每个元素要么在原始位置,要么在2倍位置

1.7头插法 死循环

1.8尾插法

5.get方法

1.对key的hashCode()做hash运算,计算index; 2.如果在bucket⾥的第⼀个节点⾥直接命中,则直接返回; 3.如果有冲突,则通过key.equals(k)去查找对应的Entry;

4.若为树,则在树中通过key.equals(k)查找,O(logn);

5.若为链表,则在链表中通过key.equals(k)查找,O(n)。

6.线程不安全

1.多线程扩容场景不安全 头插法 死循环

2.多线程put不安全,引起元素丢失

7.为什么不用hashtable,SynchronizedHashmap

全局使用sync锁,并发度很低

8.什么值作为key

⼀般⽤Integer、String这种不可变类当HashMap当key,⽽且String最为常⽤。 (1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。 这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。 这就是HashMap中的键往往都使⽤字符串。 (2)因为获取对象的时候要⽤到equals()和hashCode()⽅法,那么键对象正确的重写这两个⽅法是⾮常重要的,这些类已 经很规范的覆写了hashCode()以及equals()⽅法。

9.为啥Hashtable 不允许key和value为null

HashMap可以存放一个键是null,多个值是null 的对象,

而Hashtable则不可以存放键为null,或者是值为null的对象

而Hashtable则不可以存放键为null,或者是值为null的对象

为什么HashMap可以放null

专门做了处理当 key 为 null 时,HashMap 将会把 key-value pair放在第一个桶中,key == null时,hashcode返回为0

为什么HashTable不能存null键和null值?

原因:

  1. 当value值为null时主动抛出空指针异常
  2. 因为key值会进行哈希计算,如果为null的话,int hash = key.hashCode()时,还是会抛出空指针异常

fail-safe机制(modifyCount保证没有并发修改),直接做了检查防护 ,返回空指针异常

为什么ConcurrentHashmap不支持null

get(key)场景中,得到value为null,无法区分是value为空还是没有映射过

hashMap可以通过contains(key)方法来区分,ConcurrentHashmap的contains在并发场景下是不准确的,所以无法判断。

ConcurrentHashMap

hash在并发put常见不安全,即使1.8使用的是尾插法,不会死循环

Hashtable 和 SynchronizedMap一样,直接对get,set方法加锁,效率低

1.7 分段锁

原理

保存一个Segment数据,将hashmap分为多个段,每次对指定的段加锁,提高并发度,并发度就是段的个数,默认16

valitile修改每个桶,保证可见性

初始化

Segment 数组长度为 16,不可以扩容

Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容

这里初始化了 segment[0],其他位置还是 null,其他的段在用到的时候才会初始化

put

找到对应的segment,如果没有初始化要先初始化,调用ensureSegment方法,为了防止多线程同时执行,使用CAS保证只有一个初始化成功

尝试获取写锁,首先自旋,到达一定次数后,改为阻塞锁获取,在segmeng内put

size

size为每一个segment的size之和,乐观锁的逻辑,如果计算size期间,没有put,remove等改变结构的操作,则直接计算

如果有put,就需要重试,重试3次都不行,就强制给所有segment加锁,再计算准确结果。

扩容

segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍

每个segment自己扩容,不会影响其他段

get

先找到对应的segment,在正常取值

1.8 Synchronized + CAS

www.itqiankun.com/article/160…

pdai.tech/md/java/thr…

mp.weixin.qq.com/s/My4P_BBXD…

原理

降低锁的粒度,提高锁的效率

将segment变为node,每次对当前链表或红黑树的头节点加锁

解决1.7中并发度低的问题,利于synchronized的优化

初始化

跟hashmap一致,提供了初始化容量,计算了 sizeCtl,sizeCtl = 【 (1.5 * initialCapacity + 1)

//sizeCtl 这个值有很多情况,默认值为0, //当为 -1 时,说明有其它线程正在对表进行初始化操作 //当表初始化成功后,又会把它设置为扩容阈值 //当为一个小于 -1 的负数,用来表示当前有几个线程正在帮助扩容(后边细讲)

put

  1. 根据 key 计算出 hashcode 。
  2. 判断是否需要进行初始化。
  3. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  4. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  5. 如果都不满足,则利用 synchronized 锁写入数据。
  6. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

1.初始化表,初始化数组的时候,根据sizeCtl变量来保证只有一个数组来进行初始化

2.如果已经初始化,找到所在的桶

3.在put元素的时候,如果put的值没有发生hash冲突,此时(f = tabAt(tab, i = (n - 1) & hash)) == null中使用tabAt原子操作获取数组,并利用casTabAt(tab, i, null, new Node<K,V>(hash, key, value))CAS操作将元素插入到Hash表中

在存在hash冲突时,先把当前节点使用关键字synchronized加锁,然后再使用tabAt()原子操作判断下有没有线程对数组进行了修改,最后再进行其他操作。

4.使用synchronized加锁,将元素放入链表或红黑树

get

不需要加锁,get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。

ConcurrentHashmap数组的volatile关键字 这里要注意ConcurrentHashmap数组上面也添加了volatile关键字,但是这个volatile关键字只是针对数组的地址,数组元素的值不是volatile的,而为了保证数组里面的元素也是volatile的,所以有了tabAt()和casTabAt()和setTabAt()这几个方法

这里添加volatile关键字的目的为了使得Node数组在扩容的时候对其他线程具有可见性而加的volatile

size

每次put元素成功以后,count元素自增1

正常我们使用volatile修饰变量,每次加1,这里为了提高在高竟态下的并发度,分散到不同对象里。