线程,你到底是个什么东西?

线程基础

进程与线程

在说明白什么是线程之前,首先需要去了解一下什么是进程。进程(Process)是程序的运行实例,通俗点来说,一个运行的 Idea 就是一个进程,打开任务管理器,看到红圈的部分。
在这里插入图片描述
从任务管理器中显示的进程数量可以看出,一个系统上面运行着很多的进程,比如上面任务管理器显示运行的进程数量是 120,为什么一个系统能运行多个进程呢?

答案是上下文切换,那什么又是上下文切换呢?

上下文切换是指 CPU 从一个进程(或线程)切换到另一个进程(或线程)。上下文是指某一时间点 CPU 寄存器和程序计数器的内容。

寄存器:CPU 内部的少量的速度很快的闪存,通常存储和访问计算过程的中间值提高计算机程序的运行速度。
程序计数器:是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体实现依赖于特定的系统。

就好比你(CPU)停下手中的工作(进程 A),倒了杯茶(进程 B),然后如何继续之前的工作?

你在倒茶前记录一下做到哪一步工作就可以继续之前未完成的工作。

还存在什么问题吗?

以单核 CPU 为例,任意具体时刻都只有一个进程在占用 CPU 资源。

比如杀毒软件在检测用户电脑时,如果在某一项检测中卡住了,那么后面的检测项也会受到影响。或者说当你使用杀毒软件中的扫描病毒功能时,在扫描病毒结束之前,无法使用杀毒软件中清理垃圾的功能,这显然无法满足人们的要求。

那么能不能让这些子任务同时执行呢?

于是人们又提出了线程的概念,让一个线程执行一个子任务,这样一个进程就包含了多个线程,每个线程负责一个单独的子任务。

使用线程之后,事情就变得简单多了。当用户使用扫描病毒功能时,就让扫描病毒这个线程去执行。同时,如果用户又使用清理垃圾功能,那么可以先暂停扫描病毒线程,先响应用户的清理垃圾的操作,让清理垃圾这个线程去执行。响应完后再切换回来,接着执行扫描病毒线程。

进程和线程的区别是什么?

进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如 I/O):

  • 进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
  • 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
  • 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。

另外一个重要区别是,进程是程序向操作系统申请资源(如内存空间和文件句柄)的基本单位,而线程是是进程中可独立执行的最小单位,即 CPU 分配时间的单位。

线程的创建

了解完进程和线程,紧接着来看看在 Java 中是怎么使用多线程的?

继承 Thread

public class HelloThread extends Thread{

    @Override
    public void run() {
        System.out.println("任务处理逻辑...");
        System.out.println("执行run方法的线程为:" + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        Thread thread = new HelloThread();
        thread.start();	// 启动线程
        System.out.println("执行main方法的线程为:" + Thread.currentThread().getName());
    }
}

输出结果:

任务处理逻辑...
执行run方法的线程为:Thread-0
执行main方法的线程为:main

通过输出结果可以得知两个信息:

  • 启动 HelloThread 线程后,会执行重写后的 run 方法
  • main 方法和 run 方法是不同的线程在执行,也就是说我们是在 main 方法中新起了一个线程去执行任务

注意:run 逻辑应该由 Java 虚拟机在运行相应线程时直接调用,而不是由应用代码(main 方法)进行调用。

如果由应用代码直接调用,修改下上述 main 方法的逻辑:

public static void main(String[] args) {
    Thread thread = new HelloThread();
    thread.run();
    System.out.println("执行main方法的线程为:" + Thread.currentThread().getName());
}

输出结果:

任务处理逻辑...
执行run方法的线程为:main
执行main方法的线程为:main

可以看到并没有启动一个 Thread-0 线程去处理任务,而是由应用代码所在的 main 线程去处理的。

实现 Runnable 接口

public class HelloRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("任务处理逻辑...");
        System.out.println("执行run方法的线程为:" + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new HelloRunnable());
        System.out.println("执行main方法的线程为:" + Thread.currentThread().getName());
        thread.start();
    }
}

输出结果:

执行main方法的线程为:main
任务处理逻辑...
执行run方法的线程为:Thread-0

启动线程源码分析

Thread 类的 start 方法的作用是启动相应的线程。启动一个线程的实质是请求 Java 虚拟机运行相应的线程,而这个线程具体何时能够运行是由线程调度器(Scheduler )决定的。因此,start 方法调用结束并不意味着相应线程已经开始运行,这个线程可能稍后才被运行,甚至也可能永远不会被运行。

