【技术积累】SpringBoot+disruptor高性能队列

一、前言

队列是在数据结构中是基础的数据类型,在编程开发经常使用的数据结构,java中的数据结构有很多种,我们常用到的可能使用阻塞队列(BlockingQueue),在创建线程池是我们队列是七大参数之一,在大数据场景进行数据汇聚时,经常使用对接进行功能解耦,提高数据处理性能,在架构设计中的消息中间件基础原理也类似于队列,在使用java中的队列时我们经常考虑线程安全问题,但是很少考虑队列的性能问题;

在这里插入图片描述
之前在分析log4j2的原理时,注意到一个组件disruptor框架,基本功能与常见的队列一样,只是他的设计是为了解决java中BlockingQueue队列在高并发场景的性能问题。

二、ArrayBlockingQueue的问题

1、CAS机制

ArrayBlockingQueue 的插入和取出操作都是通过 **CAS(Compare and Swap)**等原子性操作来实现的,确保线程安全;
CAS操作比单线程无锁慢了1个数量级;有锁且多线程并发的情况下,速度比单线程无锁慢3个数量级。可见无锁速度最快。
单线程情况下,不加锁的性能 > CAS操作的性能 > 加锁的性能
在多线程情况下,为了保证线程安全,必须使用CAS或锁,这种情况下,CAS的性能超过锁的性能,前者大约是后者的8倍。

2、伪共享

共享:

注:共享(false sharing),并发编程无声的性能杀手;

CPU 缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。
按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存二级缓存,部分高端 CPU 还具有三级缓存。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近 CPU 的缓存越快也越小。所以 L1 缓存很小但很快(译注:L1 表示一级缓存),并且紧靠着在使用它的 CPU 内核。L2 大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3 在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。拥有三级缓存的的 CPU,到三级缓存时能够达到 95% 的命中率,只有不到 5% 的数据需要从内存中查询。
计算机CPU与缓存示意图:
在这里插入图片描述
缓存行:
缓存系统中是以缓存行(cache line)为单位存储的。缓存行通常是 64 字节(译注:本文基于 64 字节,其他长度的如 32 字节等不适本文讨论的重点),并且它有效地引用主内存中的一块地址。一个 Java 的 long 类型是 8 字节,因此在一个缓存行中可以存 8 个 long 类型的变量。所以,如果你访问一个 long 数组,当数组中的一个值被加载到缓存中,它会额外加载另外 7 个,以致你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。而如果你在数据结构中的项在内存中不是彼此相邻的(如链表),你将得不到免费缓存加载所带来的优势,并且在这些数据结构中的每一个项都可能会出现缓存未命中。
如果存在这样的场景,有多个线程操作不同的成员变量,但是相同的缓存行,就会发生伪共享
在并发场景下发生伪共享比没发生伪共享的场景慢很多;

下面的例子是测试利用cache line的特性和不利用cache line的特性的效果对比:

package com.sk.statemachine.test;

public class CacheLineEffect {

    //考虑一般缓存行大小是64字节,一个 long 类型占8字节
    static long[][] arr;

    public static void main(String[] args) {
        arr = new long[1024 * 1024][];
        for (int i = 0; i < 1024 * 1024; i++) {
            arr[i] = new long[8];
            for (int j = 0; j < 8; j++) {
                arr[i][j] = 0L;
            }
        }
        long sum = 0L;
        long marked = System.currentTimeMillis();
        for (int i = 0; i < 1024 * 1024; i += 1) {
            for (int j = 0; j < 8; j++) {
                sum = arr[i][j];
            }
        }
        System.out.println("====Loop times:" + (System.currentTimeMillis() - marked) + "ms");

        marked = System.currentTimeMillis();
        for (int i = 0; i < 8; i += 1) {
            for (int j = 0; j < 1024 * 1024; j++) {
                sum = arr[j][i];
            }
        }
        System.out.println("====Loop times:" + (System.currentTimeMillis() - marked) + "ms");
    }

}

运行结果:

====Loop times:14ms
====Loop times:56ms

