较真儿学源码系列-PowerJob时间轮源码分析

news2024/11/26 14:39:26

        PowerJob版本:4.3.2-main。

        之前分析过PowerJob的启动流程源码,感兴趣的可以查看《较真儿学源码系列-PowerJob启动流程源码分析》


1 简介

        试想一下,如果此时有一个需要延迟3s执行的任务,你会怎么实现呢?一种常规的思路是不断轮询,每1s轮询一次。等到轮询到第三次的时候,发现当前任务需要被执行。那么如果此时有100个延迟任务呢?并且每个任务的延迟时间都不等,小的有几毫秒,大的有几分钟甚至几天。如果还按照这个思路来实现的话,那么每个任务都需要开启一个线程去定时轮询。这样的开销未免太大了。而时间轮算法的本质是不再以任务为主体,而是以轮询线程为主体。既然之前每个任务都需要开启一个线程,开销比较大。那么换个思路,只用一个线程行不行?一个线程去统一调度所有的延迟任务(这样的话就意味着所有的任务都需要放在合适的位置上等待被调度,任务需要被分组),所以时间轮算法就应运而生了。

1.1 单层时间轮

        上图是时间轮的数据结构,主体是一个循环数组,数组的每个位置上都会挂一个链表。当来了一个新的延迟任务的时候,会根据延迟时间和当前时间计算出它应该放的位置(数组位置,找到数组位置后再挂接在链表上)。而每走一个时间刻度,时间轮指针就会前进一格,继而就会执行当前这个格内的任务。因为格内的任务是以链表排列的,所以会遍历执行。当指针走到最后一个刻度并执行完最后一个刻度里的任务后,又会重新走到初始刻度处,再次循环。

        举个例子:假如说现在有一个时间刻度为12,每1s走一格的时间轮。同时现在有3个延迟任务,分别是延迟1s、6s和13s。那么此时首先需要计算出这三个任务所需要插入的位置。计算方式是:((当前时间 + 任务延迟时间 - 时间轮开始时间) / 时间间隔 ) % 时间刻度。这里我们简化一下,时间轮开始时间是0,当前时间也是0,时间间隔如上所示1s,所以上面的公式可以简化为:任务延迟时间 % 时间刻度。经过计算后,上面3个延迟任务会分别放到第1、6、1号位置(第一和第三任务会挂接在一起)。在完成了任务的插入后,接下来等待指针转动。

  1. 1s后,指针转到了1号位置处,发现此时有两个任务。但是这个时候只会执行第一个任务,因为第三个任务是需要延迟13s后才执行的,而现在只过了1s,所以不能执行;
  2. 2s-5s期间,指针持续转动,但是发现此时没有任何任务需要执行;
  3. 6s后,指针转到了6号位置处,发现此时有第二个任务,执行它;
  4. 7s-12s期间,同样没有任务需要被执行,指针继续空转;
  5. 13s后,指针重新走到了1号位置处,此时第三个任务会被执行;
  6. 之后,指针会持续空转,直到新任务的来临。

        以上就是时间轮最简单的实现,也被称为单层时间轮。尽管满足了需求,但是单层时间轮也有自己的不足:时间轮的时间间隔到底应该取多少呢?在上面的例子中,时间间隔为1s。但是假如说有一个任务的延迟时间是200ms,因为当前时间轮的粒度是1s,所以最早也只能等到1s之后,该任务才会被执行。所以这就会造成延迟时间短的任务无法被及时执行的问题出现;而如果将时间间隔改成比如说是100ms,这个任务虽然能被及时执行,但是如果此时还有另外一个任务的延迟时间是1分钟的话,同时当前时间轮中又只有很少的任务。那么时间轮的大部分时间都会在做空转,浪费系统资源。

