• Java——聊聊JUC中的线程中断机制 & LockSupport


    文章目录:

    1.什么是中断机制?

    2.如何停止中断运行中的线程?

    2.1 通过一个volatile变量实现

    2.2 通过AtomicBoolean原子布尔类

    2.3 通过Thread类自带的中断API方法实现

    3.Thread类的三大API说明

    3.1 实例方法interrupt(),没有返回值

    3.2 实例方法isInterrupted(),返回布尔值

    3.3 当前线程的中断标识为true,是不是线程就立刻停止?

    3.4 在3.3中断程序的基础上,添加sleep睡眠

    3.5 静态方法public static boolean interrupted()

    4.LockSupport

    4.1 线程的等待唤醒机制

    4.2 wait、notify

    4.3 await、signal

    4.4 park、unpark


    1.什么是中断机制?

    • 首先,一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止。所以,Thread.stop, Thread.suspend, Thread.resume 都已经被废弃了。
    • 其次,在Java中没有办法立即停止一条线程,然而停止线程却显得尤为重要,如取消一个耗时操作。

                      因此,Java提供了一种用于停止线程的协商机制——中断。

                      中断只是一种协作协商机制,Java没有给中断增加任何语法,中断的过程完全需要程序员自己实现。

                      若要中断一个线程,你需要手动调用该线程的interrupt方法,该方法也仅仅是将线程对象的中断标识设成true;

                      接着你需要自己写代码不断地检测当前线程的标识位,如果为true,表示别的线程要求这条线程中断,此时究竟该做什么需要你自己写代码实现。

                      每个线程对象中都有一个标识,用于表示线程是否被中断;该标识位为true表示中断,为false表示未中断;

                      通过调用线程对象的interrupt方法将该线程的标识位设为true;可以在别的线程中调用,也可以在自己的线程中调用。

    尚硅谷周阳老师的例子:顾客在无烟餐厅中吸烟,服务员希望他别吸烟了,不是强行停止他吸烟,而是给他的标志位打为true,具体的停止吸烟还是要顾客自己停止。(体现了协商机制)

    • 中断相关的三大API方法如下图:↓↓↓


    2.如何停止中断运行中的线程?

    2.1 通过一个volatile变量实现

    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. public class InterruptDemo1 {
    4. static volatile boolean isStop = false;
    5. public static void main(String[] args) {
    6. new Thread(() -> {
    7. while (true) {
    8. if (isStop) {
    9. System.out.println(Thread.currentThread().getName() + " isStop被修改为true,线程停止");
    10. break;
    11. }
    12. System.out.println(Thread.currentThread().getName() + " hello volatile....");
    13. }
    14. }, "t1").start();
    15. try {
    16. TimeUnit.MILLISECONDS.sleep(20);
    17. } catch (InterruptedException e) {
    18. e.printStackTrace();
    19. }
    20. new Thread(() -> {
    21. isStop = true;
    22. }, "t2").start();
    23. }
    24. }

    2.2 通过AtomicBoolean原子布尔类

    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. import java.util.concurrent.atomic.AtomicBoolean;
    4. public class InterruptDemo2 {
    5. static AtomicBoolean atomicBoolean = new AtomicBoolean(false);
    6. public static void main(String[] args) {
    7. new Thread(() -> {
    8. while (true) {
    9. if (atomicBoolean.get()) {
    10. System.out.println(Thread.currentThread().getName() + " isStop被修改为true,线程停止");
    11. break;
    12. }
    13. System.out.println(Thread.currentThread().getName() + " hello AtomicBoolean....");
    14. }
    15. }, "t1").start();
    16. try {
    17. TimeUnit.MILLISECONDS.sleep(20);
    18. } catch (InterruptedException e) {
    19. e.printStackTrace();
    20. }
    21. new Thread(() -> {
    22. atomicBoolean.set(true);
    23. }, "t2").start();
    24. }
    25. }

    2.3 通过Thread类自带的中断API方法实现

    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. public class InterruptDemo3 {
    4. public static void main(String[] args) {
    5. Thread t1 = new Thread(() -> {
    6. while (true) {
    7. if (Thread.currentThread().isInterrupted()) {
    8. System.out.println(Thread.currentThread().getName() + " isStop被修改为true,线程停止");
    9. break;
    10. }
    11. System.out.println(Thread.currentThread().getName() + " hello isInterrupted....");
    12. }
    13. }, "t1");
    14. t1.start();
    15. try {
    16. TimeUnit.MILLISECONDS.sleep(20);
    17. } catch (InterruptedException e) {
    18. e.printStackTrace();
    19. }
    20. new Thread(() -> {
    21. t1.interrupt();
    22. }, "t2").start();
    23. }
    24. }


    3.Thread类的三大API说明

    3.1 实例方法interrupt(),没有返回值

    这个interrupt()实例方法,底层实际上调用了interrupt0()这个方法,根据后面的注释可以看到,仅仅是设置中断标识位,而interrupt0这个方法是一个native方法,底层又调用了C。

    而在jdk官方文档中可以看到有关这个方法的叙述。

    3.2 实例方法isInterrupted(),返回布尔值

    这个实例方法的底层调用了一个native方法,传入了一个布尔值,而这个值就是 是否清除中断标识位,false表示不清除,true表示清除(即将线程的中断标识位清除重新设置为false)。

    具体来说,当对一个线程,调用 interrupt() 时:

    ① 如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。所以, interrupt() 并不能真正的中断线程,需要被调用的线程自己进行配合才行。

    ② 如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),在别的线程中调用当前线程对象的interrupt方法,那么线程将立即退出被阻塞状态(中断状态将被清除),并抛出一个InterruptedException异常。

    (中断不活动的线程不会产生任何影响,看下面案例)

    3.3 当前线程的中断标识为true,是不是线程就立刻停止?

    • 否,仅仅设置了一个中断状态

    • 看看中断是否会立即停止这个300的线程。否,虽然中断标志位变了。但是i一直输完300次,才最终停止。

    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. public class InterruptDemo4 {
    4. public static void main(String[] args) {
    5. Thread t1 = new Thread(() -> {
    6. for (int i = 0; i < 300; i++) {
    7. System.out.println("--------- " + i);
    8. }
    9. System.out.println("t1调用interrupt()之后的中断标识02---- " + Thread.currentThread().isInterrupted());
    10. }, "t1");
    11. t1.start();
    12. System.out.println("t1线程默认的中断标识---- " + t1.isInterrupted());
    13. try {
    14. TimeUnit.MILLISECONDS.sleep(2);
    15. } catch (InterruptedException e) {
    16. e.printStackTrace();
    17. }
    18. t1.interrupt();
    19. System.out.println("t1调用interrupt()之后的中断标识01---- " + t1.isInterrupted());
    20. }
    21. }

    对上面的代码稍作改变,如下:↓↓↓

    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. public class InterruptDemo5 {
    4. public static void main(String[] args) {
    5. Thread t1 = new Thread(() -> {
    6. for (int i = 0; i < 300; i++) {
    7. System.out.println("--------- " + i);
    8. }
    9. System.out.println("after t1.interrupt()---第2次---- " + Thread.currentThread().isInterrupted());
    10. }, "t1");
    11. t1.start();
    12. System.out.println("before t1.interrupt()---- " + t1.isInterrupted());
    13. try {
    14. TimeUnit.MILLISECONDS.sleep(2);
    15. } catch (InterruptedException e) {
    16. e.printStackTrace();
    17. }
    18. t1.interrupt();
    19. System.out.println("after t1.interrupt()---第1次--- " + t1.isInterrupted());
    20. try {
    21. TimeUnit.MILLISECONDS.sleep(2000);
    22. } catch (InterruptedException e) {
    23. e.printStackTrace();
    24. }
    25. System.out.println("after t1.interrupt()---第3次--- " + t1.isInterrupted());
    26. }
    27. }

    在输出结果中,我们可以看到和我们预想的都一样,只有最后一行输出,t1线程它自己不是已经打断了吗?那中断标识就应该是 true 啊?为什么变成了false???

    原因是上面的代码中,t1线程打印300次i,而最后一行输出代码是在2000ms之后的,t1线程是完全可以在这个时间内完成300次i的打印工作,所以程序运行到最后一行输出,t1线程已经结束死亡了,再根据 interrupt 方法api中的这句话:

        • 中断不存在的线程不需要任何效果。

    我们就懂了,中断不存在的线程没什么意义的,所以这里的中断标识自然就恢复成了默认值 false。

    3.4 在3.3中断程序的基础上,添加sleep睡眠

    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. public class InterruptDemo6 {
    4. public static void main(String[] args) {
    5. Thread t1 = new Thread(() -> {
    6. while (true) {
    7. if (Thread.currentThread().isInterrupted()) {
    8. System.out.println(Thread.currentThread().getName() + " 中断标识位:" +
    9. Thread.currentThread().isInterrupted() + " 线程终止....");
    10. break;
    11. }
    12. try {
    13. Thread.sleep(200);
    14. } catch (InterruptedException e) {
    15. e.printStackTrace();
    16. }
    17. System.out.println("---- hello InterruptDemo6");
    18. }
    19. }, "t1");
    20. t1.start();
    21. try {
    22. TimeUnit.SECONDS.sleep(1);
    23. } catch (InterruptedException e) {
    24. e.printStackTrace();
    25. }
    26. new Thread(() -> {
    27. t1.interrupt();
    28. }, "t2").start();
    29. }
    30. }

    这个程序是停不下来的,我是不想耗费太多CPU资源,手动停止了。 

    原因就是:

    如何修改上面的代码,使得程序正常运行停止呢?   → 

    1. 中断标志位 默认是false。
    2. t2 ----->t1发出了中断协商,t2调用t1.interrupt(),中断标志位true。
    3. 中断标志位true,正常情况下,程序停止。
    4. 中断标志位true,异常情况下,InterruptedException,将会把中断状态清除,并且将收到InterruptedException。中断标志位false导致无限循环。
    5. 在catch块中,需要再次给中断标志位设置为true,2次调用停止。
    1. package com.szh.demo.interrupt;
    2. import java.util.concurrent.TimeUnit;
    3. public class InterruptDemo6 {
    4. public static void main(String[] args) {
    5. Thread t1 = new Thread(() -> {
    6. while (true) {
    7. if (Thread.currentThread().isInterrupted()) {
    8. System.out.println(Thread.currentThread().getName() + " 中断标识位:" +
    9. Thread.currentThread().isInterrupted() + " 线程终止....");
    10. break;
    11. }
    12. try {
    13. Thread.sleep(200);
    14. } catch (InterruptedException e) {
    15. Thread.currentThread().interrupt(); //关键代码
    16. e.printStackTrace();
    17. }
    18. System.out.println("---- hello InterruptDemo6");
    19. }
    20. }, "t1");
    21. t1.start();
    22. try {
    23. TimeUnit.SECONDS.sleep(1);
    24. } catch (InterruptedException e) {
    25. e.printStackTrace();
    26. }
    27. new Thread(() -> {
    28. t1.interrupt();
    29. }, "t2").start();
    30. }
    31. }

    3.5 静态方法public static boolean interrupted()

    静态方法,Thread.interrupted();判断线程是否被中断,并清除当前中断状态这个方法做了两件事:1 返回当前线程的中断状态    2 将当前线程的中断状态设为false(这个方法有点不好理解,因为连续调用两次的结果可能不一样。)

    1. package com.szh.demo.interrupt;
    2. public class InterruptDemo7 {
    3. public static void main(String[] args) {
    4. System.out.println(Thread.currentThread().getName() + "\t" + Thread.interrupted());
    5. System.out.println(Thread.currentThread().getName() + "\t" + Thread.interrupted());
    6. System.out.println("-----1");
    7. Thread.currentThread().interrupt();//中断标志位设置为true
    8. System.out.println("-----2");
    9. System.out.println(Thread.currentThread().getName() + "\t" + Thread.interrupted());
    10. System.out.println(Thread.currentThread().getName() + "\t" + Thread.interrupted());
    11. }
    12. }

    前两次调用没啥说的,因为main主线程并没有中断,第三次调用的时候,因为上面已经 interrupt 了,所以被中断了,这里中断标识位肯定就是 true。此时这个静态方法在中断之后第一次调用(返回当前线程的中断状态,被中断了就是true;第二件事,将当前线程的中断标识重置为false)。所以当最后一行再次调用它的时候,就是false了。 

    看一下这个静态方法的源码:↓↓↓    在那个isInterrupt实例方法中传入的 一个布尔值,而这个值就是 是否清除中断标识位,false表示不清除,true表示清除(即将线程的中断标识位清除重新设置为false)。

    这两个方法在底层都调用了native方法isInterrupted。  只不过传入参数ClearInterrupted一个传参传了true,一个传了false。

    静态方法interrupted() 中true表示清空当前中断状态。  实例方法isInterrupted 则不会。


    4.LockSupport

    用于创建锁和其他同步类的基本线程阻塞原语。

    这个类与每个使用它的线程相关联,一个许可证(在Semaphore类的意义上)。 如果许可证可用,则呼叫parkpark返回,在此过程中消耗它; 否则可能会阻止。 致电unpark使许可证可用,如果尚不可用。 (与信号量不同,许可证不能累积,最多只有一个。)

    核心就是park()unpark()方法

    • park()方法是阻塞线程

    • unpark()方法是解除阻塞线程

    4.1 线程的等待唤醒机制

    1. 使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程。(有局限性)

    2. 使用JUC包中Conditionawait()方法让线程等待,使用signal()方法唤醒线程。(有局限性)

    3. LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程。

    4.2 wait、notify

    1. package com.szh.demo.locksupport;
    2. import java.util.concurrent.TimeUnit;
    3. public class LockSupportDemo1 {
    4. public static void main(String[] args) {
    5. final Object obj = new Object();
    6. new Thread(() -> {
    7. synchronized (obj) {
    8. System.out.println(Thread.currentThread().getName() + " --- come in");
    9. try {
    10. obj.wait();
    11. } catch (InterruptedException e) {
    12. e.printStackTrace();
    13. }
    14. }
    15. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了");
    16. }, "t1").start();
    17. try {
    18. TimeUnit.SECONDS.sleep(3L);
    19. } catch (InterruptedException e) {
    20. e.printStackTrace();
    21. }
    22. new Thread(() -> {
    23. synchronized (obj) {
    24. obj.notify();
    25. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    26. }
    27. }, "t2").start();
    28. }
    29. }

    异常情况1:将 synchronized 同步代码块对应的代码注释掉。 

    1. package com.szh.demo.locksupport;
    2. import java.util.concurrent.TimeUnit;
    3. public class LockSupportDemo1 {
    4. public static void main(String[] args) {
    5. final Object obj = new Object();
    6. new Thread(() -> {
    7. //synchronized (obj) {
    8. System.out.println(Thread.currentThread().getName() + " --- come in");
    9. try {
    10. obj.wait();
    11. } catch (InterruptedException e) {
    12. e.printStackTrace();
    13. }
    14. //}
    15. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了");
    16. }, "t1").start();
    17. try {
    18. TimeUnit.SECONDS.sleep(3L);
    19. } catch (InterruptedException e) {
    20. e.printStackTrace();
    21. }
    22. new Thread(() -> {
    23. //synchronized (obj) {
    24. obj.notify();
    25. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    26. //}
    27. }, "t2").start();
    28. }
    29. }

    异常情况2:将wait和notify顺序调换。 

    1. package com.szh.demo.locksupport;
    2. import java.util.concurrent.TimeUnit;
    3. public class LockSupportDemo1 {
    4. public static void main(String[] args) {
    5. final Object obj = new Object();
    6. new Thread(() -> {
    7. try {
    8. TimeUnit.SECONDS.sleep(3L);
    9. } catch (InterruptedException e) {
    10. e.printStackTrace();
    11. }
    12. synchronized (obj) {
    13. System.out.println(Thread.currentThread().getName() + " --- come in");
    14. try {
    15. obj.wait();
    16. } catch (InterruptedException e) {
    17. e.printStackTrace();
    18. }
    19. }
    20. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了");
    21. }, "t1").start();
    22. // try {
    23. // TimeUnit.SECONDS.sleep(3L);
    24. // } catch (InterruptedException e) {
    25. // e.printStackTrace();
    26. // }
    27. new Thread(() -> {
    28. synchronized (obj) {
    29. obj.notify();
    30. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    31. }
    32. }, "t2").start();
    33. }
    34. }

    小总结

    • 线程先要获得并持有锁,必须在锁块(synchronized或lock)中

    • 必须要先等待后唤醒,线程才能够被唤醒。要保证先wait,后notify才OK。

    • wait和notify方法必须要在同步块或者方法里面,且成对出现使用。

    4.3 await、signal

    1. package com.szh.demo.locksupport;
    2. import javax.swing.*;
    3. import java.util.concurrent.TimeUnit;
    4. import java.util.concurrent.locks.Condition;
    5. import java.util.concurrent.locks.Lock;
    6. import java.util.concurrent.locks.ReentrantLock;
    7. public class LockSupportDemo2 {
    8. private static Lock lock = new ReentrantLock();
    9. private static Condition condition = lock.newCondition();
    10. public static void main(String[] args) {
    11. new Thread(() -> {
    12. lock.lock();
    13. try {
    14. System.out.println(Thread.currentThread().getName() + " --- come in");
    15. condition.await();
    16. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了");
    17. } catch (InterruptedException e) {
    18. e.printStackTrace();
    19. } finally {
    20. lock.unlock();
    21. }
    22. }, "t1").start();
    23. try {
    24. TimeUnit.SECONDS.sleep(1L);
    25. } catch (InterruptedException e) {
    26. e.printStackTrace();
    27. }
    28. new Thread(() -> {
    29. lock.lock();
    30. try {
    31. condition.signal();
    32. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    33. } finally {
    34. lock.unlock();
    35. }
    36. }, "t2").start();
    37. }
    38. }

    异常情况1:将对应的加锁解锁的代码注释掉,报错信息和第一个案例是一样的。 

    异常情况2:先进行 signal,再进行 await,报错信息和第一个案例是一样的。 

    小总结

    • 线程先要获得并持有锁,必须在锁块(synchronized或lock)中

    • 必须要先等待后唤醒,线程才能够被唤醒。一定要先await后signal,不能反了

    • Condition中的线程等待和唤醒方法,需要先获取锁

    4.4 park、unpark

    调用LockSupport.park()时,发现它调用了unsafe类,并且默认传了一个0。

    permit默认是零,所以一开始调用park()方法,当前线程就会阻塞,直到别的线程将当前线程的permit设置为1时,park方法会被唤醒,然后会将permit再次设置为零并返回。


    调用LockSupport.unpark();时,也调用了unsafe类。

    调用unpark(thread)方法后,就会将thread线程的许可permit设置成1(注意多次调用unpark方法,不会累加,permit值还是1)会自动唤醒thread线程,即之前阻塞中的LockSupport.park()方法会立即返回。

    解决上面两个案例的第一个问题:必须放在锁块中,LockSupport不需要这样做。 

    1. package com.szh.demo.locksupport;
    2. import java.util.concurrent.TimeUnit;
    3. import java.util.concurrent.locks.LockSupport;
    4. public class LockSupportDemo3 {
    5. public static void main(String[] args) {
    6. Thread t1 = new Thread(() -> {
    7. System.out.println(Thread.currentThread().getName() + " --- come in");
    8. LockSupport.park();
    9. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了");
    10. }, "t1");
    11. t1.start();
    12. try {
    13. TimeUnit.SECONDS.sleep(2L);
    14. } catch (InterruptedException e) {
    15. e.printStackTrace();
    16. }
    17. new Thread(() -> {
    18. LockSupport.unpark(t1);
    19. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    20. }, "t2").start();
    21. }
    22. }

    解决上面两个案例的第一个问题:必须先等待,后唤醒,LockSupport不需要这样做,先唤醒后等待照样OK。 

    1. package com.szh.demo.locksupport;
    2. import java.util.concurrent.TimeUnit;
    3. import java.util.concurrent.locks.LockSupport;
    4. public class LockSupportDemo3 {
    5. public static void main(String[] args) {
    6. Thread t1 = new Thread(() -> {
    7. try {
    8. TimeUnit.SECONDS.sleep(3L);
    9. } catch (InterruptedException e) {
    10. e.printStackTrace();
    11. }
    12. System.out.println(Thread.currentThread().getName() + " --- come in " + System.currentTimeMillis());
    13. LockSupport.park();
    14. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了 " + System.currentTimeMillis());
    15. }, "t1");
    16. t1.start();
    17. new Thread(() -> {
    18. LockSupport.unpark(t1);
    19. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    20. }, "t2").start();
    21. }
    22. }

    这里会先执行t2线程的unpark方法,此时t1线程手中就有了一张许可证,当t1线程睡眠3秒之后,执行代码,走到park方法不会再阻塞,直接拿出许可证,继续向下执行,所以看代码的花费时间就知道,这里的park是无效没有阻塞的。 

    jdk官方文档中说了,与信号量不同,许可证不能累积,最多只有一个。

    老子就不信这个邪,我非得给你来两个许可证,看看下面的代码。

    1. package com.szh.demo.locksupport;
    2. import java.util.concurrent.TimeUnit;
    3. import java.util.concurrent.locks.LockSupport;
    4. public class LockSupportDemo3 {
    5. public static void main(String[] args) {
    6. Thread t1 = new Thread(() -> {
    7. try {
    8. TimeUnit.SECONDS.sleep(3L);
    9. } catch (InterruptedException e) {
    10. e.printStackTrace();
    11. }
    12. System.out.println(Thread.currentThread().getName() + " --- come in " + System.currentTimeMillis());
    13. LockSupport.park();
    14. LockSupport.park();
    15. System.out.println(Thread.currentThread().getName() + " --- 被唤醒了 " + System.currentTimeMillis());
    16. }, "t1");
    17. t1.start();
    18. new Thread(() -> {
    19. LockSupport.unpark(t1);
    20. LockSupport.unpark(t1);
    21. System.out.println(Thread.currentThread().getName() + " --- 发出通知");
    22. }, "t2").start();
    23. }
    24. }

    可以看到,代码卡在这里了,这是因为你虽然发了两个许可证,但是最多只能持有一个,那么当第二次park尝试再去获取许可证时,已经不可能了,因为t1线程手中的那个许可证已经被第一次park的时候消费掉了。

    当调用park方法时如果有凭证,则会直接消耗掉这个凭证然后正常退出;如果无凭证,就必须阻塞等待凭证可用。

    而unpark则相反, 它会增加一个凭证, 但凭证最多只能有1个, 累加无效。 


    针对park和unpark方法的代码实测结论:

    1. park:unpark = 1:1,代码正常执行无误。
    2. park:unpark = 1:n,代码正常执行无误。(尽管unpark了多次,但是当前线程最多只能持有1个许可证,之后也只park了一次,消费了一个许可证,所以没问题,但还是不推荐这样写)
    3. park:unpark = n:1,代码卡死无法结束。(当前线程最多只能持有1个许可证,park一次消费一个,park多次直接无证,当前线程无法正常结束)
    4. park:unpark = n:n,代码卡死无法结束。(原因在上面说过了)
  • 相关阅读:
    小谈设计模式(6)—依赖倒转原则
    Java 入门:装箱与拆箱
    Servlet 请求和响应
    如何能通过表面挖掘到深层次的底层思维?
    Obsidian Publish的开源替代品Perlite
    lio-sam框架:回环检测及位姿计算
    el-date-picker 禁止选择当前年之前或者之后的年份
    如何解决3d max渲染效果图全白这类异常问题?
    C. Omkar and Baseball
    LearnOpenGL 及 ShaderToy 的 CMake 构建框架
  • 原文地址:https://blog.csdn.net/weixin_43823808/article/details/126845515