ArrayBlockingQueue产生伪共享的原因:
ArrayBlockingQueue有三个成员变量: - takeIndex:需要被取走的元素下标 - putIndex:可被元素插入的位置的下标 - count:队列中元素的数量
这三个变量很容易放到一个缓存行中,但是之间修改没有太多的关联。所以每次修改,都会使之前缓存的数据失效,从而不能完全达到共享的效果。
在这里插入图片描述
如上图所示,当生产者线程put一个元素到ArrayBlockingQueue时,putIndex会修改,从而导致消费者线程的缓存中的缓存行无效,需要从主存中重新读取,这种无法充分使用缓存行特性的现象,称为伪共享

3、队列的问题

队列通常使用链接列表或数组来存储元素的底层。 如果允许内存中队列不受限制,那么对于许多类别的问题,它可能会不受控制地增长,直到它通过耗尽内存而达到灾难性故障的程度。 当生产者超过消费者时,就会发生这种情况。 在保证生产者不会超过消费者且内存是宝贵资源的系统中,无界队列可能很有用,但如果这种假设不成立并且队列无限制地增长,则始终存在风险。 为了避免这种灾难性的结果,队列的大小通常受到限制(有界)。 要保持队列的边界,就要求它要么是由数组支持的,要么是主动跟踪大小的。

队列实现往往在头部、尾部和大小变量上存在写入争用。 在使用时,由于使用者和生产者之间的速度差异,队列通常总是接近满或接近空。 它们很少在生产率和消费率平均匹配的平衡中间地带运作。 这种始终为满或始终为空的倾向会导致高水平的争用和/或昂贵的缓存一致性。 问题在于,即使使用不同的并发对象(如锁或 CAS 变量)分离头部和尾部机制,它们通常也会占用相同的缓存行。

管理生产者声明队列的头部,消费者声明队列的尾部,以及两者之间的节点存储,这些担忧使得并发实现的设计非常复杂,除了在队列上使用单个大粒度锁之外,还要管理。 在放置和接收操作的整个队列上设置大型粒度锁易于实现,但对吞吐量来说是一个重大瓶颈。 如果并发关注点在队列的语义中被挑逗出来,那么对于单个生产者 - 单一消费者实现以外的任何事物,实现都会变得非常复杂。

在 Java 中,队列的使用还存在另一个问题,因为它们是垃圾的重要来源。 首先,必须分配对象并将其放置在队列中。 其次,如果支持链接列表,则必须分配表示列表节点的对象。 当不再引用时,需要重新回收为支持队列实现而分配的所有这些对象。

三、disruptor原理

1、Disruptor简介

1)Disruptor 是英国外汇交易公司 LMAX 开发的一个高性能的并发框架。可以认为是线程间通信的高效低延时的内存消息组件,它最大的特点是高性能。与 Kafka、RabbitMQ 用于服务间的消息队列不同,disruptor 一般用于一个 JVM 中多个线程间消息的传递。
2)从功能上来看,Disruptor 实现了“队列”的功能,而且是一个有界队列(事实上它是一个无锁的线程间通信库)。作用与 ArrayBlockingQueue 有相似之处,但是 disruptor 从功能、性能上又都远好于 ArrayBlockingQueue。

2、Disruptor 的优势

Disruptor 最直接的应用场景自然就是“生产者-消费者”模型的应用场合了,虽然这些我们使用 JDK 的 BlockingQueue 也能做到,但 Disruptor 的性能比 BlockingQueue 提高了 5~10 倍左右,也就是说 BlockingQueue 能做的,Disruptor 都能做到且做的更好,同时 Disruptor 还能做得更多。

3、Disruptor 原理

1)引入环形的数组结构:数组元素不会被回收,避免频繁的GC,通过数组预分配内存,减少节点操作空间释放和申请的过程,从而减少GC次数。并且由于数据元素内存地址是连续的,基于缓存行的机制,数组中的数据会被预加载到CPU的L1空间中,就无需到主内存中加载下一个元素,从而提高了数据访问性能;
在新建Disruptor的实例时,需要设置bufferSize,并且官方说明该值必须是2的N次方,否则会抛出异常。那么为什么会需要2的N次方呢?主要是为了求余的优化。求余操作本身是一个高耗费的操作,但是在Disruptor中,通过位操作来高效实现求余,这需要值是2的N次方才能保证结果的唯一性。
2)无锁的设计:采用CAS无锁方式,保证线程的安全性
3)属性填充:通过添加额外的无用信息,避免伪共享问题
4)元素位置的定位:采用跟一致性哈希一样的方式,一个索引,进行自增