1.2 多层时间轮

        由此,多层时间轮就出现了。顾名思义,多层时间轮会有多个时间轮,组合在一起被称为多层时间轮(好像说了一句废话-_-)。多个时间轮之间会协同工作,每个时间轮都会维护下一层时间轮的指针。拿我们日常生活中的钟表举例:钟表会有三个时间轮,分别是时、分和秒。分和秒时间轮的刻度是60,时时间轮的刻度是12(12小时钟表)。当秒时间轮转完60个刻度后,分时间轮会前进一个刻度,同时秒时间轮的指针会清零;当分时间轮转完60个刻度后,时时间轮会前进一个刻度,同时分时间轮的指针会清零,如下所示:

        多层时间轮能用较小的空间(60 + 60 + 12 = 132),来表示出尽可能多的时间跨度(60 * 60 * 12= 43200)。同样举个例子:假如说现在有三个延迟任务,分别需要延迟30s、20min10s、1h20min3s。三个任务会分别被插入到秒、分、时时间轮中(插入规则是依次从秒、分和时,从低到高的时间轮中判断。如果低一层的时间轮中能放下任务的话就放,放不下的话就往上一层的时间轮中判断,以此类推)。在完成了任务的插入后,接下来等待指针转动。

  1. 一开始秒时间轮会转动,等转动到30s时,第一个任务会被执行;
  2. 当秒时间轮转动60s后,分时间轮转动一格,同时秒时间轮清零;
  3. 之后秒时间轮和分时间轮会协同转动,分时间轮转动一格,秒时间轮转动一圈。直到分时间轮转动到第20个格的时候(20min),发现此时挂载了第二个任务。这个时候会将第二个任务拿出来,重新放到秒时间轮中第10个位置处(这个操作被称为降级操作,同时因为高层级时间轮维护着低层级时间轮的指针,所以这个操作很好实现);
  4. 此时轮到秒时间轮转动了。当秒时间轮转动10个格的时候(20min10s),此时拿取到了第二个任务去执行;
  5. 在这之后秒时间轮和分时间轮会再次协同转动,直到分时间轮转动60个格后(60min),时时间轮转动一格,同时分时间轮清零;
  6. 此时时时间轮中发现挂载了第三个任务。这个时候会将第三个任务拿出来,重新放到分时间轮中第20个位置处;
  7. 在这之后秒时间轮和分时间轮继续协同转动,直到当分时间轮转动20个格的时候(1h20min),此时拿取到了第三个任务。以此类推,将第三个任务重新放到秒时间轮中第3个位置处;
  8. 此时轮到秒时间轮转动了。当秒时间轮转动3个格的时候(1h20min3s),此时拿取到了第三个任务去执行。

        从上面的流程中可以看到,执行任务永远是在最低层级的时间轮上执行的。

        多层时间轮的思想在很多的框架中都有实现,比如Netty、Kafka、Dubbo等等。虽然多层时间轮能很好地节省空间和控制粒度,但是依然解决不了空转的问题。而在Kafka中提供了一种优化思路:使用多层时间轮+延迟队列DelayQueue的方式。延迟队列中会存放着所有的桶(也就是时间轮中的每一个位置),按照延迟时间排队(DelayQueue本质上是个小顶堆,我之前详细分析过小顶堆的运行过程,感兴趣的话可以查看《较真儿学源码系列-ScheduledThreadPoolExecutor(逐行源码带你分析作者思路)》)。Kafka中的时间轮不会按照固定的速率转动,而是等到延迟队列中能拿到过期任务的时候才会转动,并且转动的时间也取决于任务的过期时间(DelayQueue拿不到数据意味着此时没有过期的数据,这个时候线程会被休眠,杜绝了空转的情况出现)。

        说了这么多,让我们把思路拉回来,看看在PowerJob中是如何实现时间轮算法的吧。


2 schedule方法

        之前在分析PowerJob的启动流程源码的时候,服务端在启动的时候会执行多个定时任务,其中在ScheduleCronJob/ScheduleDailyTimeIntervalJob定时任务中会将任务推入时间轮中等待调度执行,方法是InstanceTimeWheelService.schedule,查看其实现:

/**
 * InstanceTimeWheelService:
 * 定时调度
 *
 * @param uniqueId  唯一 ID,必须是 snowflake 算法生成的 ID
 * @param delayMS   延迟毫秒数
 * @param timerTask 需要执行的目标方法
 */
