多线程基础入门

创建线程的几种方式

  1. 继承Thread类创建线程
public class ThreadDemo extends Thread{
    @Override
    public void run() {

    }
}
 ThreadDemo thread=new ThreadDemo();
 thread.start();
  1. 实现Runnalbe接口创建线程
public class RubbableDemo implements Runnable{
    @Override
    public void run() {

    }
}
Thread thread1=new Thread(new RubbableDemo());
thread1.start();

3.实现 Callable 接口通过 FutureTask 包装器来创建 Thread 线程,带返回值

public class CallableDemo implements Callable<String> {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //方式一
        ExecutorService executorService=
                Executors.newFixedThreadPool(1);
        CallableDemo callableDemo=new CallableDemo();
        Future<String>
                future=executorService.submit(callableDemo);
        System.out.println(future.get());
        executorService.shutdown();
		//方式二
		CallableDemo callableDemo1=new CallableDemo();
        FutureTask<String> futureTask=new FutureTask<>(callableDemo);
        Thread thread=new Thread(futureTask);
        thread.start();
    }
    @Override
    public String call() throws Exception {
        int a=1;
        int b=2;
        System.out.println(a+b);
        return "执行结果:"+(a+b);
    }
}

4.线程池

public class ThreadExecutor implements Runnable{
    public static void main(String[] args) {
        ExecutorService executorService= Executors.newCachedThreadPool();
        executorService.execute(new ThreadExecutor());
        executorService.shutdown();

    }
    @Override
    public void run() {
        System.out.println("启动");
    }
}

线程的生命周期

6种状态(NEW、RUNNABLE、BLOCKED、WAITING、TIME_WAITING、TERMINATED)
NEW:初始状态,线程被构建,但是还没有调用 start 方法
RUNNABLED:运行状态,JAVA 线程把操作系统中的就绪和运行两种状态统一称为“运行中”
BLOCKED:阻塞状态,表示线程进入等待状态,也就是线程因为某种原因放弃了 CPU 使用权,阻塞也分为几种情况
等待阻塞:运行的线程执行 wait 方法,jvm 会把当前线程放入到等待队列
同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其他线程锁占用了那么JVM会把当前线程放入 到锁池中
其他阻塞:运行的线程执行Thread.sleep或者t.join方法,或者发出了I/O请求时,JVM会把当前线程设置为阻塞状态,当sleep结束,join线程终止,io处理完毕则线程恢复
TIME_WAITING:超时等待状态,超时以后自动返回
TERMNATED:终止状态,表示当前线程执行完毕
在这里插入图片描述
可以使用jps查看(JDK1.5 提供的一个显示当前所有 java 进程 pid 的命令)
jstack 用于打印出给定的 java 进程 ID 或 core file 或远程调试服务的 Java 堆栈信息)

线程的启动原理

public synchronized void start() {
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */
        group.add(this);

        boolean started = false;
        try {
            start0();//启动线程
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
            }
        }
    }
    private native void start0();

可以看到start方法实际是调用一个start0()来启动一个线程,而start0()是在Thread的静态块中注册的

/* Make sure registerNatives is the first thing <clinit> does. */
    private static native void registerNatives();
    static {
        registerNatives();
    }

registerNatives的本地方法定义在文件Thread.c,Thread.c定义了各个操作系统关于平台要用的线程的公共数据和操作

static JNINativeMethod methods[] = {
    {"start0",           "()V",        (void *)&JVM_StartThread},
    {"stop0",            "(" OBJ ")V", (void *)&JVM_StopThread},
    {"isAlive",          "()Z",        (void *)&JVM_IsThreadAlive},
    {"suspend0",         "()V",        (void *)&JVM_SuspendThread},
    {"resume0",          "()V",        (void *)&JVM_ResumeThread},
    {"setPriority0",     "(I)V",       (void *)&JVM_SetThreadPriority},
    {"yield",            "()V",        (void *)&JVM_Yield},
    {"sleep",            "(J)V",       (void *)&JVM_Sleep},
    {"currentThread",    "()" THD,     (void *)&JVM_CurrentThread},
    {"countStackFrames", "()I",        (void *)&JVM_CountStackFrames},
    {"interrupt0",       "()V",        (void *)&},
    {"isInterrupted",    "(Z)Z",       (void *)&JVM_IsInterrupted},
    {"holdsLock",        "(" OBJ ")Z", (void *)&JVM_HoldsLock},
    {"getThreads",        "()[" THD,   (void *)&JVM_GetAllThreads},
    {"dumpThreads",      "([" THD ")[[" STE, (void *)&JVM_DumpThreads},
};

