您的位置 首页 java

多线程案例(单例模式、阻塞式队列、定时器及线程池)

一、单例模式

单例模式 是常见的设计模式之一。

什么是设计模式?

设计模式,就相当于“棋谱”中一些固定的代码套路,按照棋谱来下,一般就不会下的很差。软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏。

单例模式 能保证某个类在程序中只存在唯一一份实例 , 而不会创建出多个实例.

这一点在很多场景上都需要. 比如 JDBC 中的 DataSource 实例就只需要一个。

单例模式具体的实现方式, 分成 “饿汉” 和 “懒汉” 两种。

这里举一个例子:洗碗

  1. 中午这顿饭,使用了4个碗,吃完之后,立即把这4个碗给洗了 [饿汉]
  2. 中午这顿饭,使用了4个碗.吃完之后,先不洗,晚上这顿,只需要2个碗,然后就只洗2个即可 [懒汉]―>是一种更加高效的操作

饿汉的单例模式,是比较着急的去进行创建实例的.

懒汉的单例模式,是不太着急的去创建实例,只是在用的时候才真正创建.

1.1 饿汉模式

类加载的同时,创建实例。

一个 Java 程序中,一个类对象只存在一份( JVM 保证的)进—步的也就保证了类的 static 成员也是只有一份的。

//用过Singleton来实现单例模式,保证Singleton这个类有唯一实例

//饿汉模式

class Singleton{

//static修饰的成员—“类成员”-》“类属性/方法”

//1.使用static来创建一个实例,并且立即进行实例化

//这个instance对应的实例,就是该类的唯一实例

private static Singleton instance = new Singleton();

//2.为了防止在其他地方new这个Singleton个Singleton设为私有的

private Singleton(){};

//构造一个方法,让外面能够拿到唯一实例

public static Singleton getInstance (){

return instance;

}

}

public class Test06 {

public static void main(String[] args) {

Singleton instance = Singleton. getinstance ();

}

}

饿汉模式中getlnstance,仅仅是读取了变量的内容。如果多个 线程 只是读同一个变量,不修改,此时仍然是 线程安全 的。

1.2 懒汉模式

类加载的时候不创建实例. 第一次使用的时候才创建实例

  • 懒汉模式-单线程版

class Singleton1{

private static Singleton1 in = null;

private Singleton1(){};

public static Singleton1 getInstance(){

//不是原子的,既包含读,又包含修改

if(in == null){

in = new Singleton1();

}

return in;

}

}

懒汉模式中,既包含了读,又包含了修改.而且这里的读和修改,还是分成两个步骤的(不是原子的)存在线程安全问题。

  • 懒汉模式-多线程版

线程安全问题发生在首次创建实例时. 如果在多个线程中同时调用 getInstance 方法, 就可 能导致创建出多个实例 .

加锁操作,可以改变这里的线程安全问题。使用这里的 类对象作为锁对象 (类对象在一个程序中只有唯一一份,就能保证多个线程调用getInstance的时候都是针对同一个对象进行的加锁)。

class Singleton1{

private static Singleton1 instance= null;

private Singleton1(){};

public static Singleton1 getInstance(){

synchronized (Singleton1.class){

if(instance== null){

instance= new Singleton1();

}

}

return instance;

}

}

  • 懒汉模式-多线程版(改进)

当前虽然 加锁之后,线程安全问题得到解决了 ,但是又有了新的问题 :对于刚才这个懒汉模式的代码来说。线程不安全是发生在instance被初始化之前的.未初始化的时候,多线程调用getinstance,就可能同时涉及到读和修改.但是一旦instance被初始化之后(一定不是nul, if 条件一定不成立了),getInstance操作就只剩下两个读操作也就线程安全了。

而按照上述的加锁方式,无论代码是初始化之后,还是初始化之前,每次调用 getinstance方法都会进行加锁.也就意味着 即使是初始化之后(已经线程安全了),仍然存在大量的锁竞争

以下代码在加锁的基础上, 做出了进一步改动:

1.使用双重 if 判定, 降低锁竞争的频率。