public static void schedule(Long uniqueId, Long delayMS, TimerTask timerTask) {
    //长延迟阈值为1分钟,如果任务的延迟时间<=1分钟,则直接用精确调度时间轮(每1ms走一格)进行调度
    if (delayMS <= LONG_DELAY_THRESHOLD_MS) {
        realSchedule(uniqueId, delayMS, timerTask);
        return;
    }

    //否则,用非精确调度时间轮(每10s走一格)进行调度。等非精确调度时间轮时间快到了的时候(真正执行的时间-1分钟),再送到精确调度时间轮进行调度
    long expectTriggerTime = System.currentTimeMillis() + delayMS;
    TimerFuture longDelayTask = SLOW_TIMER.schedule(() -> {
        //CARGO是用来缓存所有等待执行的任务(延迟时间需要大于1s)
        CARGO.remove(uniqueId);
        realSchedule(uniqueId, expectTriggerTime - System.currentTimeMillis(), timerTask);
    }, delayMS - LONG_DELAY_THRESHOLD_MS, TimeUnit.MILLISECONDS);
    CARGO.put(uniqueId, longDelayTask);
}

/**
 * 第12行代码处和第21行代码处:
 */
private static void realSchedule(Long uniqueId, Long delayMS, TimerTask timerTask) {
    //用精确调度时间轮进行调度
    TimerFuture timerFuture = TIMER.schedule(() -> {
        CARGO.remove(uniqueId);
        timerTask.run();
    }, delayMS, TimeUnit.MILLISECONDS);
    //当延迟时间大于1s的时候,才放到CARGO中
    if (delayMS > MIN_INTERVAL_MS) {
        CARGO.put(uniqueId, timerFuture);
    }
}

         上面的代码中,当任务的延迟时间大于1分钟的时候,会同时用到精确时间轮和非精确时间轮。试想一下为什么?正如第1.1小节最后说的,如果此时只有一个任务,其延迟时间是10分钟,因为精确时间轮是每1ms走一格,那么在10分钟到来之前,精确时间轮已经走了很多圈了,但是这些动作都是在做空转、在做无用功,很浪费系统资源。所以,当任务的延迟时间大于1分钟的时候,会用到精确时间轮和非精确时间轮相结合的方式去运行。当延迟时间大的时候,先用跨步大的非精确时间轮去延迟执行,等到快到延迟时间的时候,再改用跨步小的精确时间轮去延迟执行。

        接下来就来看下,在上面第18行代码处和第31行代码处,时间轮schedule方法的实现吧:

/**
 * HashedWheelTimer:
 */
@Override
public TimerFuture schedule(TimerTask task, long delay, TimeUnit unit) {

    //真正执行的时间
    long targetTime = System.currentTimeMillis() + unit.toMillis(delay);
    HashedWheelTimerFuture timerFuture = new HashedWheelTimerFuture(task, targetTime);

    // 直接运行到期、过期任务
    if (delay <= 0) {
        runTask(timerFuture);
        return timerFuture;
    }

    // 写入阻塞队列,保证并发安全(性能进一步优化可以考虑 Netty 的 Multi-Producer-Single-Consumer队列)
    waitingTasks.add(timerFuture);
    return timerFuture;
}

/**
 * 第9行代码处:
 */
public HashedWheelTimerFuture(TimerTask timerTask, long targetTime) {

    this.targetTime = targetTime;
    this.timerTask = timerTask;
    //初始状态是WAITING
    this.status = WAITING;
}

/**
 * 第13行代码处:
 */
private void runTask(HashedWheelTimerFuture timerFuture) {
    //状态改为RUNNING
    timerFuture.status = HashedWheelTimerFuture.RUNNING;
    if (taskProcessPool == null) {
        //没有线程池,就直接调用目标方法
        timerFuture.timerTask.run();
    } else {
        //有线程池,就放入线程池中去执行
        taskProcessPool.submit(timerFuture.timerTask);
    }
}

3 构造器

        在上面第18行代码处,将任务写入到阻塞队列中,看起来流程是走完了。但是肯定是会有一个地方从阻塞队列中拿取任务去执行。之前在InstanceTimeWheelService类中会初始化精确时间轮和非精确时间轮:

/**
 * 定时调度任务实例
 *
 * @author tjq
 * @since 2020/7/25
 */
public class InstanceTimeWheelService {

    private static final Map<Long, TimerFuture> CARGO = Maps.newConcurrentMap();

    /**
     * 精确调度时间轮,每 1MS 走一格
     */
    private static final Timer TIMER = new HashedWheelTimer(1, 4096, Runtime.getRuntime().availableProcessors() * 4);
    /**
     * 非精确调度时间轮,用于处理高延迟任务,每 10S 走一格
     */
    private static final Timer SLOW_TIMER = new HashedWheelTimer(10000, 12, 0);

