java基础学习 八、多线程

线程和进程

  • 进程:几乎所有的操作系统都有支持进程的概念,所有运行中的任务通常对应一个进程。当一个程序进入内存运行时,即变成一个进程。进程是处于运行过程中的程序,并具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位。
  • 线程:线程也被称为轻量级进程,线程是进程的执行单元,线程是程序中独立的,并发的执行流。线程是进程的组成部分,一个进程可以拥有多个线程,一个线程必须有一个父进程。
  • 并发:并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。

线程的创建和启动

继承 Thread 类创建线程类

通过继承 Thread 类来创建并启动多线程的步骤如下:

  1. 定义 Thread 类的子类,并重写该类的 run()方法,该 run() 方法的方法体就代表了线程需要完成的任务。因此把 run() 方法称为线程执行体。
  2. 创建 Thread 类子类的实例,即创建了线程对象。
  3. 调用线程对象的 start() 方法来启动线程。
public class Testextends Thread{
    private int i;
    public void run(){
        for (int i = 0; i < 100 ;i++){
            //当线程类继承 Thread 类时,直接使用 this 即可获取当前线程
            //Thread 对象的 getName() 方法返回当前线程的名字
            //因此可以直接调用getName() 方法返回当前线程的名字
            System.out.println(getName() + " " + i);
        }
    }
    
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++){
            //调用 Thread 的 currentThread() 方法获取当前线程
            System.out.println(Thread.currentThread().getName() + " " + i);
            if (i == 20){
                //创建并启动第一个线程
                new Test().start();
                //创建并启动第二个线程
                new Test().start();
            }
        }
    }

上面程序中显示的创建并启动了 2 个线程,但实际有程序有 3 个线程,即程序线程的创建了 2 个线程和 主线程。当 Java 程序运行时,程序至少会创建一个主线程,主线程不是有 run() 方确定的,而是有 main() 方法确定的,mian() 方法的方法体代表主线程的线程执行体。

代码中用到了线程的两个方法:

  • Thread.currentThread():currentThread() 是 Thread 类的静态方法,该方法总是返回当前正在执行的线程对象。
  • getName:该方法是 Thread 类的实例方法,该方法返回调用该方法的线程名字。

注意:
使用继承 Thread 类的方法来创建线程类时,多个线程之间无法共享线程类的实例变量。

实现 Runnable 接口创建线程类

实现 Runnable 接口来创建并启动多线程的步骤如下:

  1. 定义 Runnable 接口的实现类,并重写该接口的 run() 方法,该 run() 方法的方法体同样是该线程的线程执行体。
  2. 创建 Runnable 实现类的实例,并以此实例作为 Thread 和 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
  3. 调用线程对象的 start() 方法来启动该线程。

注意:
Runnable 对象仅仅作为 Thread 对象的 target,Runnable 实现类里包含 run() 方法仅作为线程执行体。而实际的线程对象依然是 Thread 实例,只是该 Thread 线程负责执行其 target 的 run() 方法。

public class Test implements Runnable{
    private int i;

    //run() 方法同样是线程执行体
    public void run(){
        for (int i = 0; i < 100 ;i++){
            //当线程类实现 Runnable 接口时
            //如果想获取当前线程,只能使用 Thread.currentThread() 方法
            System.out.println(Thread.currentThread()+ " " + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++){
            //调用 Thread 的 currentThread() 方法获取当前线程
            System.out.println(Thread.currentThread().getName() + " " + i);
            if (i == 20){
                //创建 Runnable实现类的实例
                Test test = new Test();
                //通过 new Thread(target,name)方法创建新线程
                new Thread(test,"新线程1").start();
                new Thread(test,"新线程2").start();

            }
        }
    }
}

这两种创建方式都可以实现多线程,而实现的方式不通,前者直接创建的 Thread 子类即可代表线程对象,后者创建的 Runnable 对象只能作为线程对象的 target。

使用Callable 和 Future 创建线程

  1. 创建 Callable 接口的实现类,并实现 call() 方法,该 call() 方法将作为线程执行体,且该 call() 方法有返回值,再创建 Callable 实现类的实例。
  2. 使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call() 方法的返回值。
  3. 使用 FutureTask 对象作为 Thread 对象的 target 创建并启动新线程。
  4. 调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值。
public class Test{
    
