0
点赞
收藏
分享

微信扫一扫

Android中的并发集合类,你了解多少?

开源GIS定制化开发方案 2022-01-13 阅读 37
javaandroid

前言

并发集合类相关问题的出现频次在大厂的面试中是比较高的,例如安全队列的实现、CopyOnWriteArrayList的原理、ConcurrentHashMap的实现机制等等,相信这篇文章能够帮助你回答好这些问题。

1、多线程中的安全队列一般通过什么实现?

Java提供的线程安全Queue可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue。

对于BlockingQueue,想要实现阻塞功能,需要调用put(e) 与take() 方法。而ConcurrentLinkedQueue是基于链接节点、无界、线程安全的非阻塞队列。

阻塞队列

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程,阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。它的两个核心方法如下所示:

  • 1)、支持阻塞的插入方法put(e) :意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。

  • 2)、支持阻塞的移除方法take():意思是在队列为空时,获取元素的线程会等待队列变为非空。

2、CopyOnWriteArrayList的了解?

Copy-On-Write 是什么?具体原理?

CopyOnWriteArrayList是一个ArrayList的线程安全的变体;在计算机中就是当你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存的指针指向新的内存,但是在添加这个数据的期间,其他线程如果要去读取数据,仍然是读取到旧的容器里的数据。

优点

  • 1)、数据一致性,为什么?因为加锁了,并发数据不会乱。

  • 2)、解决了像ArrayList、Vector这种集合多线程遍历的迭代问题,记住,Vector虽然线程安全,只不过是加了synchronized关键字,迭代问题完全没有解决!

缺点

  • 1)、内存占用问题很明显:两个数组同时驻扎在内存中,如果实际应用中,数据比较多、比较大的情况下,占用内存会比较大,针对这个其实可以用ConcurrentHashMap来代替。

  • 2)、数据一致性:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

使用场景

  • 1、读多写少(白名单,黑名单,商品类目的访问和更新场景),为什么?因为写的时候会复制新集合。

  • 2、集合不大,为什么?因为写的时候会复制新集合。

  • 3、实时性要求不高,为什么,因为有可能会读取到旧的集合数据。

3、ConcurrentHashMap 如何实现并发访问?

HashTable 的问题

HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,ConcurrentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。

它有3中类型的锁:

大锁

对 HashTable 对象加锁。

长锁

直接对方法加锁。

读写锁共用

只有一把锁,从头锁到尾。

CHM 的并发优化历程

JDK 5:分段锁,必要时加锁

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器中的其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段的数据也能被其他线程所访问。

ConcurrentHashMap 实质是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁时的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

其中,每一个 segment 对应一个 table[],在hash(key)中取高位定位 segment 的位置,取低位定位 table 中的位置。

concurrencyLevel的作用?

并行级别、并发数、Segment 数,默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segment,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。

这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。其中的每个 Segment 很像 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。

存在的问题

如果 key 是整数,那么 hash 值的高位对于3万多以下的整数得到的结果都是一样的,都会堆到一个 segment 里面,退化成一个 HashTable,达不到分段优化的目的了。

而对于超过3万多的数值,到大概4,50万时仍然集中到 15,随着数值的慢慢增加,它才会均匀地分布在这个段里。

JDK 6:优化二次 Hash 算法

对 Hash 算法做了优化,具体是使用了 single-word Wang/Jenkins hash 算法,它能够保证高位低位的均匀分布,关键特性:

  • 1、雪崩性:更改输入参数的任何一位,就将引起输出有一半以上的位发生变化。

  • 2、可逆性:input ==> hash ==> inverse_hash ==> input。

JDK 7:段懒加载,volatile & cas

段没有一开始就实例化,而是需要的时候再去初始化,并且访问的时候会尽可能地使用 volatile & cas 来避免加锁。

