JDK线程池ThreadPoolExecutor运行原理详解

news2024/12/29 10:12:11

jdk线程池,是java后端处理异步任务的主要解决方案,使用广泛。jdk线程池相关的面经,网上很多,但是鱼龙混杂,很多瞎写的。要想真正了解原理,还是要看源码。所以,写一篇文章,深入的了解一下

文章目录

  • 1、关于线程池的问题
  • 2、线程池的使用及核心参数
  • 3、前置知识
    • 3.1、线程池的工作状态
    • 3.2、ctl变量
    • 3.3、线程池的线程数量获取
    • 3.4、线程池运行状态获取
    • 3.5、Worker线程
  • 4、线程池的工作流程源码解析
    • 4.1、核心线程未满,新来的请求都新建一个Worker执行任务
      • 4.1.1、判断核心线程是否已满
      • 4.1.2、增加一个Worker线程并启动
      • 4.1.3、Worker线程的启动疑问及答案
      • 4.1.4、Worker线程执行任务
    • 4.2、核心线程已满,新来的请求缓存进队列
    • 4.3、队列已满,继续创建线程至最大线程数
    • 4.4、已达最大线程,执行拒绝策略
      • 4.4.1、拒绝策略的分类

1、关于线程池的问题

先列几个关于线程池,被问到最多的问题
1)、线程池的线程是怎么被复用的?
2)、线程池的工作流程,反应到源码上,是怎么样的?
当然,你可能会有其他问题,那样就更好了,带着问题看文章,比无目的浏览文章,收获要大。这里说点题外话,相比于解决方案,一个好的问题,我认为要更难得。因为,问题往往意味着思考。

2、线程池的使用及核心参数

一般情况下,我都是使用不带返回值的API多一点,所以今天就以execute方法举例。先来一个demo,如下:

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                5,
                10,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(10));
threadPoolExecutor.execute(() -> System.out.println("正在执行任务"));

我们使用的是ThreadPoolExecutor的构造器构建的线程池,我也推荐大家用这种方式构建线程池,相比于Executors的那些静态方法构建的线程池,要容易控制。
看一下ThreadPoolExecutor的构造器全参数有哪些,以及具体参数含义

ThreadPoolExecutor(int corePoolSize,
                   int maximumPoolSize,
                   long keepAliveTime,
                   TimeUnit unit,
                   BlockingQueue<Runnable> workQueue,
                   ThreadFactory threadFactory,
                   RejectedExecutionHandler handler)

1)、corePoolSize:核心线程数。核心线程只是一个概念,并没有规定哪些线程就是核心线程。比如:我们规定核心线程数是5,最终线程池中留下来的线程就是核心线程,这些线程可能是先创建,也可以是后创建
2)、maximumPoolSize:最大线程数。如果队列放不下了,如果maximumPoolSize的值大于corePoolSize,此时就会继续创建线程至maximumPoolSize
3)、keepAliveTime:非核心线程存活时间。如果线程池的线程数超过了核心线程数,此时如果超过keepAliveTime时间后,还是没有获取到任务的话,就会开始减少线程数量,一直到核心线程数
4)、unit:keepAliveTime的时间单位
5)、workQueue:队列
6)、threadFactory:线程工厂。顾名思义,生产线程的工厂。这个线程工厂最好能自己定义一个和业务相关的,这样好排查问题
7)、handler:拒绝策略。当队列满了,已达最大线程时。就会执行拒绝策略
线程池的这几个参数,大家先了解下,随着下面的介绍,大家会对每一个参数的含义有更深的了解

3、前置知识

有一些知识,先提前说下。都是下面即将介绍的源码中多次遇到的,提前说清楚,后面看源码的时候,会更流畅

3.1、线程池的工作状态

线程池有5种状态
1)、RUNNING
2)、SHUTDOWN
3)、STOP
4)、TIDYING
5)、TERMINATED
这5种状态是5个int值。

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