进入 start 方法看下它的源码,代码如下:

/**
 * Causes this thread to begin execution; the Java Virtual Machine
 * calls the <code>run</code> method of this thread.
 * <p>
 * The result is that two threads are running concurrently: the
 * current thread (which returns from the call to the
 * <code>start</code> method) and the other thread (which executes its
 * <code>run</code> method).
 * <p>
 * It is never legal to start a thread more than once.
 * In particular, a thread may not be restarted once it has completed
 * execution.
 *
 * @exception  IllegalThreadStateException  if the thread was already
 *               started.
 * @see        #run()
 * @see        #stop()
 */
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 */
        }
    }
}

首先看下方法注释,翻译后意思如下:

  • 使该线程开始执行,Java 虚拟机调用该线程的 run 方法。结果是两个线程并发运行当前线程(该线程从对 start 方法的调用中返回)和另一个线程(执行它的 run 方法)。
  • 重复启动一个线程是不合法的。特别地,一个线程可能不会重新启动,一旦它已经完成执行。

第一点之前已经讲解过了,现在看第二点。进入方法里面,首先会看到这一行代码:

if (threadStatus != 0)
    throw new IllegalThreadStateException();

如果 threadStatus != 0,会抛出一个异常,也就是说,只有处于 threadStatus = 0(线程状态为 NEW)的时候才能启动线程。还是看下代码示例:

public class HelloThread extends Thread {

    @Override
    public void run() {
        try {
            Thread.sleep(1000); // 休眠1秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Hello Thread");
    }

    public static void main(String[] args) {
        Thread thread = new HelloThread();
        thread.start(); 
        thread.start(); 
    }
}

输出结果:

Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.start(Thread.java:708)
	at com.spurs.concurrent.thread.HelloThread.main(HelloThread.java:22)
Hello Thread
执行run方法的线程为:Thread-0

因为第二次调用 start 时,第一次 start 还未结束,在 run 里面休眠,所以会抛出异常。

Runnable 接口解析

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

Runnable 接口可以被看作对任务进行的抽象,任务的处理逻辑就体现在 run 方法之中。Thread 类实际上是Runnable 接口的一个实现类,其对 Runnable 接口的实现如下:

    @Override
    public void run() {
        // 如果构造Thread时传递了Runnable,则会执行Runnable的run方法
        if (target != null) {
            target.run();
        }
        // 否则需要重写Thread类的run方法
    }

Thread 类的 run 方法的这种处理逻辑决定了创建线程的两种方式:一种是在 Thread 子类的 run 方法中直接实现任务处理逻辑,另一种是在一个 Runnable 实例中实现任务处理逻辑,该逻辑由 Thread 类的 run 方法负责调用。

如果继承了 Thread 类,同时又实现了 Runnable 接口,最后会怎样?

public static void main(String[] args) {
    new Thread(() -> System.out.println("runnable run")) {
        @Override
        public void run() {
            System.out.println("Thread run");
        }
    }.start();
}

输出结果:

Thread run

其实是 new 了一个对象(子对象)继承了 Thread 对象(父对象),在子对象里重写了父类的 run() 方法;然后父对象里面扔了个 Runnable 进去,父对象中的 run() 方法就是最初那个带有 if 判断的 run() 方法。

执行 start()后,肯定先在子类中找 run() 方法,找到了,父类的 run() 方法自然就被干掉了,所以会打印出:Thread run。

如果我们现在假设子类中没有重写 run() 方法,那么必然要去父类找 run() 方法,父类的 run() 方法中就得判断是否有 Runnable 传进来,现在有一个,所以执行 Runnable 中的 run() 方法,那么就会打印:Runnable run 出来。

题外话:

从本质上来说,创建线程的方式只有上述两种,更细一点说,创建线程逻辑都是在 Thread 中处理的。

其他创建线程的方式底层都是使用到上述的两种方式,比如 Callable、线程池等。

线程进阶

线程的属性与方法

ID 和 Name:

    private volatile String name;
    /*
     * Thread ID
     */
    private long tid;
    
    /* For autonumbering anonymous threads. */
    private static int threadInitNumber;
    /* For generating thread ID */
    private static long threadSeqNumber;

属性赋值的逻辑在 init 方法,当你 new Thread() 时,会进入这个 init 方法。

    public Thread() {
        init(null, null, "Thread-" + nextThreadNum(), 0);
    }
    
    private static synchronized int nextThreadNum() {
        return threadInitNumber++;
    }

