前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >线程池的实现原理

线程池的实现原理

作者头像
Java架构师必看
发布2021-04-23 14:42:59
6130
发布2021-04-23 14:42:59
举报
文章被收录于专栏:Java架构师必看

线程池的实现原理

线程池做的工作主要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数超过了最大数量超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

什么是线程池

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。线程过多会带来额外的开销,其中包括创建销毁线程的开销调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

Java 中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来4个好处: 1)、降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 2)、提高响应速度。当任务达到时,任务可以不需要等到线程创建就能立即执行。 3)、**提高线程的可管理性。**线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用线程池,必须对其实现原理了如指掌。 4)、提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。

线程池解决的问题是什么

线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题: 【1】频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大; 【2】对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险; 【3】系统无法合理管理内部的资源分布,会降低系统的稳定性; 为解决资源分配这个问题,线程池采用了“池化”(Pooling)思想。池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。在计算机领域中的表现为:统一管理IT资源,包括服务器、存储、和网络资源等等。除了线程池,还有其他比较典型的几种使用策略包括: 【1】内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。 【2】连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。 【3】实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。

线程池核心设计与实现

Java中的线程池核心实现类是 ThreadPoolExecutor,还有一个工具类 **Excutors。**本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下 ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。

ThreadPoolExecutor 实现的顶层接口是Executor,顶层接口 Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供 Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由 Executor框架完成线程的调配和任务的执行部分。 ExecutorService接口增加了一些能力:(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成 Future的方法;(2)提供了管控线程池的方法,比如停止线程池的运行。 AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。最下层的实现类 ThreadPoolExecutor实现最复杂的运行部分。 ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程任务,使两者良好的结合从而执行并行任务。

线程池生命周期

线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部维护。线程池内部使用一个32 位的整数维护两个值:运行状态(runState)和线程数量 (workerCount) 两个参数维护在一起,其中高 3位用于存放线程池状态低 29 位表示线程数(CAPACITY)。如下代码所示:

代码语言:javascript
复制
// 状态 RUNNING 线程数 = 0
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// 这里 COUNT_BITS 设置为 29(32-3),意味着前三位用于存放线程状态,后29位用于存放线程数
private static final int COUNT_BITS = Integer.SIZE - 3;
//最大线程数是 2^29-1=536870911
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
//111 00000000000000000000000000000
private static final int RUNNING    = -1 << COUNT_BITS;
// 000 00000000000000000000000000000
private static final int SHUTDOWN   =  0 << COUNT_BITS;
// 001 00000000000000000000000000000
private static final int STOP       =  1 << COUNT_BITS;
// 010 00000000000000000000000000000
private static final int TIDYING    =  2 << COUNT_BITS;
// 011 00000000000000000000000000000
private static final int TERMINATED =  3 << COUNT_BITS;

// Packing and unpacking ctl
//将CAPACITY取非后和c进行取与运算,可以得到高3位的值,即线程池的状态
private static int runStateOf(int c)     {
    return c & ~CAPACITY; }
//将c和CAPACITY取与运算,可以得到低29位的值,即线程池的个数
private static int workerCountOf(int c)  {
    return c & CAPACITY; }
private static int ctlOf(int rs, int wc) {
    return rs | wc; }

ThreadPoolExecutor 的运行状态有5种,其生命周期转换如下入所示:

**【1】RUNNING:**能接收新提交的任务,也能处理阻塞队列中的任务; 【2】SHUTDOWN:关闭状态,不再接收新提交的任务,但却可以继续处理阻塞队列中已保存的消息; **【3】STOP:**不接受任务,也不处理阻塞队列中的消息,会中断正在执行的任务; **【4】TIDYING:**所有的任务已终止,workerCount(有效线程数为0); **【5】TERMINATED:**在 terminated()方法执行完后进入该状态;

线程池处理任务的流程

ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?线程池处理任务的流程图如下:

【从图中可以看出,当提交一个任务到线程池时,线程池处理流程如下】: 1)、首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在 RUNNING的状态下执行任务。 2)、线程池判断核心线程池里的线程数是否已达到上限,如果没有,就创建新线程执行任务。如果已达到上限,进入步骤3。 3)、线程池判断工作队列是否已满。如果工作队列没满,就将任务存储在队列中。如果队列满了,则进入步骤4。 4)、线程池判断线程池中的线程数是否已达上限,没有就创建新线程执行任务,如果已满则根据饱和策略处理此任务。默认的处理方式是直接抛异常。