这几个值,我们打印一下看看
-536870912
0
536870912
1073741824
1610612736
可以看到,从RUNNING到TERMINATED是逐渐增大的,这个特点需要记一下,源码中对于线程状态的判断,就是用这个int值进行比较实现的
5个状态流转的流程图
在这里插入图片描述

3.2、ctl变量

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl变量,存储了2个值,啥意思呢
我们知道int值是4个字节,反应到bit上,一共32个bit。每一位bit可以是0或1,这其实有很多组合方式。Doug lea大神就将这32个bit位拆分为了2部分。高位部分代表了线程池运行状态,低位部分代表线程池的线程数量。至于高位的多少位代表线程池运行状态以及低位的多少位代表线程池线程数量,下面会提到。这里,我们只要知道这个ctl代表了2部分含义即可。

3.3、线程池的线程数量获取

源码中获取线程池线程数量的逻辑如下:

private static int workerCountOf(int c)  {
	 return c & CAPACITY; 
}

c是当前ctl的值。
操作符是&符号,这是二进制操作符
CAPACITY的值,用二进制表示如下:
00011111111111111111111111111111
低29位都是1,高3位都是0。所以ctl和CAPACITY进行与操作,结果值的二级制位都会落在ctl二进制值的低29位
所以我们很容易得到低29位用来代表线程数量。相应的,高3位用来代表线程池运行状态
写到这里,如何从ctl中获取线程池的线程数量,应该说明白了

3.4、线程池运行状态获取

源码中获取线程池运行状态的逻辑如下:

private static int runStateOf(int c){ 
	return c & ~CAPACITY; 
}

~CAPACITY的二进制如下:
11100000000000000000000000000000
高3位都是1,低29位都是0
我们看一下线程池的5种状态值对应的二进制
1)、RUNNING:
11100000000000000000000000000000
2)、SHUTDOWN:
0
3)、STOP:
00100000000000000000000000000000
4)、TIDYING:
01000000000000000000000000000000
5)、TERMINATED:
01100000000000000000000000000000
可以看到,状态值的二进值bit位都集中在高3位。所以ctl和~CAPACITY进行与操作,结果都落在ctl值的高3位
写到这里,如何从ctl中获取线程池的运行状态,应该也说明白了

3.5、Worker线程

Worker线程是什么?和我们直接new的一个线程一样吗?
先看一下Worker类的源码(去掉了一些非主流程的代码)

private final class Worker extends AbstractQueuedSynchronizer implements Runnable{

        //初始化任务。可能为空
        Runnable firstTask;

        Worker(Runnable firstTask) {
            //直到runWorker执行前,抑制中断
            setState(-1);

            //初始化任务赋值
            this.firstTask = firstTask;

            //创建一个线程
            this.thread = getThreadFactory().newThread(this);
        }

        //将run方法代理到外部的runWorker方法上
        public void run() {
            runWorker(this);
        }

        //加锁
        public void lock(){ 
            acquire(1); 
        }

        //释放锁
        public void unlock(){ 
            release(1); 
        }
}

从Worker类的实现上可以看出,Worker类继承了AQS,说明Worker类是具备加锁、解锁功能的。另外一个,Worker类还实现了Runnable接口,这就使得Worker类变成了一个线程。从这里可以看出Worker类和我们平时使用的线程差别不大。
但是有2个点需要特别提下。
1)、firstTask变量。这个变量,大家先记一下,它和线程池工作流程的第一步有关系
2)、run方法
firstTask变量,字面意思上看,意为:第一个任务。其实意思就是:未达核心线程数之前,每一个任务都会创建一个线程来处理任务,这种情况是不需要复用的,每个任务一个线程。
run方法,run方法中有一个runWorker方法,入参是this对象,也就是Worker类对象本身。从这个方法名上推测,就是运行Worker

4、线程池的工作流程源码解析

