JDK 内置并发队列
JDK 内置并发队列按照实现方式可以分为阻塞队列和非阻塞队列两种类型,阻塞队列是基于锁实现的,非阻塞队列是基于 CAS 操作实现的。JDK 中包含多种阻塞和非阻塞的队列实现,如下图所示。
队列是一种 FIFO(先进先出)的数据结构,JDK 中定义了 java.util.Queue 的队列接口,与 List、Set 接口类似,java.util.Queue 也继承于 Collection 集合接口。
此外,JDK 还提供了一种双端队列接口 java.util.Deque,我们最常用的 LinkedList 就是实现了 Deque 接口。
下面我们简单说说上图中的每个队列的特点,并给出一些对比和总结。
阻塞队列
阻塞队列在队列为空或者队列满时,都会发生阻塞。阻塞队列自身是线程安全的,使用者无需关心线程安全问题,降低了多线程开发难度。
阻塞队列主要分为以下几种:
-
ArrayBlockingQueue:
最基础且开发中最常用的阻塞队列,底层采用数组实现的有界队列,初始化需要指定队列的容量。ArrayBlockingQueue 是如何保证线程安全的呢?
它内部是使用了一个重入锁 ReentrantLock,并搭配 notEmpty、notFull 两个条件变量 Condition 来控制并发访问。
从队列读取数据时,如果队列为空,那么会阻塞等待,直到队列有数据了才会被唤醒。
如果队列已经满了,也同样会进入阻塞状态,直到队列有空闲才会被唤醒。
-
LinkedBlockingQueue:
内部采用的数据结构是链表,队列的长度可以是有界或者无界的,初始化不需要指定队列长度,默认是 Integer.MAX_VALUE。
LinkedBlockingQueue 内部使用了 takeLock、putLock两个重入锁 ReentrantLock,以及 notEmpty、notFull 两个条件变量 Condition 来控制并发访问。
采用读锁和写锁的好处是可以避免读写时相互竞争锁的现象,所以相比于 ArrayBlockingQueue,LinkedBlockingQueue 的性能要更好。
-
PriorityBlockingQueue:
采用最小堆实现的优先级队列,队列中的元素按照优先级进行排列,每次出队都是返回优先级最高的元素。PriorityBlockingQueue 内部是使用了一个 ReentrantLock 以及一个条件变量 Condition notEmpty 来控制并发访问,
因为 PriorityBlockingQueue 是无界队列,所以不需要 notFull ,每次 put 都不会发生阻塞。PriorityBlockingQueue 底层的最小堆是采用数组实现的,当元素个数大于等于最大容量时会触发扩容,
在扩容时会先释放锁,保证其他元素可以正常出队,然后使用 CAS 操作确保只有一个线程可以执行扩容逻辑。
-
DelayQueue,
一种支持延迟获取元素的阻塞队列,常用于缓存、定时任务调度等场景。
DelayQueue 内部是采用优先级队列 PriorityQueue 存储对象。
DelayQueue 中的每个对象都必须实现 Delayed 接口,并重写 compareTo 和 getDelay 方法。向队列中存放元素的时候必须指定延迟时间,只有延迟时间已满的元素才能从队列中取出。
-
SynchronizedQueue,
又称无缓冲队列。
比较特别的是 SynchronizedQueue 内部不会存储元素。与 ArrayBlockingQueue、LinkedBlockingQueue 不同,SynchronizedQueue 直接使用 CAS 操作控制线程的安全访问。
其中 put 和 take 操作都是阻塞的,每一个 put 操作都必须阻塞等待一个 take 操作,反之亦然。
所以 SynchronizedQueue 可以理解为生产者和消费者配对的场景,双方必须互相等待,直至配对成功。
在 JDK 的线程池 Executors.newCachedThreadPool 中就存在 SynchronousQueue 的运用,对于新提交的任务,如果有空闲线程,将重复利用空闲线程处理任务,否则将新建线程进行处理。
-
LinkedTransferQueue
一种特殊的无界阻塞队列,可以看作 LinkedBlockingQueues、SynchronousQueue(公平模式)、ConcurrentLinkedQueue 的合体。
与 SynchronousQueue 不同的是,LinkedTransferQueue 内部可以存储实际的数据,当执行 put 操作时,如果有等待线程,那么直接将数据交给对方,否则放入队列中。与 LinkedBlockingQueues 相比,LinkedTransferQueue 使用 CAS 无锁操作进一步提升了性能。
非阻塞队列
说完阻塞队列,我们再来看下非阻塞队列。非阻塞队列不需要通过加锁的方式对线程阻塞,并发性能更好。
JDK 中常用的非阻塞队列有以下几种:
-
ConcurrentLinkedQueue,
它是一个采用双向链表实现的无界并发非阻塞队列,它属于 LinkedQueue 的安全版本。ConcurrentLinkedQueue 内部采用 CAS 操作保证线程安全,这是非阻塞队列实现的基础,
相比 ArrayBlockingQueue、LinkedBlockingQueue 具备较高的性能。
-
ConcurrentLinkedDeque,
也是一种采用双向链表结构的无界并发非阻塞队列。
与 ConcurrentLinkedQueue 不同的是,ConcurrentLinkedDeque 属于双端队列,
它同时支持 FIFO 和 FILO 两种模式,可以从队列的头部插入和删除数据,也可以从队列尾部插入和删除数据,适用于多生产者和多消费者的场景。
BlockingQueue阻塞队列超级接口
平时开发中使用频率最高的是 BlockingQueue。实现一个阻塞队列需要具备哪些基本功能呢?
下面看 BlockingQueue 的接口继承关系,如下图所示。
下面看 BlockingQueue 的接口的各种实现子类,如下图所示。
BlockingQueue 实现子类太多,图里放不下,还请大家通过IDEA工具,自行查看。
下面看 BlockingQueue 的接口的 抽象方法,如下图所示。
我们可以通过下面一张表格,对上述 BlockingQueue 接口的具体行为进行归类。
Java内置队列的问题
队列 | 有界性 | 锁 | 数据结构 |
---|---|---|---|
ArrayBlockingQueue | bounded | 加锁 | arraylist |
LinkedBlockingQueue | optionally-bounded | 加锁 | linkedlist |
ConcurrentLinkedQueue | unbounded | 无锁 | linkedlist |
LinkedTransferQueue | unbounded | 无锁 | linkedlist |
PriorityBlockingQueue | unbounded | 加锁 | heap |
DelayQueue | unbounded | 加锁 | heap |
队列的底层一般分成三种:数组、链表和堆。其中,堆一般情况下是为了实现带有优先级特性的队列,从数组和链表两种数据结构来看,两类结构如下:
-
基于数组线程安全的队列,比较典型的是ArrayBlockingQueue,它主要通过加锁的方式来保证线程安全;
-
基于链表的线程安全队列分成LinkedBlockingQueue和ConcurrentLinkedQueue两大类,前者也通过锁的方式来实现线程安全,而后者通过原子变量compare and swap(以下简称“CAS”)这种无锁方式来实现的。
和ConcurrentLinkedQueue一样,上面表格中的LinkedTransferQueue都是通过原子变量compare and swap(以下简称“CAS”)这种不加锁的方式来实现的。
但是,对 volatile类型的变量进行 CAS 操作,存在伪共享问题。
伪共享原理与实操
CPU的结构
下图是计算的基本结构。
L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。
-
L1缓存很小但很快,并且紧靠着在使用它的CPU内核;
-
L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;
-
L3更大、更慢,并且被单个插槽上的所有CPU核共享;
-
最后是主存,由全部插槽上的所有CPU核共享。
计算机CPU与缓存示意图
级别越小的缓存,越接近CPU, 意味着速度越快且容量越少。
L1是最接近CPU的,它容量最小(比如256个字节),速度最快,
每个核上都有一个L1 Cache(准确地说每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache);
L2 Cache 更大一些(比如256K个字节),速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache;
二级缓存就是一级缓存的存储器:
一级缓存制造成本很高因此它的容量有限,二级缓存的作用就是存储那些CPU处理时需要用到、一级缓存又无法存储的数据。
L3 Cache是三级缓存中最大的一级,例如(比如12MB个字节),同时也是最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。
三级缓存和内存可以看作是二级缓存的存储器,它们的容量递增,但单位制造成本却递减。
L3 Cache和L1,L2 Cache有着本质的区别。
L1和L2 Cache都是每个CPU core独立拥有一个,而L3 Cache是几个Cores共享的,可以认为是一个更小但是更快的内存。
缓存行 cache line
为了提高IO效率,CPU每次从内存读取数据,并不是只读取我们需要计算的数据,而是一批一批去读取的,这一批数据,也叫Cache Line(缓存行)。
也可以理解为批量读取,提升性能。为啥要一批、一批的读取呢?这也满足 空间的局部性原理。
空间的局部性原理(Spatial Locality Principle)是计算机科学中的一个重要概念,特别是在内存管理和缓存设计中。这个原理基于这样一个观察:程序在访问内存时往往表现出局部性,即倾向于访问最近访问过的内存位置附近的内存位置。
空间局部性原理的两种形式:
时间局部性(Temporal Locality):如果一个信息项被访问,那么它在不久的将来很可能再次被访问。这意味着程序倾向于重复使用相同的数据或指令。
空间局部性(Spatial Locality):如果一个信息项被访问,那么与它相邻的信息项也很可能被访问。这表明程序倾向于顺序地访问内存地址。
为什么批量读取(Cache Line)?
现代计算机系统利用空间局部性原理来提高IO效率。CPU并不是逐个字节地从内存中读取数据,而是以一批数据(称为缓存行)的形式读取。这样做有几个原因:
减少内存访问次数:批量读取可以减少CPU访问内存的次数。由于内存访问速度远低于CPU的处理速度,减少访问次数可以显著提高性能。
利用数据局部性:当程序访问某个数据时,它很可能在不久的将来访问附近的数据。通过批量读取,系统可以预取这些可能被访问的数据,从而减少未来的内存延迟。
提高总线利用率:内存总线是CPU和内存之间传输数据的通道。批量读取可以更有效地利用总线的带宽,因为一次传输更多的数据比多次传输少量数据效率更高。
匹配缓存设计:现代CPU通常有多级缓存(L1, L2, L3等),这些缓存以缓存行为单位进行数据交换。批量读取与这种缓存设计相匹配,可以更有效地利用缓存资源。
缓存行(Cache Line)
缓存行是CPU缓存中的最小数据传输单位。当一个缓存行从内存加载到缓存中时,它通常包含多个连续的内存地址。这意味着,当CPU访问一个特定内存地址时,它实际上加载了一个包含该地址的缓存行,其中可能包含了额外的、尚未被请求的数据。这种预取策略基于空间局部性原理,可以显著提高数据访问的效率。
从读取的角度来说,缓存,是由缓存行Cache Line组成的。
所以使用缓存时,并不是一个一个字节使用,而是一行缓存行、一行缓存行这样使用;
换句话说,CPU存取缓存都是按照一行,为最小单位操作的。并不是按照字节为单位,进行操作的。
一般而言,读取一行数据时,是将我们需要的数据周围的连续数据一次性全部读取到缓存中。这段连续的数据就称为一个缓存行。
一般一行缓存行有64字节。intel处理器的缓存行是64字节。目前主流的CPU Cache的Cache Line大小都是64Bytes。
假设我们有一个512 Bytes 的一级缓存,那么按照64 Bytes 的缓存单位大小来算,这个一级缓存所能存放的缓存个数就是512/64 = 8个。
所以,Cache Line可以简单的理解为CPU Cache中的最小缓存单位。
这些CPU Cache的写回和加载,都不是以一个变量作为单位。这些都是以整个Cache Line作为单位。
如果一个常量和变量放在一行,那么变量的更新,也会影响常量的使用:
CPU在加载数据时,整个缓存行过期了,加载常量的时候,自然也会把这个数据从内存加载到高速缓存。
伪共享(False Sharing)问题
CPU的缓存系统是以缓存行(cache line)为单位存储的,一般的大小为64bytes。
在多线程程序的执行过程中,存在着一种情况,多个需要频繁修改的变量存在同一个缓存行当中。
假设:有两个线程分别访问并修改X和Y这两个变量,X和Y恰好在同一个缓存行上,这两个线程分别在不同的CPU上执行。
那么每个CPU分别更新好X和Y时将缓存行刷入内存时,发现有别的修改了各自缓存行内的数据,这时缓存行会失效,从L3中重新获取。
这样的话,程序执行效率明显下降。
为了减少这种情况的发生,其实就是避免X和Y在同一个缓存行中,
如何操作呢?可以主动添加一些无关变量将缓存行填充满
比如在X对象中添加一些变量,让它有64 Byte那么大,正好占满一个缓存行。
两个线程(Thread1 和 Thread2)同时修改一个同一个缓存行上的数据 X Y:
如果线程1打算更改a的值,而线程2准备更改b的值:
Thread1:x=3;
Thread2:y=2;
由x值被更新了,所以x值需要在线程1和线程2之间传递(从线程1到线程2),
x、y的变更,都会引起 cache line 整块 64 bytes 被刷新,因为cpu核之间以cache line的形式交换数据(cache lines的大小一般为64bytes)。
在并发执行的场景下,每个线程在不同的核中被处理。
假设 x,y是两个频繁修改的变量,x,y,还位于同一个缓存行.
如果,CPU1修改了变量x时,L3中的缓存行数据就失效了,也就是CPU2中的缓存行数据也失效了,CPU2需要的y需要重新从内存加载。
如果,CPU2修改了变量y时,L3中的缓存行数据就失效了,也就是CPU1中的缓存行数据也失效了,CPU1需要的x需要重新从内存加载。
x,y在两个cpu上进行修改,本来应该是互不影响的,但是由于缓存行在一起,导致了相互受到了影响。
伪共享问题的本质
出现伪共享问题(False Sharing)的原因:
-
一个缓存行可以存储多个变量(存满当前缓存行的字节数);64个字节可以放8个long,16个int
-
而CPU对缓存的修改又是以缓存行为最小单位的;不是以long 、byte这样的数据类型为单位的
-
在多线程情况下,如果需要修改“共享同一个缓存行的其中一个变量”,该行中其他变量的状态 就会失效,甚至进行一致性保护
所以,伪共享问题(False Sharing)的本质是:
对缓存行中的单个变量进行修改了,导致整个缓存行其他不相关的数据也就失效了,需要从主存重新加载
如果 其中有 volatile 修饰的变量,需要保证线程可见性的变量,还需要进入 缓存与数据一致性的保障流程, 如mesi协议的数据一致性保障 用了其他变量的 Core的缓存一致性。
缓存一致性是根据缓存行为单元来进行同步的,假如 y是 volatile 类型的,假如a修改了x,而其他的线程用到y,虽然用到的不是同一个数据,但是他们(数据X和数据Y)在同一个缓存行中,其他的线程的缓存需要保障数据一致性而进行数据同步,当然,同步也需要时间。
一个CPU核心在加载一个缓存行时要执行上百条指令。如果一个核心要等待另外一个核心来重新加载缓存行,那么他就必须等在那里,称之为stall
(停止运转)。
伪共享问题的解决方案
减少伪共享也就意味着减少了stall
的发生,其中一个手段就是通过填充(Padding)数据的形式,来保证本应有可能位于同一个缓存行的两个变量,在被多线程访问时必定位于不同的缓存行。
简单的说,就是 以空间换时间:使用占位字节,将变量的所在的 缓冲行 塞满。
缓冲行填充示例
下面是一个填充了的缓存行的,尝试 p1, p2, p3, p4, p5, p6为AtomicLong的value的缓存行占位,将AtomicLong的value变量的所在的 缓冲行 塞满,
package com.crazymakercircle.demo.cas;
import java.util.concurrent.atomic.AtomicLong;
public class PaddedAtomicLong extends AtomicLong {
private static final long serialVersionUID = -3415778863941386253L;
/**
* Padded 6 long (48 bytes)
*/
public volatile long p1, p2, p3, p4, p5, p6 = 7L;
/**
* Constructors from {@link AtomicLong}
*/
public PaddedAtomicLong() {
super();
}
public PaddedAtomicLong(long initialValue) {
super(initialValue);
}
/**
* To prevent GC optimizations for cleaning unused padded references
*/
public long sumPaddingToPreventOptimization() {
return p1 + p2 + p3 + p4 + p5 + p6;
}
}
例子的部分结果如下:
printable = com.crazymakercircle.basic.demo.cas.busi.PaddedAtomicLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 50 08 01 f8 (01010000 00001000 00000001 11111000) (-134150064)
12 4 (alignment/padding gap)
16 8 long AtomicLong.value 0
24 8 long PaddedAtomicLong.p1 0
32 8 long PaddedAtomicLong.p2 0
40 8 long PaddedAtomicLong.p3 0
48 8 long PaddedAtomicLong.p4 0
56 8 long PaddedAtomicLong.p5 0
64 8 long PaddedAtomicLong.p6 7
Instance size: 72 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
伪共享在java 8中解决方案
JAVA 8中添加了一个@Contended的注解,添加这个的注解,将会在自动进行缓存行填充。
下面有一个@Contended的例子:
package com.crazymakercircle.basic.demo.cas.busi;
import sun.misc.Contended;
public class ContendedDemo
{
//有填充的演示成员
@Contended
public volatile long padVar;
//没有填充的演示成员
public volatile long notPadVar;
}
以上代码使得padVar和notPadVar都在不同的cache line中。@Contended 使得notPadVar字段远离了对象头部分。
printable = com.crazymakercircle.basic.demo.cas.busi.ContendedDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 50 08 01 f8 (01010000 00001000 00000001 11111000) (-134150064)
12 4 (alignment/padding gap)
16 8 long ContendedDemo.padVar 0
24 8 long ContendedDemo.notPadVar 0
Instance size: 32 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
执行时,必须加上虚拟机参数-XX:-RestrictContended,@Contended注释才会生效。
新的结果;
printable = com.crazymakercircle.basic.demo.cas.busi.ContendedDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 50 08 01 f8 (01010000 00001000 00000001 11111000) (-134150064)
12 4 (alignment/padding gap)
16 8 long ContendedDemo.notPadVar 0
24 128 (alignment/padding gap)
152 8 long ContendedDemo.padVar 0
160 128 (loss due to the next object alignment)
Instance size: 288 bytes
Space losses: 132 bytes internal + 128 bytes external = 260 bytes total
在Java 8中,使用@Contended注解的对象或字段的前后各增加128字节大小的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。我们目前的缓存行大小一般为64Byte,这里Contended注解为我们前后加上了128字节绰绰有余。
注意:如果想要@Contended注解起作用,需要在启动时添加JVM参数-XX:-RestrictContended 参数后 @sun.misc.Contended 注解才有。
可见至少在JDK1.8以上环境下, 只有@Contended注解才能解决伪共享问题, 但是消耗也很大, 占用了宝贵的缓存, 用的时候要谨慎。
另外:
@Contended 注释还可以添加在类上,每一个成员,都会加上。
伪共享性能比对实操:结论,差6倍
-
首先存在伪共享场景下的 耗时计算
-
其次是消除伪共享场景下的 耗时计算
-
再次是使用unsafe访问变量时的耗时计算
存在伪共享场景下的 耗时计算
entity类
并行的执行数据修改,这里抽取成为了一个通用的方法
测试用例
执行的时间
消除伪共享场景下的 耗时计算
entity类
测试用例
消除伪共享场景下的 耗时计算 (550ms)
使用unsafe访问变量的耗时计算
entity类
测试用例
使用unsafe访问变量的耗时计算: 54ms
性能总结
消除伪共享场景 ,比存在伪共享场景 的性能 , 性能提升 6倍左右
实验数据,从 3000ms 提升 到 500ms
使用 unsafe 取消内存可见性,比消除伪共享场景 ,性能提升 10 倍左右
实验数据,从 500ms 提升 到 50ms
通过实验的对比, 可见Java 的性能,是可以大大优化的,尤其在高性能组件。
JDK源码中如何解决 伪共享问题
在LongAdder在java8中的实现已经采用了@Contended。
LongAdder以及 Striped64如何解决伪共享问题
LongAdder是大家常用的 高并发累加器, 通过分而治之的思想,实现 超高并发累加。
LongAdder的 结构如下:
Striped64是在java8中添加用来支持累加器的并发组件,它可以在并发环境下使用来做某种计数,
Striped64的设计思路是在竞争激烈的时候尽量分散竞争,Striped64维护了一个base Count和一个Cell数组,计数线程会首先试图更新base变量,如果成功则退出计数,否则会认为当前竞争是很激烈的,那么就会通过Cell数组来分散计数,Striped64根据线程来计算哈希,然后将不同的线程分散到不同的Cell数组的index上,然后这个线程的计数内容就会保存在该Cell的位置上面,基于这种设计,最后的总计数需要结合base以及散落在Cell数组中的计数内容。
这种设计思路类似于java7的ConcurrentHashMap实现,也就是所谓的分段锁算法,ConcurrentHashMap会将记录根据key的hashCode来分散到不同的segment上,线程想要操作某个记录,只需要锁住这个记录对应着的segment就可以了,而其他segment并不会被锁住,其他线程任然可以去操作其他的segment,这样就显著提高了并发度,虽然如此,java8中的ConcurrentHashMap实现已经抛弃了java7中分段锁的设计,而采用更为轻量级的CAS来协调并发,效率更佳。
Cell元素如何消除伪共享
Striped64 中的Cell元素,是如何消除伪共享的呢?
可以打印一下 cell的 内存结构
当然,别忘记加上 vm 选项:-XX:-RestrictContended
对于伪共享,实际开发中怎么处理?
在实际的生产开发过程中,我们一定要通过缓存行填充去解决掉潜在的伪共享问题吗?其实并不一定。
首先就是多次强调的,伪共享是很隐蔽的,我们暂时无法从系统层面上通过工具来探测伪共享事件。其次,不同类型的计算机具有不同的微架构(如 32 位系统和 64 位系统的 java 对象所占自己数就不一样),如果设计到跨平台的设计,那就更难以把握了,一个确切的填充方案只适用于一个特定的操作系统。
还有,缓存的资源是有限的,如果填充会浪费珍贵的 cache 资源,并不适合大范围应用。