ThreadPoolExecutor 执行 execute() 方法的示意图,如下所示:

ThreadPoolExecutor 执行 execute 方法分下面4中情况: 1)、如果当前运行的线程少于 corePoolSize,则创建新线程来执行任务(执行需要获取全局锁)。 2)、如果运行的线程等于或大余 corePoolSize,则将任务加入 BlockingQueue。 3)、如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(需要获取全局锁) 4)、如果创建新线程将使当前运行的线程超过 maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecutor() 方法。

ThreadPoolExecutor 采取上述步骤的总体设计思路,是为了在执行 execute() 方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在 ThreadPoolExecutor 完成预热之后(当前运行线程数大于等于 corePoolSize),几乎所有的 execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。

源码分析

通过上面流程图的分析直观的了解了线程池的工作原理,下面就通过源码看看是如何实现的,方法如下:

代码语言:javascript
复制
public void execute(Runnable command) {
   
    if (command == null)
        throw new NullPointerException();

    int c = ctl.get();
      //如果当前的线程数小于corePoolSize
    if (workerCountOf(c) < corePoolSize) {
   
          //调用addWorker新建一个线程
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
       // 到这里说明,要么当前线程数大于等于核心线程数,要么刚刚 addWorker 失败了
      //校验当前线程状态是RUNNING,并将command入队
    if (isRunning(c) && workQueue.offer(command)) {
   
        int recheck = ctl.get();
          //如果不是运行状态,那么移除队列,并执行拒绝策略
        if (! isRunning(recheck) && remove(command))
            reject(command);
        // 如果线程池还是 RUNNING 的,并且线程数为 0,那么开启新的线程
          //防止任务提交到队列中了,但是线程都关闭了
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
      //到这里说明队列已经满了,所以新建一个线程,如果新建的线程数已经超过了maximumPoolSize,那么执行拒绝策略
    else if (!addWorker(command, false))
        reject(command);
}

我们下面看一下 addWorker是如何创建线程的:

代码中的 retry: 就是一个标记,标记对一个循环方法的操作(continue和break)处理点,功能类似于goto,所以 retry一般都是伴随着 for循环出现,retry:标记的下一行就是for循环,在for循环里面调用continue(或者break)再紧接着 retry标记时,就表示从这个地方开始执行 continue(或者break)操作

代码语言:javascript
复制
private boolean addWorker(Runnable firstTask, boolean core) {
   
    retry:
    for (;;) {
   
        int c = ctl.get();
          //获取当前线程池状态
        int rs = runStateOf(c);

        // 1.仅在必要时检查队列是否为空。
        if (rs >= SHUTDOWN &&
            ! (rs == SHUTDOWN &&
               firstTask == null &&
               ! workQueue.isEmpty()))
            return false;

        for (;;) {
   
            int wc = workerCountOf(c);
               //2. 校验传入的线程数是否超过了容量大小, 或者是否超过了corePoolSize或maximumPoolSize
            if (wc >= CAPACITY ||
                wc >= (core ? corePoolSize : maximumPoolSize))
                return false;
              //到了这里说明线程数没有超,那么就用CAS将线程池的个数加1
            if (compareAndIncrementWorkerCount(c))
                break retry;
            c = ctl.get();  // Re-read ctl
              //3 说明有其他的线程抢先更新了状态,继续下一轮的循环,跳到外层循环
            if (runStateOf(c) != rs)
                continue retry;
            // else CAS failed due to workerCount change; retry inner loop
        }
    }

    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
   
          //创建一个线程
        w = new Worker(firstTask);
        final Thread t = w.thread;
        if (t != null) {
   
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
   
                // Recheck while holding lock.
                // Back out on ThreadFactory failure or if
                // shut down before lock acquired.
                int rs = runStateOf(ctl.get());
                  //4 如果线程是没有问题的话,那么将worker加入到队列中
                if (rs < SHUTDOWN ||
                    (rs == SHUTDOWN && firstTask == null)) {
   
                    if (t.isAlive()) // precheck that t is startable
                        throw new IllegalThreadStateException();
                    workers.add(w);
                    int s = workers.size();
                    // largestPoolSize 用于记录 workers 中的个数的最大值
                    // 因为 workers 是不断增加减少的,通过这个值可以知道线程池的大小曾经达到的最大值
                    if (s > largestPoolSize)
                        largestPoolSize = s;
                    workerAdded = true;
                }
            } finally {
   
                mainLock.unlock();
            }
               //如果worker入队成功,那么启动线程
            if (workerAdded) {
   
                t.start();
                workerStarted = true;
            }
        }
    } finally {
   
          //如果worker启动失败,那么就回滚woker线程创建的状态
        if (! workerStarted)
            addWorkerFailed(w);
    }
      // 返回线程是否启动成功
    return workerStarted;
}