先把八股贴一下
jdk线程池的工作流程分为4步
1)、核心线程未满,新来的请求都新建一个线程执行任务
2)、核心线程已满,新来的请求缓存进队列
3)、队列已满,继续创建线程至最大线程数
4)、最大线程数已满,拒绝任务

4.1、核心线程未满,新来的请求都新建一个Worker执行任务

4.1.1、判断核心线程是否已满

这个过程,在源码中是怎么体现的呢?
我们看一下execute方法的源码,这里去掉了与该流程无关的代码

public void execute(Runnable command) {
        ......
        int c = ctl.get();
        //判断线程池数量是否小于核心线程
        if (workerCountOf(c) < corePoolSize) {
            //小于核心线程时,增加核心线程,直接执行任务
            if (addWorker(command, true))
                return;
        }
        ......
}

这段代码,有2个点。
1)、判断线程池数量是否小于核心线程
2)、小于核心线程时,增加核心线程,直接执行任务
第一点,我们上面已经聊过了,主要就是使用ctl变量提取线程池线程数量的一个过程。现在直接看第二点就行了,也就是addWorker方法的实现

4.1.2、增加一个Worker线程并启动

private boolean addWorker(Runnable firstTask, boolean core) {
        retry:
        //死循环添加Worker线程
        for (;;) {
            ......

            for (;;) {
                //获取线程池当前Worker数量
                int wc = workerCountOf(c);

                //判断Worker数量是否已超过限定值
                if (wc >= CAPACITY ||
                    wc >= (core ? corePoolSize : maximumPoolSize))
                    //超过限定值,直接返回。这样就会进入线程池工作流程的第二步,进入缓存队列或者直接拒绝任务
                    return false;
                if (compareAndIncrementWorkerCount(c))//CAS增加Worker数量
                    break retry;
                ......
            }
        }

        boolean workerStarted = false;
        boolean workerAdded = false;
        Worker w = null;
        try {
            //新增Worker线程处理任务,这里可以看到,firstTask任务作为构造器的入参传给了Worker线程
            w = new Worker(firstTask);

            //获取Worker类中的线程变量
            final Thread t = w.thread;
            if (t != null) {
                ......
                if (workerAdded) {
                    //启动线程,开始处理任务
                    t.start();
                    workerStarted = true;
                }
            }
        }
        return workerStarted;
}

4.1.3、Worker线程的启动疑问及答案

我读到Worker线程启动这一段源码的时候,就有点纳闷源码中直接启动的就是Worker类的中thread,和我们的Worker类并没有关系啊。我们平时启动一个Runnable任务,通常是下面这样的

Thread thread = new Thread(new Runnable() {

       @Override
       public void run() {
           System.out.println("执行异步任务");
       }
});
thread.start();

但是在源码中,并没有看到类似的代码。
这个问题的答案就在于Worker类的这个thread变量,我们看看这个thread是怎么生成的。
thread的生成是在Worker类的构造器中,我们在上面已经看过Worker类构造器的实现,再拿出来看一下

Worker(Runnable firstTask) {
    //直到runWorker执行前,抑制中断
    setState(-1);

    //初始化任务赋值
    this.firstTask = firstTask;

    //创建一个线程
    this.thread = getThreadFactory().newThread(this);
 }

可以看到thread是通过线程工厂的一个newThread方法创建的,入参是Worker对象本身。threadFactory,是ThreadPoolExecutor的一个入参。如果未指定的话,有一个默认的threadFactory,默认实现很简单,就不提了。我们直接看newThread方法

public Thread newThread(Runnable r) {
       Thread t = new Thread(group, r,
                             namePrefix + threadNumber.getAndIncrement(),
                             0);
       if (t.isDaemon())
           t.setDaemon(false);
       if (t.getPriority() != Thread.NORM_PRIORITY)
           t.setPriority(Thread.NORM_PRIORITY);
       return t;
}

