JavaEE篇:多线程(1)

一 认识线程(Thread)

1.1 概念

1.1.1 线程是什么?

线程被创建出来是为了完成分配给它的任务。线程又称轻量级进程,是操作系统的基本调度单位。一个线程就是一个执行流。线程的创建销毁和切换都比进程更加的方便。进程是操作系统分配资源的基本单位,线程的创建和释放不涉及资源分配和释放,使用的都是所属同一个进程的资源。进程被创建时自带一个主线程(mian)。在Java中,每个线程都有自己的一个PCB。

1.1.2 为什么要有线程?

随着时代的发展,硬件设备的性能也越来越好,现在的CPU都是多核心的。所以为了充分发挥多核心CPU的性能,所以就有了线程。有了线程自然就有了多线程,编写多下次你代码需要通过一些编程技巧(这里说的编程机巧指的就是并发编程,并发=并行+并发。),把要完成的任务分解为多个部分,让多个线程在多个CPU核心上运行以来发挥多核心的实力来提高效率。

这里解释一下并发编程中的并发?

并发=并行+并发。并行就是多个线程同时在CPU的不同核心上执行。并发是指多个线程分别在CPU的核心上执行,执行一个时间片后切换其他线程上CPU核心执行。

并发在微观上看是不同的线程分别执行,宏观上是不同线程一起执行,因为切换太快,肉眼看不到。并行在微观上宏观上都是不同线程一起执行。

所以统称为并发。

1.1.3 线程和进程的区别

• 进程是包含线程的. 每个进程⾄少有⼀个线程存在,即主线程
• 进程和进程之间不共享内存空间. 同⼀个进程的线程之间共享同⼀个内存空间.
• 进程是系统分配资源的最⼩单位,线程是系统调度的最⼩单位。
• ⼀个进程挂了⼀般不会影响到其他进程. 但是⼀个线程挂了, 可能把同进程内的其他线程⼀起带⾛(整个进程崩溃).

1.1.4 Java中线程和操作系统线程的关系

线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对⽤⼾层提供了⼀些 API 供用户使⽤(例如 Linux 的 pthread 库).
Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进⾏了进⼀步的抽象和封装。

1.2编写第一个线程

java 复制代码
class MyThread extends Thread
{
    @Override //注解,相当于提示编译器,这里要仔细检查看看是否和继承的类中参数是否一致
    public void run()//重写线程执行的方法
    {
        while (true) {
            try {
                System.out.println("hello,thread");
                Thread.sleep(1000);
                } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class demo01 {
    public static void main(String[] args) {
        Thread t1 = new MyThread();
        t1.start();//启动t1线程
        try {
            System.out.println("hello,main");
            Thread.sleep(1000);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
    }
}

jdk提供了一个jconsole工具可以监控进程的相关信息。

可以看到这个线程是在运行中。

1.3 创建线程

创建线程有五种方法,其中第三四种都是针对第一二种的变性。第三四种不常用,他们的本质是1)创建了一个匿名子类2)重写run方法。如果不懂就记住就行了。其中常用的就是一二五。

1.3.1 继承Thread类

1)继承Thread来创建⼀个线程类

java 复制代码
class MyThread extends Thread {
 @Override
 public void run() {
 System.out.println("这⾥是线程运⾏的代码");
 }
}

2)创建 MyThread 类的实例

java 复制代码
MyThread t = new MyThread();

3)调⽤ start ⽅法启动线程

java 复制代码
 t.start(); // 线程开始运⾏

1.3.2 实现Runnable接口

1)实现Runnable接口

java 复制代码
class MyRunnable implements Runnable {
 @Override
 public void run() {
 System.out.println("这⾥是线程运⾏的代码");
 }
}
  1. 创建 Thread 类实例, 调⽤ Thread 的构造⽅法时将 Runnable 对象作为 target 参数
java 复制代码
 Thread t = new Thread(new MyRunnable());

3)调⽤ start ⽅法

1.3.3 匿名内部类创建 Thread ⼦类对象

java 复制代码
// 使⽤匿名类创建 Thread ⼦类对象
Thread t1 = new Thread() {
 @Override
 public void run() {
 System.out.println("使⽤匿名类创建 Thread ⼦类对象");
 }
};

1.3.4匿名内部类创建 Runnable ⼦类对象

java 复制代码
// 使⽤匿名类创建 Runnable ⼦类对象
Thread t2 = new Thread(new Runnable() {
 @Override
 public void run() {
 System.out.println("使⽤匿名类创建 Runnable ⼦类对象");
 }
});

1.3.5 lambda 表达式