这里主要是列举了几个条件不能创建新的 worker的情况: 【1】线程池状态大于 SHUTDOWN,其实也就是 STOP, TIDYING, 或 TERMINATED; 【2】firstTask != null; 【3】workQueue.isEmpty() 如果线程池处于 SHUTDOWN,但是 firstTask 为 null,且 workQueue 非空,那么是允许创建 worker 的;

如果传入的 core参数是 true代表使用核心线程数 corePoolSize 作为创建线程的界限,也就说创建这个线程的时候,如果线程池中的线程总数已经达到 corePoolSize,那么不能响应这次创建线程的请求;如果是false,代表使用最大线程数 maximumPoolSize 作为界限;

如果 CAS失败并不是因为有其他线程在操作导致的,那么就直接在里层循环继续下一次的循环就好了,如果是因为其他线程的操作,导致线程池的状态发生了变更,如有其他线程关闭了这个线程池,那么需要回到外层的 for循环;

如果是小于 SHUTTDOWN 那就是 RUNNING,则继续往下继续,或者状态是 SHUTDOWN但是传入的 firstTask为空,代表继续处理队列中的任务

**addWorkerFailed:**将 workers集合里面的 worker移除,然后 count减1,

代码语言:javascript
复制
private void addWorkerFailed(Worker w) {
   
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
   
        if (w != null)
            workers.remove(w);
        decrementWorkerCount();
        tryTerminate();
    } finally {
   
        mainLock.unlock();
    }
}

工作线程

线程池创建线程时,会将线程封装成工作线程 Worker,Worker是继承 AQS对象的,在创建 Worker对象的时候会传入一个Runnable对象,并设置 AQS的 state状态为 -1,并从线程工厂中新建一个线程。调用 thread.start方法会调用到 Worker的 run方法中。

代码语言:javascript
复制
private final class Worker extends AbstractQueuedSynchronizer implements Runnable
{
   

    final Thread thread;
    /** Initial task to run. Possibly null. */
    Runnable firstTask;
    /** Per-thread task counter */
    volatile long completedTasks;

    Worker(Runnable firstTask) {
   
        setState(-1); // inhibit interrupts until runWorker
        this.firstTask = firstTask;
        this.thread = getThreadFactory().newThread(this);
    }
      ....
}

Worker 在执行完任务后,还会循环获取工作队列里的任务来执行。我们可以从Worker 类的 run()方法里看到这点。

代码语言:javascript
复制
public void run() {
   
    runWorker(this);
}

Worker的 run方法会调用到 ThreadPoolExecutor的 runWorker方法

