Java多线程(六)

127 阅读7分钟

九、JUC线程安全包

1、概述

图片1.png

2、Hashmap (死链)

死链出现在并发扩容时,由于两个线程同时对哈希表扩容时,由于头插法时原来链表会逆序插入新的节点中(此时导致了链表指针反向。当一个线程使得链表中部分节点指针的顺序逆向; 部分:表示没有插完;另一个线程在扩容时再次插入,就很可能产生循环链。

一个例子比如,某一节点处存储链表初始是1->2->3->null。

(1)线程1 遍历旧链表准备插入到新节点中,设此时局部变量(当前节点为 1(指向2);下一个节点为2(指向3))。

(2)此时线程2占领cpu,遍历旧链表准备插入到新节点中。

(3)线程2 完成1和2节点的插入:2->1->null(头插法,先来的插在链表头)。

(4)线程1占领cpu准备继续插入,此时局部变量 (当前节点为 1(指向null);下一个节点为2(指向1));注意引用不会变,但引用的内容变了(指向next变了)。

(5)注意此时将当前节点为 1使用头插法,插入就会导致 1->2->1; 产生死链。

最好debug一遍。

3、Concurrenthashmap(JDK8)

(1)概念

Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)。dk8版本的ConcurrentHashMap相对于jdk7版本,发送了很大改动,jdk8直接抛弃了Segment的设计,采用了较为轻捷的Node + CAS + Synchronized设计,保证线程安全,都是数组(初始为16) + 链表(当链表长度大于8时,链表结构转为红黑二叉树)结构。

初始化,使用 cas 来保证并发安全,懒惰初始化 table

  • 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头
  • put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
  • get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新table 进行搜索
  • 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
  • size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加即可

(2)图解

图片2.png

图片3.png

(3)源码

  • Put流程
  • Rehash流程

当往hashMap中成功插入一个key/value节点时,有可能触发扩容动作(三种情况):

1.如果新增节点之后,所在链表的元素个数达到了阈值 8,则会调用treeifyBin方法把链表转换成红黑树,不过在结构转换之前,会对数组长度进行判断。如果数组长度n小于阈值MIN_TREEIFY_CAPACITY,默认是64,则会调用tryPresize方法把数组长度扩大到原来的两倍,并触发transfer方法,重新调整节点的位置。

2.新增节点之后,会调用addCount方法记录元素个数,并检查是否需要进行扩容,当数组元素个数达到阈值时,会触发transfer方法,重新调整节点的位置。

3.当发现其他线程扩容时,帮其扩容。如果准备加入扩容的线程,发现以下情况,放弃扩容,直接返回。A,发现transferIndex=0,即所有node均已分配。B, 发现扩容线程已经达到最大扩容线程数。

参考:ConcurrentHashMap1.8 - 扩容详解blog.csdn.net/ZOKEKAI/art…

  • Get流程
  • Size流程

4、Concurrenthashmap(JDK7)

(1)概念

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

首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。另外,ConcurrentHashMap可以做到读取数据不加锁,并且其内部的结构可以让其在进行写操作的时候能够将锁的粒度保持地尽量地小,不用对整个ConcurrentHashMap加锁。

(2)图解

图片4.png

图片5.png

(3)源码

  • Put流程

1.为输入的Key做Hash运算,得到hash值。

2.通过hash值,定位到对应的Segment对象

3.获取可重入锁

4.再次通过hash值,定位到Segment当中数组的具体位置。

5.插入或覆盖HashEntry对象。

6.释放锁

  • Rehash流程

发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全

  • Get流程

1.为输入的Key做Hash运算,得到hash值。

2.通过hash值,定位到对应的Segment对象

3.再次通过hash值,定位到Segment当中数组的具体位置。

  • Size流程

多线程,乐观锁设计,计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回。如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回)

ConcurrentHashMap的Size方法是一个嵌套循环,大体逻辑如下:

1.遍历所有的Segment。

2.把Segment的元素数量累加起来。

3.把Segment的修改次数累加起来。

4.判断所有Segment的总修改次数是否大于上一次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数+1;如果不是。说明没有修改,统计结束。

5.如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。

6.再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。

7.释放锁,统计结束。

5、Linkedblockingqueue (加锁)

(1)出队入队

注意入队的时候是在dummy 后面,

出队的时候,之前的dummy 被丢弃,然后被出队的node 成为新的dummpy

图片6.png

图片7.png

图片8.png

(2)加锁

图片9.png

获取数据

take():首选。当队列为空时阻塞

poll():弹出队顶元素,队列为空时,返回空

peek():和poll烈性,返回队队顶元素,但顶元素不弹出。队列为空时返回null

remove(Object o):移除某个元素,队列为空时抛出异常。成功移除返回true

添加数据

put():首选。队满是阻塞

offer():队满时返回false

(3)性能

主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较

  • Linked 支持有界,Array 强制有界
  • Linked 实现是链表,Array 实现是数组
  • Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
  • Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
  • Linked 两把锁,Array 一把锁

6、Concurrentlinkedqueue (CAS)

ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是:两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行。dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争。只是这【锁】使用了 cas 来实现。

事实上,ConcurrentLinkedQueue 应用还是非常广泛的。例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用。

7、Copyonwritelist

图片10.png

图片11.png