    /**
     * 支持取消的时间间隔,低于该阈值则不会放进 CARGO
     */
    private static final long MIN_INTERVAL_MS = 1000;
    /**
     * 长延迟阈值
     */
    private static final long LONG_DELAY_THRESHOLD_MS = 60000;

    //...

}

        那么接下来就来看下HashedWheelTimer构造器的实现,看看有没有什么逻辑:

/**
 * HashedWheelTimer:
 * 新建时间轮定时器
 *
 * @param tickDuration     时间间隔,单位毫秒(ms)
 * @param ticksPerWheel    轮盘个数
 * @param processThreadNum 处理任务的线程个数,0代表不启用新线程(如果定时任务需要耗时操作,请启用线程池)
 */
public HashedWheelTimer(long tickDuration, int ticksPerWheel, int processThreadNum) {

    //多少ms走一格,后面会看到,实际上就是线程睡眠的时间(Thread.sleep)
    this.tickDuration = tickDuration;

    // 初始化轮盘,大小格式化为2的N次,可以使用 & 代替取余
    int ticksNum = CommonUtils.formatSize(ticksPerWheel);
    wheel = new HashedWheelBucket[ticksNum];
    for (int i = 0; i < ticksNum; i++) {
        //每一个槽位初始化桶(HashedWheelBucket继承于LinkedList,本质上是个链表)
        wheel[i] = new HashedWheelBucket();
    }
    //掩码,取余(%)替换为按位与(&)所需
    mask = wheel.length - 1;

    // 初始化执行线程池
    if (processThreadNum <= 0) {
        taskProcessPool = null;
    } else {
        ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("HashedWheelTimer-Executor-%d").build();
        // 这里需要调整一下队列大小
        BlockingQueue<Runnable> queue = Queues.newLinkedBlockingQueue(8192);
        int core = Math.max(Runtime.getRuntime().availableProcessors(), processThreadNum);
        // 基本都是 io 密集型任务
        //为了尽量减少执行任务所带来的额外时间损耗,这里选择使用线程池来执行
        taskProcessPool = new ThreadPoolExecutor(core, 2 * core,
                60, TimeUnit.SECONDS,
                queue, threadFactory, RejectedExecutionHandlerFactory.newCallerRun("PowerJobTimeWheelPool"));
    }

    startTime = System.currentTimeMillis();

    // 启动后台线程
    //真正干事的线程
    indicator = new Indicator();
    new Thread(indicator, "HashedWheelTimer-Indicator").start();
}

/**
 * 第15行代码处:
 * 将大小格式化为 2的N次
 * HashMap的实现方式,取cap的最小2次幂(我之前也对HashMap的源码进行过分析,其中对取cap的最小2次幂的方法详细分析过,感兴趣的话可以查看:https://blog.csdn.net/weixin_30342639/article/details/107383800)
 *
 * @param cap 初始大小
 * @return 格式化后的大小,2的N次
 */