改进方案: 让getInstance初始化之前,才进行加锁,初始化之后,就不再加锁了。在加锁这里再加上一层条件判定即可.条件就是当前是否已经初始化完成 (instance == null)。

在使用了双重if判定之后,当前这个代码中 还存在一个重要的问题 :如果多个线程,都去调用这里的getlnstance 方法,就会造成大量的读instance内存的操作,这样 可能会让编译器把这个读内存操作优化成读寄存器操作

—旦这里触发了优化,后续如果第一个线程已经完成了针对instance的修改,那么紧接着后面的线程都感知不到这个修改,仍然把 instance当成null 。所以这里需要给 instance 加上了 volatile

2.给 instance 加上了 volatile

class Singleton2{

//不是立即初始化实例

//volatile 保证内存可见性

private static volatile Singleton2 instance = null;

private Singleton2(){};

//只有在真正使用这个实例的时候,才会真正的去创建这个实例

public static Singleton2 getInstance(){

//使用这里的类对象作为锁对象,类对象在一个程序中只有一份,

//判定的是是否要加锁。降低了锁竞争

if(instance == null){

//加锁操作,保证了线程安全

synchronized (Singleton2.class){

//判定的是是否要创建实例

if(instance == null){

instance = new Singleton2();

}

}

}

return instance;

}

}

public class Test07 {

public static void main(String[] args) {

Singleton2 instance = Singleton2.getInstance();

}

}

二、阻塞式队列

阻塞队列是什么?

阻塞队列是一种特殊的队列. 也遵守 “ 先进先出 ” 的原则.

阻塞队列是一种 线程安全 的数据结构, 并且具有以下特性 : 产生阻塞效果

  • 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
  • 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.

阻塞队列的一个典型应用场景就是 “生产者消费者模型”. 这是一种非常典型的开发模型。

2.1 生产者消费者模型

生产者消费者模式就是通过一个容器来解决生产者和消费者的 强耦合问题

生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.

1.阻塞队列也能使生产者和消费者之间 解耦

生产者消费者模型,是实际开发中非常有用的一种 多线程 开发手段。尤其是在 服务器开发 的场景中:

假设有两个服务器AB,A作为入口服务器直接接收用户的网络请求,B作为应用服务器,来给A提供一些数据。

多线程案例(单例模式、阻塞式队列、定时器及线程池)

如果 不使用 生产者消费者模型。此时A和B的 耦合性是比较强 的:在开发A代码的时候就得充分了解到B提供的一些接口;开发B代码的时候也得充分了解到A是怎么调用的;—旦想把B换成C,A的代码就需要较大的改动,而且如果B挂了,也可能直接导致A也顺带挂了。

使用生产者消费者模型,就可以降低这里的耦合.

多线程案例(单例模式、阻塞式队列、定时器及线程池)

对于 请求 :A是生产者,B是消费者.对于 响应 :A是消费者,B是生产者.阻塞队列都是作为 交易场所 ,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

A只需要关注如何和队列交互,不需要认识B;

B也只需要关注如何和队列交互,也不需要认识A;

队列是不变的,如果B挂了,对于A没啥影响;如果把B换成C,A也完全感知不到。

2.能够对于请求进行“ 削峰填谷

未使用生产者消费者模型的时候,如果请求量突然暴涨(不可控)

多线程案例(单例模式、阻塞式队列、定时器及线程池)

A暴涨导致B暴涨;

A作为入口服务器,计算量很轻,请求暴涨,问题不大.B作为应用服务器,计算量可能很大,需要的系统资源也更多.如果请求更多了,需要的资源进—步增加,如果主机的硬件不够,可能程序就挂了。

多线程案例(单例模式、阻塞式队列、定时器及线程池)

A请求暴涨=>阻塞队列的请求暴涨,由于阻塞队列没啥计算量,就只是单纯的存个数据,就能抗住更大的压力.

B这边仍然按照 原来的速度 来消费数据,不会因为A的暴涨而引起暴涨.B就被保护的很好,就不会因为这种请求的波动而引起崩溃 。