java 复制代码
// 使⽤ lambda 表达式创建 Runnable ⼦类对象
Thread t3 = new Thread(() -> System.out.println("使⽤匿名类创建 Thread ⼦类对象"));
Thread t4 = new Thread(() -> {
 System.out.println("使⽤匿名类创建 Thread ⼦类对象");
});

1.4 一个有关处理异常处理的问题

在继承Thread类或者Runnable类重写run函数时,调用Thread下的sleep函数时,因为sleep是会抛出受查异常的,也就是编译时异常。处理这个异常只有一种方法,try/catch。而在main中调用时有两种方法可以解决这个异常,try/catch和throws。这是因为Thread类和Runnable类的run函数在实现的时候没有规定可以抛这个异常,所以只能用try/catch解决。

子类在重写父类函数时,要保证方法签名一致,方法签名包含1)方法名称 2)方法的参数列表(个数和类型) 3)声明异常的抛出

二 Thread类及常见方法

Thread 类是 JVM 封装OS提供pthread库的API来管理线程的⼀个类,换句话说,每个线程都有⼀个唯⼀的 Thread 实例化对象与之关联。 ⽤我们上⾯的例⼦来看,每个执⾏流,也需要有⼀个对象来描述,类似下图所⽰,⽽ Thread 类的对象 就是⽤来描述⼀个线程执⾏流的,JVM 会将这些 Thread 对象组织起来,⽤于线程调度,线程管理。

2.1 Thread 的常见构造方法

2.2 Thread 的几个常见属性


• ID 是线程的唯⼀标识,不同线程不会重复,这个ID不是OS管理PCB分配的唯一标识,而是JVM分配的。
• 名称是各种调试⼯具⽤到,比如在用上面说到的jconsole。
• 状态表⽰线程当前所处的⼀个情况。
• 优先级⾼的线程理论上来说更容易被调度到。
• 关于后台线程,需要记住⼀点:JVM会在⼀个进程的所有⾮后台线程结束后,才会结束运⾏。和C++的守护进程一样。一个线程被创建处理默认时前台线程,setDaemon(true)可以将线程改为后台线程。
• 是否存活,即简单的理解,为 run ⽅法是否运⾏结束了,和线程在OS中PCB的声明周期不一样,这个存活在被new就开始了,PCB那个是在start以后才会有。

java 复制代码
public class demo10 {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ": 线程执行开始");
                    Thread.sleep(1 * 1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + ": 线程执行结束");
        });
        System.out.println(Thread.currentThread().getName()
                + ": ID: " + thread.getId());
        System.out.println(Thread.currentThread().getName()
                + ": 名称: " + thread.getName());
        System.out.println(Thread.currentThread().getName()
                + ": 状态: " + thread.getState());
        System.out.println(Thread.currentThread().getName()
                        + ": 优先级: " + thread.getPriority());
        System.out.println(Thread.currentThread().getName()
                + ": 后台线程: " + thread.isDaemon());
        System.out.println(Thread.currentThread().getName()
                + ": 活着: " + thread.isAlive());
        System.out.println(Thread.currentThread().getName()
                + ": 被中断: " + thread.isInterrupted());
        thread.start();
        while (thread.isAlive()) {}
        System.out.println(Thread.currentThread().getName()
                + ": 状态: " + thread.getState());
    }
}

2.3 启动⼀个线程 - start()

之前我们已经看到了如何通过覆写 run ⽅法创建⼀个线程对象,但线程对象被创建出来并不意味着线 程就开始运⾏了。
覆写 run ⽅法是提供给线程要做的任务,⽽调⽤ start() ⽅法,才是真正让线程运行起来。调⽤ start ⽅法, 才真的在操作系统的底层创建出⼀个线程。

2.4 中断⼀个线程

中断一个线程在Java中不是真正意义上的中断线程,而是提醒一下线程你可以中断,但是是否要中断取决于线程自己。OS提供了强制线程中断的方法,不过JVM并没有封装这个,原因是因为强制中断不好,可能会引起BUG。比如一个线程在执行一个重要的步骤,突然给他中断,可能就出BUG了。 ⽬前常⻅的方式:调⽤ interrupt() ⽅法来通知
使⽤ Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替⾃定义标志位 Thread 内部包含了⼀个 boolean 类型的变量作为线程是否被中断的标记。
使⽤ Thread 对象的 interrupt() ⽅法通知线程结束。interrupt函数相当于把内置的boolean标志位设置为true。处理之外,还会唤醒阻塞/睡眠的线程。让其从阻塞状态到就就绪状态。线程就绪后就会执行异常处理,并将boolean标志位从true改为false。如果没有异常处理,则会直接结束。

