【多线程高并发系列】J.U.C并发容器

J.U.C并发容器

同步容器如Vector、Hashtable、Collections.synchronizedXXX虽然保证线程安全,但同一时间只允许有一个线程访问,使各个线程的操作变成了线性操作。为了提高并发度,juc包下的并发容器诞生。

并发容器List、Set、Map

在这里插入图片描述

一、ConcurrentHashMap

JDK1.7

在这里插入图片描述

  • JDK1.7采用数组+链表,结构与1.7的HashMap类似。使用分段锁思想保证线程安全。
    每个ConcurrentHashMap中有一个Segment[]数组,每个Segment继承自ReentrantLock,即Segment[]数组存放的是多个锁。在每个Segement中有一个HashEntry[]数组,存放Entry(即key-value对),与HashMap中的Node(或Entry)结构类似,唯一区别就是:HashEntry中的value和指向下一个节点的引用被volatile修饰,保证了线程之间的可见性。
  • 多线程访问时要获取对应的分段锁,如果不同线程获取不同的锁(也就是Segment)则不会引起冲突。

put()过程

  • 先用key的高几位定位到对应Segment并获取锁,然后通过(tab.length - 1) & hash得到对应HashEntry[]数组下标,然后找到对应的HashEntry进行之后的操作。之后如:遍历、创建节点、替换值和扩容等操作类似1.7的HashMap。
  • 在获取Segment分段锁时,会先尝试获取一次锁,如果失败则说明锁被其他线程占用,此时开始自旋并寻找put的位置。当自旋次数超过一定次数时会改为阻塞获取锁。
  • put成功后需要将Segment中统计元素个数的count属性加1。

get()过程

  • get操作不需要加锁,先定位Segment然后定位HashEntry,最后返回对应的value即可。
  • 由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取的都是最新值。

size()过程

  • 先不加锁,将所有Segment中的count相加,这样计算两次,比较两次的计算结果,如果相同则认为此时得到的size是正确的。
  • 如果两次得到的结果不同,则先对所有Segment加锁,然后再计算size值。

JDK1.8

采取分段锁保证了多线程操作的线程安全,但支持的并发数较少。如果两个线程需要操作的HashEntry位于同一个分段锁下,则两个线程的操作必须是线性的,大大降低了效率,因此在JDK1.8中废弃了分段锁,改用synchronized和CAS,而且锁的粒度从原来的“锁多个桶”降低为“锁一个桶”。

在这里插入图片描述

  • JDK1.8采用数组+链表+红黑树的结构,与1.8的HashMap结构类似,每个Node存放key-value对,每个Node的value和指向下一个Node的引用也使用volatile修饰,保证了线程之间的可见性。TreeBin代表红黑树结构,红黑树中的每个节点是TreeNode类型。
  • ForwardingNode是一个特殊的Node节点,hash值为-1,其中存储nextTable的引用,nextTable是扩容时新生成的数据,数组为table的两倍。只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或则已经被移动。
  • sizeCtl是一个控制标识符,用来控制table初始化和扩容操作,不同情况有不同的值,也代表着不同的含义:
    • 负数代表正在进行初始化或扩容操作
      • -1代表正在初始化
      • -N 表示有N-1个线程正在进行扩容操作
    • 0表示hash表还未初始化
    • 正数代表下一次扩容的阈值,默认是当前table容量的0.75倍
  • ConcurrentHashMap的扩容和HashMap不一样,它在多线程情况下或使用多个线程同时扩容,每个线程扩容指定的一部分hash桶,当前线程扩容完指定桶之后会继续获取下一个扩容任务,直到扩容全部完成。