    private static synchronized long nextThreadID() {
        return ++threadSeqNumber;
    }

每个线程都有一个 id 和 name,id 是一个递增的整数,每创建一个线程就加一,name 的默认值是 “Thread-” 后跟一个编号,name 可以在 Thread 的构造方法中进行指定,也可以通过 setName 方法进行设置。

优先级:

线程有一个优先级的概念,在 Java 中,优先级从 1 到 10,默认为 5,相关方法是:

    /**
     * The minimum priority that a thread can have.
     */
    public final static int MIN_PRIORITY = 1;

   /**
     * The default priority that is assigned to a thread.
     */
    public final static int NORM_PRIORITY = 5;

    /**
     * The maximum priority that a thread can have.
     */
    public final static int MAX_PRIORITY = 10;


    public final void setPriority(int newPriority) {
        ThreadGroup g;
        checkAccess();
        if (newPriority > MAX_PRIORITY || newPriority < MIN_PRIORITY) {
            throw new IllegalArgumentException();
        }
        if((g = getThreadGroup()) != null) {
            if (newPriority > g.getMaxPriority()) {
                newPriority = g.getMaxPriority();
            }
            setPriority0(priority = newPriority);
        }
    }

    public final int getPriority() {
        return priority;
    }

Java 线程的优先级属性本质上只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行。但是它并不能保证线程按照其优先级高低的顺序运行。

是否 daemo 线程:

Thread 有一个是否 daemo 线程的属性,相关方法是:

    /* Whether or not the thread is a daemon thread. */
    private boolean daemon = false;
        
    public final void setDaemon(boolean on) {
        checkAccess();
        if (isAlive()) {
            throw new IllegalThreadStateException();
        }
        daemon = on;
    }

    public final boolean isDaemon() {
        return daemon;
    }

按照线程是否会阻止 Java 虚拟机正常停止,可以将 Java 中的线程分为守护线程(Daemon Thread)和用户线程。线程的 daemon 属性用于表示相应线程是否为守护线程。 用户线程会阻止 Java 虚拟机的正常停止,即一个Java 虚拟机只有在其所有用户线程都运行结束(即 Thread.run() 调用未结束)的情况下才能正常停止。而守护线程则不会影响 Java 虚拟机的正常停止,即应用 程序中有守护线程在运行也不影响 Java 虚拟机的正常停止。

用个代码示例说明一下:

public class DaemonThread {

    public static void main(String[] args) throws Exception{
        Thread heartbeatThread = new HeartbeatThread();
        heartbeatThread.setDaemon(true);
        heartbeatThread.start();
        Thread.sleep(1000);
        System.out.println("main线程执行完毕");
    }