new Thread的时候,将Worker作为参数传递进了Thread的构造器中,最终被赋予给了Thread对象的一个target变量。好了,知道Worker被赋予给了Thread对象target变量就可以了。为什么呢?
我们上面讲到,在线程池中,只启动了Worker类中的thread。我们知道java的Thread启动后,会执行Thread中的run方法,我们看一下Thread中的run方法

@Override
public void run() {
     if (target != null) {
         target.run();
     }
}

看到target应该就明白了吧。直接就执行了target的run方法,这样就间接启动了Worker类中的run方法。这就和Worker类关联上了。在Worker类中的run方法,又调用了runWorker方法

4.1.4、Worker线程执行任务

public void run() {
    runWorker(this);
}

runWorker方法的实现如下:

final void runWorker(Worker w) {
        ......
        Runnable task = w.firstTask;
        w.firstTask = null;
        ......
        try {
            //线程池首个任务不为空。或者从队列中获取到的任务不为空
            while (task != null || (task = getTask()) != null) {
                //加锁
                w.lock();
                ......
                try {
                    //任务执行前,执行前置方法
                    beforeExecute(wt, task);
                    .....
                    try {
                        //执行任务
                        task.run();
                    } catch (RuntimeException x) {
                        thrown = x; throw x;
                    } catch (Error x) {
                        thrown = x; throw x;
                    } catch (Throwable x) {
                        thrown = x; throw new Error(x);
                    } finally {
                        //任务执行完成,执行后置方法
                        afterExecute(task, thrown);
                    }
                }
            }
            ......
        }
    }

从Worker类中取出需要执行的任务firstTask。
如果firstTask不为空,则执行firstTask的run方法,这就相当于把我们定义的任务给执行了。如果firstTask为空,则执行getTask方法。getTask方法的逻辑如下:

private Runnable getTask() {
	//最后一次拉取是否超时
    boolean timedOut = false;

    for (;;) {
        int c = ctl.get();
        
        //获取当前线程的数量
        int wc = workerCountOf(c);

        //worker是否会被杀掉的判断条件
        //允许核心线程关闭或者当前线程数已经超过了核心线程
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
        //(worker数量大于最大线程数 或者 超过keepAliveTime后,还是未从队列中获取到任务)并且(worker数量大于1 或者 工作队列为空)
        if ((wc > maximumPoolSize || (timed && timedOut))
            && (wc > 1 || workQueue.isEmpty())) {
            //这个wc > 1的意思是:如果我们允许销毁核心线程,那核心线程就会一直减。但是我们不能减没,因为最少也得留一个线程来处理队列中的任务。除非,我们的队列是空的。
            //降低worker数量
            if (compareAndDecrementWorkerCount(c))
                return null;
            continue;
        }

        try {
            //从队列获取任务。如果设置了keepAliveTime,等待keepAliveTime时间后还是未获取到任务,会将timedOut变量置为true,这就代表最后一次拉取超时,说明队列中已经没有任务。
            Runnable r = timed ?
                workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                workQueue.take();
            if (r != null)
                //返回从队列中获取到的任务
                return r;
            timedOut = true;
        } catch (InterruptedException retry) {
            timedOut = false;
        }
        }
 }

getTask方法,就是从队列中获取任务执行,那队列中的任务是何时放入的呢?看下一节

4.2、核心线程已满,新来的请求缓存进队列

继续回到ThreadPoolExecutor的execute方法中。如果已达核心线程,此时会将请求放入队列中缓存

//判断条件。线程池正在运行 && 队列可以继续接受任务
if (isRunning(c) && workQueue.offer(command)) {
    int recheck = ctl.get();
    //线程池没有在运行并且从队列移除任务成功
    if (! isRunning(recheck) && remove(command))
        //拒绝任务,此时会按照拒绝策略执行
        reject(command);
    else if (workerCountOf(recheck) == 0)
        //如果线程数量是0,此时再次增加线程,但这批线程不是核心线程
        addWorker(null, false);
}