代码语言:javascript
复制
final void runWorker(Worker w) {
   
    Thread wt = Thread.currentThread();
    Runnable task = w.firstTask;
    w.firstTask = null;
    w.unlock(); // allow interrupts
    boolean completedAbruptly = true;
    try {
   
          //如果task为空,那么就从workQueue里面获取task
        while (task != null || (task = getTask()) != null) {
   
            w.lock();
              // 如果线程池状态大于等于 STOP,那么意味着该线程也要中断
            if ((runStateAtLeast(ctl.get(), STOP) ||
                 (Thread.interrupted() &&
                  runStateAtLeast(ctl.get(), STOP))) &&
                !wt.isInterrupted())
                wt.interrupt();
            try {
   
                  // 这是一个钩子方法
                beforeExecute(wt, task);
                Throwable thrown = null;
                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);
                }
            } finally {
   
                   // 置空 task,准备 getTask 获取下一个任务
                task = null;
                w.completedTasks++;
                w.unlock();
            }
        }
        completedAbruptly = false;
    } finally {
   
          //异常情况或getTask获取不到任务时会执行关闭
        processWorkerExit(w, completedAbruptly);
    }
}

传入一个 Worker首先去校验 firstTask是不是 null,如果是那么就调用 getTask方法从 workQueue队列里面获取,然后判断一下当前的线程是否需要中断,如需要的话执行钩子方法,然后调用 task的 run方法执行 task; 如果 while循环里面 getTask获取不到任务的话,就结束循环调用 processWorkerExit方法执行关闭;如果是异常原因导致的 while循环退出,那么会调用 processWorkerExit并传入为 true;

ThreadPoolExecutor 中线程执行任务的示意图如下:

线程池中的线程执行分为两种情况,如下: 【1】在 execute() 方法中创建一个线程时,会让这个线程执行当前任务。 【2】这个线程完成1的任务后,会反复从BlockingQueue 获取任务来执行。 【Java 线程池中的核心线程是如何被重复利用的】**:**看一下 runWorker()方法的代码,有一个 while循环,当执行完 firstTask后task==null了,那么就会执行判断条件 (task = getTask()) != null,我们假设这个条件成立的话,那么这个线程就不止只执行一个任务了,可以执行多个任务了,也就实现了重复利用了。答案呼之欲出了,接着看 **getTask()**方法。

代码语言:javascript
复制
// 为分析而简化后的代码
private Runnable getTask() {
   
    boolean timedOut = false;
    for (;;) {
   
        int c = ctl.get();
        int wc = workerCountOf(c);

        // timed变量用于判断是否需要进行超时控制。
        // allowCoreThreadTimeOut默认是false,也就是核心线程不允许进行超时;
        // wc > corePoolSize,表示当前线程池中的线程数量大于核心线程数量;
        // 对于超过核心线程数量的这些线程,需要进行超时控制
        boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

        if (timed && timedOut) {
   
            // 如果需要进行超时控制,且上次从缓存队列中获取任务时发生了超时,那么尝试将workerCount减1,即当前活动线程数减1,
            // 如果减1成功,则返回null,这就意味着runWorker()方法中的while循环会被退出,其对应的线程就要销毁了,也就是线程池中少了一个线程了
            if (compareAndDecrementWorkerCount(c))
            return null;
            continue;
        }

        try {
   
            Runnable r = timed ?
            workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
            workQueue.take();

            // 注意workQueue中的poll()方法与take()方法的区别
            //poll方式取任务的特点是从缓存队列中取任务,最长等待keepAliveTime的时长,取不到返回null
            //take方式取任务的特点是从缓存队列中取任务,若队列为空,则进入阻塞状态,直到能取出对象为止

            if (r != null)
            return r;
            timedOut = true;
            } catch (InterruptedException retry) {
   
                timedOut = false;
        }
    }
}

从以上代码可以看出,getTask()的作用:如果当前活动线程数大于核心线程数,当去缓存队列中取任务的时候,如果缓存队列中没任务了,则等待 keepAliveTime的时长,此时还没任务就返回null,这就意味着 runWorker()方法中的 while循环会被退出,其对应的线程就要销毁了,也就是线程池中少了一个线程了。因此只要线程池中的线程数大于核心线程数就会这样一个一个地销毁这些多余的线程。如果当前活动线程数小于等于核心线程数,同样也是去缓存队列中取任务,但当缓存队列中没任务了,就会进入阻塞状态,直到能取出任务为止,因此这个线程是处于阻塞状态的,并不会因为缓存队列中没有任务了而被销毁。这样就保证了线程池有N个线程是活的,可以随时处理任务,从而达到重复利用的目的。