四、disruptor使用示例

MessageModel.java

package com.sk.statemachine.init.bean;

import lombok.Data;

@Data
public class MessageModel {

    private String message;

}

MessageEventFactory.java

package com.sk.statemachine.init;

import com.lmax.disruptor.EventFactory;
import com.sk.statemachine.init.bean.MessageModel;

public class MessageEventFactory implements EventFactory<MessageModel> {
    @Override
    public MessageModel newInstance() {
        return new MessageModel();
    }
}

MessageEventHandler.java

package com.sk.statemachine.init.consumer;

import com.lmax.disruptor.EventHandler;
import com.sk.statemachine.init.bean.MessageModel;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class MessageEventHandler implements EventHandler<MessageModel> {

    @Override
    public void onEvent(MessageModel event, long l, boolean b) throws Exception {
        if (event != null) {
            log.info("===========consume message is:{}", event);
        }
    }

}

MessageEventProducer.java

package com.sk.statemachine.init.producer;

import com.lmax.disruptor.EventTranslator;
import com.lmax.disruptor.RingBuffer;
import com.sk.statemachine.init.bean.MessageModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class MessageEventProducer {

    @Autowired
    private RingBuffer<MessageModel> ringBuffer;
    
    public synchronized void sayHelloMq(String message){
        EventTranslator<MessageModel> et = (messageModel, l) -> messageModel.setMessage(message);
        ringBuffer.publishEvent(et);
    }

}

MqManager.java

package com.sk.statemachine.init;

import com.lmax.disruptor.RingBuffer;
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.util.DaemonThreadFactory;
import com.sk.statemachine.init.bean.MessageModel;
import com.sk.statemachine.init.consumer.MessageEventHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MqManager {

    @Bean("ringBuffer")
    public RingBuffer<MessageModel> messageModelRingBuffer() {
        //define the thread pool for consumer message hander, Disruptor touch the consumer event to process by java.util.concurrent.ExecutorSerivce
        //ExecutorService executor = Executors.newFixedThreadPool(2);
        //define Event Factory
        MessageEventFactory factory = new MessageEventFactory();
        //ringbuffer byte size
        int bufferSize = 1024 * 256;
        //单线程模式,获取额外的性能
        //Disruptor<MessageModel> disruptor = new Disruptor<>(factory, bufferSize, executor, ProducerType.SINGLE, new BlockingWaitStrategy());
        Disruptor<MessageModel> disruptor = new Disruptor<>(factory, bufferSize, DaemonThreadFactory.INSTANCE);
        //set consumer event
        disruptor.handleEventsWith(new MessageEventHandler());
        //start disruptor thread
        disruptor.start();
        //gain ringbuffer ring,to product event
        RingBuffer<MessageModel> ringBuffer = disruptor.getRingBuffer();

        return ringBuffer;
    }

}

InitAction.java

package com.sk.statemachine.init;

import com.sk.statemachine.init.producer.MessageEventProducer;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

@Log4j2
@Configuration
public class InitAction {

    @Resource
    private MessageEventProducer messageEventProducer;

    @PostConstruct
    public void start() {
        new Thread(() -> {
            for (int i = 1; i < 100; i++) {
                try {
                    Thread.sleep(1000);
                    messageEventProducer.sayHelloMq("-----测试消息:" + i);
                } catch (InterruptedException e) {
                    log.error("------error:{}", e);
                }
            }
        }).start();
    }

}

程序运行结果:

2024-08-14T22:58:09,464 INFO  [Thread-109] com.sk.statemachine.init.consumer.MessageEventHandler: ===========consume message is:MessageModel(message=-----测试消息:1)
2024-08-14T22:58:10,477 INFO  [Thread-109] com.sk.statemachine.init.consumer.MessageEventHandler: ===========consume message is:MessageModel(message=-----测试消息:2)
2024-08-14T22:58:11,484 INFO  [Thread-109] com.sk.statemachine.init.consumer.MessageEventHandler: ===========consume message is:MessageModel(message=-----测试消息:3)
2024-08-14T22:58:12,498 INFO  [Thread-109] com.sk.statemachine.init.consumer.MessageEventHandler: ===========consume message is:MessageModel(message=-----测试消息:4)
2024-08-14T22:58:13,511 INFO  [Thread-109] com.sk.statemachine.init.consumer.MessageEventHandler: ===========consume message is:MessageModel(message=-----测试消息:5)

