文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

并发编程之定时任务&定时线程池原理解析

2024-12-03 16:21

关注

 前言

线程池的具体实现有两种,分别是ThreadPoolExecutor 默认线程池和ScheduledThreadPoolExecutor 定时线程池,上一篇已经分析过ThreadPoolExecutor原理与使用了,本篇我们来重点分析下ScheduledThreadPoolExecutor的原理与使用。

《并发编程之Executor线程池原理与源码解读》

ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor 与 ThreadPoolExecutor 线程池的概念有些区别,它是一个支持任务周期性调度的线程池。

ScheduledThreadPoolExecutor 继承 ThreadPoolExecutor,同时通过实现 ScheduledExecutorSerivce 来扩展基础线程池的功能,使其拥有了调度能力。其整个调度的核心在于内部类 DelayedWorkQueue ,一个有序的延时队列。

定时线程池类的类结构图如下:

ScheduledThreadPoolExecutor 的出现,很好的弥补了传统 Timer 的不足,具体对比看下表:

TimerScheduledThreadPoolExecutor线程单线程多线程多任务任务之间相互影响任务之间不影响调度时间绝对时间相对时间异常单任务异常,后续任务受影响无影响

工作原理

它用来处理延时任务或定时任务

它接收SchduledFutureTask类型的任务,是线程池调度任务的最小单位,有三种提交任务的方式:

  1. schedule,特定时间延时后执行一次任务
  2. scheduledAtFixedRate,固定周期执行任务(与任务执行时间无关,周期是固定的)
  3. scheduledWithFixedDelay,固定延时执行任务(与任务执行时间有关,延时从上一次任务完成后开始)

它采用 DelayedWorkQueue 存储等待的任务

  1. DelayedWorkQueue 内部封装了一个 PriorityQueue ,它会根据 time 的先后时间排序,若 time 相同则根据 sequenceNumber 排序;
  2. DelayedWorkQueue 也是一个无界队列;

因为前面讲阻塞队列实现的时候,已经对DelayedWorkQueue进行了说明,更多内容请查看《阻塞队列 — DelayedWorkQueue源码分析》

工作线程的执行过程:

take方法是什么时候调用的呢? 在ThreadPoolExecutor中,getTask方法,工作线程会循环地从workQueue中取任务。但定时任务却不同,因为如果一旦getTask方法取出了任务就开始执行了,而这时可能还没有到执行的时间,所以在take方法中,要保证只有在到指定的执行时间的时候任务才可以被取走。

PS:对于以上原理的理解,可以通过下面的源码分析加深印象。

源码分析

构造方法

ScheduledThreadPoolExecutor有四个构造形式:

  1. public ScheduledThreadPoolExecutor(int corePoolSize) { 
  2.  super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, 
  3.   new DelayedWorkQueue()); 
  4.  
  5. public ScheduledThreadPoolExecutor(int corePoolSize, 
  6.                                        ThreadFactory threadFactory) { 
  7.  super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, 
  8.      new DelayedWorkQueue(), threadFactory); 
  9.  
  10. public ScheduledThreadPoolExecutor(int corePoolSize, 
  11.                                        RejectedExecutionHandler handler) { 
  12.  super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, 
  13.   new DelayedWorkQueue(), handler); 
  14.  
  15. public ScheduledThreadPoolExecutor(int corePoolSize, 
  16.                                        ThreadFactory threadFactory, 
  17.                                        RejectedExecutionHandler handler) { 
  18.  super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, 
  19.      new DelayedWorkQueue(), threadFactory, handler); 

 当然我们也可以使用工具类Executors的newScheduledThreadPool的方法,快速创建。注意这里使用的DelayedWorkQueue。

ScheduledThreadPoolExecutor没有提供带有最大线程数的构造函数的,默认是Integer.MAX_VALUE,说明其可以无限制的开启任意线程执行任务,在大量任务系统,应注意这一点,避免内存溢出。

核心方法

