6319 HashMap 原理
数据结构:数组 + 链表 (JAVA8之后:数组+链表+红黑树)
-
数据结构
- jdk1.7:数组+链表,数组每个元素是一个链表的表头,当发生冲突,将新元素添加在头部(头插法)
- hash冲突:头插法,❗扩容时可能造成环形链表
- jdk1.8:
- 引入红黑树,当链表节点超过8个,那么这个链表会转换为红黑树。查询时间由 O(n) 优化为 O(logn)。当节点小于6个,再转换为链表。
- hash冲突:尾插法,避免环形链表❗
- 扩容机制
- jdk1.7
- 扩容,元素会重新计算hash值,并分配到新的扩容数组中。⭐比较耗时
- 扩容时,头插法,在多线程情况下,可能造成环形链表
- jdk1.8
- 扩容时,利用了元素哈希值和旧数组容量关系,减少了重新计算的哈希次数
- 扩容时,尾插法,避免环形链表
- jdk1.7
- jdk1.7:数组+链表,数组每个元素是一个链表的表头,当发生冲突,将新元素添加在头部(头插法)
-
使用键的
hashcode()计算hash值,然后(n-1) & hash确定数组中的位置。- n-1 => 10000 - 1 = 01111
-
HashMap的初始默认容量为16,负载因子为0.75。当存储的元素达到75%时,进行扩容,扩容为原来的2倍空间
扩展知识:
-
hashmap的红黑树优化:
- JAVA8开始,为了优化hash冲突时的查找性能。但链表的长度超过8时,链表会转变为红黑树。红黑树是一种自平衡的二叉搜索树 查找时间 O(n) => O(logn)。 当元素少于6个,切换为链表
-
hashCode() 和 equals()的重要性:
- hashCode计算hash值,决定键的存储位置。 而hashCode相同=>冲突。 equals()比较的值
1// HashMap Node class
2static class Entry<K,V> {
3 final K key; // 键
4 V value; // 值
5 final int hash; // 计算后的哈希值
6 Entry<K,V> next; // 指向下一个元素的指针(形成链表)
7}
为什么头插法&多线程&扩容会造成环形链表?
1void transfer(Entry[] newTable) {
2 Entry[] src = table;
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { // 遍历旧表
5 Entry<K,V> e = src[j]; // 取出当前桶的头结点
6 if (e != null) { // 旧桶不为空
7 src[j] = null; // 释放旧桶的引用(防止 GC 误回收)
8 do {
9 Entry<K,V> next = e.next; // 记录下一个节点
10 int i = indexFor(e.hash, newCapacity); // 计算新索引(线程1暂停)
11 e.next = newTable[i]; // 头插法:将 e 连接到 newTable[i] 上
12 newTable[i] = e; // 将 e 作为 newTable[i] 的新头
13 e = next; // 继续处理下一个节点
14 } while (e != null); // 直到旧链表遍历完
15 }
16 }
17}
18
19old: [ ][ ][ ] old: [ ][ ][ ]
20 [1] [2]
21 [2] =>
22
23new: [ ][ ][ ] new: [ ][ ][ ] // 头插法,将节点进行转移
24 [1]
25
26// 环的情况,HashMap多个线程共享
27Thread1: e -> [1][ ][ ] Thread2: [3][ ][ ]
28 next-> [2] 扩容成功 [2]
29 [3] [1]
30
31执行
32{
33 e.next = newTable[i];
34 newTable[i] = e;
35 e = next;
36}
37
38newTabel[0] => [1]->[3]->[2]->[1]
39 ⬆️ ⬅️ ⬅️
4948 HashMap,有哪些提升性能的技巧?
-
合理设置初始容量,减少resize,减少扩容操作。默认16
-
调整负载因子,查找多的设置小一点,减少冲突情况,冲突少了(查询效率就高了),但会提高内存占用情况,反之亦然。默认0.75
-
确保hashCode(), 均匀的是均匀分布的,以减少hash冲突。
扩展:
- 当元素数量 > 装填因子 * 数组大小 => 进行扩容,新数组为当前的2倍,并把当前hash中的数据重新分配到新的hashmap中;
- LinkedHashMap,拉链法,能够保存插入顺序
- 保存有序 => TreeMap.
- 需要线程安全 => ConcurrentHashMap
4947 Hash碰撞 & 解决
不同key使用hashcode()&n-1得到相同的坑,然后冲突
- 链地址法(拉链法)
- 开放地址法,顺序往下or左右横跳
- 线性探测
- 平方探测
- 再hash法(双重hash)
- 出现碰撞后,使用第二个hash函数计算新的索引位置,减少碰撞的概率。
4946 CopyOnWriteArrayList & Collections.synchronizeddList 区别
CopyOnWriteArrayList 是线程安全的List实现,特性是写时复制
每次对List的修改操作(add, remove, set)都会创建一个新的底层数组。 读操作不需要加锁,而写操作需要加锁
优点:
- 读操作无锁,写操作会创建数组副本 => 读写不冲突
- ⭐读操作在当前数组上执行; 写操作(add,set,remove)会创建一个新数组,新数组上修改再替换旧的(加锁)。
缺点:
- 写操作开销大:每次写操作都会创建并复制新数组,并且将数据复制到新的数组中,写频繁的场景下性能会比较低
- 内存消耗大
🎉适合读多写少的场景
Collections.synchronizeddList 包装方法,将任何数组转换为线程安全的版本,会对每个访问方法(get, set, add, remove)进行同步(加锁),线程安全。
优点:
- 方便
缺点:
- 并发效率低
🎉适用于简单的将List转为线程安全的版本使用。
444 Java中有哪些集合类?
-
List接口
- ArrayList
- LinkedList
- Vector
- 基于动态数组实现,且线程安全,所有方法都添加了synchronized
-
Set接口
- HashSet // 冲突的链表元素无序
- LinkedHashSet // 继承HashSet,底层由双向链表实现,保证插入顺序
- TreeSet // 基于红黑树, 自动排序,提供排序功能
-
Queue接口
- PriorityQueue, 优先队列,内部元素按级别排序。只能比较器
- LinkedList,可以作为队列使用,FIFO
-
Map接口
- HashMap
- LinkedHashMap
- TreeMap
- HashTable不推荐使用,线程安全的哈希表。**早期线程安全的HashMap,锁的粒度为整个表,这样并发能力不高。**底层使用synchronized关键字实现
- ConcurrentHashMap,线程安全的hashmap。
- 高性能的线程安全HashMap
- 提供更细粒度的锁 分段锁和CAS操作
- 读操作不加锁
- 写操作 => CAS + synchronized细粒度锁
9179 ArrayList的扩容机制是什么?
默认容量10,当元素数量超过其当前容量,会触发扩容机制。 扩容大小为原数组的1.5倍
449 HashSet & HashMap
HashSet存储不重复元素 => 不能冲突的HashMap
HashMap,K=>V, 键唯一,但不同键的值可以相同
451 HashMap的扩容机制
负载因子默认0.75, 但比例超过负载因子,进行扩容,扩容为原来的2倍。
当前hashmap中的元素在新hashmap的位置怎么计算? 就是hashCode() & (n-1) => hashCode() & (2n-1)
优化过程
1// example:
2 n:16= 1,0000 n-1: 0,1111 2^4
32n:32=10,0000 2n-1:01,1111 2^5
4
5// 通过hash值 & 2n-1:
6// 只要判断最高位是否即可
7
8if 第5位== 0:then
9 位置不变;
10else
11 位置偏移16位
452 为什么HashMap扩容采用2的n次方?
- 计算散列位置决定=> hashcode & (n-1) 其中 n-1=01111.. 可以将位置映射到数组的每个元素上,分布均匀。
位运算比取余效率高
457 Java中的LinkedhashMap
基于HashMap & 双向链表 & 额外的顺序指针
🏷️作用:保持插入顺序or访问顺序
💠定义如下:
1static class Entry<K,V> extends HashMap.Node<K,V> {
2 Entry<K,V> before, after;
3 Ectry(){...} // constructor
4}
🏷️图示:
构想=> 由于可以根据插入时间排序,因此可以模拟LRU(least recently used)算法,进行淘汰最不常用的节点。
461 ConcurrentHashMap
🏷️背景:在多线程并发条件下,普通的共享HashMap会存在线程安全问题,因为没有加锁,导致数据被覆盖…;
🏷️作用:通过同步机制加锁,使得HashMap在背景下是线程安全的,且还需要保证效率;
🏷️定义:
- JDK1.7之前,采用分段锁,即每个Segment是独立的,将锁的粒度下方,提高线程并发度
- 利用数组+链表实现
=> 锁的粒度是segment;
缺点:segment不能扩容,而HashMap会扩容
- JDK1.8后,移除Segment,锁的粒度更加细化,锁只在链表or红黑树节点级别上竞争锁。通过CAS进行插入操作,只有在更新链表or红黑树时才使用synchronized关键字加锁,并且只锁住链表的头节点。
- 同步HashMap,数组+链表+红黑树实现;
🏷️添加节点过程
- 计算key的hash后的下标,如果没有元素 => 利用CAS添加元素;
- 冲突 => 给这个节点上锁使用synchronized。这样其他线程就无法访问该节点以及之后的节点了
🏷️额外扩展(为什么分为CAS&Synchronized呢?)
- 无锁机制CAS,能进一步提高并发性能; 因此首次添加节点时,利用CAS很OK,判断&插入
- 而后续对链表的修改,很难原子化操作orCAS比较交换。因此使用Synchronized,实现互斥,且该锁的粒度很小。
1// 自旋:如果 CAS 失败就重试
2while (true) {
3 currentNode = bucket.get();
4
5 if (currentNode == null) {
6 // 如果桶位置为空,尝试通过 CAS 插入新节点
7 if (bucket.compareAndSet(null, newNode)) {
8 return null; // 插入成功
9 }
10 } else {
11 // 如果当前位置有节点,进行链表遍历,寻找合适位置插入
12 synchronized (currentNode) { // 在访问链表节点时加锁,防止并发修改
13 Node<K, V> prev = null;
14 while (currentNode != null) {
15 if (currentNode.key.equals(key)) {
16 // 如果找到相同的 key,更新 value
17 currentNode.value = value;
18 return currentNode.value;
19 }
20 prev = currentNode;
21 currentNode = currentNode.next;
22 }
23 // 如果没有找到相同的 key,在链表末尾添加新的节点
24 prev.next = newNode;
25 return null; // 插入成功
26 }
27 }
28}
464 CopyOnwriteArrayList
线程安全的动态数组,通过写时复制机制,保证数据最终一致性和隔离性,并不保证强一致性。
- 写操作和读操作(Volatile修饰)分离,读完全ok(可能读到旧版本);写需要加锁且复制数组,再修改旧数组的引用。
线程安全问题:
- 竞争条件
- 多个线程对共享变量进行累加,可以导致统计不对
- 死锁
- 多个线程操作数据时,没有正确的请求资源和释放顺序,相互等待
- 可见性问题
- 某个线程对共享变量的修改,其他线程不一定及时可见
- => volatile 强制从内存中修改&读取
- 某个线程对共享变量的修改,其他线程不一定及时可见
- 原子性问题
- 逻辑由多个操作步骤组成,其中CPU时间片到期了,中途其他线程修改了共享变量,导致不一致。
- 线程饥饿
- 多线程争抢CPU,非公平,有些永远抢不到
- 不一致视图
- 多个线程同时修改一个共享对象,导致一部分数据不一致
- 强制视图一致,就是我第一次拿到的数据,和最终的数据要一致,其他线程别给我改了一部分啊 => 加synchronized
- 多个线程同时修改一个共享对象,导致一部分数据不一致