削峰 ”这种峰值很多时候不是持续的,就一阵过去了(比如双十一秒杀活动)就又恢复了 。

填谷 “B仍然是按照原有的频率来处理之前积压的数据。

实际开发中使用到的” 阻塞队列 “并不是一个简单的数据结构了,而是一个/一组 专门的服务器程序 。并且它提供的功能也不仅仅是阻塞队列的功能,还会在这基础之上提供更多的功能(对于数据持久化存储,支持多个数据通道,支持多节点容灾冗余备份,支持管理面板,方便配置参数…),这样的队列又起了个新的名字,” 消息队列 ”(未来开发中广泛使用到的组件)。

2.2 标准库中的阻塞队列

在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.

  • BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
  • put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
  • BlockingQueue 也有 offer, poll , peek 等方法, 但是这些方法不带有阻塞特性.

import java.util.concurrent.BlockingQueue;

import java.util.concurrent.LinkedBlockingQueue;

public class Demo21 {

public static void main(String[] args) throws Interrupted Exception {

BlockingDeque<String> queue = new LinkedBlockingDeque<>();

//入队列

queue.put(“hello”);

//出队列

String s = queue.take();

System.out.println(s);//hello

}

}

2.3 阻塞队列实现

  • 通过 “循环队列” 的方式来实现.
  • 使用 synchronized 进行加锁控制.
  • put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一定队列就不满了, 因为同时可能是唤醒了多个线程).
  • take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)

先实现一个普通的队列,再加上线程安全,再加上阻塞(使用wait和notify机制) .

对于put来说,阻塞条件,就是队列为满。put 中的wait要由take来唤醒.只要take成功了一个元素,不就队列不满了,就可以进行唤醒了.

对于take来说,阻塞条件,就是队列为空。对于take 中的等待,条件是队列为空.队列不为空,也就是put成功之后,就来唤醒.

import java.util.concurrent.BlockingDeque;

import java.util.concurrent.LinkedBlockingDeque;

class MyBlocking{

//基于数组实现阻塞队列

private int[] data = new int[1000];

//队列长度

private int size = 0;

//队首下标

private int head = 0;

//队尾下标

private int tail = 0;

private static Object locker = new Object();

public void put(int value) throws InterruptedException {

synchronized (locker){

if(size == data.length){

//开始时站实现返回return

// return;

//针对哪个对象加锁,就返回哪个对象的wait 如果是针对this加锁,就this.wait

locker.wait();

}

//把新的元素方法tail位置上

data[tail] = value;

tail++;

//处理tail到达元素末尾的情况,需要从头开始,重新循环

//第1种写法

if(tail >= data.length){

tail = 0;

}

//第2种写法

// tail = tail % data.length;

size++;

//如果入队列成功,则队列非空,就唤醒take中的阻塞等待

locker.notify();

}

}

//出队列

//使用包装类

public Integer take() throws InterruptedException {

synchronized (locker){

if(size == 0){

// return null;

locker.wait();

}

int ret = data[head];

head++;

if(head >= data.length){

head = 0;

}

size–;

// take成功之后,就唤醒put中的等待.

locker.notify();

return ret;

}

}

}

public class Test08 {

public static void main(String[] args) {

MyBlocking queue = new MyBlocking();

//实现一个生产者消费者模式

Thread t = new Thread(()->{

int num = 0;

while (true){

System.out.println(“生产了:” + num);

try {

queue.put(num);

// 当生产者生产的慢一些的时候, 消费者就得跟着生产者的步伐走.生产一个消费一个

// Thread.sleep(500);

} catch (InterruptedException e) {

e.printStackTrace();

}

num++;

}

});

t.start();

Thread t2 = new Thread(()->{

int num = 0;

while (true){

System.out.println(“消费了:” + num);

try {

num = queue.take();

Thread.sleep(500);

} catch (InterruptedException e) {

e.printStackTrace();

}

}

});

t2.start();

}

}

上述代码输出结果:

多线程案例(单例模式、阻塞式队列、定时器及线程池)

当生产者生产的慢一些的时候, 消费者就得跟着生产者的步伐走,生产一个消费一个:

