1、并发 & 并行

并发:在操作系统中,一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行

那么,操作系统是如何实现这种并发的呢?

现在我们用到的操作系统(Windows、Linux、MacOS)都是多用户多任务分时操作系统。使用这些操作系统的用户是可以“同时”干多件事的

但是实际上,对于单 CPU 的计算机来说,在 CPU 中,同一时间是只能干一件事儿的。为了看起来像是“同时干多件事”,分时操作系统是把 CPU 的时间划分成长短基本相同的时间区间,即“时间片”,通过操作系统的管理,把这些时间片依次轮流地分配给各个用户使用

如果某个作业在时间片结束之前,整个任务还没有完成,那么该作业就被暂停下来,放弃 CPU,等待下一轮循环再继续做,此时 CPU 又分配给另一个作业去使用

由于计算机的处理速度很快,只要时间片的间隔取得适当,那么一个用户作业从用完分配给它的一个时间片到获得下一个CPU时间片,中间有所”停顿”,但用户察觉不出来,好像整个系统全由它”独占”似的

并行:当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行

  • 并行:多个 CPU 同时执行多个任务
  • 并发:一个 CPU 通过时间片轮询的方式去“同时”执行多个任务

2、程序、进程、线程

上下文切换:在多任务处理系统中,CPU 需要处理所有程序的操作,当 CPU 切换到另一个进程时,需要保存当前进程的状态并恢复另一个进程的状态:当前运行任务转为就绪(或者挂起、删除)状态,另一个被选定的就绪任务成为当前任务

  • 程序(静态):为完成特定的任务,用某种语言编写的一组指令的集合。即:一段静态的代码
  • 进程(动态):程序的一次执行的过程,或者是正在执行的一个程序。它是一个动态的过程,它有自己的生命周期:产生、存在、消亡。如:运行中的 QQ
    • 进程作为资源分配的单位,系统在运行时会为每个进行分配不同的内存区域

在多个进程之间切换的时候,需要进行上下文切换。但是上下文切换势必会耗费一些资源。于是人们考虑,能不能在一个进程中增加一些“子任务”,这样减少上下文切换的成本。比如:我们使用 Word 的时候,它可以同时进行打字、拼写检查、字数统计等,这些子任务之间共用同一个进程资源,但是他们之间的切换不需要进行上下文切换。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程

  • 线程:进程可进一步细化为线程,是一个程序内部的一条执行路径
    • 线程本身基本上不拥有系统资源,只是拥有一些在运行时需要用到的系统资源,例如程序计数器,寄存器和栈等。一个进程中的所有线程可以共享进程中的所有资源
    • 线程作为调度和执行的单位,每个线程拥有独立的运行栈(虚拟机栈)和程序计数器 PC,线程切换开销小
    • 一个进程中的多个线程共享相同的内存单元:它们从同一个堆中分配对象,可以访问相同的对象,这使得线程间的通信更简洁、高效。但是,多个线程操作共享的系统资源可能就会带来安全隐患。
  • 多线程:若一个进程同一时间并行执行多个线程,则是支持多线程的
  • 线程共享:方法区、堆;
  • 线程独有:虚拟机栈、程序计数器

3、线程创建

线程常见的创建的方式有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口、使用线程池

【注意】:启动一个线程前,最好为这个线程设置线程名称,因为这样在使用 jstack 分析程序或者进行问题排查时,就会给开发人员提供一些提示,自定义的线程最好能够起个名字

3.1 继承 Thread 类

步骤:

  1. 创建一个类,继承 Thread 类
  2. 重写 run() 方法
  3. 创建 Thread 类的子类的对象
  4. 通过子类对象调用 start() 方法

例:

public class ThreadTest extends Thread{

    @Override
    public void run() {
        for (int i = 0; i < 200; i++) {
            System.out.println("我在看代码=====" + i);
        }
    }

    public static void main(String[] args) {
        ThreadTest threadTest = new ThreadTest();
        // 线程开启后,不一定立即执行,由 CPU 调度
        threadTest.start();

        for (int i = 0; i < 1000; i++) {
            System.out.println("【主线程】我在学习多线程" + i);
        }
    }
}

上述代码执行后,main 线程打印:

【主线程】我在学习多线程;子线程打印:我在看代码=====
  • run():表示此线程中要干的什么事
  • start():调用完 start() 方法后,线程不一定立即执行,由 CPU 调度,调度完后,会执行线程中的 run() 方法

问:如果我不调用 start() 方法,直接调用 run() 方法,会有什么结果?