核心方法主要介绍ScheduledThreadPoolExecutor的调度方法,其他方法与 ThreadPoolExecutor 一致。调度方法均由 ScheduledExecutorService 接口定义:

  1. public interface ScheduledExecutorService extends ExecutorService { 
  2.     // 特定时间延时后执行一次Runnable 
  3.     public ScheduledFuture schedule(Runnable command, 
  4.                                        long delay, TimeUnit unit); 
  5.     // 特定时间延时后执行一次Callable 
  6.     public  ScheduledFuture schedule(Callable callable, 
  7.                                            long delay, TimeUnit unit); 
  8.     // 固定周期执行任务(与任务执行时间无关,周期是固定的) 
  9.     public ScheduledFuture scheduleAtFixedRate(Runnable command, 
  10.                                                   long initialDelay, 
  11.                                                   long period, 
  12.                                                   TimeUnit unit); 
  13.      // 固定延时执行任务(与任务执行时间有关,延时从上一次任务完成后开始) 
  14.     public ScheduledFuture scheduleWithFixedDelay(Runnable command, 
  15.                                                      long initialDelay, 
  16.                                                      long delay, 
  17.                                                      TimeUnit unit); 

 我们再来看一下接口的实现,具体是怎么来实现线程池任务的提交。因为最终都回调用 delayedExecute 提交任务。所以,我们这里只分析schedule方法,该方法是指任务在指定延迟时间到达后触发,只会执行一次。源代码如下:

  1. public ScheduledFuture schedule(Runnable command, 
  2.                                    long delay, 
  3.                                    TimeUnit unit) { 
  4.     //参数校验 
  5.     if (command == null || unit == null
  6.         throw new NullPointerException(); 
  7.     //这里是一个嵌套结构,首先把用户提交的任务包装成ScheduledFutureTask 
  8.     //然后在调用decorateTask进行包装,该方法是留给用户去扩展的,默认是个空方法 
  9.     RunnableScheduledFuture t = decorateTask(command, 
  10.         new ScheduledFutureTask(command, null
  11.                                       triggerTime(delay, unit))); 
  12.    //包装好任务以后,就进行提交了 
  13.  delayedExecute(t); 
  14.     return t; 

 delayedExecute 任务提交方法:

  1. private void delayedExecute(RunnableScheduledFuture task) { 
  2.     //如果线程池已经关闭,则使用拒绝策略把提交任务拒绝掉 
  3.  if (isShutdown()) 
  4.         reject(task); 
  5.     else { 
  6.   //与ThreadPoolExecutor不同,这里直接把任务加入延迟队列 
  7.         super.getQueue().add(task);//使用用的DelayedWorkQueue 
  8.   //如果当前状态无法执行任务,则取消 
  9.         if (isShutdown() && 
  10.             !canRunInCurrentRunState(task.isPeriodic()) && 
  11.             remove(task)) 
  12.             task.cancel(false); 
  13.         else 
  14.          //这里是增加一个worker线程,避免提交的任务没有worker去执行 
  15.          //原因就是该类没有像ThreadPoolExecutor一样,woker满了才放入队列 
  16.            ensurePrestart(); 
  17.     } 

 我们可以看到提交到线程池的任务都包装成了 ScheduledFutureTask,继续往下我们再来研究下。

ScheduledFutureTask

从ScheduledFutureTask类的定义可以看出,ScheduledFutureTask类是ScheduledThreadPoolExecutor类的私有内部类,继承了FutureTask类,并实现了RunnableScheduledFuture接口。也就是说,ScheduledFutureTask具有FutureTask类的所有功能,并实现了RunnableScheduledFuture接口的所有方法。ScheduledFutureTask类的定义如下所示:

  1. private class ScheduledFutureTask extends FutureTask implements RunnableScheduledFuture 

ScheduledFutureTask类继承图如下:


成员变量

SchduledFutureTask接收的参数(成员变量):

  1. // 任务开始的时间 
  2.  
  3. private long time
  4.  
  5. // 任务添加到ScheduledThreadPoolExecutor中被分配的唯一序列号 
  6.  
  7. private final long sequenceNumber; 
  8.  
  9. // 任务执行的时间间隔 
  10.  
  11. private final long period; 
  12.  
  13. //ScheduledFutureTask对象,实际指向当前对象本身 
  14.  
  15. RunnableScheduledFuture outerTask = this; 
  16.  
  17. //当前任务在延迟队列中的索引,能够更加方便的取消当前任务 
  18.  
  19. int heapIndex; 

 解析:

构造方法

ScheduledFutureTask类继承了FutureTask类,并实现了RunnableScheduledFuture接口。在ScheduledFutureTask类中提供了如下构造方法。

  1. ScheduledFutureTask(Runnable r, V result, long ns) { 
  2.  super(r, result); 
  3.  this.time = ns; 
  4.  this.period = 0; 
  5.  this.sequenceNumber = sequencer.getAndIncrement(); 
  6.  
  7. ScheduledFutureTask(Runnable r, V result, long ns, long period) { 
  8.  super(r, result); 
  9.  this.time = ns; 
  10.  this.period = period; 
  11.  this.sequenceNumber = sequencer.getAndIncrement(); 
  12.  
  13. ScheduledFutureTask(Callable callable, long ns) { 
  14.  super(callable); 
  15.  this.time = ns; 
  16.  this.period = 0; 
  17.  this.sequenceNumber = sequencer.getAndIncrement(); 

 FutureTask的构造方法如下:

  1. public FutureTask(Runnable runnable, V result) { 
  2.  this.callable = Executors.callable(runnable, result); 
  3.  this.state = NEW;       // ensure visibility of callable 

 通过源码可以看到,在ScheduledFutureTask类的构造方法中,首先会调用FutureTask类的构造方法为FutureTask类的callable和state成员变量赋值,接下来为ScheduledFutureTask类的time、period和sequenceNumber成员变量赋值。理解起来比较简单。

getDelay方法

我们先来看getDelay方法的源码,如下所示:

  1. //获取下次执行任务的时间距离当前时间的纳秒数 
  2. public long getDelay(TimeUnit unit) { 
  3.  return unit.convert(time - now(), NANOSECONDS); 

 getDelay方法比较简单,主要用来获取下次执行任务的时间距离当前系统时间的纳秒数。

compareTo方法

ScheduledFutureTask类在类的结构上实现了Comparable接口,compareTo方法主要是对Comparable接口定义的compareTo方法的实现。源码如下所示:

  1. public int compareTo(Delayed other) { 
  2.  if (other == this)  
  3.   return 0; 
  4.  if (other instanceof ScheduledFutureTask) { 
  5.   ScheduledFutureTask x = (ScheduledFutureTask)other; 
  6.   long diff = time - x.time
  7.   if (diff < 0) 
  8.    return -1; 
  9.   else if (diff > 0) 
  10.    return 1; 
  11.   else if (sequenceNumber < x.sequenceNumber) 
  12.    return -1; 
  13.   else 
  14.    return 1; 
  15.  } 
  16.  long diff = getDelay(NANOSECONDS) - other.getDelay(NANOSECONDS); 
  17.  return (diff < 0) ? -1 : (diff > 0) ? 1 : 0; 

 这段代码看上去好像是对各种数值类型数据的比较,本质上是对延迟队列中的任务进行排序。排序规则为:

isPeriodic方法

isPeriodic方法的源代码如下所示:

  1. //判断是否是周期性任务 
  2. public boolean isPeriodic() { 
  3.  return period != 0; 

 这个方法主要是用来判断当前任务是否是周期性任务。这里只要判断运行任务的执行周期不等于0就能确定为周期性任务了。因为无论period的值是大于0还是小于0,当前任务都是周期性任务。

setNextRunTime方法

setNextRunTime方法的作用主要是设置当前任务下次执行的时间,源码如下所示:

  1. private void setNextRunTime() { 
  2.  long p = period; 
  3.  //固定频率,上次执行任务的时间加上任务的执行周期 
  4.  if (p > 0) 
  5.   time += p; 
  6.  //相对固定的延迟执行,当前系统时间加上任务的执行周期 
  7.  else 
  8.   time = triggerTime(-p); 

 这里再一次证明了使用isPeriodic方法判断当前任务是否为周期性任务时,只要判断period的值是否不等于0就可以了。

这里我们看到在setNextRunTime方法中,调用了ScheduledThreadPoolExecutor类的triggerTime方法。接下来我们看下triggerTime方法的源码。

ScheduledThreadPoolExecutor类的triggerTime方法

triggerTime方法用于获取延迟队列中的任务下一次执行的具体时间。源码如下所示。

  1. private long triggerTime(long delay, TimeUnit unit) { 
  2.  return triggerTime(unit.toNanos((delay < 0) ? 0 : delay)); 
  3.  
  4. long triggerTime(long delay) { 
  5.  return now() + 
  6.   ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay)); 

 这两个triggerTime方法的代码比较简单,就是获取下一次执行任务的具体时间。有一点需要注意的是:delay < (Long.MAX_VALUE >> 1判断delay的值是否小于Long.MAX_VALUE的一半,如果小于Long.MAX_VALUE值的一半,则直接返回delay,否则需要处理溢出的情况。

我们看到在triggerTime方法中处理防止溢出的逻辑使用了ScheduledThreadPoolExecutor类的overflowFree方法,接下来,我们就看看ScheduledThreadPoolExecutor类的overflowFree方法的实现。

ScheduledThreadPoolExecutor类的overflowFree方法

overflowFree方法的源代码如下所示:

  1. private long overflowFree(long delay) { 
  2.  //获取队列中的节点 
  3.  Delayed head = (Delayed) super.getQueue().peek(); 
  4.  //获取的节点不为空,则进行后续处理 
  5.  if (head != null) { 
  6.   //从队列节点中获取延迟时间 
  7.   long headDelay = head.getDelay(NANOSECONDS); 
  8.   //如果从队列中获取的延迟时间小于0,并且传递的delay 
  9.   //值减去从队列节点中获取延迟时间小于0 
  10.   if (headDelay < 0 && (delay - headDelay < 0)) 
  11.    //将delay的值设置为Long.MAX_VALUE + headDelay 
  12.    delay = Long.MAX_VALUE + headDelay; 
  13.  } 
  14.  //返回延迟时间 
  15.  return delay; 

 通过对overflowFree方法的源码分析,可以看出overflowFree方法本质上就是为了限制队列中的所有节点的延迟时间在Long.MAX_VALUE值之内,防止在compareTo方法中溢出。

cancel方法

cancel方法的作用主要是取消当前任务的执行,源码如下所示:

  1. public boolean cancel(boolean mayInterruptIfRunning) { 
  2.  //取消任务,返回任务是否取消的标识 
  3.  boolean cancelled = super.cancel(mayInterruptIfRunning); 
  4.  //如果任务已经取消 
  5.  //并且需要将任务从延迟队列中删除 
  6.  //并且任务在延迟队列中的索引大于或者等于0 
  7.  if (cancelled && removeOnCancel && heapIndex >= 0) 
  8.   //将当前任务从延迟队列中删除 
  9.   remove(this); 
  10.  //返回是否成功取消任务的标识 
  11.  return cancelled; 

 这段代码理解起来相对比较简单,首先调用取消任务的方法,并返回任务是否已经取消的标识。如果任务已经取消,并且需要移除任务,同时,任务在延迟队列中的索引大于或者等于0,则将当前任务从延迟队列中移除。最后返回任务是否成功取消的标识。

run方法

run方法可以说是ScheduledFutureTask类的核心方法,是对Runnable接口的实现,源码如下所示:

  1. public void run() { 
  2.  //当前任务是否是周期性任务 
  3.  boolean periodic = isPeriodic(); 
  4.  //线程池当前运行状态下不能执行周期性任务 
  5.  if (!canRunInCurrentRunState(periodic)) 
  6.   //取消任务的执行 
  7.   cancel(false); 
  8.  //如果不是周期性任务 
  9.  else if (!periodic) 
  10.   //则直接调用FutureTask类的run方法执行任务 
  11.   ScheduledFutureTask.super.run(); 
  12.  //如果是周期性任务,则调用FutureTask类的runAndReset方法执行任务 
  13.  //如果任务执行成功 
  14.  else if (ScheduledFutureTask.super.runAndReset()) { 
  15.   //设置下次执行任务的时间 
  16.   setNextRunTime(); 
  17.   //重复执行任务 
  18.   reExecutePeriodic(outerTask); 
  19.  } 

 整理一下方法的逻辑:

  1. 首先判断当前任务是否是周期性任务。如果线程池当前运行状态下不能执行周期性任务,则取消任务的执行,否则执行步骤2;
  2. 如果当前任务不是周期性任务,则直接调用FutureTask类的run方法执行任务,会设置执行结果,然后直接返回,否则执行步骤3;
  3. 如果当前任务是周期性任务,则调用FutureTask类的runAndReset方法执行任务,不会设置执行结果,然后直接返回,否则执行步骤4;
  4. 如果任务执行成功,则设置下次执行任务的时间,同时,将任务设置为重复执行。

这里,调用了FutureTask类的run方法和runAndReset方法,并且调用了ScheduledThreadPoolExecutor类的reExecutePeriodic方法。接下来,我们分别看下这些方法的实现。

FutureTask类的run方法

FutureTask类的run方法源码如下所示:

  1. public void run() { 
  2.     //状态如果不是NEW,说明任务或者已经执行过,或者已经被取消,直接返回 
  3.     //状态如果是NEW,则尝试把当前执行线程保存在runner字段中 
  4.     //如果赋值失败则直接返回 
  5.     if (state != NEW || 
  6.         !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread())) 
  7.         return
  8.     try { 
  9.         Callable c = callable; 
  10.         if (c != null && state == NEW) { 
  11.             V result; 
  12.             boolean ran; 
  13.             try { 
  14.                 //执行任务 
  15.                 result = c.call(); 
  16.                 ran = true
  17.             } catch (Throwable ex) { 
  18.                 result = null
  19.                 ran = false
  20.                 //任务异常 
  21.                 setException(ex); 
  22.             } 
  23.             if (ran) 
  24.                 //任务正常执行完毕 
  25.                 set(result); 
  26.         } 
  27.     } finally { 
  28.  
  29.         runner = null
  30.         int s = state; 
  31.         //如果任务被中断,执行中断处理 
  32.         if (s >= INTERRUPTING) 
  33.             handlePossibleCancellationInterrupt(s); 
  34.     } 

 代码的整体逻辑为:

FutureTask类的runAndReset方法

方法的源码如下所示:

  1. protected boolean runAndReset() { 
  2.  if (state != NEW || 
  3.   !UNSAFE.compareAndSwapObject(this, runnerOffset, 
  4.           null, Thread.currentThread())) 
  5.   return false
  6.  boolean ran = false
  7.  int s = state; 
  8.  try { 
  9.   Callable c = callable; 
  10.   if (c != null && s == NEW) { 
  11.    try { 
  12.     c.call(); // don't set result 
  13.     ran = true
  14.    } catch (Throwable ex) { 
  15.     setException(ex); 
  16.    } 
  17.   } 
  18.  } finally { 
  19.   // runner must be non-null until state is settled to 
  20.   // prevent concurrent calls to run() 
  21.   runner = null
  22.   // state must be re-read after nulling runner to prevent 
  23.   // leaked interrupts 
  24.   s = state; 
  25.   if (s >= INTERRUPTING) 
  26.    handlePossibleCancellationInterrupt(s); 
  27.  } 
  28.  return ran && s == NEW; 

 FutureTask类的runAndReset方法与run方法的逻辑基本相同,只是runAndReset方法会重置当前任务的执行状态。

ScheduledThreadPoolExecutor类的reExecutePeriodic方法

reExecutePeriodic重复执行任务方法,源代码如下所示:

  1. void reExecutePeriodic(RunnableScheduledFuture task) { 
  2.  //线程池当前状态下能够执行任务 
  3.  if (canRunInCurrentRunState(true)) { 
  4.   //与ThreadPoolExecutor不同,这里直接把任务加入延迟队列 
  5.         super.getQueue().add(task);//使用用的DelayedWorkQueue 
  6.   //线程池当前状态下不能执行任务,并且成功移除任务 
  7.   if (!canRunInCurrentRunState(true) && remove(task)) 
  8.    //取消任务 
  9.    task.cancel(false); 
  10.   else 
  11.    //这里是增加一个worker线程,避免提交的任务没有worker去执行 
  12.             //原因就是该类没有像ThreadPoolExecutor一样,woker满了才放入队列   
  13.    ensurePrestart(); 
  14.  } 

 总体来说reExecutePeriodic方法的逻辑比较简单,需要注意的是:调用reExecutePeriodic方法的时候已经执行过一次任务,所以,并不会触发线程池的拒绝策略;传入reExecutePeriodic方法的任务一定是周期性的任务。

DelayedWorkQueue

ScheduledThreadPoolExecutor之所以要自己实现阻塞的工作队列,是因为 ScheduleThreadPoolExecutor 要求的工作队列有些特殊。

DelayedWorkQueue是一个基于堆的数据结构,类似于DelayQueue和PriorityQueue。在执行定时任务的时候,每个任务的执行时间都不同,所以DelayedWorkQueue的工作就是按照执行时间的升序来排列,执行时间距离当前时间越近的任务在队列的前面(注意:这里的顺序并不是绝对的,堆中的排序只保证了子节点的下次执行时间要比父节点的下次执行时间要大,而叶子节点之间并不一定是顺序的)。

堆结构如下图:

 可见,DelayedWorkQueue是一个基于最小堆结构的队列。堆结构可以使用数组表示,可以转换成如下的数组:

 在这种结构中,可以发现有如下特性: 假设“第一个元素” 在数组中的索引为 0 的话,则父结点和子结点的位置关系如下:

为什么要使用DelayedWorkQueue呢?

因为前面讲阻塞队列实现的时候,已经对DelayedWorkQueue进行了说明,更多内容请查看《阻塞队列 — DelayedWorkQueue源码分析》

总结

  1. 与Timer执行定时任务比较,相比Timer,ScheduledThreadPoolExecutor有说明优点?(文章前面分析过)
  2. ScheduledThreadPoolExecutor继承自ThreadPoolExecutor,所以它也是一个线程池,也有 coorPoolSize 和 workQueue,但是 ScheduledThreadPoolExecutor特殊的地方在于,自己实现了优先工作队列 DelayedWorkQueue ;
  3. ScheduledThreadPoolExecutor 实现了 ScheduledExecutorService,所以就有了任务调度的方法,如 schedule 、 scheduleAtFixedRate 、 scheduleWithFixedDelay ,同时注意他们之间的区别;
  4. 内部类 ScheduledFutureTask 继承者FutureTask,实现了任务的异步执行并且可以获取返回结果。同时实现了Delayed接口,可以通过getDelay方法获取将要执行的时间间隔;
  5. 周期任务的执行其实是调用了FutureTask的 runAndReset 方法,每次执行完不设置结果和状态。
  6. DelayedWorkQueue的数据结构,它是一个基于最小堆结构的优先队列,并且每次出队时能够保证取出的任务是当前队列中下次执行时间最小的任务。同时注意一下优先队列中堆的顺序,堆中的顺序并不是绝对的,但要保证子节点的值要比父节点的值要大,这样就不会影响出队的顺序。

总体来说,ScheduedThreadPoolExecutor的重点是要理解下次执行时间的计算,以及优先队列的出队、入队和删除的过程,这两个是理解ScheduedThreadPoolExecutor的关键。

PS:以上代码提交在 Github :

https://github.com/Niuh-Study/niuh-juc-final.git

 

来源:今日头条内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