这段逻辑比较简单,核心逻辑就是将任务放入队列中,中间有一些对线程池运行状态以及线程池线程数量的校验逻辑。新增加到队列中的任务,会继续被3.1中的getTask方法处理,这样整个循环就转起来了

4.3、队列已满,继续创建线程至最大线程数

还是在ThreadPoolExecutor的execute方法中,如果线程池没有在运行或者队列已满,此时就会执行这一逻辑

//队列已满,再次尝试添加线程
else if (!addWorker(command, false))
    //最大线程添加失败,此时会拒绝任务,执行拒绝策略
    reject(command);

4.4、已达最大线程,执行拒绝策略

3.3里我们看到添加线程失败后会执行拒绝方法reject。看一下reject方法的逻辑

final void reject(Runnable command) {
	 //handler是RejectedExecutionHandler接口
     handler.rejectedExecution(command, this);
}

4.4.1、拒绝策略的分类

handler是RejectedExecutionHandler接口,这个接口有4种实现。我们分别看一下
1)、直接抛出异常。这是默认实现

public static class AbortPolicy implements RejectedExecutionHandler {
    public AbortPolicy() { }

        
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        throw new RejectedExecutionException("Task " + r.toString() +
                                             " rejected from " +
                                             e.toString());
    }
}

2)、丢弃较早进入队列的任务
较早进入听着有点别扭。这里稍微展开说下。比如:我们使用的是ArrayBlockingQueue。这个队列内部存储元素是用数组来实现的。
从下标为0的位置开始入队,从下标为0的位置开始出队,典型的FIFO。较早进入的元素,也就是索引下标较小的位置存储的元素,如果使用这个拒绝策略,下标越小的会被丢弃。这样说,大家应该能理解吧。

public static class DiscardOldestPolicy implements RejectedExecutionHandler {
    public DiscardOldestPolicy() { }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
            e.getQueue().poll();
            e.execute(r);
        }
    }
}

可以看到,如果线程池没有关闭。会执行两步操作。较老的任务出队,以及再次尝试执行任务。所以丢弃较老的任务,并不是直接丢弃。这个小知识点,不看源码的话,是不知道的。
3)、静默丢弃任务
这个拒绝策略实现的rejectedExecution方法中,啥也没有,一个空方法实现。这也就代表着,任务投递进来,并不会执行。相当于静默抛弃任务

public static class DiscardPolicy implements RejectedExecutionHandler {
    public DiscardPolicy() { }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    }
}

4)、主线程执行

public static class CallerRunsPolicy implements RejectedExecutionHandler {
        
    public CallerRunsPolicy() { }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
            r.run();
        }
    }
}

这个拒绝策略,直接就执行了Runnable任务的run方法,执行这个方法的线程和执行threadPoolExecutor.execute的线程是同一个线程。也就是主线程执行任务,相当于不走异步。显而易见,任务量大的情况下,会直接影响主线程的性能。当然也有好处,如果一些控制端,对响应时间不敏感,并且不想让任务执行失败,就可以选择这种拒绝策略,确保每一个任务都执行成功

以上,我们就借着jdk线程池的两道面试题,把线程池的execute方法的主要流程过了一遍。当然,ThreadPoolExecutor还有其他的方法,我们没有说到,包括很多实现的细节也并没有说到。但是,主流程已经清晰了,细节自己看看,不行的话,再查查,基本就能弄明白

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

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

相关文章

web项目打包成可以离线跑的exe软件

目录 引言打开PyCharm安装依赖创建 Web 应用运行应用程序打包成可执行文件结语注意事项 引言 在开发桌面应用程序时&#xff0c;我们经常需要将网页集成到应用程序中。Python 提供了多种方法来实现这一目标&#xff0c;其中 pywebview 是一个轻量级的库&#xff0c;它允许我们…

【渗透工具】内网多级代理工具Venom详细使用教程