答:程序依旧能执行,只不过是没有创建出一个新的线程,就只有一个 main 线程在运行,但依旧会运行 run() 方法哈。

可以将代码修改为:

@Override
public void run() {
    for (int i = 0; i < 200; i++) {
        System.out.println("我在看代码=====" + i);
        // 打印出当前线程
        System.out.println(Thread.currentThread().getName());
    }
}

可以看出,都是 main 线程在执行。

问:如果我已经调用完某个线程的 start() 方法后,再次调用一下 start() 方法,会有什么结果?

答:会抛出异常 IllegalThreadStateException。当某个线程调用完 start() 方法后,就不能再次调用 start() 方法了。

案例:

使用多线程的方式去下载图片

public class WebDownLoader {

    public void downloader(String url, String fileName) {
        try {
            FileUtils.copyURLToFile(new URL(url), new File(fileName));
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("【异常】出现IO异常");
        }
    }
}

调用 FileUtils.copyURLToFile() 方法需要引入 commons-io-2.6.jar

此类就是专门下载图片的

public class TestThread2 extends Thread {
    private String url;
    private String fileName;

    public TestThread2(String url, String fileName) {
        this.url = url;
        this.fileName = fileName;
    }

    @Override
    public void run() {
        WebDownLoader webDownLoader = new WebDownLoader();
        webDownLoader.downloader(url, fileName);
        System.out.println("下载了文件名为:" + fileName);
    }

    public static void main(String[] args) {
        String url = "https://www.baidu.com/img/flexible/logo/pc/result.png";
        String fileName = "result.png";
        TestThread2 t1 = new TestThread2(url, fileName);
        TestThread2 t2 = new TestThread2(url, fileName);
        TestThread2 t3 = new TestThread2(url, fileName);

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

上述代码:创建了 3 个线程,每一个线程都会去执行 run() 方法,去下载相应的图片

3.2 实现 Runnable 接口

步骤:

  1. 新建一个类,并实现 Runnable 接口,重写 run() 方法
  2. new 一个线程对象,并将其放入 Thread 类的构造方法中

例:

public class TestThread3 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 200; i++) {
            System.out.println("我在看代码=====" + i);
        }
    }

    public static void main(String[] args) {
        TestThread3 testThread3 = new TestThread3();
        new Thread(testThread3).start();

        for (int i = 0; i < 1000; i++) {
            System.out.println("【主线程】我在学习多线程" + i);
        }
    }
}

接下使用实现 Runnable 接口的方法来演示一个案例,但这个案例有并发问题哈。

并发问题

并发问题产生的原因:多个线程同时访问了共享资源,并对共享资源做了相应的处理。

场景:有3个人在不同的窗口同时卖10张票。

public class TicketThreadTest implements Runnable {

    private int ticketNums = 10;

    @Override
    public void run() {
        while (true) {
            if (ticketNums <= 0) {
                break;
            }
            // 模拟延迟
            try {
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName() + "----->卖了第" + ticketNums-- + "张票");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        TicketThreadTest ticketThreadTest = new TicketThreadTest();

        Thread t1 = new Thread(ticketThreadTest, "小明");
        Thread t2 = new Thread(ticketThreadTest, "小红");
        Thread t3 = new Thread(ticketThreadTest, "小张");
        t1.start();
        t2.start();
        t3.start();
    }
}

运行程序后,发现:有不同的人卖了相同的票。
在这里插入图片描述
这就是并发问题哈。后面会介绍到的。

案例:使用实现 Runnable 接口方式来实现龟兔赛跑

public class RaceTest implements Runnable{

    private static String winner;