2.5 等待⼀个线程 - join()

有时,我们需要等待⼀个线程完成它的⼯作后,才能进⾏⾃⼰的下⼀步⼯作。例如,张三只有等李四转账成功,才决定是否存钱,这时我们需要⼀个⽅法明确等待线程的结束。在C++中,join()是主线程回收工作线程的资源的,在Java中,因为有GC(垃圾回收)机制会帮忙回收资源,所以join没有这个功能。join有三个版本,可传入等待时间,过了等待时间如果还没执行完就不等待了,接着向后执行。一般在项目中用的都是带参数的。

想让t1执行完再执行t2,就可以用这个join

java 复制代码
public class demo10{
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
            for(int i=0; i<5; i++){
                System.out.println("t1线程在执行");
                try {
                    Thread.sleep(800);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(() ->{
            for(int i=0; i<5; i++){
                System.out.println("t2线程在执行");
            }
        });

        t1.start();
        t1.join();
        t2.start();
    }
}

如果将t1.join()注释掉,会发现t1和t2是并行的。

java 复制代码
public class demo10{
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() ->{
            for(int i=0; i<5; i++){
                System.out.println("t1线程在执行");
                try {
                    Thread.sleep(800);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(() ->{
            for(int i=0; i<5; i++){
                System.out.println("t2线程在执行");
            }
        });

        t1.start();
        t1.join(2000);
        t2.start();
    }
}

2.6 获取当前线程引用

java 复制代码
public class demo10{
    public static void main(String[] args) throws InterruptedException {
        Thread t = Thread.currentThread();
        System.out.println(t.getName());
    }
}

2.7 休眠当前线程

sleep也有两个版本,其实大差不差,第二个版本就是多传一个纳秒参数,纳秒级别其实对于OS来说也是很快很快的,所以一般用不到。

在 Java 中,使用 native 关键字修饰的函数表示该函数是用其他编程语言(通常是C/C++)实现的,而不是用 Java 编写的。这样的函数被称为"本地方法"或"原生方法"。 这么做的目的有两个:

  • 性能优化:某些操作在 Java 中可能效率较低,使用 C 或 C++ 实现的本地方法可以提高性能。
  • 访问系统资源:通过本地方法,可以访问 Java 运行时环境以外的系统资源或硬件,比如底层系统调用、图形界面、网络接口等。

三 线程的状态

3.1 观察线程的所有状态

java 复制代码
public class demo10{
        public static void main(String[] args) {
            for (Thread.State state : Thread.State.values()) {
                System.out.println(state);
            }
        }
}


• NEW: new了,但是没调用start()
• RUNNABLE: 正在⼯作中和就绪等待上CPU都用这个表示。
• TERMINATED: 线程终止,PCB被回收。
• BLOCKED: 阻塞,由于锁竞争没有竞争到锁资源而进入阻塞等待。
• WAITING: 死等进入阻塞,join()。
• TIMED_WAITING: 带时间等待进入阻塞,join(xxxx)。

四 多线程带来的的风险-线程安全

4.1 观察线程不安全

java 复制代码
// 此处定义⼀个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
 Thread t1 = new Thread(() -> {
 // 对 count 变量进⾏⾃增 5w 次
 for (int i = 0; i < 50000; i++) {
 count++;
 }
 });
 Thread t2 = new Thread(() -> {
 // 对 count 变量进⾏⾃增 5w 次
 for (int i = 0; i < 50000; i++) {
 count++;
 }
 });
 t1.start();
 t2.start();
 // 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 cou
 t1.join();
 t2.join();
 // 预期结果应该是 10w
 System.out.println("count: " + count);
}

多次执行都和预期的10w不符合。为什么会这样呢? count++分解成二进制指令其实是有三部分,1)将count的值从主存拿到寄存器 2)count+1 3)将count的值从寄存器存回主存。

t1和t2可能会覆盖主存count的值,导致最终结果小于10w。

4.2 线程安全的概念

如果多线程环境下代码运⾏的结果是符合我们预期的,则说这个程序是线程安全的。

4.3 线程不安全的原因

1)在如今主流的操作系统中,线程在被调度时都是随即调度,抢占式执行的。这个原因是万恶之

2)多个线程对同一个变量修改

3)对变量的修改不是原子性的

4)内存可见性问题,引起线程不安全

5)执行重排序,引起线程不安全

4.4 解决线程安全问题