上面方法返回 null有如下几种情况: 【1】当前状态是 SHUTDOWN并且 workQueue队列为空; 【2】当前状态是 STOP及以上; 【3】池中有大于 maximumPoolSize 个 workers 存在(通过调用 setMaximumPoolSize 进行设置);

processWorkerExit 方法源码:

代码语言:javascript
复制
private void processWorkerExit(Worker w, boolean completedAbruptly) {
   
      //如果是异常原因中断,那么需要将运行线程数减一
    if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
        decrementWorkerCount();

    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
   
          //设置完成任务数
        completedTaskCount += w.completedTasks;
          //将worker从集合里移除
        workers.remove(w);
    } finally {
   
        mainLock.unlock();
    }
      //判断当前的线程池是否处于SHUTDOWN状态,判断是否要终止线程
    tryTerminate();

    int c = ctl.get();
      //如果是RUNNING或SHUTDOWN则会进入这个方法
    if (runStateLessThan(c, STOP)) {
   
          //如不是以外中断则会往下走
        if (!completedAbruptly) {
   
              //判断是否保留最少核心线程数
            int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
            if (min == 0 && ! workQueue.isEmpty())
                min = 1;
            if (workerCountOf(c) >= min)
                return; // replacement not needed
        }
          //如果当前运行的Worker数比当前所需要的Worker数少的话,那么就会调用addWorker,添加新的Worker
        addWorker(null, false);
    }
}

【1】判断是否是意外退出的,如果是意外退出的话,那么就需要把 WorkerCount–; 【2】加完锁后,同步将 completedTaskCount进行增加,表示总共完成的任务数,并且从 WorkerSet中将对应的 Worker移除; 【3】调用tryTemiate,进行判断当前的线程池是否处于 SHUTDOWN状态,判断是否要终止线程; 【4】判断当前的线程池状态,如果当前线程池状态比 STOP大的话,就不处理; 【5】判断是否是意外退出,如果不是意外退出的话,那么就会判断最少要保留的核心线程数,如果allowCoreThreadTimeOut被设置为true的话,那么说明核心线程在设置的KeepAliveTime之后,也会被销毁; 【6】如果最少保留的 Worker数为0的话,那么就会判断当前的任务队列是否为空,如果任务队列不为空的话而且线程池没有停止,那么说明至少还需要1个线程继续将任务完成; 【7】判断当前的 Worker是否大于min,也就是说当前的 Worker总数大于最少需要的 Worker数的话,那么就直接返回,因为剩下的 Worker会继续从 WorkQueue中获取任务执行; 】加完锁后,同步将 completedTaskCount进行增加,表示总共完成的任务数,并且从 WorkerSet中将对应的 Worker移除; 【3】调用tryTemiate,进行判断当前的线程池是否处于 SHUTDOWN状态,判断是否要终止线程; 【4】判断当前的线程池状态,如果当前线程池状态比 STOP大的话,就不处理; 【5】判断是否是意外退出,如果不是意外退出的话,那么就会判断最少要保留的核心线程数,如果allowCoreThreadTimeOut被设置为true的话,那么说明核心线程在设置的KeepAliveTime之后,也会被销毁; 【6】如果最少保留的 Worker数为0的话,那么就会判断当前的任务队列是否为空,如果任务队列不为空的话而且线程池没有停止,那么说明至少还需要1个线程继续将任务完成; 【7】判断当前的 Worker是否大于min,也就是说当前的 Worker总数大于最少需要的 Worker数的话,那么就直接返回,因为剩下的 Worker会继续从 WorkQueue中获取任务执行; 【8】如果当前运行的 Worker数比当前所需要的 Worker数少的话,那么就会调用 addWorker,添加新的 Worker,也就是新开启线程继续处理任务;

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是线程池
  • 线程池解决的问题是什么
  • 线程池核心设计与实现
  • 线程池生命周期
  • 线程池处理任务的流程
  • 源码分析
  • 工作线程
相关产品与服务
云服务器
云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档