在实例化的过程中,有一个问题,由于 segment 本身也涉及到一个可见性的点,当线程 A 把它给初始化了,线程 B 同时也在访问它的时候可能会由于可见性的问题访问不到刚刚被初始化的 segment,也就是因为这一点,在 JDK 7 里面大量使用了对数组的 volatile 访问,这个访问基于 unsafe 这个类。(对应方法为 getObjectVolatile())。

put 方法做了什么?

  • 1、首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。

  • 2、虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。

  • 3、首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 尝试自旋获取锁。如果重试的次数达到了 MAX_SCAN_RETRIES 就改为阻塞锁获取,保证能获取成功。

  • 4、然后将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。

  • 5、遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value,为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。

  • 6、最后会调用unlock()解除当前 Segment 的锁。

get 方法做了什么?

  • 1、只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

  • 2、由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

  • 3、ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。

JDK 8:摒弃段,基于 HashMap 原理的并发实现

1.7 已经解决了并发问题,并且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题:那就是查询遍历链表效率太低。和 1.8 HashMap 结构类似:其中抛弃了原有的 Segment 分段锁,而采用了 synchronized & cas 来保证并发安全性。详细点说就是:

  • 1、对于一些不必加锁的地方都使用 volatile 来访问,对于需要加锁的地方尽可能选择最小的范围加锁。加锁仅针对于 table[] 中的 slot 区对应的 entry 元素加锁,而把我们新来的元素放到它后面的链表里面。

  • 2、它比起Segment,锁拆分得更细:首先使用无锁操作CAS插入头结点,失败则循环重试,若头结点已存在,则尝试获取头结点的同步锁再进行操作。

而1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率为O(logn),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

put 方法做了什么?

  • 1、判断Node[]数组是否初始化,没有则进行初始化操作。

  • 2、通过hash定位到数组的索引坐标,如果没有Node节点则使用CAS进行添加到链表的头结点,添加失败则进入下次循环。

  • 3、如果检查到内部正在扩容,就帮助它一块扩容。

  • 4、如果链表/红黑二叉树的头元素不为null,则使用synchronized锁住它:
    • 1)、如果是Node链表结构则执行链表的添加操作。

    • 2)、如果是TreeNode树形结构则执行树的添加操作。

  • 5、最后会判断链表长度是否已经超过临界值8,超过则将链表转换为红黑树结构。

get 方法做了什么?

  • 1、根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。

  • 2、如果是红黑树那就按照树的方式获取值,否则按照链表的方式遍历获取值。

小结

  • 小锁:分段锁(5~7),桶节点锁(8)。

  • 短锁:先尝试cas获取,失败再加锁。

  • 分离读写锁:读失败再加锁(5~7),volatile 读 CAS 写(7~8)。

CHM 如何计数?

  • JDK 5~7:基于段元素个数求和,二次不同就加锁。

  • JDK 8:引入 CounterCell,本质上也是分段计数。

CHM 的弱一致性是什么?

  • 1)、添加元素后不一定马上能读到。

  • 2)、清空后可能仍然会有元素。

  • 3)、遍历之前的段元素的变化会读到。

  • 4)、遍历之后的段元素变化读不到。

  • 5)、遍历时元素发生变化不抛异常。

如何进行锁优化?

  • 1)、长锁不如短锁:尽可能只锁必要的部分。

  • 2)、大锁不如小锁:尽可能对加锁的对象拆分。

  • 3)、公锁不如私锁:尽可能将锁的逻辑放到私有代码中。

  • 4)、嵌套锁不如扁平锁:尽可能在代码设计时避免嵌套锁。

  • 5)、分离读写锁:尽可能将读锁和写锁分离。

  • 6)、粗化高频锁:尽可能合并处理频繁过短的锁。

  • 7)、消除无用锁:尽可能不加锁,或用 volatile 替代锁。

Contact Me

● 微信:

很感谢您阅读这篇文章,希望您能将它分享给您的朋友或技术群,这对我意义重大。

举报

相关推荐

0 条评论