put()过程

  • 使用key的hashcode计算hash值。hash = (h ^ (h >>> 16)) & HASH_BITS
  • 判断table是否为空,为空则进行初始化。
  • table不为空则使用(tab.length - 1) & hash计算数组下标,定位到对应的桶。如果该桶为空则CAS为该位置赋值,失败则进行下一次循环。
  • 如果该桶不为空则判断是否正在扩容(即判断该桶是否是ForwardingNode 类型),如果是则协助扩容操作。协助扩容大体流程:
    • 首先,每个线程进来会先领取自己的任务区间(计算出本线程应该处理多少个桶),然后开始 --i 来遍历自己的任务区间,对每个桶进行处理。
    • 如果遇到桶的头结点是空的,那么使用 ForwardingNode 标识该桶已经被处理完成了。
    • 如果遇到已经处理完成的桶(即已经被标识为ForwardingNode ),则直接跳过进行下一个桶的处理。
    • 如果是正常的桶,则对桶首节点加锁,正常的迁移即可,迁移结束后依然会将原表的该位置标识为已经处理(标识为ForwardingNode)。
      迁移过程与HashMap类似,先创建两个链表分别用来保存:迁移后对应桶位置不变的节点 和 迁移后对应桶位置为 “旧数组桶位置+旧数组长度” 的节点。即新增位是1的节点存到一个链表中,新增位为0的节点存到另一个链表中,最后将两个链表头赋值到新数组中对应桶的位置。
    • 当 i < 0时,说明本线程领取的任务已经执行完毕,然后判断整个table的扩容是否已经完成,如果还没有完成则当前线程继续领取扩容任务,直到整个table的扩容操作全部完成。最后将table指向nextTable(新数组),同时更新sizeCtl = nextTable长度的0.75倍。
  • 否则使用synchronized代码块对该位置的Node(即所处链表的头节点或树的根节点)上锁,然后开始遍历链表(或红黑树)进行查找、替换、插入等操作,之后判断链表长度是否大于阈值,大于则将链表转为红黑树。
  • 更新size值,记录table中元素的数量,判断是否需要扩容。

get()过程

  • 使用key的hashcode计算hash值定位桶的位置,如果就在桶上那么直接返回值。
  • 如果是红黑树就按照树的方式获取值,否则按照链表的方式遍历获取值。

size()方法

在每次执行put()和remove()方法时,都会调用addCount()方法更新计数器,所以在调用size()方法获取大小时,只需要简单的计算即可。
在ConcurrentHashMap中,有一个volatile修饰的baseCount成员变量,用来保存Node的个数。由于对baseCount的修改是CAS的,所以在高并发的情况下,多个线程的CAS操作可能会导致性能降低(多个线程不断循环),因此又引入了CounterCell类。CounterCell内部只有一个volatile修饰的value,相当于一个计数器。在ConcurrentHashMap内部有一个CounterCell[ ]数组,用来保存一个个计数器,容量必须为2的n次幂,第一次初始化容量为2,最大容量为当前CPU个数。CounterCell[ ]数组使用了类似LongAdder的计数方法,即在高并发情况下使用多个计数器,当需要获取总数时将各个计数器相加。addCount()方法主要分为两个部分:更新计数器和判断是否需要扩容。
addCount()方法中更新计数器的流程:

  • 首先获取到CounterCell[ ]数组即counterCells成员变量,判断counterCells是否为null,为null则说明并发数比较低,还不存在多个线程同时修改baseCount的情况,则直接使用CAS方式修改baseCount值。

  • 如果CAS修改失败则说明此时有多个线程在同时修改baseCount,修改失败的线程会判断counterCells中是否有与当前线程对应的计数器,如果有则CAS更新对应计数器的值,如果更新失败或者counterCells为null或者与当前线程对应的计数器为null则执行fullAddCount()方法。

    在CounterCell[ ]数组中如何定位与当前线程对应的计数器?

    执行的语句为:ThreadLocalRandom.getProbe() & cs.length - 1,其中cs为counterCells计数器数组。

    ThreadLocalRandom是多线程版的Random,也用来生成伪随机数,但避免了Random在多个线程可能同时产生相同随机数的问题。Random之所以会出现这种问题,是因为多个线程可能在同一时刻使用了相同的种子(seed)来生成随机数。ThreadLocalRandom的效果类似ThreadLocal,本身只是一个工具类,它将计算随机数用到的种子以成员变量的方式保存在了Thread类中,这样就能做到每个线程都有属于自己的种子。在计算随机数时先从当前Thread中获取种子,得到随机数后更新旧种子的值以便下次使用。

    在定位过程中,会先判断当前Thread中的threadLocalRandomProbe是否已经初始化,如果没初始化(即值为0)则进行初始化为其赋值,这个值就相当于当前线程对于计数器数组的hash值,使用该值和计数器数组长度-1做与运算”&“得到对应下标,其定位思想与HashMap中定位思想类似,同样的计数器数组也要求容量必须是2的n次幂。

  • fullAddCount()方法会先判断counterCells(计数器数组)是否为null,如果为null则 “获取伪自旋锁” 来初始化counterCells,默认创建容量为2的CounterCell[ ]数组,然后计算出与当前线程对应的计数器数组下标并new一个计数器存入数组,计数器中保存当前线程对容量的修改值(如果是put一个元素则计数器值为1,remove一个元素则计数器值为 -1)。

    当一个线程要初始化counterCells时或要向其中添加一个计数器或要对counterCells扩容时必须要先 “获取伪自旋锁”来保证并发操作安全。这里说的 “锁”并不是真正的锁,在ConcurrentHashMap中有一个volatile修饰的cellsBusy成员变量,它的值只有0和1,0代表 “未上锁”,1代表 “已上锁”,想要 “获取锁”必须通过CAS的方式原子更改cellsBusy的值,通过这种方式模拟一个自旋锁来保证并发操作安全。

  • 如果counterCells不为空则判断与当前线程对应的计数器是否为null,如果为null则先 “获取伪自旋锁” 然后向counterCells中添加新计数器并赋值。如果对应计数器不为null则CAS修改计数器中的值,如果CAS修改连续失败两次并且counterCells大小未达到最大限制时将counterCells扩容为原来的2倍,当然,扩容之前也要先 “获取伪自旋锁”才能开始扩容,扩容过程使用Arrays.copyOf(),底层使用的是System.arraycopy()。

