线程安全集合类概述
线程安全集合类可以分为三大类:
-
遗留的线程安全集合如Hashtable(put,get都是用synchronized修饰,并发性不是很好), Vector
-
使用Collections装饰的线程安全集合,如:
-
Collections.synchronizedCollection
-
Collections.synchronizedList
-
Collections. synchronizedMap
-
Collections. synchronizedSet
-
Collections . synchronizedNavigableMap
-
Collections.synchronizedNavigableSet
-
Collections.synchronizedSortedMap
-
Collections . synchronizedSortedSet
-
java. util. concurrent.*
-
以map结合为例解释怎么讲线程不安全的集合转为线程安全的
重点介绍java.util. concurrent. *下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:
Blocking、CopyOnWrite、 Concurrent
- Blocking(不满足条件的时候阻塞住)大部分实现基于锁,并提供用来阻塞的方法(内部使用的是reentrantLock)
- CopyOnWrite 之类容器修改开销相对较重(采用拷贝的方式,用于读多写少)
- Concurrent(并发)类型的容器
- 内部很多操作使用cas优化,一般可以提供较高吞吐量
- 弱一致性
- 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍
历,这时内容是旧的 - 求大小弱一致性, size操作未必是100%准确
- 读取弱一致性
- 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍
遍历时如果发生了修改,对于非安全容器来讲,使用fail-fast 机制也就是让遍历立刻失败,抛出
ConcurrentModificationException,不再继续遍历
1.ConcurrentHashMap
练习:单词计数
将每个字母生成两百次,之后打乱其在集合中的顺序,在将其输入到文件中,如果中间步骤都正确,那一个字母就应该出现两百次。
在多线程中用Hashmap集合来统计每个单词出现的次数,会出现线程安全问题。
那你该想那可以改为线程安全的ConcurrentHashMap,那也不对,
因为ConcurrentHashMap集合中每个方法是原子的,但是你把多个方法组合到一起,各个方法加起来就不是原子的。
解决方法:可以在方法上加一个synchrnized,使其方法为原子的,但是并发性不是很好
ConcurrentHashMap中有一个方法’computeIfAbsent()’ 作用:如果一个key在map中不存在,则将其key存进去,并且生成一个value,一起存进去,该操作是原子的。之后使用累加器进行原子累加。
map.computeIfAbsent():保证get和put方法的原子性,和累加器一起使用,返回的是上一个操作中的累加器
ConcurrentHashMap原理
I. JDK 7 HashMap并发死链
数组+链表实现,先计算key的hash值:应该放在那个位置。
jdk7中会将后插入的元素放在链表的头部,而jdk8中后插入的元素会被插入到链表的尾部
死链发生在并发下数组扩容时:当数组元素超过数组长度的3/4时,扩容为原数组的2倍,再将原数组的元素前移到新数组里面
桶下标相同的key
transfer
e:当前要去迁移的结点
小结
- 究其原因,是因为在多线程环境下使用了非线程安全的map集合
- JDK 8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),但仍不意味
着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)
2.JDK 8 ConcurrentHashMap
重要属性和内部类
sizeCtl:下一次扩容的阈值大小(数组容量的3/4)
ForwardingNode:作用一:用来标记该下标的元素链表已经处理过了
第二个作用:在扩容的时候,有人来get值,发现有ForwardingNode,就会去新数组里面查找
TreeBin:头节点, TreeNode:树上的每个节点
当链表长度达到八,再看数组长度是不是达到64,没有达到就扩容,达到了就将链表转为树。当你删除元素,树上的结点又小于8了,就会在将红黑树转为链表。
重要方法:
构造器分析
可以看到实现了懒惰初始化,在构造方法中仅仅计算了table 的大小,以后在第一次使用时才会真正创建
初始容量,负载因子(3/4,0.75),并发度
1、初始容量要保证并发度那么大
2、懒惰初始化,当你第一次用到的时候才会去初始化数组
3、容量一定是2的n次方
get流程
1、先判断数组长度是否大于0,在定位要查找的桶的下标,去找链表
2、先判断头节点为不为空,再判断头节点的hash码是不是等于key的hash码,是再用equals去比较,如果是,直接返回
3、头节点的hash码为负数:表示当前数组正在扩容(那就去新的数组中去找值)或者是树的头节点(去红黑树里面查找)
4、以上都不符合,就去遍历链表,寻找对应的key
put流程
以下数组简称(table),链表简称(bin)
会把重复的key覆盖掉之前的,并且key,v不能为null
1、看哈希表是不是为空或者长度为0(懒惰初始化)
2、有了哈希表看头结点时不是空的,是创建节点将键值就存放在这
3、再存放键值的时候,数组正处于扩容状态,回去帮忙扩容(锁住某个链表,保证其线程安全)
进到下面的else说明桶下标冲突—加锁,只对链表的头节点加锁。
遍历链表去查找有没有要插入的key,如果有就覆盖掉原来的value,没有就在链表尾部追加
如果此时是红黑树,那就将键值插入到树中
addCount():里面不仅有增加计数的功能,还有扩容的逻辑
创建哈希表
懒惰初始化,并且创建的时候要保证线程安全,只能有一个线程能创建哈希表,其他线程都是忙等待,并没有阻塞住
用cas的方式将sizectl改为-1(表示正在创建哈希表)
增加hash表中元素的个数
计数值,check链表的长度
LongAdder设置多个累计单元,多线程去做技术增长时,冲突就减少了,增加性能
扩容流程
sizeCtl:扩容的阈值,扩容的时候将其值改为负数
size计算流程
size计算实际发生在put, remove 改变集合元素的操作之中
- 没有竞争发生,向baseCount累加计数
- 有竞争发生,新建counterCells, 向其中的一个cell累加计数
- counterCells初始有两个cell
- 如果计数竞争比较激烈,会创建新的cell来累加计数
扩容流程transfer
1、原始的tab,扩容后新的nextTab(懒惰初始化,并且长度为之前的2倍)
2、开始以链表为单位,搬迁
3、如果链表头为null,说明该链表已经被处理完了,将链表头替换为fwd(forwardingNode),forwardingNode的哈希码为-1
4、如果链表头是有元素的,那他就上锁,将其链表头锁住。如果链表头的哈希码为正数说明是普通节点,如果是负数,到了这里就看是不是树的头节点
3.JDK 7 ConcurrentHashMap
它维护了-个segment数组,每个segment对应一把锁(jdk8将锁加在链表头)
- 优点:如果多个线程访问不同的segment,实际是没有冲突的,这与jdk8中是类似的
- 缺点: Segments数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化
每个HashEntry里面又是数组+链表的结构
整个segment数组和segment[0]的元素不是懒惰加载的,其他位置的元素都是懒惰加载的
LinkedBlockingQueue原理
链表
Node中的结构:
item:将来这个结点要关联的元素
next有三种情况:真实后继,自己(出队时),null
1.基本的入队出队
**初始化链表:**last = head =new Node< E>(null); Dummy节点用来占位(哑元结点), item 为null
入队
新加一个节点: 当一个节点入队last = last.next =node;
再来一个结点时:
出队
2.加锁分析
高明之处在于用了两把锁和dummy节点
-
用-把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选- -) 执行
-
用两把锁(锁住的是队列的头和尾),同一时刻,可以允许两个线程同时(- -个生产者与- 一个消费者)执行
- 消费者与消费者线程仍然串行
- 生产者与生产者线程仍然串行
线程安全分析
- 当节点总数大于2时(包括dummy节点),putLock保证的是last节点的线程安全,takeL ock保证的是
head节点的线程安全。两把锁保证了’入队和出队没有竞争 - 当节点总数等于2时(即一个dummy节点,-个正常节点)这时候,仍然是两把锁锁两个对象,不会竞
争 - 当节点总数等于1时(就-一个dummy节点)这时take线程会被notEmpty条件阻塞,有竞争,会阻塞
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eT9IFLYp-1634439044019)(…/…/…/…/…/AppData/Roaming/Typora/typora-user-images/image-20211017094841460.png)]
put操作
1、先判断元素是不是null,是就抛出空指针异常。即不允许加入null空元素
c是用来检查空位的。put和take采用的是不同的锁
2、先上一个putLock锁,是可以被打断的
3、当元素的个数等于队列的容量,队列满了,就应该等待(putLock的条件变量notFull,倒过来读,等待不满的状态)(即队列满了,不生产了,休息会)
4、不满就enqueue,将元素加入到队列尾部(并且修改Node里面的指针指向),计数加一
5、如果发现除了自己put的之外,队列还有空位,由自己叫醒其他put线程(使用singnal唤醒一个等待线程)
6、如果生产者队列中只有一个元素了,由生产者线程(put)叫醒消费者线程(take)
take操作
使用takeLock上锁,等待队列不为空去消费,dequeue出队。消费者自己唤醒其他的消费者。但当队列中只有一个空位时,去唤醒生产者线程
3.性能比较
主要列举LinkedBlockingQueue 与ArrayBlockingQueue的性能比较
- Linked支持有界, Array强制有界.
- Linked实现是链表,Array实现是数组
- Linked是懒惰的,而Array需要提前初始化Node数组
- Linked 每次入队会生成新Node,而Array的Node是提前创建好的
- Linked两把锁(分别锁住头和尾),Array 一把锁
ConcurrentLinkedQueue
ConcurrentL inkedQueue的设计与LinkedBlockingQueue非常像,也是
-
两把[锁],同一时刻,可以允许两个线程同时(一个生产者队列未加入元素 与一 个消费者 队列头消费元素)执行
-
dummy节点的引入让两把[锁]将来锁住的是不同对象,避免竞争(头结点是dummy,尾节点是实例,两把锁锁住不同的对象)
-
只是这[锁]使用了cas来实现,无阻塞
事实上,ConcurrentI inkedQueue应用还是非常广泛的
例如之前讲的Tomcat的Connector结构时,Acceptor 作为生产者向Poller消费者传递事件信息时,正是采用
了ConcurrentI inkedQueue将SocketChannel给Poller使用
CopyOnWriteArrayList
CopyOnWriteArraySet是它的马甲
底层实现采用了写入时拷贝的思想,增删改操作会将底层数组拷贝- -份,更改操作在新数组上执行,这时不
影响其它线程的并发读,读写分离。
以新增为例: .
这里的源码版本是Java 11,在Java 1.8中使用的是可重入锁(ReentrantLock)而不是synchronized
其它读操作并未加锁,例如:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dECpaX6V-1634439044024)(…/…/…/…/…/AppData/Roaming/Typora/typora-user-images/image-20211017101813382.png)]
读操作可以并发,写操作的时候要加锁,避免其他的线程写
适用于读多写少。用空间换取线程安全。
弱一致性
读取的是旧数组的元素,仍然可以得到新数组已经删除的元素
迭代器弱一致性
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MINJkmXR-1634439044028)(…/…/…/…/…/AppData/Roaming/Typora/typora-user-images/image-20211017102111445.png)]
在一个线程A去遍历数组的时候,另外一个线程B去删除该数组的元素,线程A得到的还是旧数组的引用
不要觉得弱一致性就不好(做到了读写并发,数据库的多版本并发控制)
- 数据库的MVCC都是弱一致性的表现
存中…(img-dECpaX6V-1634439044024)]
读操作可以并发,写操作的时候要加锁,避免其他的线程写
适用于读多写少。用空间换取线程安全。
弱一致性
[外链图片转存中…(img-biFUGAJY-1634439044026)]
[外链图片转存中…(img-Fv1IBGw7-1634439044027)]
读取的是旧数组的元素,仍然可以得到新数组已经删除的元素
迭代器弱一致性
[外链图片转存中…(img-MINJkmXR-1634439044028)]
在一个线程A去遍历数组的时候,另外一个线程B去删除该数组的元素,线程A得到的还是旧数组的引用
不要觉得弱一致性就不好(做到了读写并发,数据库的多版本并发控制)
- 数据库的MVCC都是弱一致性的表现
- 并发高和一致性是矛盾的,需要权衡