多线程案例(单例模式、阻塞式队列、定时器及线程池)

三、定时器

定时器也是软件开发中的一个重要组件。类似于一个 “闹钟”: 达到一个设定的时间之后, 就执行某个指定好的代码。

3.1 标准库中的定时器

  • 标准库中提供了一个 Timer 类. Timer 内部是有专门的线程,来负责执行注册的任务的,Timer 类的核心方法为 schedule .
  • schedule 包含两个参数:第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒)。

import java.util.Timer;

import java.util.TimerTask;

public class Test09 {

public static void main(String[] args) {

Timer timer = new Timer();

timer.schedule(new TimerTask() {

@Override

public void run() {

System.out.println(“hello”);

}

},3000);

System.out.println(“main”);

}

}

先执行main,三秒之后执行hello。

输出结果:

多线程案例(单例模式、阻塞式队列、定时器及线程池)

3.2 实现定时器

Timer内部需要什么?

1.描述任务

创建一个专门的类来表示一个定时器中的任务.(TimerTask)

2.组织任务(使用一定的数据结构把一些任务给放到一起)

通过—定的数据结构(一个 带优先级的阻塞队列 )来组织.

为啥要带优先级呢?

因为阻塞队列中的任务都有各自的执行时刻 (delay). 最先执行的任务一定是 delay 最小的. 使用带优先级的队列就可以高效的把这个 delay 最小的任务找出来.

3.执行时间到了的任务

需要先执行时间最靠前的任务,就需要有一个线程,不停的去检查当前优先队列的队首元素,看看当前最靠前的这个任务时间是否到了。

import java.util.concurrent.PriorityBlockingQueue;

//创建一个类,表示一个任务

class MyTask implements Comparable<MyTask>{ //实现Comparable接口,设定比较规则

//任务具体要干什么

private Runnable runnable;

//任务具体啥时候干,保存任务要执行的毫秒级时间戳

private long time;

//提供一个 构造方法

public MyTask(Runnable runnable, long delay) { //delay是一个时间间隔,不是绝对的时间戳的值

this.runnable = runnable;

this.time = System.currentTimeMillis() + delay;

}

public void run(){

菠萝的博客();

}

public long getTime() {

return time;

}

@Override

public int compareTo(MyTask o) {

//让时间小的在前,时间大的在后

return (int)(this.time – o.time);

}

}

//定时器

class MyTimer{

//定时器内部能够存放多个任务

//此处的队列要考虑到线程安全问题 可能在多个线程里进行注册任务.

// 同时还有一个专门的线程来取任务执行.此处的队列就需要注意线程安全问题.

private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

//使用schedule方法来注册任务到队列中

public void schedule(Runnable runnable,long delay){

MyTask task = new MyTask(runnable,delay);

queue.put(task);

//每次任务插入成功之后,都唤醒一下扫描线程,让线程重新检查一下队首的任务,看是否时间到了要执行

synchronized (locker){

locker.notify();

}

}

private Object locker = new Object();

//创建一个扫描线程

public MyTimer(){

Thread t = new Thread(()->{

while (true){

try {

//先取出队首元素

MyTask task = queue.take();

long curTime = System.currentTimeMillis();

//比较一下看当前时间到了吗

if(curTime < task.getTime()){

//时间没到,把任务塞回到队列中

queue.put(task);

//指定一个等待时间

synchronized (locker){

//wait可以被中途唤醒 sleep不能被中途唤醒

locker.wait(task.getTime() – curTime);

}

}else {

//时间到了,执行任务

task.run();

}

} catch (InterruptedException e) {

e.printStackTrace();

}

}

});

t.start();

}

}

public class Test10 {

public static void main(String[] args) {

MyTimer myTimer = new MyTimer();

myTimer.schedule(new Runnable() {

@Override

public void run() {

System.out.println(“hello”);

}

},3000);

System.out.println(“main”);

}

}

1.描述—个任务: runnable + time

2.使用优先队列来组织若干个任务. PriorityBlockingQueue