五、生产者和消费者模式

Disruptor中生产者分为单生产者和多生产者,而消费者并没有区分。单生产者情况下,就是普通的生产者向RingBuffer中放置数据,消费者获取最大可消费的位置,并进行消费。

多生产者时候,又多出了一个跟RingBuffer同样大小的Buffer,称为AvailableBuffer。在多生产者中,每个生产者首先通过CAS竞争获取可以写的空间,然后再进行慢慢往里放数据,放完数据后就将AvailableBuffer中对应的位置置位,标记为写入成功。如果正好这个时候消费者要消费数据,那么每个消费者都需要获取最大可消费的下标,这个下标是在AvailableBuffer进行获取得到的最长连续的序列下标。

在并发系统中提高性能最好的方式之一就是单一写者原则,对Disruptor也是适用的。如果在你的代码中仅仅有一个事件生产者,那么可以设置为单一生产者模式来提高系统的性能

1、一次生产,串行消费

比如:现在触发一个注册Event,需要有一个Handler来存储信息,一个Hanlder来发邮件等等。
在这里插入图片描述

disruptor.handleEventsWith(new MessageEventHandler()).then(new MessageEventHandler02());

2、菱形方式执行

在这里插入图片描述

disruptor.handleEventsWith(new MessageEventHandler(),new MessageEventHandler02()).then(new MessageEventHandler03());

3、链式并行计算

在这里插入图片描述

disruptor.handleEventsWith(new MessageEventHandler()).then(new MessageEventHandler02());
        disruptor.handleEventsWith(new MessageEventHandler03()).then(new MessageEventHandler04());

4、相互隔离模式

在这里插入图片描述

disruptor.handleEventsWith(new MessageEventHandler(),new MessageEventHandler02());
        disruptor.handleEventsWith(new MessageEventHandler03(),new MessageEventHandler04());
注:这里消费者和生产者模式是ArrayBlockingQueue队列不具备的。

六、disruptor框架为什么使用者较少

我们前面介绍了很多disruptor框架的优点,但是为什么disruptor框架在开发者中不流行,平时在工作中也很少见有人在用,大概总结了一下,应该有一下几点:
1、技术门槛较高:
Disruptor框架的设计和实现相对复杂,要求开发者对并发编程、内存模型等有较深的理解。这使得很多开发者望而却步,尤其是在没有强烈性能需求的情况下,更倾向于使用更简单、更直观的框架或工具。
2、学习和文档资源有限:
相比于一些广泛使用的并发框架(如Java中的java.util.concurrent包),Disruptor的学习资源和官方文档可能相对较少。这增加了新手上手的难度,也限制了其在更广泛范围内的应用。
3、应用场景局限性:
Disruptor框架特别适用于需要极高性能的并发处理场景,如高频交易系统、实时数据分析等。然而,在大多数日常开发场景中,对性能的要求可能并没有那么高,因此使用Disruptor的必要性不大。
4、竞争框架的存在:
在Java并发编程领域,存在许多其他优秀的框架和工具,如java.util.concurrent包中的BlockingQueue、ConcurrentHashMap等。这些框架和工具已经足够满足大多数并发编程的需求,并且拥有更广泛的用户基础和更完善的生态系统。
5、市场宣传和认知度不足:
尽管Disruptor框架在性能上表现出色,但由于其相对较新的出现时间(相比于一些传统的并发框架)以及可能的市场宣传不足,导致很多开发者对其了解不多,从而限制了其流行度。
6、维护和升级成本:
对于已经在使用其他并发框架的项目来说,迁移到Disruptor可能需要付出一定的成本,包括代码重写、测试验证等。此外,随着技术的发展,Disruptor框架本身也需要不断更新和维护,这也会增加一定的成本。

注:技术选型时,不能只在技术层面考虑,在学习成本和维护成本都要考虑;

----------------------------------👇👇👇注:源码请关注公众号获取👇👇👇--------------------------------------------

  • 25
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Dylan~~~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值