怎么解决线程不安全问题呢?肯定是针对原因下手, 针对1来说,是不可改变的,因为这就是操作系统的特性。 针对2原因来说,可以办到但是不是很普适,因为有的情况下就必须多个线程对一个变量改动 针对3来说,可以通过加锁的方式,将操作打包成一个"原子性"操作。

4.4.1 synchronized关键字 - 监视器锁

锁,本质上是OS提供的功能,OS提供API供上层调用,JVM又对API做了封装。

4.4.1.1 synchronized 的特性
4.4.1.1.1 互斥

synchronized 是一种关键字,它用来获取对象的监视器锁(monitor lock)。当你使synchronized 关键字修饰一个方法或者代码块时,它实际上是请求一个对象的监视器锁。synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执行到同⼀个对象 synchronized 就会阻塞等待.
• 进⼊ synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁

4.4.1.1.2 可重⼊

synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题;
理解 "把⾃⼰锁死"
⼀个线程没有释放锁, 然后⼜尝试再次加锁.
// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();
按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第⼆
个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想⼲了, 也就⽆法进
⾏解锁操作. 这时候就会 死锁

这样的锁称为 不可重⼊锁.

Java 中的 synchronized 是 可重⼊锁, 因此没有上⾯的问题. 在可重⼊锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息. • 如果某个线程加锁的时候, 发现锁已经被⼈占⽤, 但是恰好占⽤的正是⾃⼰, 那么仍然可以继续获取到锁, 并让计数器⾃增. • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

4.4.1.2 synchronized 使用示例

synchronized 要搭配⼀个 具体的对象来使用,synchronized(....){....} 圆括号中跟具体的对象,这个对象是什么类型无所谓,都会间接或者直接继承自Object类。主要的作用就是 唯一标识 的作用。花括号跟的是加锁内部要执行的代码,进入花括号代表自动加锁,出了花括号代表自动解锁。省去了像C++手动lock()和unlock()

java 复制代码
public class SynchronizedDemo {
 private Object locker = new Object();
 public void method() {
     synchronized (locker) {
     .....
     }
 }
}
4.4.1.2.1 直接修饰普通方法

普通方法直接被synchronize修饰等价于synchronized(this)

java 复制代码
public class SynchronizedDemo {            
 public synchronized void methond() {
    ..... 
  }
}
4.4.1.1.2 修饰静态方法

静态方法直接被synchronize修饰等价于synchronized(类名,class)

java 复制代码
public class SynchronizedDemo {
 public synchronized static void method() {
 }
}

类名.class是什么?

类名.class是类对象,每个类只有一个,属性信息在字节码文件被JVM加载到内存后,在内存中保存这个类的一些属性信息的对象就叫类对象。属性信息包括类的名称,继承那几个类等等。反射就是从这个类对象中获取信息。

4.4.1.3 synchronized引出的死锁问题

synchronized是一个可重入锁

可重入锁:一个线程,对于某个锁,加锁多次不会造成死锁问题。

java 复制代码
class counter
{
    private int count = 0;
    public void add()
    {
        synchronized (this){
            count++;
        }
    }
}

public class demo14 {
    public static void main(String[] args) {
        counter c1 = new counter();
        Thread t1 = new Thread(() ->{
           for(int i=0; i<1000; i++)
           {
               synchronized (c1){
                   c1.add();
               }
           }
        });
        t1.start();
    }
}

当t1执行到synchronized加锁时,代表上锁,接着执行add时,add里面也加锁了,二者的锁对象其实是一个对象。按理说,在执行add时候,应该等待c1锁释放掉自己才能拿到,自己一直等待,导致死锁。但是结果是不会死锁,这就证明了synchronized是一个可重入锁。 锁对象会记录当前是哪个线程获得了使用权 和 记录加锁了几次的一个计数。 像其他语言这种情况就会导致死锁,比如:C++。

死锁的两种情况:

1)锁是一个不可重入锁,一个线程对锁进行多次加锁。
2)N个M把锁,比如,现在有线程1和线程2,锁A,锁B。线程1获取了锁A,线程2获取了锁B。两个线程又分别想获取对方的锁。就会导致死锁问题。

java 复制代码
public class demo14 {
    public static void main(String[] args) {
        Object lock1 = new Object();
        Object lock2 = new Object();

        Thread t1 = new Thread(() ->{
           synchronized (lock1){
               try {
                   Thread.sleep(200);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               synchronized (lock2){
                   System.out.println("t1获取了两把锁");
               }
           }
        });

        Thread t2 = new Thread(() ->{
            synchronized (lock2){
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1){
                    System.out.println("t2获取了两把锁");
                }
            }
        });

        t1.start();
        t2.start();
    }
}
4.4.1.3.1 死锁的四个必要条件