    @Override
    public void run() {
        for (int i = 1; i < 101; i++) {
            if ("兔子".equals(Thread.currentThread().getName()) && i % 10 == 0) {
                try {
                    Thread.sleep(10);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            boolean flag = gameOver(i);
            if (flag) {
                break;
            }
            System.out.println(Thread.currentThread().getName() + "跑了" + i + "步");
        }
    }

    private boolean gameOver(int step) {
        boolean flag = false;
        if (null != winner) {
            flag = true;
        }
        if (100 == step) {
            winner = Thread.currentThread().getName();
            System.out.println("The winner is " + winner);
            flag = true;
        }
        return flag;
    }

    public static void main(String[] args) {
        RaceTest raceTest = new RaceTest();
        new Thread(raceTest, "兔子").start();
        new Thread(raceTest, "乌龟").start();
    }
}

3.3 实现 Callable 接口

步骤:

  1. 新建一个类,实现 Callable 接口,重写其 call() 方法
  2. new 一个 实现 Callable 接口的对象
  3. new 一个 FutureTask 对象,其中参数是 Callable 接口对应的对象
  4. new 一个 Thread 对象,其中参数是 FutureTask 对象
  5. 执行线程:Thread#start() 方法

【注意】:FutureTask 用于接收 Callable 执行线程完毕之后的结果,在线程结果没有被 get 到的时候,它会阻塞后面的内容

public class CallableTest implements Callable<Integer> {
    private int x;
    private int y;

    public CallableTest(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public Integer call() throws Exception {
        int result = this.x + this.y;
        return result;
    }

    public static void main(String[] args) {
        try {
            CallableTest callableTest = new CallableTest(1, 2);
            FutureTask<Integer> task = new FutureTask<>(callableTest);
            new Thread(task).start();
            Integer result = task.get();
            System.out.println(result);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

【使用线程池】

public static void main(String[] args) {
    try {
        CallableTest callableTest = new CallableTest(1, 2);
        // 1.开启线程池服务
        ExecutorService executorService = Executors.newFixedThreadPool(1);
        // 2.调度线程
        Future<Integer> submit = executorService.submit(callableTest);
        // 3.获取线程执行后的结果
        Integer integer = submit.get();
        System.out.println(integer);
        // 4.关闭线程池服务
        executorService.shutdown();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

3.4 线程池

public class TestThreadPool {

    public static void main(String[] args) {
        ExecutorService service = Executors.newFixedThreadPool(10);
        // 执行
        service.execute(new MyThread());
        service.execute(new MyThread());
        service.execute(new MyThread());
        service.execute(new MyThread());
        // 关闭
        service.shutdown();
    }
}

class MyThread implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }
}

4. Thread 与静态代理

静态代理:代理对象与被代理对象都得实现同一个接口

案例:你去打篮球,有个专业运动员代替你打篮球,他在打篮球前喝喝水,打完球后跑步。

public interface IBall {
    void play();
}
public class Student implements IBall {

    @Override
    public void play() {
        System.out.println("STUDENT---PLAY");
    }
}
public class StudentProxy implements IBall {
    private IBall target;
    
    public StudentProxy(IBall target) {
        this.target = target;
    }

    @Override
    public void play() {
        drink();
        target.play();
        run();
    }

    public void drink() {
        System.out.println("PROXY---DRINK");
    }

    public void run() {
        System.out.println("PROXY---RUN");
    }
}
public static void main(String[] args) {
	// 静态代理
	IBall iBall= new Student();
    new StudentProxy(iBall).play();
	
	// 类比
	Runnable runnable = () -> {};
    new Thread(runnable).start();
}

上述代码:Thread 类实现了接口 Runnable ,且它是代理类,Thread 构造方法的入参为 lambda 表达式,它是被代理类,调用了 start() 方法后,将会执行 Thread#run() 方法:

pubcli Thread implements Runnable {
	private Runnable target;

	public Thread(Runnable target) {
        init(null, target, "Thread-" + nextThreadNum(), 0);
    }
	
	public void run() {
    	if (target != null) {
        	target.run();
    	}
	}
}

5、线程的状态

线程是有状态的,并且这些状态之间也是可以互相流转的。Java 中线程的状态分为 6 种。

java.lang.Thread 类中有一个枚举类 State

public enum State {
    NEW,
    RUNNABLE,
    BLOCKED, 
    WAITING, 
    TIMED_WAITING, 
    TERMINATED;
}
  1. NEW:初始。新创建了一个线程对象,但还没有调用 start() 方法

  2. RUNNABLE:可运行。Java线程中将就绪(READY)和运行中(RUNNING)两种状态笼统地称为“运行”

    • READY:就绪。线程对象创建后,其他线程(比如main线程)调用了该对象的 start() 方法。该状态的线程位于可运行线程池中,等待被线程调度选中并分配 CPU 使用权
    • RUNNING:运行中。READY 的线程获得了 CPU 时间片,开始执行程序代码。
  3. BLOCKED:阻塞。表示线程阻塞于锁

  4. WAITING:等待。进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)

  5. TIMED_WAITING:超时等待。该状态不同于 WAITING,它可以在指定的时间后自行返回

  6. TERMINATED:终止。表示该线程已经执行完毕

线程状态的流转图:
在这里插入图片描述

6、线程启动

public class Thread implements Runnable {
    
    private static native void registerNatives();
    static {
        registerNatives();
    }
    
    public synchronized void start() {
        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            //...
        }
    }
    
    // JVM 创建并启动线程,并调用 run() 方法,由 C/C++ 代码编写
    private native void start0();
}

start() 方法实际上是调用一个 native 方法(由 Thread 类的静态代码块中进行注册) start0() 来启动一个线程。该线程并不会立即执行,只是变成了可运行状态。至于什么时候执行,取决于 CPU,由 CPU 统一调度。

7、线程中断

中断:理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。中断好比其它线程对该线程打了个招呼,其他线程通过调用该线程的 interrupt() 方法对其进行中断操作

Thread 类中有 3 个中断的方法:

  1. interrupt() 方法:设置目标线程 targetThread 的中断状态(targetThread#interrupt());如果目标线程处于阻塞状态(调用了 wait()join()sleep() 方法),那么该方法会触发一个 InterruptedException 异常,并清除中断状态(JVM 抛异常之前清除) & 解除阻塞状态
  2. interrupted() 方法:返回目标线程的中断状态(true | false),并在返回结果后清除中断状态(置为 false;每次调用都会清除中断状态,因此通常用于一次性检查并清除中断标记
  3. isInterrupted() 方法:返回目标线程的中断状态(true | false

8、线程终止

8.1、3 个被废弃的方法:

  • suspend() 方法:暂停目标线程 targetThread(targetThread#suspend()

    • 问题:在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题
  • resume() 方法:恢复被 suspend() 方法暂停的线程

    • 问题: 没有内置的同步机制来确保正确匹配 suspend()resume() 调用,很容易出现意外的线程恢复或永远挂起的情况
  • stop() 方法:停止线程,立即释放所有的资源(包括锁)

    • 问题:在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下

暂停和恢复操作可以用后面提到的等待/通知机制来替代

8.2、优雅停止线程

8.2.1、标识位

public class Test implements Runnable{
    // 标志位
    private boolean flag = true;

    @Override
    public void run() {
        int i = 0;
        while (flag) {
            System.out.println("run...thread" + i++);
        }
    }
    
    // 对外提供方法,改变标示位
    public void stop() {
        this.flag = false;
    }
    
    public static void main(String[] args) throws InterruptedException {
        Test task = new Test();
        new Thread(task).start();

        Thread.sleep(2000);
        task.stop();
        System.out.println("线程停止了...");
    }

}

8.2.2、中断方法

public class Test {

    private static int  i ;
    
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            //默认情况下 isInterrupted() 返回 false,通过 thread.interrupt() 变成了 true
            while(!Thread. currentThread ().isInterrupted()){
                i++;
            }
            System.out.println("Num:"+ i);
        },"interruptDemo");
        thread.start();
        Thread.sleep(2000);
        thread.interrupt();
    }
}

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

9、线程通信

9.1、等待/通知机制

线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,这将会带来巨大的价值

等待/通知机制:一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者,这种模式隔离了“做什么”(what)和“怎么做”(How),在功能层面上实现了解耦,体系结构上具备了良好的伸缩性

9.2、wait-notify

Java 通过内置的等待/通知机制来实现此功能。

等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在所有对象的超类java.lang.Object

方法名称描述
wait调用该方法的线程进入 WAITING 状态,释放 CPU 资源 & 释放锁 ,只有等待其它线程的 notify/notifyAll 或被中断才会返回,进而执行后续操作
notify通知一个在对象上等待锁的线程,使其从 wait() 方法返回,前提是该线程获取到了对象上的锁
notifyAll通知所有在对象上等待锁的线程

上述 3 个方法使用的注意细节:

  1. 均是 Object 类中的方法,都只能在同步方法或者同步代码块中调用。否则,会抛出 IllegalMonitorStateException
  2. 调用 wait() 方法后,线程状态由 RUNNING 变为 WAITING,并将当前线程放置到对象的
    等待队列
  3. notify()notifyAll() 方法调用后,等待线程依旧不会从 wait() 返回,需要调用 notify()notifAll() 的线程释放锁之后,等待线程才有机会从 wait() 返回(竞争锁)
  4. notify() 方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而 notifyAll() 方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由 WAITING 变为 BLOCKED

在这里插入图片描述
WaitThread 首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁并进入了对象的等待队列 WaitQueue 中,进入等待状态。由于 WaitThread 释放了对象的锁,NotifyThread 随后获取了对象的锁,并调用对象的 notify() 方法,将 WaitThread 从 WaitQueue 移到 SynchronizedQueue 中,此时 WaitThread 的状态变为阻塞状态。NotifyThread 释放了锁之后,WaitThread 再次获取到锁并从 wait() 方法返回继续执行

注意两个队列:

  • 等待队列:notifyAll/notify 唤醒的就是等待队列中的线程;
  • 同步线程:就是竞争锁的所有线程,等待队列中的线程被唤醒后进入同步队列

10、线程的常用方法

10.1、sleep() 方法

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

sleep()方法:它是 Thread 类的静态方法,被 native 关键字修饰:让目标线程进入 WAITING 状态,释放 CPU 资源,但不释放锁资源

实现原理:当调用 Thread.sleep() 方法后,底层将当前线程从运行态转换为休眠态,并将其移出 CPU 的运行队列,内核根据传递的毫秒数(millis)设置一个定时器(通常由硬件支持),定时器开始倒计时,当计数达到零时,触发一个硬件中断,内核的中断处理程序会被唤醒,处理这个中断事件。对于定时器中断,内核会识别出这是由于线程睡眠时间已到触发的,并将相应线程从休眠态转回为就绪态,等待 CPU 调度。如果在休眠期间有其他线程对当前线程发出中断请求,操作系统会在唤醒线程时附带这一信息。JVM 接收到这一信号后,会抛出 InterruptedException,并将当前线程的中断状态设置为true。程序应当捕获并妥善处理这个异常,通常会清理资源、重置中断状态或采取其他适当的响应动作

10.2、join() 方法

join() 方法:等待目标线程结束。

public class ThreadTest {

    public static void main(String[] args) throws Exception {
        System.out.println("main 线程开始执行");
        Thread targetThread = new Thread(() -> {
            try {
                System.out.println("target 线程开始执行");
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        targetThread.start();
        targetThread.join();
        System.out.println("main 线程结束");
    }
}

运行结果:main 线程等待 target 线程执行完毕再继续执行

main 线程开始执行
target 线程开始执行
main 线程结束

实现原理:在 join() 方法内部,通常有一个循环结构,循环条件为 targetThread.isAlive(),即目标线程是否仍然存活。当目标线程尚未结束时,当前线程会进入循环体内部调用 wait() 方法进行等待(释放锁);当目标线程在其 run() 方法执行完毕后,其生命周期状态变为已终止(TERMINATED),并自动调用 notifyAll() 方法,会唤醒所有因为调用 wait() 而在目标线程对象上等待的线程,包括通过 join() 方法暂停的当前线程

10.3、yield()

yield() 方法:礼让线程。让当前正在执行的线程暂停,但不阻塞(将线程从运行状态转换为就绪状态),但不一定成功,完全取决于 CPU。简单来说,就是让 CPU 重新调度线程

11、注意

11.1、start() 方法和 run() 方法的区别?

  1. start() 方法可以启动一个新线程,run() 方法只是类的一个普通方法而已,如果直接调用 run() 方法,程序中依然只有主线程这一个线程
  2. start() 方法不能被重复调用,而 run() 方法可以

11.2、为什么 start() 方法不能被重复调用

public synchronized void start() {  
	if (threadStatus != 0) {
		throw new IllegalThreadStateException();
	}
    try {
    	// 底层开启一个新线程,就会修改 threadStatus 值
        start0();
        started = true;
    }
    //...
	
	private native void start0();
}

11.3、Runnable 和 Callable有什么区别?

  1. Runnable 接口中的 run() 方法没有返回值,是 void 类型,它做的事情只是纯粹地去执行 run() 方法中的代码而已;Callable 接口中的 call() 方法是有返回值的,是一个泛型。它一般配合 Future、FutureTask 一起使用,用来获取异步执行的结果
  2. Callable 接口 call() 方法允许抛出异常;而 Runnable 接口 run() 方法不能继续上抛异常

11.4、sleep()wait() 方法区别

  • sleep() 方法:让当前线程休眠指定时间
    • sleep() 是 Thread 类里的静态方法
    • 释放 CPU 资源,但不释放已获取的锁资源
    • 可通过调用 interrupt() 方法来唤醒休眠线程
  • wait() 方法:
    • wait() 是 Object 类中的方法
    • 当 wait() 方法调用时,当前线程将会释放已获取的对象锁资源,并进入等待队列,其他线程就可以尝试获取对象上的锁资源
    • wait() 方法必须在同步上下文中调用
    • 让当前线程进入等待状态,当别的其他线程调用 notify()、notifyAll() 方法时,当前线程进入就绪状态
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