免责申明 本公众号的技术文章仅供参考&#xff0c;此文所提供的信息只为网络安全人员对自己所负责的网站、服务器等&#xff08;包括但不限于&#xff09;进行检测或维护参考&#xff0c;未经授权请勿利用文章中的技术资料对任何计算机系统进行入侵操作。利用此文所提供的信息…

SecureBoost:一种无损的联邦学习框架

SecureBoost&#xff1a;一种无损的联邦学习框架 文章目录 SecureBoost&#xff1a;一种无损的联邦学习框架1 引言2 预备知识与相关工作3 问题描述4 联邦学习与SecureBoost5 联邦推理6 无损属性的理论分析7 安全讨论8 实验9 结论 摘要——用户隐私保护是机器学习中的一个重要问…

MySQL高级-SQL优化- limit优化(覆盖索引加子查询)

文章目录 0、limit 优化0.1、从表 tb_sku 中按照 id 列进行排序&#xff0c;然后跳过前 9000000 条记录0.2、通过子查询获取按照 id 排序后的第 9000000 条开始的 10 条记录的 id 值&#xff0c;然后在原表中根据这些 id 值获取对应的完整记录 1、上传5个sql文件到 /root2、查看…

AV Foundation学习笔记二 - 播放器

ASSets AVFoundation框架的最核心的类是AVAsset&#xff0c;该类是整个AVFoundation框架设计的中心。AVAsset是一个抽象的&#xff08;意味着你不能调用AVAsset的alloc或者new方法来创建一个AVAsset实例对象&#xff0c;而是通过该类的静态方法来创建实例对象&#xff09;、不…

Python:探索高效、智能的指纹识别技术(简单易懂)

目录 概括 导入库 函数一 参数&#xff1a; 函数二 函数三 主函数 运行结果 src&#xff1a; model_base 7.bmp ​编辑 总结 概括 指纹识别是一种基于人体生物特征的身份验证技术。它通过捕捉和分析手指上的独特纹路和细节特征&#xff0c;实现高准确度的身份识别。…

多地高温持续“热力”爆表 约克VRF中央空调带你清凉舒爽一夏

“出门5分钟&#xff0c;流汗2小时”,夏季高温天气&#xff0c;怎一个“热”字了得&#xff1f;6月以来&#xff0c;我国多地迎来高温“炙烤”&#xff0c;全国出现40℃以上高温的范围持续增加&#xff0c;随着中央气象台高温预警持续拉响&#xff0c;人们都很纳闷&#xff1a;…

springboot + Vue前后端项目(第二十一记)

项目实战第二十一记 写在前面1. springboot文件默认传输限制2. 安装视频插件包命令3. 前台Video.vue4. 创建视频播放组件videoDetail.vue5. 路由6. 效果图总结写在最后 写在前面 本篇主要讲解系统集成视频播放插件 1. springboot文件默认传输限制 在application.yml文件中添…

5. Spring IoCDI ★ ✔

5. Spring IoC&DI 1. IoC & DI ⼊⻔1.1 Spring 是什么&#xff1f;★ &#xff08;Spring 是包含了众多⼯具⽅法的 IoC 容器&#xff09;1.1.1 什么是容器&#xff1f;1.1.2 什么是 IoC&#xff1f;★ &#xff08;IoC: Inversion of Control (控制反转)&#xff09;总…

2.用BGP对等体发送路由

2.用BGP对等体发送路由 实验拓扑&#xff1a; 实验要求&#xff1a;用BGP对等体发送路由信息 实验步骤&#xff1a; 1.完成基本配置&#xff08;略&#xff09; 2.建立BGP对等体&#xff08;略&#xff09; 3.创建路由信息&#xff08;用创建一个loop back接口就能产生一个直连…

毅速丨金属3D打印是制造业转型升级的重要技术