#undef THD
#undef OBJ
#undef STE

JNIEXPORT void JNICALL
Java_java_lang_Thread_registerNatives(JNIEnv *env, jclass cls)
{
    (*env)->RegisterNatives(env, cls, methods, ARRAY_LENGTH(methods));
}

start0()实际会启动JVM_StartThread方法
在hostpot源码jvm.cpp中可以看到

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
  JVMWrapper("JVM_StartThread");
  JavaThread *native_thread = NULL;
  ...
  native_thread = new JavaThread(&thread_entry, sz);
  ...
  Thread::start(native_thread);

JVM_END
JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz) :
  Thread()
#if INCLUDE_ALL_GCS
  , _satb_mark_queue(&_satb_mark_queue_set),
  _dirty_card_queue(&_dirty_card_queue_set)
#endif // INCLUDE_ALL_GCS
{
  if (TraceThreadEvents) {
    tty->print_cr("creating thread %p", this);
  }
  initialize();
  _jni_attach_state = _not_attaching_via_jni;
  set_entry_point(entry_point);
  // Create the native thread itself.
  // %note runtime_23
  os::ThreadType thr_type = os::java_thread;
  thr_type = entry_point == &compiler_thread_entry ? os::compiler_thread :
                                                     os::java_thread;
  os::create_thread(this, thr_type, stack_sz);
  _safepoint_visible = false;
  // The _osthread may be NULL here because we ran out of memory (too many threads active).
  // We need to throw and OutOfMemoryError - however we cannot do this here because the caller
  // may hold a lock and all locks must be unlocked before throwing the exception (throwing
  // the exception consists of creating the exception object & initializing it, initialization
  // will leave the VM via a JavaCall and then all locks must be unlocked).
  //
  // The thread is still suspended when we reach here. Thread must be explicit started
  // by creator! Furthermore, the thread must also explicitly be added to the Threads list
  // by calling Threads:add. The reason why this is not done here, is because the thread
  // object must be fully initialized (take a look at JVM_Start)
}

这个方法有二个参数,第一个是函数名称,线程创建成功后会根据这个函数名称调用对应的函数
第二个是当前进程内已经有的线程数量
os::create_thread(this, thr_type, stack_sz);实际就是调用平台创建线程的方法来创建线程

void Thread::start(Thread* thread) {
  trace("start", thread);
  // Start is different from resume in that its safety is guaranteed by context or
  // being called from a Java method synchronized on the Thread object.
  if (!DisableStartThread) {
    if (thread->is_Java_thread()) {
      // Initialize the thread state to RUNNABLE before starting this thread.
      // Can not set it after the thread started because we do not know the
      // exact thread state at that time. It could be in MONITOR_WAIT or
      // in SLEEPING or some other state.
      java_lang_Thread::set_thread_status(((JavaThread*)thread)->threadObj(),
                                          java_lang_Thread::RUNNABLE);
    }
    os::start_thread(thread);
  }
}

start方法中有一个函数调用os::start_thread(thread);调用平台启动线程的方法最终调用Thread.cpp中javaThread::run()方法

线程的终止?

如stop(),suspend(),resume()都是过期的方法都是不建议使用的,可能会导致结束一个线程出现不确认的状态
提供了interruppt方法

interrupt方法

当其他线程通过调用当前线程的interrupt方法,表示向当前线程大概招呼,告诉他可以中断线程的执行,至于什么时候中断取决于线程自己
可以通过isInterrupted()来判断是否被中断默认false,例:

