• 高并发之线程池


    1.实现原理

    下图所示为线程池的实现原理:调⽤⽅不断地向线程池中提交任务;线程池中有⼀组线程,不断地从队列中取任
    务,这是⼀个典型的⽣产者 消费者模型。

     

    要实现这样⼀个线程池,有⼏个问题需要考虑:
    1. 队列设置多⻓?如果是⽆界的,调⽤⽅不断地往队列中放任务,可能导致内存耗尽。如果是有界的,当队列
    满了之后,调⽤⽅如何处理? 2. 线程池中的线程个数是固定的,还是动态变化的?
    3. 每次提交新任务,是放⼊队列?还是开新线程?
    4. 当没有任务的时候,线程是睡眠⼀⼩段时间?还是进⼊阻塞?如果进⼊阻塞,如何唤醒?
    针对问题 4 ,有 3 种做法:
    1. 不使⽤阻塞队列,只使⽤⼀般的线程安全的队列,也⽆阻塞 / 唤醒机制。当队列为空时,线程池中的线程只能
    睡眠⼀会⼉,然后醒来去看队列中有没有新任务到来,如此不断轮询。
    2. 不使⽤阻塞队列,但在队列外部、线程池内部实现了阻塞 / 唤醒机制。
    3. 使⽤阻塞队列。
    很显然,做法 3 最完善,既避免了线程池内部⾃⼰实现阻塞 / 唤醒机制的麻烦,也避免了做法 1 的睡眠 / 轮询带来的
    资源消耗和延迟。正因为如此,接下来要讲的 ThreadPoolExector/ScheduledThreadPoolExecutor 都是基于阻塞队列
    来实现的,⽽不是⼀般的队列,⾄此,各式各样的阻塞队列就要派上⽤场了。
    2.线程池的类继承体系
    在这⾥,有两个核⼼的类: ThreadPoolExector ScheduledThreadPoolExecutor ,后者不仅可以执⾏某个
    任务,还可以周期性地执⾏任务。 向线程池中提交的每个任务,都必须实现 Runnable 接⼝,通过最上⾯的 Executor 接⼝中的
    execute(Runnable command) 向线程池提交任务。
    然后,在 ExecutorService 中,定义了线程池的关闭接⼝ shutdown() ,还定义了可以有返回值的任务,也就
    Callable ,后⾯会详细介绍。
    3. ThreadPoolExecutor
    基于线程池的实现原理,下⾯看⼀下 ThreadPoolExector 的核⼼数据结构。
    1. public class ThreadPoolExecutor extends AbstractExecutorService {
    2. //...
    3. private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    4. // 存放任务的阻塞队列
    5. private final BlockingQueue<Runnable> workQueue;
    6. // 对线程池内部各种变量进⾏互斥访问控制
    7. private final ReentrantLock mainLock = new ReentrantLock();
    8. // 线程集合
    9. private final HashSet<Worker> workers = new HashSet<Worker>();
    10. //...
    11. }
    每⼀个线程是⼀个 Worker 对象。 Worker ThreadPoolExector 的内部类,核⼼数据结构如下:
    1. private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
    2. // ...
    3. final Thread thread; // Worker封装的线程
    4. Runnable firstTask; // Worker接收到的第1个任务
    5. volatile long completedTasks; // Worker执⾏完毕的任务个数
    6. // ...
    7. }

     

    由定义会发现, Worker 继承于 AQS ,也就是说 Worker 本身就是⼀把锁。这把锁有什么⽤处呢?⽤于线程池的关
    闭、线程执⾏任务的过程中。
    4.核⼼配置参数解释
    ThreadPoolExecutor在其构造⽅法中提供了⼏个核⼼配置参数,来配置不同策略的线程池
    上⾯的各个参数,解释如下:
    1. corePoolSize :在线程池中始终维护的线程个数。
    2. maxPoolSize :在 corePooSize 已满、队列也满的情况下,扩充线程⾄此值。
    3. keepAliveTime/TimeUnit maxPoolSize 中的空闲线程,销毁所需要的时间,总线程数收缩回
    corePoolSize
    4. blockingQueue :线程池所⽤的队列类型。
    5. threadFactory :线程创建⼯⼚,可以⾃定义,有默认值 Executors.defaultThreadFactory()
    6. RejectedExecutionHandler corePoolSize 已满,队列已满, maxPoolSize 已满,最后的拒绝策略。
    下⾯来看这 6 个配置参数在任务的提交过程中是怎么运作的。在每次往线程池中提交任务的时候,有如下的处理流
    程:
    步骤⼀:判断当前线程数是否⼤于或等于 corePoolSize 。如果⼩于,则新建线程执⾏;如果⼤于,则进⼊步骤
    ⼆。
    步骤⼆:判断队列是否已满。如未满,则放⼊;如已满,则进⼊步骤三。
    步骤三:判断当前线程数是否⼤于或等于 maxPoolSize 。如果⼩于,则新建线程执⾏;如果⼤于,则进⼊步骤
    四。
    步骤四:根据拒绝策略,拒绝任务。 总结⼀下:⾸先判断 corePoolSize ,其次判断 blockingQueue 是否已满,接着判断 maxPoolSize ,最后使⽤拒绝
    策略。
    很显然,基于这种流程,如果队列是⽆界的,将永远没有机会⾛到步骤三,也即 maxPoolSize 没有使⽤,也⼀定
    不会⾛到步骤四。
    线程池的优雅关闭
    线程池的关闭,较之线程的关闭更加复杂。当关闭⼀个线程池的时候,有的线程还正在执⾏某个任务,有的调⽤
    者正在向线程池提交任务,并且队列中可能还有未执⾏的任务。因此,关闭过程不可能是瞬时的,⽽是需要⼀个平滑
    的过渡,这就涉及线程池的完整⽣命周期管理。
    6. 线程池的⽣命周期
    JDK 7 中,把线程数量( workerCount )和线程池状态( runState )这两个变量打包存储在⼀个字段⾥⾯,即 ctl
    变量。如下图所示,最⾼的 3 位存储线程池状态,其余 29 位存储线程个数。⽽在 JDK 6 中,这两个变量是分开存储的。

     

    由上⾯的代码可以看到, ctl 变量被拆成两半,最⾼的 3 位⽤来表示线程池的状态,低的 29 位表示线程的个数。线
    程池的状态有五种,分别是 RUNNING SHUTDOWN STOP TIDYING TERMINATED
    下⾯分析状态之间的迁移过程,如图所示:
    线程池有两个关闭⽅法, shutdown() shutdownNow() ,这两个⽅法会让线程池切换到不同的状态。在队列为
    空,线程池也为空之后,进⼊ TIDYING 状态;最后执⾏⼀个钩⼦⽅法 terminated() ,进⼊ TERMINATED 状态,线程池
    才真正关闭。
    这⾥的状态迁移有⼀个⾮常关键的特征:从⼩到⼤迁移, -1 0 1 2 3 ,只会从⼩的状态值往⼤的状态值迁
    移,不会逆向迁移。例如,当线程池的状态在 TIDYING=2 时,接下来只可能迁移到 TERMINATED=3 ,不可能迁移回
    STOP=1 或者其他状态。
    terminated() 之外,线程池还提供了其他⼏个钩⼦⽅法,这些⽅法的实现都是空的。如果想实现⾃⼰的线程
    池,可以重写这⼏个⽅法
    1. protected void beforeExecute(Thread t, Runnable r) { }
    2. protected void afterExecute(Runnable r, Throwable t) { }
    3. protected void terminated() { }
    2. 正确关闭线程池的步骤
    关闭线程池的过程为:在调⽤ shutdown() 或者 shutdownNow() 之后,线程池并不会⽴即关闭,接下来需要调⽤
    awaitTermination() 来等待线程池关闭。关闭线程池的正确步骤如下:
    awaitTermination(...) ⽅法的内部实现很简单,如下所示。不断循环判断线程池是否到达了最终状态
    TERMINATED ,如果是,就返回;如果不是,则通过 termination 条件变量阻塞⼀段时间,之后继续判断。

     

    7.shutdown() shutdownNow() 的区别
    1. shutdown() 不会清空任务队列,会等所有任务执⾏完成, shutdownNow() 清空任务队列。
    2. shutdown() 只会中断空闲的线程, shutdownNow() 会中断所有线程。

     

    下⾯看⼀下在上⾯的代码⾥中断空闲线程和中断所有线程的区别。
    shutdown() ⽅法中的interruptIdleWorkers()⽅法的实现:

     

    关键区别点在 tryLock() :⼀个线程在执⾏⼀个任务之前,会先加锁,这意味着通过是否持有锁,可以判断出线程
    是否处于空闲状态。 tryLock() 如果调⽤成功,说明线程处于空闲状态,向其发送中断信号;否则不发送。
    tryLock()⽅法

     

     

     shutdownNow()调⽤了 interruptWorkers(); ⽅法:

    interruptIfStarted() ⽅法的实现: 

    在上⾯的代码中,shutdown() shutdownNow()都调⽤了tryTerminate()⽅法,如下所示: 

     

    tryTerminate() 不会强⾏终⽌线程池,只是做了⼀下检测:当 workerCount 0 workerQueue 为空时,先把状态
    切换到 TIDYING ,然后调⽤钩⼦⽅法 terminated() 。当钩⼦⽅法执⾏完成时,把状态从 TIDYING 改为 TERMINATED
    接着调⽤ termination.sinaglAll() ,通知前⾯阻塞在 awaitTermination 的所有调⽤者线程。

    所以,TIDYINGTREMINATED的区别是在⼆者之间执⾏了⼀个钩⼦⽅法terminated(),⽬前是⼀个空实现。

    8.任务的提交过程分析
    提交任务的⽅法如下:
     
    1. public void execute(Runnable command) {
    2. if (command == null)
    3. throw new NullPointerException();
    4. int c = ctl.get();
    5. // 如果当前线程数⼩于corePoolSize,则启动新线程
    6. if (workerCountOf(c) < corePoolSize) {
    7. // 添加Worker,并将command设置为Worker线程的第⼀个任务开始执⾏。
    8. if (addWorker(command, true))
    9. return;
    10. c = ctl.get();
    11. }
    12. // 如果当前的线程数⼤于或等于corePoolSize,则调⽤workQueue.offer放⼊队列
    13. if (isRunning(c) && workQueue.offer(command)) {
    14. int recheck = ctl.get();
    15. // 如果线程池正在停⽌,则将command任务从队列移除,并拒绝command任务请求。
    16. if (! isRunning(recheck) && remove(command))
    17. reject(command);
    18. // 放⼊队列中后发现没有线程执⾏任务,开启新线程
    19. else if (workerCountOf(recheck) == 0)
    20. addWorker(null, false);
    21. }
    22. // 线程数⼤于maxPoolSize,并且队列已满,调⽤拒绝策略
    23. else if (!addWorker(command, false))
    24. reject(command);
    25. }
    26. // 该⽅法⽤于启动新线程。如果第⼆个参数为true,则使⽤corePoolSize作为上限,否则使⽤maxPoolSize
    27. 作为上限。
    28. private boolean addWorker(Runnable firstTask, boolean core) {
    29. retry:
    30. for (int c = ctl.get();;) {
    31. // 如果线程池状态值起码是SHUTDOWN和STOP,或则第⼀个任务不是null,或者⼯作队列为空
    32. // 则添加worker失败,返回false
    33. if (runStateAtLeast(c, SHUTDOWN)
    34. && (runStateAtLeast(c, STOP)
    35. || firstTask != null
    36. || workQueue.isEmpty()))
    37. return false;
    38. for (;;) {
    39. // ⼯作线程数达到上限,要么是corePoolSize要么是maximumPoolSize,启动线程失败
    40. if (workerCountOf(c)
    41. >= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))
    42. return false;
    43. // 增加worker数量成功,返回到retry语句
    44. if (compareAndIncrementWorkerCount(c))
    45. break retry;
    46. c = ctl.get(); // Re-read ctl
    47. // 如果线程池运⾏状态起码是SHUTDOWN,则重试retry标签语句,CAS
    48. if (runStateAtLeast(c, SHUTDOWN))
    49. continue retry;
    50. // else CAS failed due to workerCount change; retry inner loop
    51. }
    52. }
    53. // worker数量加1成功后,接着运⾏:
    54. boolean workerStarted = false;
    55. boolean workerAdded = false;
    56. Worker w = null;
    57. try {
    58. // 新建worker对象
    59. w = new Worker(firstTask);
    60. // 获取线程对象
    61. final Thread t = w.thread;
    62. if (t != null) {
    63. final ReentrantLock mainLock = this.mainLock;
    64. // 加锁
    65. mainLock.lock();
    66. try {
    67. // Recheck while holding lock.
    68. // Back out on ThreadFactory failure or if
    69. // shut down before lock acquired.
    70. int c = ctl.get();
    71. if (isRunning(c) ||
    72. (runStateLessThan(c, STOP) && firstTask == null)) {
    73. // 由于线程已经在运⾏中,⽆法启动,抛异常
    74. if (t.isAlive()) // precheck that t is startable
    75. throw new IllegalThreadStateException();
    76. // 将线程对应的worker加⼊worker集合
    77. workers.add(w);
    78. int s = workers.size();
    79. if (s > largestPoolSize)
    80. largestPoolSize = s;
    81. workerAdded = true;
    82. }
    83. } finally {
    84. // 释放锁
    85. mainLock.unlock();
    86. }
    87. // 如果添加worker成功,则启动该worker对应的线程
    88. if (workerAdded) {
    89. t.start();
    90. workerStarted = true;
    91. }
    92. }
    93. } finally {
    94. // 如果启动新线程失败
    95. if (! workerStarted)
    96. // workCount - 1
    97. addWorkerFailed(w);
    98. }
    99. return workerStarted; }

     9.
    任务的执⾏过程分析
    在上⾯的任务提交过程中,可能会开启⼀个新的 Worker ,并把任务本身作为 firstTask 赋给该 Worker 。但对于⼀
    Worker 来说,不是只执⾏⼀个任务,⽽是源源不断地从队列中取任务执⾏,这是⼀个不断循环的过程。
    下⾯来看 Woker run() ⽅法的实现过程。
    1. private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
    2. // 当前Worker对象封装的线程
    3. final Thread thread;
    4. // 线程需要运⾏的第⼀个任务。可以是null,如果是null,则线程从队列获取任务
    5. Runnable firstTask;
    6. // 记录线程执⾏完成的任务数量,每个线程⼀个计数器
    7. volatile long completedTasks;
    8. /**
    9. * 使⽤给定的第⼀个任务并利⽤线程⼯⼚创建Worker实例
    10. * @param firstTask 线程的第⼀个任务,如果没有,就设置为null,此时线程会从队列获取任务。
    11. */
    12. Worker(Runnable firstTask) {
    13. setState(-1); // 线程处于阻塞状态,调⽤runWorker的时候中断
    14. this.firstTask = firstTask;
    15. this.thread = getThreadFactory().newThread(this);
    16. }
    17. // 调⽤ThreadPoolExecutor的runWorker⽅法执⾏线程的运⾏
    18. public void run() {
    19. runWorker(this);
    20. }
    21. }
    22. final void runWorker(Worker w) {
    23. Thread wt = Thread.currentThread();
    24. Runnable task = w.firstTask;
    25. w.firstTask = null;
    26. // 中断Worker封装的线程
    27. w.unlock();
    28. boolean completedAbruptly = true;
    29. try {
    30. // 如果线程初始任务不是null,或者从队列获取的任务不是null,表示该线程应该执⾏任务。
    31. while (task != null || (task = getTask()) != null) {
    32. // 获取线程锁
    33. w.lock();
    34. // 如果线程池停⽌了,确保线程被中断
    35. // 如果线程池正在运⾏,确保线程不被中断
    36. if ((runStateAtLeast(ctl.get(), STOP) ||
    37. (Thread.interrupted() &&
    38. runStateAtLeast(ctl.get(), STOP))) &&
    39. !wt.isInterrupted())
    40. // 获取到任务后,再次检查线程池状态,如果发现线程池已经停⽌,则给⾃⼰发中断信号
    41. wt.interrupt();
    42. try {
    43. // 任务执⾏之前的钩⼦⽅法,实现为空
    44. beforeExecute(wt, task);
    45. try {
    46. task.run();
    47. // 任务执⾏结束后的钩⼦⽅法,实现为空
    48. afterExecute(task, null);
    49. } catch (Throwable ex) {
    50. afterExecute(task, ex);
    51. throw ex;
    52. }
    53. } finally {
    54. // 任务执⾏完成,将task设置为null
    55. task = null;
    56. // 线程已完成的任务数加1
    57. w.completedTasks++;
    58. // 释放线程锁
    59. w.unlock();
    60. }
    61. }
    62. // 判断线程是否是正常退出
    63. completedAbruptly = false;
    64. } finally {
    65. // Worker退出
    66. processWorkerExit(w, completedAbruptly);
    67. }
    68. }
    10.shutdown()与任务执⾏过程综合分析
    把任务的执⾏过程和上⾯的线程池的关闭过程结合起来进⾏分析,当调⽤ shutdown() 的时候,可能出现以下⼏种
    场景:
    1. 当调⽤ shutdown() 的时候,所有线程都处于空闲状态。
    这意味着任务队列⼀定是空的。此时,所有线程都会阻塞在 getTask() ⽅法的地⽅。然后,所有线程都会收到
    interruptIdleWorkers() 发来的中断信号, getTask() 返回 null ,所有 Worker 都会退出 while 循环,之后执⾏
    processWorkerExit
    2. 当调⽤ shutdown() 的时候,所有线程都处于忙碌状态。
    此时,队列可能是空的,也可能是⾮空的。 interruptIdleWorkers() 内部的 tryLock 调⽤失败,什么都不会
    做,所有线程会继续执⾏⾃⼰当前的任务。之后所有线程会执⾏完队列中的任务,直到队列为空, getTask()
    才会返回 null 。之后,就和场景 1 ⼀样了,退出 while 循环。
    3. 当调⽤ shutdown() 的时候,部分线程忙碌,部分线程空闲。
    有部分线程空闲,说明队列⼀定是空的,这些线程肯定阻塞在 getTask() ⽅法的地⽅。空闲的这些线程会和场
    1 ⼀样处理,不空闲的线程会和场景 2 ⼀样处理。
    下⾯看⼀下 getTask() ⽅法的内部细节:
    1. private Runnable getTask() {
    2. boolean timedOut = false; // Did the last poll() time out?
    3. for (;;) {
    4. int c = ctl.get();
    5. // 如果线程池调⽤了shutdownNow(),返回null
    6. // 如果线程池调⽤了shutdown(),并且任务队列为空,也返回null
    7. if (runStateAtLeast(c, SHUTDOWN)
    8. && (runStateAtLeast(c, STOP) || workQueue.isEmpty())) {
    9. // ⼯作线程数减⼀
    10. decrementWorkerCount();
    11. return null;
    12. }
    13. int wc = workerCountOf(c);
    14. // Are workers subject to culling?
    15. boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
    16. if ((wc > maximumPoolSize || (timed && timedOut))
    17. && (wc > 1 || workQueue.isEmpty())) {
    18. if (compareAndDecrementWorkerCount(c))
    19. return null;
    20. continue;
    21. }
    22. try {
    23. // 如果队列为空,就会阻塞pool或者take,前者有超时时间,后者没有超时时间
    24. // ⼀旦中断,此处抛异常,对应上⽂场景1。
    25. Runnable r = timed ?
    26. workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
    27. workQueue.take();
    28. if (r != null)
    29. return r;
    30. timedOut = true;
    31. } catch (InterruptedException retry) {
    32. timedOut = false;
    33. }
    34. }
    35. }

     

    11.shutdownNow() 与任务执⾏过程综合分析
    和上⾯的 shutdown() 类似,只是多了⼀个环节,即清空任务队列。如果⼀个线程正在执⾏某个业务代码,即使向
    它发送中断信号,也没有⽤,只能等它把代码执⾏完成。因此,中断空闲线程和中断所有线程的区别并不是很⼤,除
    ⾮线程当前刚好阻塞在某个地⽅。
    当⼀个 Worker 最终退出的时候,会执⾏清理⼯作:
    1. private void processWorkerExit(Worker w, boolean completedAbruptly) {
    2. // 如果线程正常退出,不会执⾏if的语句,这⾥⼀般是⾮正常退出,需要将worker数量减⼀
    3. if (completedAbruptly)
    4. decrementWorkerCount();
    5. final ReentrantLock mainLock = this.mainLock;
    6. mainLock.lock();
    7. try {
    8. completedTaskCount += w.completedTasks;
    9. // 将⾃⼰的worker从集合移除
    10. workers.remove(w);
    11. } finally {
    12. mainLock.unlock();
    13. }
    14. // 每个线程在结束的时候都会调⽤该⽅法,看是否可以停⽌线程池
    15. tryTerminate();
    16. int c = ctl.get();
    17. // 如果在线程退出前,发现线程池还没有关闭
    18. if (runStateLessThan(c, STOP)) {
    19. if (!completedAbruptly) {
    20. int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
    21. // 如果线程池中没有其他线程了,并且任务队列⾮空
    22. if (min == 0 && ! workQueue.isEmpty())
    23. min = 1;
    24. // 如果⼯作线程数⼤于min,表示队列中的任务可以由其他线程执⾏,退出当前线程
    25. if (workerCountOf(c) >= min)
    26. return; // replacement not needed
    27. }
    28. // 如果当前线程退出前发现线程池没有结束,任务队列不是空的,也没有其他线程来执⾏
    29. // 就再启动⼀个线程来处理。
    30. addWorker(null, false);
    31. }
    32. }

    12.线程池的4种拒绝策略

    execute(Runnable command)的最后,调⽤了reject(command)执⾏拒绝策略,代码如下所示: 

     

     

     

    handler就是我们可以设置的拒绝策略管理器:

    RejectedExecutionHandler 是⼀个接⼝,定义了四种实现,分别对应四种不同的拒绝策略,默认是
    AbortPolicy

    ThreadPoolExecutor类中默认的实现是:

     

     

    四种策略的实现代码如下:
    策略 1 :调⽤者直接在⾃⼰的线程⾥执⾏,线程池不处理,⽐如到医院打点滴,医院没地⽅了,到你家⾃⼰操作
    吧:

     

     策略2:线程池抛异常:

     策略3:线程池直接丢掉任务,神不知⻤不觉:

    策略4:删除队列中最早的任务,将当前任务⼊队列 

     

     

    示例程序:

    1. import java.util.concurrent.ArrayBlockingQueue;
    2. import java.util.concurrent.ThreadPoolExecutor;
    3. import java.util.concurrent.TimeUnit;
    4. public class ThreadPoolExecutorDemo {
    5. public static void main(String[] args) {
    6. ThreadPoolExecutor executor = new ThreadPoolExecutor(
    7. 3,
    8. 5,
    9. 1,
    10. TimeUnit.SECONDS,
    11. new ArrayBlockingQueue<>(3),
    12. // new ThreadPoolExecutor.AbortPolicy()
    13. // new ThreadPoolExecutor.CallerRunsPolicy()
    14. // new ThreadPoolExecutor.DiscardOldestPolicy()
    15. new ThreadPoolExecutor.DiscardPolicy()
    16. );
    17. for (int i = 0; i < 20; i++) {
    18. int finalI = i;
    19. executor.execute(new Runnable() {
    20. @Override
    21. public void run() {
    22. System.out.println(Thread.currentThread().getId() + "[" + finalI
    23. + "] -- 开始");
    24. try {
    25. Thread.sleep(5000);
    26. } catch (InterruptedException e) {
    27. e.printStackTrace();
    28. }
    29. System.out.println(Thread.currentThread().getId() + "[" + finalI
    30. + "] -- 结束");
    31. }
    32. });
    33. try {
    34. Thread.sleep(200);
    35. } catch (InterruptedException e) {
    36. e.printStackTrace();
    37. }
    38. }
    39. executor.shutdown();
    40. boolean flag = true;
    41. try {
    42. do {
    43. flag = !executor.awaitTermination(1, TimeUnit.SECONDS);
    44. System.out.println(flag);
    45. } while (flag);
    46. } catch (InterruptedException e) {
    47. e.printStackTrace();
    48. }
    49. System.out.println("线程池关闭成功。。。");
    50. System.out.println(Thread.currentThread().getId());
    51. }
    52. }

     

  • 相关阅读:
    华为ensp:vrrp双机热备负载均衡
    [杂记]C++中关于虚函数的一些理解
    React 共享组件状态及其实践
    【OpenCV实现图像阈值处理】
    Git(3)——Git的三大区域
    J2EE从入门到入土03 XML的解析&建模
    4.6函数的简单解释
    基于Matlab求解高教社杯全国大学生数学建模竞赛(CUMCM2018A题)——高温作业服的优化设计(源码+数据)
    算法升级之路(一)
    全屏-多语言-tab页-主题切换
  • 原文地址:https://blog.csdn.net/t18112925650/article/details/119862771