3.实现schedule方法来注册任务到队列中.

4.创建一个 扫描线程 ,这个扫描线程不停的获取到队首元素,并且判定时间是否到达.

另外要注意,让MyTask 类能够支持比较:实现Comparable接口,并设定比较规则

注意解决这里的 忙等 问题:

在扫描线程当中,如果队列中的任务是空着的,就还好,这个线程就在这里阻塞了 (没问题)就怕队列中的任务不空,并且任务时间还没到,此时就称为” 忙等 “(等确实是等了,但是又没闲着,既没有实质性的工作产出,同时又没有进行休息)

忙等这种操作是非常浪费CPU的,可以基于wait这样的机制解决忙等问题:

wait有一个版本,指定等待时间.(不需要notify,时间到了自然唤醒)

计算出当前时间和任务的目标之间的时间差,就等待这么长时间即可。

locker.wait(task.getTime() – curTime);

在等待过程中,可能要插入新的任务。新的任务是可能出现在之前所有任务的最前面的在schedule操作中,就需要加上一个notify操作。

四、线程池

进程比较重,若果频繁创建销毁,会导致开销大 。

线程虽然比进程轻了,但是如果创建销毁的频率进一步增加,仍然会发现开销还是有的。

解决方案: 线程池or协程

把线程提前创建好,放到池子里,后面需要用线程,直接从池子里取,就不必从系统这边申请了;线程用完了,也不是还给系统,而是放回池子里,以备下次再用 ,这回创建销毁过程,速度就更快了 。 线程池最大的好处就是减少每次创建、销毁线程的损耗

为什么线程放在池子里,就比从系统这边申请释放来的更快呢 ?

操作系统分为两种状态: 用户态和内核态

咱们自己写的代码,就是在最上面的 应用程序 这一层来运行的,这里的代码都称为“ 用户态 “运行的代码 。

有些代码,需要调用操作系统的API,进—步的逻辑就会在内核中执行。

例如,调用一个System.out.println,本质上要经过write系统调用,进入到内核中,内核执行一堆逻辑,控制显示器输出字符串。

在内核中运行的代码,称为”内核态”运行的代码

创建线程,本身就需要内核的支持.(创建线程本质是在内核中搞个PCB,加到链表里)

调用的 Thread.start其实归根结底,也是要进入内核态来运行 ;而把创建好的线程放到”池子里”,由于池子就是用户态实现的,这个放到池子/从池子取的过程不需要涉及到内核态,就是纯粹的用户态代码就能完成.

一般认为, 纯用户态的操作,效率要比经过内核态处理的操作,要效率更高

认为内核态效率低,倒不是说一定就真的低,而是代码进入了内核态,就不可控了。内核啥时候给你把活干完,把结果给你(有的时候快,有的时候慢).

4.1 标准库中的线程池

标准库中的线程池叫做:ThreadPoolExecutor

juc(java.util.concurrent): concurrent并发的意思.Java中很多和多线程相关的组件都在这个concurrent包里.

ThreadPoolExecutor中的第4个构造方法:

ThreadPoolExecutor(

int corePoolSize,

int maximumPoolSize,

long keepAliveTime,

TimeUnit unit,

BlockingQueue<Runnable> workQueue,

ThreadFactory threadFactory,

RejectedExecutionHandler handler)

int corePoolSize :核心线程(正式员工的数量)

int maximumPoolSize:最大线程(正式员工+临时工)

long keepAliveTime:允许临时工摸鱼的时间

TimeUnit unit:时间的单位(s, ms, us…)

BlockingQueue<Runnable> workQueue:任务队列.线程池会提供一个submit方法让程序猿把任务注册到线程池中,即加到这个任务队列中.

ThreadFactory threadFactory:线程工厂.线程是怎么创建出来的

RejectedExecutionHandler handler:拒绝策略 ,当任务队列满了,怎么做? 1.直接忽略最新的任务 2.阻塞等待3.直接丢弃最老的任务…

虽然线程池的参数这么多,但是使用的时候最重要的参数,还是第一组参数: 线程池中线程的个数