    public static void main(String[] args) {
        //使用 FutureTask 来包装 Callable 对象,此处的 Callable 对象使用 Lambda 创建
        FutureTask<Integer> task = new FutureTask<>((Callable<Integer>)()->{
            int i = 0;
            for (;i < 100; i++){
                System.out.println(Thread.currentThread().getName() + " " + i);
            }
            return i;
        });
        
        for (int i = 0; i < 100; i++){
            //调用 Thread 的 currentThread() 方法获取当前线程
            System.out.println(Thread.currentThread().getName() + " " + i);
            if (i == 20){
                //创建 Runnable实现类的实例
                //通过 new Thread(target,name)方法创建新线程
                new Thread(task,"有返回值的线程").start();
            }
            
            try{
                System.out.println("子线程的返回值:" + task.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

线程的生命周期

当程序使用 new 关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的 Java 对象一样,仅仅有 Java 虚拟机为期分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
当线程对象调用了 start() 方法后,该线程处于就绪状态,Java 虚拟机会为其创建方法调用栈和程序计数器,处于这个状态的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时可以开始运行,取决于 JVM 里线程调度器的调度。

注意:
启动线程使用的是 start() 方法,而不是 run() 方法,永远不要调用线程对象的 run() 方法,如果直接调用 run() 方法,系统把线程对象当成一个普通对象,而 run() 方法也是一个普通方法,而不是线程执行体。

如果处于就绪状态的线程获得了 CPU,开时执行 run() 方法的线程执行体,则该线程处于运行状态,如果计算机只有一个 CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个处理器的机器上,将会有多个线程并行执行,当线程数大于处理器数时,依然会存在多个线程在同一个 CPU 上轮换的现象。
当线程被人为或者进入某些特殊状态后,线程会被阻塞,当线程进入阻塞后,其他线程就可以获得执行的机会,被阻塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态,而不是运行状态。

当发生如下情况时,线程将会进入阻塞状态:

  • 线程调用 sleep() 方法主动放弃所占有的处理器资源。
  • 线程调用了一个阻塞式 IO 方法,在该方法返回之前,该线程被阻塞。
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。
  • 线程在等待某个通知。
  • 程序调用了线程的 suspend() 方法将该线程挂起–该方法容易造成死锁,不建议使用。

针对上面几种情况,发生如下特定情况时可以解除上面的阻塞,让线程重新进入就绪状态:

  • 调用 sleep() 方法的线程经过了指定时间。
  • 线程调用的 IO 方法已经返回。
  • 线程成功地获得了试图取得的同步监视器。
  • 线程正在等待某个通知时,其他线程发出了一个通知。
  • 处于挂起状态的线程被调用了 resume() 恢复方法。

在这里插入图片描述
从图中有看出,线程从阻塞状态只能进入就绪状态看,无法直接进入运行状态。而就绪状态和运行状态之间的转换通常不受程序控制,而是有系统线程调度所决定。

线程会以以下三种方式结束,结束后即为死亡状态:

  • run() 或者 call() 方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的异常 Exception 或者 Error。
  • 直接调用该线程的 stop() 方法来结束该线程–该方法容易造成死锁,通常不推荐使用。

注意:
当主线程结束时,其他线程不受任何影响,并不会随之结束。一旦子线程启动起来后,它就拥有了和主线程相同的地位,它不会受主线程的影响。
不要试图对一个已经死亡的线程调用 start() 方法使它重新启动,死亡就是死亡,该线程将不可再次作为线程执行。
不要对处于死亡状态的线程调用 start() 方法,程序只能对新建状态的线程调用 start() 方法,对新建状态的线程两次调用start() 方法也是错误。两种方式都会引发 IllegalThreadStateException 异常。

控制线程

join 线程

Thread 提供了让一个线程等待另一个线程完成的方法-----join() 方法。
当某个程序执行流中调用了其他线程的 join() 方法时,调用线程将被阻塞,直到被 join() 方法加入的 join 线程执行完为止。

public class Test extends Thread{

    // 提供一个有参数的构造器,用于设置该线程的名字
    public Test(String name) {
        super(name);
    }

    //重写 run() 方法,定义线程执行体
    public void run(){
        for (int i = 0; i < 100; i++){
            System.out.println(getName() + " "  + i);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //启动子线程
        new Test ("新线程").start();

        for (int i = 0; i < 100 ; i++){
            if (i  == 20 ){
                //再次启动一个线程
                Test test = new Test ("被 join 的线程");
                test.start();

                //main 线程调用了 test 线程的 join() 方法,main 线程必须等待 test 执行结束才会向下继续执行
                test.join();
            }
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }
}

上面的代码执行后,可以看到,当 mian 方法执行了 join() 方法后,mian 线程就停止了进入挂起状态,只有当被 join 的方法执行结束,mian 线程才会继续执行。

join() 方法有三种重载形式:

  • join():等待被 join 的线程执行完成
  • join(long millis):等待被 join 的线程时间最长为 millis 毫秒。如果在 millis 毫秒被被 join 的线程还没有执行结束,则不在等待。
  • join(long millis, int nanos):等待被 join 的线程时间最长为 millis 毫秒加 nanos 毫微秒。

后台线程

后台线程,它的主要任务是为其他的线程提供服务,有被称为“守护线程”或者“精灵线程”。JVM 的垃圾回收线程就是典型的后台线程。
后台线程的特征,如果所有的前台线程死亡,后台线程会自动死亡。
调用 Thread 对象的 setDaemon(true) 方法即可将指定线程设置为后台线程。

public class Test extends Thread{

    //重写 run() 方法,定义线程执行体,与普通线程没有如何区别
    public void run(){
        for (int i = 0; i < 100; i++){
            System.out.println(getName() + " "  + i);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //初始化 Thread 对象
        Test test = new Test () ;
        //将此线程设置为后台线程
        test.setDaemon(true);
        //启动后台线程
        test.start();
        for (int i = 0; i < 100 ; i++){
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
        //程序到此结束,前台线程结束,后台线程不管是否执行完成也随之结束。
    }
}

值得注意的是,并不是所有的线程默认都是前台线程,有些线程默认就是后台线程----前台线程创建的子线程,默认是前台线程,后台线程创建的子线程默认是后台线程。

PS:
前台线程死亡后, JVM 会通知后台线程死亡,但从它接收指令到做出响应,需要一定时间。而且要将某个线程设置为后台线程,必须在该线程启动之前设置,也就是说,setDaemon(true) 必须在 start() 方法之前调用,否则会引发 IllegalThreadStateException 异常。

线程睡眠:sleep

如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用 Thread 类的静态 sleep() 方法来实现:

  • static void sleep(long millis):让当前正在执行的线程暂停 millis 毫秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确的度的影响。
  • static void sleep(long millis,int nanos):让当前正在执行的线程暂停 millis 毫秒加 nanos毫微秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确的度的影响。

当调用 sleep() 方法进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于 sleep() 中线程也不会执行,因此 sleep() 方法常用来暂定程序的执行。

public class Test{
    public static void main(String[] args) throws InterruptedException {

        System.out.println("当前时间:" + new Date());
        //调用 sleep() 方法让当前线程暂停 1 秒 
        Thread.sleep(1000);
        System.out.println("当前时间:" + new Date());
    }
}

线程让步:yield

yield() 方法与 sleep() 方法有点类似,它也是 Thread 类提供的一个静态方法,它可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是让该线程转入就行专题。

public class Test extends Thread{

    public Test (String name) {
        super(name);
    }

    //重写 run() 方法,定义线程执行体
    public void run(){
        for (int i = 0; i < 100; i++){
            System.out.println(getName() + " "  + i);
            // 当线程等于 20 时,使用 yield() 方法让当前线程让步
            if (i == 20){
                Thread.yield();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //启动两个并发线程
        Test test1 = new Test ("高级");
        // 将 test1 设置为最高优先级
//        test1.setPriority(Thread.MAX_PRIORITY);
        test1.start();

        Test test2 = new Test("低级");
        // 将 test1 设置为最低优先级
//        test2.setPriority(Thread.MIN_PRIORITY);
        test2.start();
    }
}

上面程序中,当前线程执行到 20 时,调用了 yield() 方法让当前正在执行的线程暂停,让系统重新调度,所以执行会执行第二段 test 线程。如果放开设置优先级部分代码,由于 test1 优先级更高,所以当test1调了 yield() 方法重新调度后,执行的还是 test1。

注意:
在多 CPU 并行的环境下,yield() 方法的功能有时候并不明显。这边 CPU重新调度该线程时,可能其他线程正在其他 CPU 中处理,所以它依然只能执行当前线程。

sleep() 方法和 yield() 方法的区别:

  • sleep() 方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程优先级,当 yield() 方法只会给优先级相同,或优先级更高的线程执行机会。
  • sleep() 方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就行状态,而 yield()不会进入阻塞状态,它会将线程强制进入就行状态。完全有可能某个线程调用了 yield() 方法暂停后,立即又获得执行机会。
  • sleep() 会声明抛出 IllegalThreadStateException 异常,而 yield()方法则没有声明抛出任何异常。
  • sleep() 方法比 yield() 方法有更好的移植性,通常不建议使用 yield() 方法来控制并发线程的执行。

改变线程优先级

每个线程执行时都有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。
通常情况下,main线程具有普通优先级,由 main 线程创建的子线程也具有平台优先级。
Thread 类提供了 setPriority(int newPriority)、getPriority() 方法来设置和返回指定线程的优先级,getPriority() 方法的参数可以是一个整数,范围是 1 ~10 之间,也可以是这三个静态常量:

  • Thread.MAX_PRIORITY: 值为 10 。
  • Thread.NORM_PRIORITY:值为 5。
  • Thread.MIN_PRIORITY:值为 1。

示例代码在 yield() 方法介绍中。

PS:
设置优先级时,因为不同操作系统的优先级并不相同,不能很好与 Java 提供的10个优先级对应,最好使用Thread 类提供的静态常量,这样可以保证程序具有最好的可移植性。

线程同步

同步代码块

由于 run() 方法的方法体不具有同步安全性,如果程序中有两个并发线程在修改同一个对象,就有可能造成线程冲突的问题。譬如,一个账户有 100 元,A线程调用取钱方法取钱 60 元,A线程执行到一半时,B线程获得执行机会,B线程也取钱 60 元,B线程执行完成后,A线程继续执行,最后一共被取走了 120 元,账户余额 -20,这明显是错误的。

为了解决这一个问题,Java 的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的方法就是同步代码块:

synchronize(obj){
	//同步代码块
}

括号的中的 obj 就是同步监视器,线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。

PS:
任何时候只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。

上面举例中,可以将 100 元做为同步监视器,任何线程进入同步代码块前,必须先获得对 100 元账号的锁定,其他线程无法获得锁,也就无法修改它。

任何线程在修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改该资源,当该线程完成后,该线程释放对该资源的锁定。通过这种方式就可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区,所以同意时刻最多只有一个线程处于共享资源的代码区中,从而保证了线程的安全。

同步方法

Java 提供了 synchronize 关键字来修饰方法,被 synchronize 修饰的方法是线程安全的,这种方法被称为同步方法,与同步代码块类似。
对于 synchronize 修饰的方法,不需要显式的声明同步监视器,该方法的同步监视器就是 this,即调用该方法的对象。

通过使用同步方法,可以非常方便的实现线程安全的类,线程安全的类有如下几特征:

  • 该类的对象可以被多个线程安全的访问。
  • 每个线程调用该对象的任意方法之后都将得到正确结果。
  • 每个线程调用该对象的任意方法之后,该对象专状态依然保持合理状态。

释放同步器的锁定

线程会在如下几种情况下释放对同步监视器的锁定:

  • 当前线程的同步方法、同步代码块执行结束。
  • 当前线程在同步代码块、同步方法中遇到 break、return 终止了该代码块、该方法的继续执行。
  • 当前线程在同步代码块、同步方法中出现了未处理的异常,导致了该代码块、该方法异常结束时。
  • 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的 wait() 方法,则当前线程暂停。

值得注意的是,有部分情况下,线程停止,但不会释放同步监视器:

  • 线程执行同步代码块或者同步方法时,程序调用了 Thread.sleep() 方法、Thread.yield() 方法来暂停当前线程的执行。
  • 线程执行同步代码块时,其他线程调用了该线程的 susppend() 方法将该线程挂起。并不推荐使用 susppend() 或者 resume() 方来控制线程。

同步锁(Lock)

Java 5 之后,Java 提供了一种功能更加强大的线程同步机制,通过显示定义同步锁对象来实现同步,在这种机制下,同锁由 Lock 对象充当。

Lock 是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象。

Lock、ReadWriteLock 是Java 5 提供的两个根接口,并为 Lock 提供了 ReentrantLock(可重入锁)实现类,为 ReadWriteLock 提供了 ReentrantReadWriteLock 实现类。

在实现线程安全的控制中,比较常用的是 ReentrantLock:

public class Test{
    // 定义锁对象
    private final ReentrantLock lock = new ReentrantLock();

    //定义需要保证线程安全的方法
    public void test(){
        // 加锁
        lock.lock();
        
        try{
            // 需要保证线程安全的代码
        }finally {
            //使用 finally 来保证释放锁
            lock.unlock();
        }
        
    }
}

PS:
可重入性:一个线程可以对已被加锁的 ReentrantLock 锁再次加锁,ReentrantLock 对象会维持一个计数器来追踪 lock() 方法的嵌套调用,线程在每次调用 lock() 加锁后,必须显式的调用 unlock() 来释放锁,所以一段被锁保护的代码可以被另一个相同锁保护的对象。

死锁

当两个线程相互等待对方释放同步监视器时就会发生死锁,Java 虚拟机没有检测,也没有采取措施来处理死锁情况,所有多线程编程时应该采取措施避免死锁出现。

一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

线程池

系统启动一个新线程成本是比较高的,它涉及到与操作系统交互,而使用线程池可以很好的提供性能。
线程池在系统启动时会创建大量空闲的线程,程序将一个 Runnable 对象或 Callable 对象传给线程池,线程池会启动一个线程来执行他们的 run() 方法或者 call() 方法,当 run() 方法或者 call() 执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个 Runnable 对象的 run() 方法或者 call() 方法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值