Java 核心集合容器全解(下)
Queue
BlockingQueue 是什么?
Java.util.concurrent.BlockingQueue 是一个队列,在进行检索或移除一个元素的时候,线程会等待队列变为非空;
当在添加一个元素时,线程会等待队列中的可用空间。
BlockingQueue 接口是 Java 集合框架的一部分,主要用于实现生产者-消费者模式。
Java 提供了几种 BlockingQueue的实现,比如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等。
在 Queue 中 poll()和 remove()有什么区别?
● 相同点:都是返回第一个元素,并在队列中删除返回的对象。
● 不同点:如果没有元素 poll()会返回 null,而 remove()会直接抛出 NoSuchElementException 异常。
Map 接口
Map 整体结构如下所示:
Hashtable 比较特别,作为类似 Vector、Stack 的早期集合相关类型,它是扩展了 Dictionary 类的,类结构上与 HashMap 之类明显不同。
HashMap 等其他 Map 实现则是都扩展了 AbstractMap,里面包含了通用方法抽象。
不同 Map 的用途,从类图结构就能体现出来,设计目的已经体现在不同接口上。
HashMap 的实现原理?
在 JDK 1.7 中 HashMap 是以数组加链表的形式组成的,JDK 1.8 之后新增了红黑树的组成结构,当链表大于 8 并且容量大于 64 时,链表结构会转换成红黑树结构。
HashMap 基于 Hash 算法实现的:
1.当我们往 Hashmap 中 put 元素时,利用 key 的 hashCode 重新 hash 计算出当前对象的元素在数组中的下标。
2.存储时,如果出现 hash 值相同的 key,此时有两种情况。
● 如果 key 相同,则覆盖原始值;
● 如果 key 不同(出现冲突),则将当前的 key-value 放入链表中
3.获取时,直接找到 hash 值对应的下标,在进一步判断 key 是否相同,从而找到对应值。
4.理解了以上过程就不难明白 HashMap 是如何解决 hash 冲突的问题,核心就是使用了数组的存储方式,然后将冲突的 key 的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
JDK1.7 VS JDK1.8 比较
JDK1.8 主要解决或优化了一下问题:
- resize 扩容优化
- 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
- 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
如何有效避免哈希碰撞
主要是因为如果使用 hashCode 取余,那么相当于参与运算的只有 hashCode 的低位,高位是没有起到任何作用的。
所以我们的思路就是让 hashCode 取值出的高位也参与运算,进一步降低 hash 碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的 hash()函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
HashMap 的 put 方法的具体流程?
当我们 put 的时候,首先计算 key的hash值,这里调用了 hash方法,hash方法实际是让key.hashCode()与key.hashCode()>>>16进行异或操作,高 16bit 补 0,一个数和 0 异或不变,所以 hash 函数大概的作用就是:高 16bit 不变,低 16bit 和高 16bit 做了一个异或,目的是减少碰撞。
①.判断键值对数组 table[i]是否为空或为 null,否则执行 resize()进行扩容;
②.根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加,转向 ⑥,如果 table[i]不为空,转向 ③;
③.判断 table[i]的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向 ④,这里的相同指的是 hashCode 以及 equals;
④.判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向 ⑤;
⑤.遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
⑥.插入成功后,判断实际存在的键值对数量 size 是否超多了最大容量 threshold,如果超过,进行扩容。
HashMap 的扩容操作是怎么实现的?
①.在 jdk1.8 中,resize 方法是在 hashmap 中的键值对大于阀值时或者初始化时,就调用 resize 方法进行扩容;
②.每次扩展的时候,都是扩展 2 倍;
③.扩展后 Node 对象的位置要么在原位置,要么移动到原偏移量两倍的位置。
在 1.7 中,扩容之后需要重新去计算其 Hash 值,根据 Hash 值对其进行分发.
但在 1.8 版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为 0,0 -表示还在原来位置,否则就移动到原数组位置 + oldCap。
重新进行 hash 分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上。
任何类都可以作为 Key 么?
可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:
● 如果类重写了 equals() 方法,也应该重写 hashCode() 方法。
● 类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。
● 如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
● 用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。
不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。
为什么 HashMap 中 String、Integer 这样的包装类适合作为 K?
String、Integer 等包装类的特性能够保证 Hash 值的不可更改性和计算准确性,能够有效的减少 Hash 碰撞的几率。
- 都是 final 类型,即不可变性,保证 key 的不可更改性,不会存在获取 hash 值不同的情况
- 内部已重写了equals()、hashCode()等方法,遵守了 HashMap 内部的规范(不清楚可以去上面看看 putValue 的过程),不容易出现 Hash 值计算错误的情况;
HashMap 为什么不直接使用 hashCode()处理后的哈希值直接作为 table 的下标?
hashCode()方法返回的是 int 整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有 40 亿个映射空间,而 HashMap 的容量范围是在 16(初始化默认值)~2 ^ 30,HashMap 通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
HashMap 的长度为什么是 2 的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
这个算法应该如何设计呢?
我们首先可能会想到采用 % 取余的操作来实现。
但是,重点来了:取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。
并且采用二进制位操作 &,相对于 % 能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。
那为什么是两次扰动呢?
答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少 Hash 冲突,两次就够了,已经达到了高位低位同时参与运算的目的;
HashMap 和 ConcurrentHashMap 的区别
- ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),每一个分段上都用 lock 锁进行保护,相对于 HashTable 的 synchronized 锁的粒度更精细了一些,并发性能更好,而 HashMap 没有锁机制,不是线程安全的。(JDK1.8 之后 ConcurrentHashMap 启用了一种全新的方式实现,利用 synchronized + CAS 算法。)
- HashMap 的键值对允许有 null,但是 ConCurrentHashMap 都不允许。
ConcurrentHashMap 实现原理
JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在 JDK1.7 中,ConcurrentHashMap 采用 Segment + HashEntry 的方式进行实现,结构如下:
一个 ConcurrentHashMap 里包含一个 Segment 数组。
Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
- 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
- HashEntry 内部使用 volatile 的 value 字段来保证可见性,get 操作需要保证的是可见性,所以并没有什么同步逻辑。
- Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个 HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
get 操作需要保证的是可见性,所以并没有什么同步逻辑
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key.hashCode());
//利用位操作替换普通数学运算
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 以Segment为单位,进行定位
// 利用Unsafe直接进行volatile access
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
//省略
}
return null;
}
而对于 put 操作,首先是通过二次哈希避免哈希冲突,然后以 Unsafe 调用方式,直接获取相应的 Segment,然后进行线程安全的 put 操作:
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 二次哈希,以保证数据的分散性,避免哈希冲突
int hash = hash(key.hashCode());
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
其核心逻辑实现在下面的内部方法中:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// scanAndLockForPut会去查找是否有key相同Node
// 无论如何,确保获取锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有value...
}
else {
// 放置HashEntry到特定位置,如果超过阈值,进行rehash
// ...
}
}
} finally {
unlock();
}
return oldValue;
}
JDK1.8
在JDK1.8 中,放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发安全进行实现。
synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。
● 总体结构上,它的内部存储和 HashMap 结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。
● 其内部仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。
● 因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
● 数据存储利用 volatile 来保证可见性。
● 使用 CAS 等操作,在特定场景进行无锁并发操作。
● 使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。
另外,需要注意的是,“线程安全”这四个字特别容易让人误解,因为ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的。
误区
我们来看一个使用 Map 来统计 Key 出现次数的场景吧,这个逻辑在业务代码中非常常见。
开发人员误以为使用了 ConcurrentHashMap 就不会有线程安全问题,于是不加思索地写出了下面的代码:
● 在每一个线程的代码逻辑中先通过 containsKey 方法判断可以 是否存在。
● key 存在则 + 1,否则初始化 1.
// 共享数据
ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
public void normaluse(String key) throws InterruptedException {
if (freqs.containsKey(key)) {
//Key存在则+1
freqs.put(key, freqs.get(key) + 1);
} else {
//Key不存在则初始化为1
freqs.put(key, 1L);
}
}
大错特错啊朋友们,需要注意 ConcurrentHashMap 对外提供的方法或能力的限制:
● 使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁。
● 诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映 ConcurrentHashMap 的中间状态。
因此在并发情况下,这些方法的返回值只能用作参考,而不能用于流程控制。
显然,利用 size 方法计算差异值,是一个流程控制。
● 诸如 putAll 这样的聚合方法也不能确保原子性,在 putAll 的过程中去获取数据可能会获取到部分数据。
正确写法:
//利用computeIfAbsent()方法来实例化LongAdder,然后利用LongAdder来进行线程安全计数
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
● 使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 来做复合逻辑操作,判断 Key 是否存在 Value,如果不存在则把 Lambda 表达式运行后的结果放入 Map 作为 Value,也就是新创建一个 LongAdder 对象,最后返回 Value。
● 由于 computeIfAbsent 方法返回的 Value 是 LongAdder,是一个线程安全的累加器,因此可以直接调用其 increment 方法进行累加。
文章转载自公众号:码哥字节