最后再看下size()方法:当调用size()方法时,会调用sumCount()方法,**在sumCount()中将baseCount与counterCells里计数器的值相加得到最终的size值。**由此看来,size()方法返回的值也不一定就是绝对精准的,因为在求和时其他线程可能正在执行put或remove操作影响最终结果。如果想得到绝对精准的结果必须加锁实现,但对于高并发容器来说,如果只是为了获取某一时刻的精确值而加锁显然是不值得的。

二、CopyOnWriteArrayList

  • 线程安全版的ArrayList,底层为volatile修饰的Object[]数组,保证了线程间的可见性,无参构造函数默认创建容量为0的Object[]数组。
  • 写操作需要加synchronized同步块(之前使用的是ReentrantLock),内部有一个Object实例作为锁对象。进入同步代码块之后先将原来的数组拷贝一份,在副本上进行写操作,最后再调用setArray()方法将原数组引用指向修改后的副本,操作结束释放锁。
  • 读操作无需加锁,保证了读的效率,适合读多写少的场景,但不能保证读取到的值一定是最新的,
  • 迭代器采用快照风格“snapshot”,即复制一份副本,在副本上遍历,因此不能通过迭代器对原数组操作,调用迭代器的add()、set()、remove()方法都会抛出UnsupportedOperationException异常。

三、CopyOnWriteArraySet

  • 底层使用CopyOnWriteArrayList实现,唯一区别就是add()方法调用的是CopyOnWriteArrayList的addIfAbsent()方法,该方法先遍历整个数组,调用equals()方法判断数组中是否包含要添加的元素,如果添加成功则返回true。

四、ConcurrentSkipListMap

我们知道,通常意义上的链表是不能支持随机访问的(通过索引快速定位),其查找的时间复杂度是O(n),而数组这一可支持随机访问的数据结构,虽然查找很快,但是插入/删除元素却需要移动插入点后的所有元素,时间复杂度为O(n)。

为了解决这一问题,引入了树结构,树的增删改查效率比较平均,一棵平衡二叉树(AVL)的增删改查效率一般为O(logn),比如工业上常用红黑树作为AVL的一种实现。

但是,AVL的实现一般都比较复杂,插入/删除元素可能涉及对整个树结构的修改,特别是并发环境下,通常需要全局锁来保证AVL的线程安全,于是又出现了一种类似链表的数据结构——跳表。

在这里插入图片描述

  • 并发版的TreeMap,但底层是跳表实现的,最下面的一层是按照key有序排列的单链表,跳表的查询/插入/删除的时间复杂度都是O(logn)。默认按照key的自然顺序进行比较,所谓key的自然顺序是指key需要实现Comparable接口,也可以传入Comparator 实现自定义排序。
  • 修改和插入操作使用CAS更改节点指针。
  • key和value都不能为空。

put()过程

  • 首先从 head 节点开始在当前的索引层上寻找最后一个比给定 key 小的结点,然后CAS将节点插入到最底层的链表中,如果失败则继续重试直到成功。
  • 然后通过随机数与特定的常量值做与操作“&”根据结果是0还是1来计算新的level值,为1则新level+1。如果最终得到的新level值小于等于当前跳表level值,则为插入的节点向上建立索引。如果新level大于当前跳表level则将现有跳表新增一个level层,然后建立对应索引并连接各层的新节点。