随着科技的进步&#xff0c;金属3D打印技术已成为制造业升级的重要驱动力。它以其独特的优势&#xff0c;正引领着制造业迈向新的未来。 金属3D打印技术的突破&#xff1a; 设计自由。金属3D打印能制造任意形状和结构的零件&#xff0c;为设计师提供了无限的创意空间。 快速制…

AI数据分析003:用kimi生成一个正弦波数学动画

文章目录 一、正弦波公式二、输入内容三、输出内容一、正弦波公式 ƒ(x) = a * sin(x + x0) + b 公式中: a: 决定正弦函数振动幅度的大小; x0:表示x开始比0拖后的弧度值; b:表示函数偏离X轴的距离; 对于难以理解的学生来说,可以用动画把这个公式直观的展现出来。 二…

深入理解 XML 和 HTML 之间的区别

在现代网络技术的世界中&#xff0c;XML&#xff08;可扩展标记语言&#xff09;和 HTML&#xff08;超文本标记语言&#xff09; 是两个非常重要的技术。尽管它们都使用标签和属性的格式来描述数据&#xff0c;但它们在形式和用途上有显著的区别。 概述 什么是 XML&#xff…

【51单片机入门】数码管原理

文章目录 前言共阴极与共阳极数码管多个数码管显示原理 总结 前言 在我们的日常生活中&#xff0c;数码管被广泛应用于各种电子设备中&#xff0c;如电子表、计时器、电子钟等。数码管的主要功能是显示数字和一些特殊字符。在这篇文章中&#xff0c;我们将探讨数码管的工作原理…

搭建ASPP:多尺度信息提取网络

文章目录 介绍代码实现 介绍 ASPP&#xff08;Atrous Spatial Pyramid Pooling&#xff09;&#xff0c;空洞空间卷积池化金字塔。简单理解就是个至尊版池化层&#xff0c;其目的与普通的池化层一致&#xff0c;尽可能地去提取特征。ASPP 的结构如下&#xff1a; 如图所示&…

容联云容犀Desk在线客服:全渠道+全场景+全智能辅助,提升客户体验

如今&#xff0c;客户体验已经从基础的对话、交易、业务办理&#xff0c;转变为深度的生活联结、情感共鸣、价值认可。客户期待的转变&#xff0c;也让更多企业越发重视“以客户为中心”的业务增长战略。 容犀Desk营销服统一体验工作空间应运而生&#xff0c;其核心能力在线客…

INDEMIND:智效赋能,让服务机器人服务于人

商用清洁机器人的价值战。 随着行业发展势头回归冷静&#xff0c;“卖家秀”时代成为过去&#xff0c;机器人拼技术、拼产品的价值战时代已然到来。 庞大的前景是香饽饽也是镜中花 作为被业内寄予厚望的服务机器人之一&#xff0c;背后的信心是来自于明确的需求和庞大的市场…

JAVA-矩阵置零

给定一个 m x n 的矩阵&#xff0c;如果一个元素为 0 &#xff0c;则将其所在行和列的所有元素都设为 0 。请使用 原地 算法。 思路&#xff1a; 找到0的位置&#xff0c;把0出现的数组的其他值夜置为0 需要额外空间方法&#xff1a; 1、定义两个布尔数组标记二维数组中行和列…

vue3 【提效】自动路由(含自定义路由) unplugin-vue-router 实用教程

不再需要为每一个路由编写冗长的 routes 配置啦&#xff0c;新建文件便可自动生成路由&#xff01; 使用方法 1. 安装 unplugin-vue-router npm i -D unplugin-vue-router2. 修改 vite 配置 vite.config.ts import VueRouter from unplugin-vue-router/viteplugins 中加入 V…

【前端】简易化看板

【前端】简易化看板 项目简介 看板分为三个模块&#xff0c;分别是待办&#xff0c;正在做&#xff0c;已做完三个部分。每个事件采取"卡片"式设计&#xff0c;支持任务间拖拽&#xff0c;删除等操作。 代码 import React, { useState } from react; import { Car…