Thread thread=new Thread(()->{
           while(!Thread.currentThread().isInterrupted()) {//默认false    _interrupted state?
               i++;
           }
           System.out.println(i);
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        thread.interrupt();//把isInterrupted设置成true

这种通过标识或者中断操作方式能使够线程在终止时有机会去清理资源,而不是武断的将线程停止,因此这种终止线程的做法显得更加安全和优雅

interrupted方法

Thread.interrupted()对设置中断标识的线程复位。例:

Thread thread1=new Thread(()->{
           while(true){
               if(Thread.currentThread().isInterrupted()){
                   System.out.println("before:"+Thread.currentThread().isInterrupted());
                   Thread.interrupted(); //复位- 回到初始状态
                   System.out.println("after:"+Thread.currentThread().isInterrupted());
               }
           }
        });
        thread1.start();
        TimeUnit.SECONDS.sleep(1);
        thread1.interrupt();

其他线程的复位

除了通过Thread.interruped()方法对线程中断的标识符进行复位以外,还有一种被动复位的场景
对抛出InterruptedException异常的方法在被抛出之前,JVM会先把中断标识位清楚,然后才会抛出InterruptedException
这个时候调用isInterrupted将会返回fanle

为什么要复位

Thread.interrupted()是属于当前线程的,是当前线程对外界中断信号的一个响应,标识得到的中断信号,具体什么时候中断由自己决定

线程终止原理

 public void interrupt() {
        if (this != Thread.currentThread())
            checkAccess();

        synchronized (blockerLock) {
            Interruptible b = blocker;
            if (b != null) {
                interrupt0();           // Just to set the interrupt flag
                b.interrupt(this);
                return;
            }
        }
        interrupt0();//JVM_Interrupt
    }
JVM_ENTRY(void, JVM_Interrupt(JNIEnv* env, jobject jthread))
  JVMWrapper("JVM_Interrupt");

  // Ensure that the C++ Thread and OSThread structures aren't freed before we operate
  oop java_thread = JNIHandles::resolve_non_null(jthread);
  MutexLockerEx ml(thread->threadObj() == java_thread ? NULL : Threads_lock);
  // We need to re-resolve the java_thread, since a GC might have happened during the
  // acquire of the lock
  JavaThread* thr = java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread));
  if (thr != NULL) {
    Thread::interrupt(thr);
  }

调用Thread::interrupt(thr);这个方法定义在Thread.cpp文件中

void Thread::interrupt(Thread* thread) {
  trace("interrupt", thread);
  debug_only(check_for_dangling_thread_pointer(thread);)
  os::interrupt(thread);
}

Thread::interrupt(thr);调用os::interrupt(thread);调用平台的interrupt方法,os_*.cpp代表不同的平台例:

void os::interrupt(Thread* thread) {
  assert(Thread::current() == thread || Threads_lock->owned_by_self(),
    "possibility of dangling Thread pointer");
    //获取本地线程对象
  OSThread* osthread = thread->osthread();

  if (!osthread->interrupted()) {//判断本地线程对象是否中断
    osthread->set_interrupted(true);//设置中断状态为true,
    // More than one thread can get here with the same value of osthread,
    // resulting in multiple notifications.  We do, however, want the store
    // to interrupted() to be visible to other threads before we execute unpark().
    OrderAccess::fence();
    ParkEvent * const slp = thread->_SleepEvent ;
    if (slp != NULL) slp->unpark() ;
  }

  // For JSR166. Unpark even if interrupt status already was set
  if (thread->is_Java_thread())
    ((JavaThread*)thread)->parker()->unpark();

  ParkEvent * ev = thread->_ParkEvent ;
  if (ev != NULL) ev->unpark() ;

}

可以知道thread.interrupt()方法实际就是设置一个interrupted状态标记为true,并且通过ParkEvent的unpark来唤醒线程
1.对于synchronized阻塞的线程,被唤醒后会继续尝试获取锁,如果失败仍然park
2.在调用parkEvent的park方法之前,会先判断线程的中断状态,如果为true,会清除当前线程的中断标记

如何保证线程的安全性?

synchronized的基础认识
1.java SE 1.6以前是重量级的锁,java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁,和轻量级锁
synchronized的基本语法
1.修饰实例方法,作用于当前实例加锁,进入代码块钱要获得当前实例的锁
2.静态方法,作用于当前类对象加锁,进入同步代码块前要获得当前类对象的锁
3.修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
注意:不同的修饰类型,代表锁的控制粒度

锁是如何存储的?

对象在内存中的布局
在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

当我们在 Java 代码中,使用 new 创建一个对象实例的时候,(hotspot 虚拟机)JVM 层面实际上会创建一instanceOopDesc 对象。
Hotspot 虚拟机采用 OOP-Klass 模型来描述 Java 对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass 用来描述对象实例的具体类型。Hotspot 采用instanceOopDesc 和 arrayOopDesc 来 描述对象 头,arrayOopDesc 对象用来描述数组类型