1)锁资源既有互斥性,锁资源每时每刻只能被一个线程持有

2)锁不可剥夺,线程在执行完任务之前,锁资源无法被其他线程强行剥夺

3)请求和保持,一个线程在持有不可剥夺锁资源时,又想获取其他线程的不可剥夺锁资源。

4)循环等待,多个线程间形成一种互相循环等待获取锁资源。

4.4.1.3.2 解决死锁问题

四个必要条件破坏其中一个就可以防止死锁,1) 2)显然不可以破坏,这就是锁的特性。解决3)尽量减少嵌套获取锁资源情况。4)约定好获取锁的顺序,比如:想获取锁B就得先获取锁B。

4.4.2 volatile关键字解决线程安全第四点-----内存可见性问题

可见性问题是指,一个线程对共享变量修改,同一个进程中的其他线程对其不可见。

java 复制代码
public class demo14 {
    private static int count = 0;  
    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
           while(count == 0)
           {

           }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个数字:");
            count = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

线程1的执行顺序是1)load 从内存将count的值读取到自己的寄存器中 2)cmp CPU进行逻辑运算,比较count和0的值是否相等,成立则走循环内,不成立则走外部。 计算机的速度是很快的,一瞬间就会有多次的load和cmp,load指令执行的速度比cmp会慢很多,因为load是缓存和主存交互,寄存器将缓存的count读取走。而cmp是寄存器和寄存器间读取,速度快几个数量级。于是JVM在判断多次读取到的count一直是一个值,就会做优化,CPU再用到count就会直接从寄存器读取,不会再从主存读取。至此,别的线程再改变count的值,t1线程不知道。还是用的之前的count=0。

给count加上volatile就可以解决这个问题,volatile在Java中有两个作用,一个是CPU用到被volatile修饰的变量时,只能从主存读取,不能从线程的寄存器读取。

java 复制代码
public class demo14 {
    private static volatile int count = 0;  // 使用 volatile 确保线程间可见性
    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
           while(count == 0)
           {

           }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个数字:");
            count = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

4.4.3 volatile关键字解决线程问题第五点-----指令重排序问题

volatile不仅可以解决内存可见性问题,还可以帮助解决指令重排序问题。指令重排序也是由于编译器(编译器在Java中指的是Javac和JVM(Java),统称为编译器)优化造成的问题。 编写的Java源文件代码经javac编译成字节码文件,相当于C++中说的二进制文件,整个文件都是一条条二进制指令,再经JVM从磁盘加载到主存运行起来。volatile禁止指令重排序是禁止对二进制指令执行顺序发生改变,就按照编译成字节码文件的二进制指令顺序一条条运行。 编译器对指令优化目的是为了加快运行效率,但有时候会因为更改顺序导致出BUG。 举个例子生活中的例子说明一下为什么更改指令执行顺序可以加快执行效率。

我要到游乐园玩,我买了过山车,跷跷板,摩天轮,旋转木马的票。我按照票的顺序游玩肯定会比按照设施的建设顺序游玩慢很多,所以我经过一个景点我看看我又没有买这个设施的票。

现在有一句代码,instance = new Singleton();这句代码大致有三个步骤1)申请内存空间 2)调用初始化函数初始化变量3)将地址给instance。如果编译器对这句代码的执行顺序发生改变,132。如果执行到第二步时候,这个线程wait了,其他线程拿个这个没有初始化的instance变量执行其他操作就会报错。所以对于不确定的变量要加上volatile防止指令重排序。

编译器优化是不可控的,有可能测试验证了成千上百次也验证不出来,所以为了以防万一就要加上volatile。

相关推荐
许嵩662 分钟前
IC脚本之perl
开发语言·perl
长亭外的少年13 分钟前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
直裾13 分钟前
Scala全文单词统计
开发语言·c#·scala
心仪悦悦14 分钟前
Scala中的集合复习(1)
开发语言·后端·scala
JIAY_WX16 分钟前
kotlin
开发语言·kotlin
阿龟在奔跑35 分钟前
引用类型的局部变量线程安全问题分析——以多线程对方法局部变量List类型对象实例的add、remove操作为例
java·jvm·安全·list
飞滕人生TYF37 分钟前
m个数 生成n个数的所有组合 详解
java·递归
代码小鑫1 小时前
A043-基于Spring Boot的秒杀系统设计与实现
java·开发语言·数据库·spring boot·后端·spring·毕业设计
真心喜欢你吖1 小时前
SpringBoot与MongoDB深度整合及应用案例
java·spring boot·后端·mongodb·spring