hashMap:存放键值对的容器
1、数据结构:
1.7:数组+链表
1.8:数组+链表+红黑树
(1.7Entry类;1.8Node类。本质一样)
内部包含了一个 Entry 类型的数组 table。Entry 存储着键值对,又存放了下一个Entry。所以Entry其实是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。
1.8较1.7引入了红黑树对hashMap数据结构进行优化;
引入原因:提高性能
当链表过长导致索引效率慢,利用红黑树快速增删改查优点,将时间复杂度由O(n)–>O(log(n))应用场景:链表长度 >8,转化为红黑树
2、主要使用API:
1.7与1.8基本相同
V get(Object key); // 获得指定键的值
V put(K key, V value); // 添加键值对
void putAll(Map<? extends K, ? extends V> m); // 将指定Map中的键值对 复制到 此Map中
V remove(Object key); // 删除该键值对
boolean containsKey(Object key); // 判断是否存在该键的键值对;是 则返回true
boolean containsValue(Object value); // 判断是否存在该值的键值对;是 则返回true
Set<Map.Entry<K,V>> entrySet() //将所有key-value生成一个Set
Set<K> keySet(); // 单独抽取key序列,将所有key生成一个Set
Collection<V> values(); // 单独value序列,将所有value生成一个Collection
void clear(); // 清除哈希表中的所有键值对
int size(); // 返回哈希表中所有 键值对的数量
boolean isEmpty(); // 判断HashMap是否为空;size == 0时 表示为 空
3、一般使用流程:
- 声明1个 HashMap的对象
- 向 HashMap 添加数据(放入 键 - 值对)
- 获取 HashMap 的某个数据
- 获取 HashMap 的全部数据:遍历HashMap
//1. 声明1个 HashMap的对象
Map<String, Integer> map = new HashMap<String, Integer>();
// 2. 向HashMap添加数据(放入 键 - 值对)
map.put("Android", 1);
map.put("Java", 2);
map.put("产品经理", 3);
//3. 获取 HashMap 的某个数据
System.out.println("key = 产品经理时的值为:" + map.get("产品经理"));
/**
* 4. 获取 HashMap 的全部数据:遍历HashMap
* 核心思想:
* 步骤1:获得key-value对(Entry) 或 key 或 value的Set集合
* 步骤2:遍历上述Set集合(使用for循环 、 迭代器(Iterator)均可)
* 方法共有3种:分别针对 key-value对(Entry) 或 key 或 value
*/
// 方法1:获得key-value的Set集合 再遍历
// 1. 获得key-value对(Entry)的Set集合
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
// 2. 遍历Set集合,从而获取key-value
// 2.1 通过for循环
for(Map.Entry<String, Integer> entry : entrySet){
System.out.print(entry.getKey());
System.out.println(entry.getValue());
}
// 2.2 通过迭代器:先获得key-value对(Entry)的Iterator,再循环遍历
Iterator iter1 = entrySet.iterator();
while (iter1.hasNext()) {
// 遍历时,需先获取entry,再分别获取key、value
Map.Entry entry = (Map.Entry) iter1.next();
System.out.print((String) entry.getKey());
System.out.println((Integer) entry.getValue());
}
// 方法2:获得key的Set集合 再遍历
// 1. 获得key的Set集合
Set<String> keySet = map.keySet();
// 2. 遍历Set集合,从而获取key,再获取value
// 2.1 通过for循环
for(String key : keySet){
System.out.print(key);
System.out.println(map.get(key));
}
// 2.2 通过迭代器:先获得key的Iterator,再循环遍历
Iterator iter2 = keySet.iterator();
String key = null;
while (iter2.hasNext()) {
key = (String)iter2.next();
System.out.print(key);
System.out.println(map.get(key));
}
// 方法3:获得value的Set集合 再遍历
// 1. 获得value的Set集合
Collection valueSet = map.values();
// 2. 遍历Set集合,从而获取value
// 2.1 获得values 的Iterator
Iterator iter3 = valueSet.iterator();
// 2.2 通过遍历,直接获取value
while (iter3.hasNext()) {
System.out.println(iter3.next());
}
}
}
// 注:对于遍历方式,推荐使用针对 key-value对(Entry)的方式:效率高
// 原因:
// 1. 对于 遍历keySet 、valueSet,实质上 = 遍历了2次:1 = 转为 iterator 迭代器遍历、2 = 从 HashMap 中取出 key 的 value 操作(通过 key 值 hashCode 和 equals 索引)
// 2. 对于 遍历 entrySet ,实质 = 遍历了1次 = 获取存储实体Entry(存储了key 和 value )
4、重要参数:
// 1. 容量(capacity): 必须是2的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量 = 16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 = 2的30次方(若传入的容量过大,将被最大值替换)
// 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
final float loadFactor; // 实际加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子 = 0.75
// 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表
// 扩容阈值 = 容量 x 加载因子
int threshold;
// 4. 其他
transient Node<K,V>[] table;
transient int size;// HashMap的大小,即 HashMap中存储的键值对的数量
/**
* 与红黑树相关的参数
*/
// 1. 桶的树化阈值:当链表长度 > 该值时,则将链表转换成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 2. 桶的链表还原阈值:当在扩容resize(),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
static final int UNTREEIFY_THRESHOLD = 6;
// 3. 最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
// 否则,若桶内元素太多时,则直接扩容,而不是树形化
// 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
加载因子(负载因子):
在查找效率&空间利用率之间寻找一种平衡,
loadFactor越小,链表越短,查找效率越高,但也需要注意频繁扩容损耗性能
loadFactor越大,链表越长,空间利用率越高。
5、map.put():存储操作流程
1、计算桶下标:hash(key)
tab = resize()
创建hashMap时并没有初始化,第一次调用put时触发初始化条件h = key.hashCode()
计算hash值(得到32位的hash值)h ^ (h >>> 16)
将键的hashcode的高16位异或低16位(高位运算),减少hash冲突(简单理解为使元素分布更加均匀,提高查询效率)JDK1.8(length - 1) & hash
取模:取模运算开销大,可以利用位运算代替
令一个数y与x-1做 与运算(前提x是2的n次方)
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010
这个性质和 y 对 x 取模效果是一样的
y : 10110010
x : 00010000
y%x : 00000010
- 所以key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
JDK1.8做了2次扰动处理 =1次位运算 + 1次异或运算;
JDK1.7做了9次扰动处理 =4次位运算 + 5次异或运算;
初始化: (1.8集成在扩容函数中,1.7是inflateTable(threshold)😉)(补充)对key为null(返回hash值为0),HashMap 使用第 0 个桶存放键为 null 的键值对。
2、put():具体存放的数据结构 (JDK1.8)
- 尝试插入or更新数组;
- 如果发生Hash碰撞,判断当前节点的数据结构(红黑树or链表)
2.1:优先判断红黑树,如果是则插入or更新红黑树
2.2:否则插入or更新链表 - 如果是插入链表节点,需要判断(是否树化?链表长度>8)
- 插入节点后判断(是否扩容)
1.7采用头插法
1.8采用尾插法
5、扩容:基本原理
-
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。
-
为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
-
扩容resize()实现需要将oldTable中所有键值对放入newTable中,这很费时,并且要重新计算桶下标
6、扩容:重新计算桶下标
由上述可知,通过位运算代替取模运算。假如原来容量为16,扩容两倍为32。
capacity : 00010000(16)
new capacity : 00100000(32)
capacity -1 : 00001111
new capacity -1 : 00011111
只有第五位不同
JDK1.8 对于一个 Key,它的哈希值 hash 在第 5 位:
为 0,那么 hash%00010000 = hash%00100000,桶位置和原来一致;
为 1,hash%00010000 = hash%00100000 + 16,桶位置是原位置 + 原容量。
7、扩容:具体流程JDK1.8
- 异常情况判断(当前为最大容量则不扩容)
- 创建两倍原容量新数组
- (重点区别)遍历旧数组中的元素,重新计算下标(优化),尾插法添加到新数组中
1.8较1.7在扩容方面优化较大:
1、重新计算下标时:
1.8 直接if ((e.hash & oldCap) == 0)
判断放在原索引还是原索引+oldCap
1.7则要重新执行计算下标(包括9次扰动)2、插入位置不同:
1.8 采用尾插法
1.7 采用头插法(在并发扩容会出现环形链表死锁情况)3、插入数据的时机:
1.8 先插入后扩容
1.7 先扩容后插入
7、JDK 1.8与 JDK 1.7 的区别总结
- 数据结构
- hash值计算
- 初始化方式(调用函数不同,但都是第一次调用put方法才初始化Map)
- 插入数据方式
- 扩容后重新计算下标方式
- 插入数据需要扩容时的插入时机
8、计算数组容量capacity:主要应用在构造函数中
因为要保持capacity是2的n次方才能满足位运算代替取模。
但是hashMap的构造方法中并没有强制要求传入容量为2的n次方,这里因为内部会自动转换成2的n次方,原理如下:
-
掩码定义:
-
先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:
mask |= mask >> 1 11011000
mask |= mask >> 2 11111110
mask |= mask >> 4 11111111
- mask+1 是大于原始数字的最小的 2 的 n 次方。
num 10010000
mask+1 100000000
- 以下是 HashMap 中计算数组容量的代码:
static final int MAXIMUM_CAPACITY = 1 << 30; //int类型最大值
static final int tableSizeFor(int cap) {
int n = cap - 1;
//移位求掩码,int为32位
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;
}
>> : 带符号位右移
>>> : 无符号位右移
^ : 按位异或
9、补充:1.7并发死锁问题
HashMap在jdk1.7中采用头插入法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题。
而在jdk1.8中采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
具体参考:Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?
10、补充:为什么String、Integer 这样的包装类适合作为 key 键:
因为这些包装类保证了hash的不可更改性&计算准确性
- final类型保证key不可更改
- 重写了equals和hashCode方法,计算准确减少hash冲突
11、补充:HashMap 中的 key若 Object类型, 则需实现哪些方法:
equals和hashCode方法:
- hashCode:实现不好会导致严重的hash冲突
- equals:要保证key在hashMap中的唯一性
12、补充:线程不安全:
因为多线程环境下,使用Hashmap进行put操作会引起死循环,所以在并发情况下不能使用HashMap。
而hashTable性能不佳,在并发情况下推荐使用ConcurrentHashMap。
参考文章:Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?
ConcurrentHashMap(1.7)
1、出现原因:
- hashMap并发不安全,不能使用;
- hashTable虽然线程安全,但是并发效率极低,加锁后排斥读写操作
- Collections提供的同步包装器利用比较粗粒度的同步方式,高并发下性能不佳
2、数据结构:
1、ConcurrentHashMap在JDK1.7是由Segment数组和HashEntry链表结构组成。
2、Segment扮演锁(ReentrantLock)的角色,HashEntry中用volatile修饰value&next
3、ConcurrentHashMap采用了分段锁,并发度高,默认的并发级别为 16 即 Segment数组长度。
volatile特性:可见性、有序性、不能保证原子性
*有些方法需要跨段访问,size()等,可能需要锁住这个表而不是某段
这需要按顺序加锁解锁,否则容易出现死锁,因此顺序是固定的,由final修饰段数组
3、put操作原理:
- 异常判断(判断val==null?报错 (不能插入null值)
- 根据 key 计算出 hashcode(二次哈希)
- 定位到segment
scanAndLockForPut()
会去查找是否有key相同的Node(即通过 key 的 hashcode 定位到 HashEntry)tryLock()
尝试获取锁。失败则存在线程竞争,利用scanAndLockForPut()
自旋获取锁- 如果重试的次数达到了
MAX_SCAN_RETRIES
则改为阻塞锁获取,无论如何都保证能获取锁成功 - 拿到锁后,遍历HashEntry,更新or插入key-value;
扩容问题ConcurrentHashMap同样存在,但它进行的不是整体的扩容,而是单独对Segment进行扩容
4、get操作原理:
- 与put类似,首先两次定位到HashEntry上
- 由于value是volatile修饰,保证可见性,所有get操作不用加锁,非常高效
5、size()操作原理:涉及分段锁的一个副作用
- 等价于统计所有Segment里元素的大小后求和。在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?
- 不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove方法全部锁住,但是这种做法显然非常低效。
- 因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的;如果尝试的次数超过 3 次,就需要对每个Segment 加锁。
- 那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
其实分段锁还限制了Map的初始化操作
6、以上基于JDK1.7存在的问题:
- 查询时需要遍历链表,查询效率不高,1.8中优化引入红黑树,
O(n)->O(logn)
1.8中ConcurrentHashMap
主要优化:
- 抛弃了segment锁(ReentrantLock),改用CAS+synchronized,并发度进一步提升
- 不再使用Segment,初始化操作大大简化,修改为lazy_load形式,有效避免初始开销
- 引入红黑树
1、put流程:
if (key == null || value == null) throw new NullPointerException();
key和value不能为空- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化
initTable();
。(1.7中没有这一步,分段锁限制了初始化) - 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。(遍历链表or红黑树、更新or尾部插入数据)
- 如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
CAS是什么?
是乐观锁的一种实现方式,,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。CAS局限性:
CAS不一定能保证数据没被其它线程修改,例如ABA问题;解决方法:
版本号、时间戳
CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?
- synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。
- 针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
- 所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的
- 另外,相比于ReentrantLock,他可以减少内存消耗
2、get流程:与hashMap一致
3、size()操作原理:
也是利用分而治之计数,最后求和即可
SynchronizedMap&HashTable
1、讲讲早期的并发容器:
- 粗粒度的同步方式,对所有方法上锁,锁住整张表,在高并发下性能极低
2、SynchronizedMap:
- Collections.synchronizedMap(Map)创建一个SynchronizedMap
- 它的内部维护了一个普通对象Map,还有排斥锁mutex
- 它有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。
- 再操作map时,它的所有方法都会上锁
3、HashTable:
HashTable效率也比较低下,原因是:它只要操作数据都会上锁,例如get方法
HashTable和HashMap区别:
- 对null值的处理不同;HashMap的key-value都能为null,但HashTable则不允许
- 初始化容量不同;HashMap 的初始容量为16,Hashtable 初始容量为11,两者的负载因子默认都是:0.75。
- 扩容机制不同;当现有容量大于扩容阈值时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
- 迭代器不同;HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。
- 实现方式不同;Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。
4、fail—fast & fail—safe:
HashTable的key-value为空值会报空指针异常;原因是:
这是因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。
fail-fast是啥?
- 快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
- 原理:迭代器遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
- Tip:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。
因此,不能依赖于这个异常是否抛出而进行并发操作的编程
场景:
快速失败(fail—fast),java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。
安全失败(fail—safe),java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
来源:CSDN
作者:Rhin0cer0s
链接:https://blog.csdn.net/Rhin0cer0s/article/details/104674175