public static int formatSize(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

4 Indicator

        在上面第44行代码处,新开启了一个Indicator线程去执行,那么接下来就来看下其run方法的实现:

/**
 * Indicator:
 */
@Override
public void run() {

    while (!stop.get()) {

        // 1. 将任务从队列推入时间轮
        pushTaskToBucket();
        // 2. 处理取消的任务
        processCanceledTasks();
        // 3. 等待指针跳向下一刻
        tickTack();
        // 4. 执行定时任务
        //计算当前时间点的槽位
        int currentIndex = (int) (tick & mask);
        //拿取此时的槽
        HashedWheelBucket bucket = wheel[currentIndex];
        //执行任务
        bucket.expireTimerTasks(tick);

        //每走过一个刻度,tick就会+1。同时在上面第7行代码处是个while循环,也就意味着指针会一直转动,直到时间轮被停止,stop赋值为true
        tick++;
    }
    //当时间轮停止的时候,getUnprocessedTasks方法获取所有处于WAITING状态的任务,需要等待这里latch.countDown方法执行完成后才能获取。其实也就是在等待最后一轮时间轮执行完毕
    latch.countDown();
}

/**
 * 第10行代码处:
 * 将队列中的任务推入时间轮中
 */
private void pushTaskToBucket() {

    while (true) {
        //这里就可以看到,从阻塞队列中拿取任务
        HashedWheelTimerFuture timerTask = waitingTasks.poll();
        //循环执行,只有拿取的任务是空的话,才会终止循环。这就意味着一次会把所有任务一起取出来
        if (timerTask == null) {
            return;
        }

        // 总共的偏移量
        //偏移量的计算方式是目标时间的时间戳-开始时间的时间戳
        long offset = timerTask.targetTime - startTime;
        // 总共需要走的指针步数
        timerTask.totalTicks = offset / tickDuration;
        // 取余计算 bucket index
        //计算槽位
        int index = (int) (timerTask.totalTicks & mask);
        HashedWheelBucket bucket = wheel[index];

        // TimerTask 维护 Bucket 引用,用于删除该任务
        timerTask.bucket = bucket;

        if (timerTask.status == HashedWheelTimerFuture.WAITING) {
            //只有状态是WAITING的时候才会添加任务进槽里
            bucket.add(timerTask);
        }
    }
}

/**
 * 第12行代码处:
 * 处理被取消的任务
 */
private void processCanceledTasks() {
    while (true) {
        //同pushTaskToBucket方法一样,一次把所有取消的任务都取出来
        HashedWheelTimerFuture canceledTask = canceledTasks.poll();
        if (canceledTask == null) {
            return;
        }
        // 从链表中删除该任务(bucket为null说明还没被正式推入时间格中,不需要处理)
        if (canceledTask.bucket != null) {
            canceledTask.bucket.remove(canceledTask);
        }
    }
}

/**
 * 第14行代码处:
 * 模拟指针转动,当返回时指针已经转到了下一个刻度
 */
private void tickTack() {

    // 下一次调度的绝对时间
    //tick是指针每走过一个刻度,就会+1。这里也就是在计算指针转动到下一个刻度的时间
    long nextTime = startTime + (tick + 1) * tickDuration;
    //需要睡眠的时间=指针转动到下一个刻度的时间-当前时间
    long sleepTime = nextTime - System.currentTimeMillis();

    //当需要睡眠的时间不大于0的时候,意味着此时不需要睡眠,直接执行任务就好了
    if (sleepTime > 0) {
        try {
            //这里就可以看到,指针跳动是通过Thread.sleep方法来模拟实现的。当线程被重新唤醒时,此时也就走过了一个刻度
            Thread.sleep(sleepTime);
        } catch (Exception ignore) {
        }
    }
}

/**
 * HashedWheelBucket:
 * 第21行代码处:
 */
public void expireTimerTasks(long currentTick) {

    //里面返回true代表任务需要被删除
    removeIf(timerFuture -> {

        // processCanceledTasks 后外部操作取消任务会导致 BUCKET 中仍存在 CANCELED 任务的情况
        if (timerFuture.status == HashedWheelTimerFuture.CANCELED) {
            return true;
        }

        if (timerFuture.status != HashedWheelTimerFuture.WAITING) {
            log.warn("[HashedWheelTimer] impossible, please fix the bug");
            return true;
        }

        // 本轮直接调度
        if (timerFuture.totalTicks <= currentTick) {

            if (timerFuture.totalTicks < currentTick) {
                log.warn("[HashedWheelTimer] timerFuture.totalTicks < currentTick, please fix the bug");
            }

            try {
                // 提交执行
                runTask(timerFuture);
            } catch (Exception ignore) {
            } finally {
                //执行完毕后,状态赋值为FINISHED
                timerFuture.status = HashedWheelTimerFuture.FINISHED;
            }
            //这里返回true意味着任务执行完成后,需要从时间轮的槽中删除
            return true;
        }

        return false;
    });

}

        从上面的代码中可以看出,Indicator线程才是时间轮真正干事的线程。值得一提的是,在上面第124行代码处的判断是很有必要的。正如第1.1小节说的,试想一下,如果时间轮的槽数是12,时间轮一秒转一次。此时有两个任务,分别是在第1秒和第13秒执行。那么这两个任务都会被放在第1号槽中(对12取余)。当1秒过后,取出1号槽中的任务链表,发现这两个任务都会被取出。那么这两个任务都会被执行吗?肯定不是的。只有第一个任务需要被执行,第二个任务此时需要再延迟12秒后才会被执行。所以这里也就是在判断是不是本轮时间轮需要执行的任务,不是的话就等到下一轮(currentTick也就是传进来的tick,时间轮每转一轮就会+1,会不断累加。totalTicks是当前这个任务总共需要走的步数,当totalTicks>currentTick时,意味着这个任务不是本轮需要执行的。还是拿上面的例子来说,1秒过后,currentTick=1,此时第一个任务的totalTicks=1,会被执行;而第二个任务的totalTicks为13,此轮不会被执行)。


原创不易,未得准许,请勿转载,翻版必究

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1042646.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

洗地机哪个牌子好用又实惠?口碑最好的洗地机推荐

智能技术飞速发展的时代&#xff0c;扫地机器人这类智能家电其实也在顺应潮流和用户需求&#xff0c;不断更新迭代。暂且不说市面上现有多少个洗地机品牌&#xff0c;单单一个洗地机品牌旗下&#xff0c;其实每年都会有多个系列的新品亮相&#xff0c;我们面对的选择多了&#…

Python交叉验证实现

目录 <font colorblue size4 face"楷体">HoldOut 交叉验证<font colorred size4 face"楷体">K 折交叉验证<font colorblue size4 face"楷体">分层 K 折交叉验证<font colorblue size4 face"楷体">Leave P Out…

融云 CallPlus + X,通话场景一站式解决方案

融云近期上线的 CallPlus SDK&#xff0c;针对音视频呼叫场景单独设计后端服务 Call Server&#xff0c;信令延时低至 150ms&#xff0c;确保各端计时准确、一致&#xff1b;上线了音视频通话互转、灵活的多人通话、通话记录管理能力等功能。关注【融云全球互联网通信云】了解更…

掌动智能兼容性测试有哪些优势

兼容性测试为企业带来市场竞争优势&#xff0c;并提高用户满意度。在软件开发过程中&#xff0c;将兼容性测试作为一个重要的环节&#xff0c;将为企业的成功和用户满意度打下坚实的基础。那么&#xff0c;掌动智能兼容性测试的具体优势是什么?下面&#xff0c;就来看看具体介…

【面试题】说说你对 async和await 理解

前端面试题库 &#xff08;面试必备&#xff09; 推荐&#xff1a;★★★★★ 地址&#xff1a;前端面试题库 表妹一键制作自己的五星红旗国庆头像&#xff0c;超好看 async await详解 原理&#xff1a; async声明该函数是异步的&#xff0c;且该函数会返回一个…

比例导引详解(Proportional navigation guidance,PNG)-及Python程序

模型算法推导 比例导引是一种制导算法&#xff0c;其经典程度相当于控制器中的PID&#xff0c;在本文中&#xff0c;只对其二维平面的情况做分析&#xff0c;考虑一个拦截弹拦截机动目标&#xff08;固定目标相当于目标速度为0&#xff09;&#xff0c;其运动如下图所示&#…

变配电智能化系统:提高效率与安全性

随着科技的发展&#xff0c;电力系统正在逐步向智能化、数字化方向转型。变配电系统作为电力系统的重要组成部分&#xff0c;其智能化水平直接影响着电力系统的运行效率和稳定性。 一、系统概述 力安科技变配电智能化系统是一种采用先进技术&#xff0c;实现对变配电设…

DD5 进制转换

目录 一、题目 二、分析 三、代码 一、题目 进制转换_牛客题霸_牛客网 二、分析 三、代码 #include <iostream> #include <vector> #include <string> using namespace std; string Greater_than_Ten(int digit)//余数大于等于10的时候转换成对应的字母…

低照度增强算法(图像增强+目标检测+代码)

本文介绍 在增强低光图像时&#xff0c;许多深度学习算法基于Retinex理论。然而&#xff0c;Retinex模型并没有考虑到暗部隐藏的损坏或者由光照过程引入的影响。此外&#xff0c;这些方法通常需要繁琐的多阶段训练流程&#xff0c;并依赖于卷积神经网络&#xff0c;在捕捉长距…

从零搭建开发脚手架 顺应潮流开启升级 - SpringBoot 从2.x 升级到3.x

文章目录 涉及升级项导入包修改SpringBoot3.x中spring.factories功能被移除 涉及升级项 升级JDK 8 -> JDK17 Spring Boot 2.3.7 -> Spring Boot 3.1.3 Mysql5.7.x -> Mysql8.x Mybatis-Puls 3.4.2 -> 3.5.3 knife4j 2.x -> 4.3.x sa-token 1.24.x -> 1.…

Apache Derby的使用

Apache Derby是关系型数据库&#xff0c;可以嵌入式方式运行&#xff0c;也可以独立运行&#xff0c;当使用嵌入式方式运行时常用于单元测试&#xff0c;本篇我们就使用单元测试来探索Apache Derby的使用 一、使用IDEA创建Maven项目 打开IDEA创建Maven项目&#xff0c;这里我…

C++: 模板(进阶)

学习目标 1.了解非类型模板参数 2.了解类模板的特化 3.知道模板分离编译会出现的问题 1.非类型模板参数(整型常量) 模板参数: 1.类型形参:在模板参数列表中,class/typename后的参数名称 2.非类型形参:整型常量 示例: template<class T ,size_t N>class arr{public://....…

Docker和Docker compose的安装使用指南

一&#xff0c;环境准备 Docker运行需要依赖jdk&#xff0c;所以需要先安装一下jdk yum install -y java-1.8.0-openjdk.x86_64 二&#xff0c;Docker安装和验证 1&#xff0c;安装依赖工具 yum install -y yum-utils 2&#xff0c;设置远程仓库 yum-config-manager --add-r…

什么是AI问答机器人?它的应用场景有哪些?

近年来&#xff0c;由于技术的进步和对个性化客户体验的需求不断增长&#xff0c;AI问答机器人也是获得了巨大的关注。AI问答机器人&#xff0c;也被称为AI聊天机器人&#xff0c;是一种旨在模拟人类对话并通过基于文本或语音的界面与用户交互的计算机程序。其能够自动执行各种…

idea Springboot在线商城系统VS开发mysql数据库web结构java编程计算机网页源码maven项目

一、源码特点 springboot 在线商城系统是一套完善的信息系统&#xff0c;结合springboot框架和bootstrap完成本系统&#xff0c;对理解JSP java编程开发语言有帮助系统采用springboot框架&#xff08;MVC模式开发&#xff09;&#xff0c;系统具有 完整的源代码和数据库&…

SpringCloud篇

SpringCloud五大组件是啥&#xff1f; rabbin gateway feign 注册中心&#xff08;nacos,Eureka&#xff09;,服务保护 &#xff08;sentinel&#xff09; &#xff1b; nacos和eureka的区别是什么&#xff1f; 负载均衡是如何实现的&#xff1f;&#xff1f; ribbon负载策略…

kaggle新赛:Optiver 美股价格预测赛题解析

赛题名称&#xff1a;Optiver - Trading at the Close 赛题链接&#xff1a;https://www.kaggle.com/competitions/optiver-trading-at-the-close 赛题背景 证券交易所是快节奏、高风险的环境&#xff0c;每一秒都很重要。随着交易日接近尾声&#xff0c;强度不断升级&#…

常见逻辑漏洞总结

Web安全测试中常见逻辑漏洞解析&#xff08;实战篇&#xff09; 简要&#xff1a; 越权漏洞是比较常见的漏洞类型&#xff0c;越权漏洞可以理解为&#xff0c;一个正常的用户A通常只能够对自己的一些信息进行增删改查&#xff0c;但是由于程序员的一时疏忽&#xff0c;对信息…

2024年浙江理工大学MBA项目报考形势如何?

浙江理工大学MBA项目怎么样&#xff0c;值不值得报考&#xff1f;2024年的最新招生政策已经出来&#xff0c;浙江理工大学MBA项目有全日制和非全日制两个MBA方向招生&#xff0c;分别招收19人和52人&#xff0c;总体招生规模不大&#xff0c;这也是浙江理工大学MBA项目近些年的…

锐思WMS和金蝶云星辰V1单据接口对接

锐思WMS和金蝶云星辰V1单据接口对接 来源系统:金蝶云星辰V1 金蝶云星辰基于金蝶云苍穹云原生PaaS平台构建&#xff0c;聚焦小型企业在线经营和数字化管理&#xff0c;提供财务云、税务云、进销存云、零售云、订货商城等SaaS服务&#xff0c;支持企业拓客开源、智能管理、实时决…