    static class HeartbeatThread extends Thread{
        @Override
        public void run() {
            try {
                while (true){
                    System.out.println("heartbeatThread线程发送心跳");
                    Thread.sleep(500);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

输出结果:

heartbeatThread线程发送心跳
heartbeatThread线程发送心跳
main线程执行完毕

当 main 线程执行完毕,如果没有其他线程,虚拟机就会正常停止。虽然这里 heartbeatThread 线程还在,但是由于它是守护线程,并不会阻止虚拟机停止。所以虚拟机在执行完 T 线程后就会停止。

sleep 方法:

public static native void sleep(long millis) throws InterruptedException;

sleep 方法会使当前线程进入指定毫秒数的休眠,暂停执行,虽然给定了一个休眠的时间,但是最终要以系统的定时器和调度器的精度为准,休眠有一个非常重要的特性,那就是其不会放弃 monitor 锁的所有权。

yield 方法:

public static native void yield();

yield 方法属于一种启发式的方法,其会提醒调度器我愿意放弃当前的 CPU 资源,如果 CPU 的资源不紧张,则会忽略这种提醒。

yield 和 sleep 的区别:

  • sleep 会导致当前线程暂停指定的时间,没有 CPU 时间片的消耗。
  • yield 只是对 CPU 调度器的一个提示,如果 CPU 调度器没有忽略这个提示,它会导致线程上下文切换。
  • sleep 几乎百分之百地完成了给定时间的休眠,而 yield 的提示并不能一定担保。
  • 一个线程 sleep 另一个线程调用 interrupt 会捕获到中断信号, 而 yield 则不会。

join 方法:

    public final void join() throws InterruptedException {
        join(0);
    }

让调用 join 的线程等待该线程结束,在等待线程结束的过程中,这个等待可能被中断,如果被中断,会抛出 InterruptedException。

举个例子说明一下:

public class JoinDemo {

    public static void main(String[] args) throws Exception{
        Thread thread1 = new Thread(new JoinThread(),"线程1");
        Thread thread2 = new Thread(new JoinThread(),"线程2");
        thread1.start();
        thread1.join();
        thread2.start();

    }

    static class JoinThread implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                try {
                    System.out.println(Thread.currentThread().getName());
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

输出结果:

线程1
线程1
线程1
//...
线程2
线程2
线程2
//...

thread0.join() 这一行的代码的意思是 main 线程必须等待线程1 执行完才可以往下执行。即线程1 执行完后才会到 main 线程执行 thread1.start() 这一行代码。

线程的中断:

在 Java 中,停止一个线程的主要机制是中断,中断并不是强迫终止一个线程,它是一种协作机制,是给线程传递一个取消信号,但是由线程来决定如何以及何时退出。

    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();
    }

    public static boolean interrupted() {
        return currentThread().isInterrupted(true);
    }

    public boolean isInterrupted() {
        return isInterrupted(false);
    }

    /**
     * Tests if some Thread has been interrupted.  The interrupted state
     * is reset or not based on the value of ClearInterrupted that is
     * passed.
     */
    private native boolean isInterrupted(boolean ClearInterrupted);

每个线程都有一个标志位,表示该线程是否被中断了。

interrupt:表示中断对应的线程,只是将中断标志位设置为 true,并不会立即停止线程。

interrupted:返回当前线程的中断标志位是否为 true,但它还有一个重要的副作用,就是清空中断标志位,也就是说,连续两次调用 interrupted(),第一次返回的结果为 true,第二次一般就是 false (除非同时又发生了一次中断)。

isInterrupted:就是返回对应线程的中断标志位是否为 true。

代码示例:

public class InterruptRunnableDemo extends Thread {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()){
            // 单次循环的代码
        }
        System.out.println("done");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new InterruptRunnableDemo();
        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

输出结果:

// 1秒后
done

注意:这并不是停止线程的最佳实践。最佳实践需要结合 volatile 关键字来实现。

线程的生命周期

Java 语言中, 多线程是由 Thread 驱动的。因为多线程中的每一个线程都相互独立,有着自己的生命周期和状态转换。

关于线程的生命周期,可以看一下 java.lang.Thread.State 这个类,它是线程的内部枚举类,定义了线程的各种状态。

    public enum State {
        /**
         * Thread state for a thread which has not yet started.
         */
        NEW,

        /**
         * Thread state for a runnable thread.  A thread in the runnable
         * state is executing in the Java virtual machine but it may
         * be waiting for other resources from the operating system
         * such as processor.
         */
        RUNNABLE,

        /**
         * Thread state for a thread blocked waiting for a monitor lock.
         * A thread in the blocked state is waiting for a monitor lock
         * to enter a synchronized block/method or
         * reenter a synchronized block/method after calling
         * {@link Object#wait() Object.wait}.
         */
        BLOCKED,

        /**
         * Thread state for a waiting thread.
         * A thread is in the waiting state due to calling one of the
         * following methods:
         * <ul>
         *   <li>{@link Object#wait() Object.wait} with no timeout</li>
         *   <li>{@link #join() Thread.join} with no timeout</li>
         *   <li>{@link LockSupport#park() LockSupport.park}</li>
         * </ul>
         *
         * <p>A thread in the waiting state is waiting for another thread to
         * perform a particular action.
         *
         * For example, a thread that has called <tt>Object.wait()</tt>
         * on an object is waiting for another thread to call
         * <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
         * that object. A thread that has called <tt>Thread.join()</tt>
         * is waiting for a specified thread to terminate.
         */
        WAITING,

        /**
         * Thread state for a waiting thread with a specified waiting time.
         * A thread is in the timed waiting state due to calling one of
         * the following methods with a specified positive waiting time:
         * <ul>
         *   <li>{@link #sleep Thread.sleep}</li>
         *   <li>{@link Object#wait(long) Object.wait} with timeout</li>
         *   <li>{@link #join(long) Thread.join} with timeout</li>
         *   <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
         *   <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
         * </ul>
         */
        TIMED_WAITING,

        /**
         * Thread state for a terminated thread.
         * The thread has completed execution.
         */
        TERMINATED;
    }

注释解释得很清楚了,下面通过一张图来说明一下:

image-20200701100505300
NEW:新建状态,线程还未开始。

RUNNABLE:表示当前线程正在运行中。处于 RUNNABLE 状态的线程在 Java 虚拟机中运行,也有可能在等待其他系统资源(比如 I/O)。

BLOCKED:阻塞状态。处于 BLOCKED 状态的线程正等待锁的释放以进入同步区。

WAITING:等待状态。处于等待状态的线程变成 RUNNABLE 状态需要其他线程唤醒。

TIMED_WAITING:超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

TERMINATED:终止状态。此时线程已执行完毕。

展开阅读全文

Git 实用技巧

11-24
这几年越来越多的开发团队使用了Git,掌握Git的使用已经越来越重要,已经是一个开发者必备的一项技能;但很多人在刚开始学习Git的时候会遇到很多疑问,比如之前使用过SVN的开发者想不通Git提交代码为什么需要先commit然后再去push,而不是一条命令一次性搞定; 更多的开发者对Git已经入门,不过在遇到一些代码冲突、需要恢复Git代码时候就不知所措,这个时候哪些对 Git掌握得比较好的少数人,就像团队中的神一样,在队友遇到 Git 相关的问题的时候用各种流利的操作来帮助队友于水火。 我去年刚加入新团队,发现一些同事对Git的常规操作没太大问题,但对Git的理解还是比较生疏,比如说分支和分支之间的关联关系、合并代码时候的冲突解决、提交代码前未拉取新代码导致冲突问题的处理等,我在协助处理这些问题的时候也记录各种问题的解决办法,希望整理后通过教程帮助到更多对Git操作进阶的开发者。 本期教程学习方法分为“掌握基础——稳步进阶——熟悉协作”三个层次。从掌握基础的 Git的推送和拉取开始,以案例进行演示,分析每一个步骤的操作方式和原理,从理解Git 工具的操作到学会代码存储结构、演示不同场景下Git遇到问题的不同处理方案。循序渐进让同学们掌握Git工具在团队协作中的整体协作流程。 在教程中会通过大量案例进行分析,案例会模拟在工作中遇到的问题,从最基础的代码提交和拉取、代码冲突解决、代码仓库的数据维护、Git服务端搭建等。为了让同学们容易理解,对Git简单易懂,文章中详细记录了详细的操作步骤,提供大量演示截图和解析。在教程的最后部分,会从提升团队整体效率的角度对Git工具进行讲解,包括规范操作、Gitlab的搭建、钩子事件的应用等。 为了让同学们可以利用碎片化时间来灵活学习,在教程文章中大程度降低了上下文的依赖,让大家可以在工作之余进行学习与实战,并同时掌握里面涉及的Git不常见操作的相关知识,理解Git工具在工作遇到的问题解决思路和方法,相信一定会对大家的前端技能进阶大有帮助。

实用主义学Python(小白也容易上手的Python实用案例)

12-24
原价169,限时立减100元! 系统掌握Python核心语法16点,轻松应对工作中80%以上的Python使用场景! 69元=72讲+源码+社群答疑+讲师社群分享会  【哪些人适合学习这门课程?】 1)大学生,平时只学习了Python理论,并未接触Python实战问题; 2)对Python实用技能掌握薄弱的人,自动化、爬虫、数据分析能让你快速提高工作效率; 3)想学习新技术,如:人工智能、机器学习、深度学习等,这门课程是你的必修课程; 4)想修炼更好的编程内功,优秀的工程师肯定不能只会一门语言,Python语言功能强大、使用高效、简单易学。 【超实用技能】 从零开始 自动生成工作周报 职场升级 豆瓣电影数据爬取 实用案例 奥运冠军数据分析 自动化办公:通过Python自动化分析Excel数据并自动操作Word文档,最终获得一份基于Excel表格的数据分析报告。 豆瓣电影爬虫:通过Python自动爬取豆瓣电影信息并将电影图片保存到本地。 奥运会数据分析实战 简介:通过Python分析120年间奥运会的数据,从不同角度入手分析,从而得出一些有趣的结论。 【超人气老师】 二两 中国人工智能协会高级会员 生成对抗神经网络研究者 《深入浅出生成对抗网络:原理剖析与TensorFlow实现》一书作者 阿里云大学云学院导师 前大型游戏公司后端工程师 【超丰富实用案例】 0)图片背景去除案例 1)自动生成工作周报案例 2)豆瓣电影数据爬取案例 3)奥运会数据分析案例 4)自动处理邮件案例 5)github信息爬取/更新提醒案例 6)B站百大UP信息爬取与分析案例 7)构建自己的论文网站案例
©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值