面试题 :有一个程序,这个程序要并发的/多线程的来完成一些任务, 如果使用线程池的话,这里的线程数设为多少合适 ?

没有一个具体的数字,这要通过 性能测试 的方式,找到合适的值。

例如,写一个服务器程序,服务器里通过线程池,多线程的处理用户请求,就可以对这个服务器进行 性能测试 ,比如构造一些请求,发送给服务器,这里的请求就需要构造很多,比如每秒发送50/100/20. .… 根据实际的业务场景,构造一个合适的值

根据这里不同的线程池的线程数,来观察程序处理任务的速度,程序持有的CPU的占用率。

当线程数多了,整体的速度是会变快,但是CPU占用率也会高.

当线程数少了,整体的速度是会变慢,但是CPU占用率也会下降.

需要找到一个让程序速度能接受,并且CPU占用也合理这样的平衡点

不同类型的程序,因为单个任务,里面CPU上计算的时间和阻塞的时间是分布不相同的.

因此这里指定一个具体的数字往往是不靠谱。

标准库中还提供了一个简化版本的线程池–Executors,本质是针对ThreadPoolExecutor进行了封装,提供了—些默认参数。

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

public class Test11 {

public static void main(String[] args) {

//创建一个固定线程数目的线程池,参数指定了线程的个数

ExecutorService pool = Executors.newFixedThreadPool(10);

//创建一个自动扩容的线程池,会根据任务量来进行自动扩容

// Executors.newCachedThreadPool();

//创建一个只有一个线程的线程池

// Executors.newSingleThreadExecutor();

//创建一个带有定时器功能的线程池,类似于Timer

// Executors.newScheduledThreadPool();

for (int i = 0; i < 100; i++) {

pool.submit(new Runnable() {

@Override

public void run() {

System.out.println(“hello”);

}

});

}

}

}

4.2 实现线程池

线程池里面有什么?

  1. 先能够描述任务(直接使用Runnable)
  2. 需要组织任务(直接使用BlockingQueue)
  3. 能够描述工作线程.
  4. 还需要组织这些线程.
  5. 需要实现,往线程池里添加任务

import java.util.ArrayList;

import java.util.List;

import java.util.concurrent.BlockingDeque;

import java.util.concurrent.LinkedBlockingDeque;

class MyThreadPool{

//1.描述一个任务,直接使用Runnable

//2.使用一个数据结构来组织任务

private BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>();

//3.描述一个线程,工作线程的功能就是从任务队列中取任务并执行

static class Worker extends Thread{

//当前线程池中有若干个Worker线程,这些线程内部都持有上述的任务队列

private BlockingDeque<Runnable> queue = null;

public Worker( BlockingDeque<Runnable> queue) {

this.queue = queue;

}

@Override

public void run() {

while (true){

try {

//循环的去获取任务队列的任务,

//如果队列为空就直接阻塞,如果队列非空,就获取到里面的内容

Runnable runnable = queue.take();

//获取到之后,就执行任务

菠萝的博客();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

}

//4.创建一个数据结构来组织若干个线程

private List<Thread> workers = new ArrayList<>();

public MyThreadPool(int n){

//构造方法中创建出若干个线程,放到上述的数组中

for (int i = 0; i < n; i++) {

Worker worker = new Worker(queue);

worker.start();

workers.add(worker);

}

}

//5.创建一个方法,允许程序员放任务到线程池当中

public void submit(Runnable runnable){

try {

queue.put(runnable);

} catch (InterruptedException e) {

e.printStackTrace();

}

}

}

public class Test12 {

public static void main(String[] args) {

MyThreadPool myThreadPool = new MyThreadPool(10);

for (int i = 0; i < 100; i++) {

myThreadPool.submit(new Runnable() {

@Override

public void run() {

System.out.println(“hello myThreadPool”);

}

});

}

}

}

[机智]

文章来源:智云一二三科技

文章标题:多线程案例(单例模式、阻塞式队列、定时器及线程池)

文章地址:https://www.zhihuclub.com/176349.shtml

关于作者: 智云科技

热门文章

网站地图