get()过程

  • get过程与put前半部分的过程类似,从head指针开始查找,如果下一个节点的key值大于要查找的key值,则到下一层查找,直到查找到最后一层。

size()方法

  • 内部有一个LongAdder专门计算容量,在第一次调用size()时初始化。

五、ConcurrentSkipListSet

  • 底层基于ConcurrentSkipListMap实现,调用add()方法实质上调用的是内部ConcurrentSkipListMap的putIfAbsent()方法,如果该元素原来不存在则添加。

并发容器Queue

在这里插入图片描述

普通队列

一、ConcurrentLinkedQueue
  • FIFO非阻塞队列,并发版LinkedList,底层单链表实现,默认容量为int最大值
  • 内部没有使用锁,而是通过VarHandle类CAS改变head和tail的指向。
  • 不能添加null元素。

阻塞队列

  • put()、take():阻塞添加、获取。
  • add()、remove():添加、移除失败后抛异常。
  • offer()、poll():添加、移除失败后返回false。
一、ArrayBlockingQueue
  • 底层使用Object[ ]数组实现,初始化时必须指定容量,而且容量不可变,即FIFO有界队列
  • 使用一个ReentrantLock保证线程安全,add、remove操作之前都需要获取锁。使用两个Condition作为等待队列:notEmpty保存消费者,notFull保存生产者。
  • putIndex和takeIndex保存入队和出队数组下标,int类型count保存队列中元素个数(ReentrantLock保证同一时刻只有一个线程的操作可以修改count值,因此int类型即可),下标的维护类似环形队列。
  • remove操作涉及到数组内其他元素的移动。
二、LinkedBlockingQueue
  • 底层使用单向链表,初始化时可指定最大容量用来判断队列是否已满,如果初始化时不指定容量则默认为int类型最大值。
  • 使用两个ReentrantLock保证线程安全,分别是takeLock和putLock,并且有与之对应的两个Condition等待队列。内部有两个指针head和last分别指向队列头和队列尾,head永远指向Node中item为空的节点(哨兵结点),即head的下一个元素才是队列中的第一个有效元素。
  • 入队时获取putLock锁并在队列尾添加新元素,出队时获取takeLock锁并在队列头移除元素(先将head节点指向的下一个节点的值取出作为返回值,然后将此节点设置为head并将此节点的item置为null)。因为在多线程访问下可能有两个线程同时操作队列,一个线程入队而另一个线程出队,两个线程可以同时修改count的值,因此LinkedBlockingQueue中的count是AtomicInteger类型。
  • 不能添加null元素。
三、LinkedBlockingDeque
  • 类似LinkedBlockingQueue,默认队列容量为int最大值,也可以在初始化时指定。但底层使用双向链表实现,双端队列,头和尾都能添加或移除元素。
  • 使用一个ReentrantLock保证线程安全,因此count是int类型。
四、PriorityBlockingQueue
  • 优先级队列(非FIFO),底层使用Object[ ]数组构建小顶堆,每次出队都是最小元素,因此元素必须实现Comparable接口,也可以传入Comparator自定义排序,不能存放null元素
  • 真正意义上的无界队列,它不像ArrayBlockingQueue那样构造时必须指定最大容量,也不像LinkedBlockingQueue默认最大容量为Integer.MAX_VALUE,它的大小只受内存大小限制,数组容量不够则自动扩容,因此插入元素永远不会阻塞。
  • 使用ReentrantLock保证线程安全,只有一个Condition作为等待队列:notEmpty,这就意味着只有当队列为空时才导致阻塞,当向队列中添加元素时,如果容量不够大则尝试扩容。
  • 入队时先获取锁,获取成功则将元素添加到数组元素的末尾(也就是下标为当前数组size的位置),然后将该节点上浮:如果父节点大于当前节点则交换,直到父节点小于等于当前节点为止。这样就保证了树根是最小的元素(针对小顶堆来说)。
  • 出队的过程简单来说就是:保存根节点的值作为返回值,然后用树中最下面且最右面的节点替换根节点,然后做根节点的下沉。出队时同样先获取锁,然后保存数组[0]处的值作为最后的返回结果(即数组[0]处的元素为队列中的最小值,如果是大顶堆则为队列中的最大值),再从根节点开始查找,先找出两个子节点中较小的一个节点,然后与数组中最后一个值(树的最下面最右的节点)作比较,如果子节点较小则将根节点的值设置为该子节点的值(这样就保证了根节点的值最小),然后再将该子节点视为根节点,做同样的操作,直到找到一个合适的位置,使得树中最下最右面节点的值小于两个子节点为止,就将最下最右节点的值放在此位置,将原来位置设为null,并做size–操作。