class instanceOopDesc : public oopDesc {
 public:
  // aligned header size.
  static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }

  // If compressed, the offset of the fields of the instance may not be aligned.
  static int base_offset_in_bytes() {
    // offset computation code breaks if UseCompressedClassPointers
    // only is true
    return (UseCompressedOops && UseCompressedClassPointers) ?
             klass_gap_offset_in_bytes() :
             sizeof(instanceOopDesc);
  }

  static bool contains_field_offset(int offset, int nonstatic_field_size) {
    int base_in_bytes = base_offset_in_bytes();
    return (offset >= base_in_bytes &&
            (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
  }
};

instanceOopDesc继承自 oopDesc,oopDesc 的定义载 Hotspot 源码中的oop.hpp 文件中

  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;

oopDesc 的定义包含两个成员,分别是 _mark 和 _metadata
_mark 表示对象标记、属于 markOop 类型,它记录了对象和锁有关的信息
_metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中 Klass 表示普通指针、_compressed_klass 表示压缩类指针
而markOop 的定义在 markOop.hpp 文件

class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  enum { age_bits                 = 4,//分代年龄
         lock_bits                = 2,//锁标识
         biased_lock_bits         = 1,//是否为偏向锁
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,//对象的hashCode
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2//偏向锁的时间戳
  };

Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一
系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。
Mark Word 里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 中情况
在这里插入图片描述

为什么任何对象都可以实现锁

  1. 首先,Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象oop/oopDesc 进行对应。
  2. 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor
hotspot 源码的markOop.hpp
 ObjectMonitor* monitor() const {
    assert(has_monitor(), "check");
    // Use xor instead of &~ to provide one extra tag-bit check.
    return (ObjectMonitor*) (value() ^ monitor_value);
  }

多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识

synchronized锁的升级

场景:使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。
JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。
因此大家会发现在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级,且这个升级过程是不可逆的

偏向锁的基本原理(大部分情况下总是又同一个线程获得锁)

当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了
偏向锁的获取和撤销逻辑
3. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
4. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
2.1,如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块
2.2,如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
3.如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
3.1,如果相等,不需要再次获得锁,可直接执行同步代码块
3.2,如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁
偏向锁的撤销
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况
1.原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程
2. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗
可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁

流程图:
在这里插入图片描述

轻量级锁的基本原理

锁升级为轻量级锁之后,对象的 Markword 也会进行相应的的变化。升级为轻量级锁的过程:
1.线程在自己的栈桢中创建锁记录 LockRecord。
2,将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
3.将锁记录中的 Owner 指针指向锁对象。
4.将锁对象的对象头的 MarkWord替换为指向锁记录的指针。
在这里插入图片描述
在这里插入图片描述

自旋锁

轻量级的锁在加锁的过程中,用到了自旋锁
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到哪个获得锁的线程释放锁后,这个线程就可以马上获得锁
注意:锁在原地循环的时候,是会消耗CPU,相当于执行一个啥都没有的for循环
所以,轻量级锁适用于那些同步代码执行很快的场景,这样,线程原地等待很短时间就能获得锁了

自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的
循环反而能提升锁的性能。但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自
旋的时间以及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源

轻量级锁的解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的
MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁
在这里插入图片描述

重量级锁的基本原理

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。
每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。

monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器

monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能

任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

wait/notify/notifyall 基本概念

wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放 cpu 资源并进入等待状态。

notify:表示持有对象锁的线程 A 准备释放对象锁权限,通知 jvm 唤 醒 某 个 竞 争 该 对 象 锁 的 线 程 X 。 线 程 A synchronized 代码执行结束并且释放了锁之后,线程 X 直接获得对象锁权限,其他竞争线程继续等待(即使线程 X 同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的 notify ,notifyAll 被调用)。

notifyAll:notifyall 和 notify 的区别在于,notifyAll 会唤醒所有竞争同一个对象锁的所有线程,当已经获得锁的线程A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限

注意:三个方法都必须在 synchronized 同步关键字 所 限 定 的 作 用 域 中 调 用 , 否 则 会 报 错java.lang.IllegalMonitorStateException ,意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法
通过同步机制来确保线程从 wait 方法返回时能够感知到感知到 notify 线程对变量做出的修改

1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md或论文文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。 5、资源来自互联网采集,如有侵权,私聊博主删除。 6、可私信博主看论文后选择购买源代码。 1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md或论文文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。 5、资源来自互联网采集,如有侵权,私聊博主删除。 6、可私信博主看论文后选择购买源代码。 1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md或论文文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。 5、资源来自互联网采集,如有侵权,私聊博主删除。 6、可私信博主看论文后选择购买源代码。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值