五、SynchronousQueue
  • 内部不保存任何元素,即容量为0,数据直接在配对的生产者和消费者线程之间传递,该队列只充当调节作用,不会将数据缓冲到队列中。
  • 执行入队操作的线程和执行出队操作的线程必须一 一匹配,否则先到的一方将被阻塞,直到匹配成功。
  • 按照公平/非公平策略采用队列 (TransferQueue) 或栈 (TransferStack) 结构,默认是非公平即栈结构,底层调用TransferQueue或TransferStack的transfer()方法,依据传入的参数判断是出队还是入队。
  • 公平策略采用队列结构(即单向链表,内部存储等待的Thread和当前线程的类型:消费者或生产者)。当一个线程执行入队或出队操作时,会查看队列头中元素的类型是否与其相匹配,如果匹配则将队列头中的节点唤醒并出队并做数据的传输工作,如果不匹配则说明当前线程与head节点类型相同,则会将当前线程封装为节点入队并阻塞等待。
  • 非公平策略采用栈结构,当一个线程执行入队或出队操作时,会先封装成节点入栈,然后判断原栈顶节点类型是否与之相匹配,如果匹配则将原栈顶元素唤醒并做数据传输工作,然后将匹配成功的两个节点一起出栈。如果与原栈顶节点类型不匹配则阻塞当前线程等待被其他线程匹配并唤醒。
  • 公平与非公平策略指的是先阻塞的生产者/消费者是否能被先匹配成功。
六、LinkedTransferQueue
  • LinkedTransferQueue是 SynchronousQueue 和LinkedBlockingQueue的合体,使用CAS操作,比 SynchronousQueue能存储更多的元素。
    put操作是非阻塞的(存入数据后直接返回),take操作是阻塞的(队列中没有数据则阻塞等待)。
  • 当生产者需要put数据时,会先检查head节点是否是消费者,如果是则直接将put进去的值赋给消费者的item并将消费者唤醒,然后将此消费者出队,如果head节点不是消费者则将数据包装为Node入队,生产者直接返回继续做其他事情,而不用入队等待(这也说明了put是异步的)。
  • 当消费者需要take数据时,会先检查head节点是否是生产者存放的数据,如果是则直接将该数据取走,如果不是则进入队列阻塞等待。

在这里插入图片描述

七、DelayQueue
  • 底层基于PriorityQueue实现,因此也是无界阻塞队列,使用ReentrantLock保证线程安全,有一个Condition作为消费者的等待队列。添加的元素必须实现Delayed接口,接口中的getDelay()方法用于返回对象的剩余有效时间,只有对象的剩余时间耗尽才能准备出队。
  • 如果一个类实现了Delayed接口,在创建该类的对象并添加到DelayQueue之后,只有当该对象的getDalay方法返回的剩余时间≤0时才会出队。因为底层依赖PriorityQueue实现,内部维护一个最小堆,每次出队其实就是从堆顶返回并移除剩余时间<=0的最小元素。
  • put操作因为队列是无界队列所以不会阻塞。
  • take操作:DelayQueue每次只会出队一个过期的元素(调用getDelay返回值<=0的元素),因此当堆顶元素没有过期时会阻塞消费者线程。如果有多个消费者线程,则会进入内部的Condition等待队列。为了提升性能,不会让所有线程都无限等待,DelayQueue采用一种名为 “Leader-Follower”的多线程设计模式:DelayQueue内部有一个Thread类型的leader用来保存第一个尝试执行出队操作的消费者线程,且leader线程的等待时间是该线程执行take()时堆顶对象的剩余时间,而其他非leader线程则在Condition队列中无限等待。当leader线程被唤醒时(即此时堆顶元素已经过期,可以被leader线程取走),在take()方法返回之前需要先唤醒一个在Condition等待队列中等待的线程,此线程会成为新任leader(如果Condition队列中还有其他线程在等待的话),而新leader等待指定的时间后又会唤醒下一个线程做leader,如此持续下去就避免了无效的等待,提升了性能。

参考

透彻理解Java并发编程

并发包中ThreadLocalRandom类原理剖析

基于跳跃表的 ConcurrentSkipListMap 内部实现(Java 8)

【死磕Java并发】-----J.U.C之Java并发容器:ConcurrentHashMap

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值