JAVA进阶 —— 多线程

目录

一、什么是多线程 ?

1. 线程与进程

2. 并发和并行

 二、 多线程的实现方式

1. 多线程的第一种实现方式:Thread类

2. 多线程的第二种实现方式:Runnable接口

3. 多线程的第三种实现方式:Callable接口和Future接口

4. 多线程三种实现方式的对比

三、多线程常用的成员方法

四、线程的生命周期

五、线程安全问题

 1. 同步代码块

2. 同步方法

3. lock锁

六、死锁

七、生产者和消费者 ( 等待唤醒机制 )

 1.  消费者代码实现

2.  生产者代码实现

八、线程池

 1. 线程池方法实现

2.自定义线程池

3.最大并行数

 九、综合练习

1.  抢红包

2.  抽奖


一、什么是多线程 ?

  • 有了多线程,我们就可以让程序同时做多件事情。

1. 线程与进程

  • 线程是操作系统中能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
  • 简单理解:应用穿件中相互独立,可以同时运行的功能。

  • 进程是程序的基本执行实体。

2. 并发和并行

  • 并发:在同一时刻,有多个指令在单个CPU交替执行。
  • 并行:在同一时刻,有多个指令在多个CPU同时执行。

 二、 多线程的实现方式

  1. 继承Thread类的方式进行实现
  2. 实现Runnable接口的方式进行实现
  3. 利用Callable接口Future接口的方式进行实现

1. 多线程的第一种实现方式:Thread类

首先我们通过查找API帮助文档 了解 Thread类是什么?

多线程第一种实现方式:

  1.  自己手动定义一个类继承Thread类
  2.  重写里面run方法
  3.  创建子类对象,并启动线程。
public class ThreadDemo01 {
	public static void main(String[] args) {
		// 多线程第一种实现方式:
		// 1.自己定义一个类继承Thread类
		// 2.重写里面run方法
		// 3.创建子类对象,并启动线程

		myThread t1 = new myThread();
		myThread t2 = new myThread();
		// t1.run() 只是单纯调用一个方法

		t1.setName("线程1");
		t1.setName("线程2");

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

myThread.java
public class myThread extends Thread {
	@Override
	public void run() {
		//书写线程执行代码
		for (int i = 0; i < 100; i++) {
			System.out.println(getName() + "HelloWrold");
		}
	}
}

2. 多线程的第二种实现方式:Runnable接口

首先我们通过查找API帮助文档 了解 Runnable接口 是什么?

多线程第二种实现方式:

  1. 自己手动定义一个类去实现Runnable接口
  2.  重写里面的run方法
  3.  创建自己的类的对象。
  4.  创建一个Thread类的对象,并开启线程。
public class ThreadDemo2 {
	public static void main(String[] args) {
		// 多线程第二种实现方式:
		// 1.自己定义一个类去实现Runnable接口
		// 2.重写里面的run方法
		// 3.创建自己的类的对象
		// 4.创建一个Thread类的对象,并开启线程

		// 创建MyRun的任务对象
		MyRun mr = new MyRun();

		// 创建线程对象
		// 将任务mr传递给线程
		Thread t1 = new Thread(mr);
		Thread t2 = new Thread(mr);

		t1.setName("线程1");
		t2.setName("线程2");

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

//MyRun.java
public class MyRun implements Runnable {
	@Override
	public void run() {
		// 书写线程执行代码
		for (int i = 0; i < 100; i++) {
			//获取当前线程对象
			Thread thread = Thread .currentThread()
			System.out.println(thread.getName() + "HelloWrold");
		}
	}
}

3. 多线程的第三种实现方式:Callable接口和Future接口

多线程第三种实现方式:

  1. 创建一个类MyCallable实现Callable接口
  2. 重写里面的call方法。( 返回值表示多线程运行结果 )
  3. 创建MyCallable的对象。( 表示多线程要执行的任务 )
  4. 创建FutureTask的对象。( 作用管理多线程运行的结果 )
  5. 创建Thread类的对象,并启动线程。( 表示线程 ) 

特点: 可以获取到多线程运行的结果。

public class ThreadDemo3 {
	public static void main(String[] args) throws InterruptedException, ExecutionException {
		// 多线程第三种实现方式:
		  // 特点: 可以获取到多线程运行的结果
		// 1.创建一个类Mycallable实现Callable接口
		// 2.重写里面的call方法(返回值表示多线程运行结果)
		// 3.创建MyCallable的对象(表示多线程要执行的任务)
		// 4.创建FutureTask的对象(作用管理多线程运行的结果)
		// 5.创建Thread类的对象,并启动(表示线程)
		
		//创建MyCallable对象
		MyCallable mc = new MyCallable();
		//创建FuturaTask对象
		FutureTask<Integer> ft = new FutureTask<>(mc);
		//创建线程对象
		Thread t1 = new Thread();
		
		t1.start();
		
		//获取线程运行结果
		Integer result = ft.get();
		System.out.println(result);
 	}

//MyCallable.java
public class MyCallable implements Callable<Integer> {

	@Override
	public Integer call() throws Exception {
		// 求1 ~ 100 和
		int sum = 0;
		for (int i = 0; i < 100; i++) {
			sum = sum + i;
		}
		return sum;
	}

}

4. 多线程三种实现方式的对比

优点 缺点
继承Thread类 变成比较简单,可以直接使用Thread类中的方法 可以扩展性较差,不能再继承其他的类
实现Runnable 扩展性强,实现该接口的同时还可以继承其他的类 编程相对复杂,不能直接使用Thread类中的方法
实现Callable接口

三、多线程常用的成员方法

方法名称 说明
String getName ( ) 返回此线程的名称
void setName ( String name ) 设置线程的名字(构造方法也可以设置名字)
static Thread currentThread ( ) 获取当前线程的对象
static void sleep ( long time ) 让线程休眠指定的时间,单位为毫秒
setPriority (int newPriority ) 设置线程的优先级
final int getPriority ( ) 获取线程的优先级
final void setDaemon ( boolean on ) 设置为守护线程
public static void yield ( ) 出让线程 / 礼让线程
public static void join ( ) 插入线程 / 插队线程
public class ThreadDemo {
	public static void main(String[] args) throws InterruptedException {
		// 1.getName 返回此线程的名称
		myThread t1 = new myThread();
		// 如果我们没有给线程命名,线程默认名字
		// 格式:Thread-X(X序号,从0开始)
		t1.start();

		// 2.currentThread 获取当前线程的对象 (静态方法)
		Thread t = Thread.currentThread();
		// 哪条线程执行到这个方法,此时获取的就是哪条线程的对象
		System.out.println(t.getName());

		// 3.sleep 让线程休眠指定的时间
		// 方法参数:表示睡眠时间,单位好眠
		// 当时间到了之后,线程就会自动的醒来,继续执行下面的其他代码
		System.out.println("1111");
		Thread.sleep(5000);
		System.out.println("2222");
	}
}

线程的优先级:

  • 抢占式调度:CPU执行每一条的线程的时机和执行时间都是不确定的。
  • 非抢占式调度:所有的线程轮流进行,执行时间是差不多的。
public class ThreadDemo {
	public static void main(String[] args)  {
		//创建线程要执行的参数对象
		MyRunnable mr = new MyRunnable();
		//创建线程对象
		Thread t1 = new Thread(mr,"飞机");
		Thread t2 = new Thread(mr,"坦克");
		
		//优先级默认 : 5
		System.out.println(t1.getPriority());
		System.out.println(t2.getPriority());
		System.out.println(Thread.currentThread().getPriority());

        //细节:当其他的非守护线程执行完毕之后,守护线程将会陆续结束。
        // 把第二个线程设置为守护线程
		t2.setDaemon(true);
	}
}

四、线程的生命周期

 问: sleep方法会让线程睡眠,睡眠时间到了之后,立马就会执行下面的代码吗?

  • 答:不会。sleep方法结束后会进入就绪状态,抢到CPU执行权才会运行下面的代码。

线程的六大状态: 

新建状态(New) 创建线程对象
就绪状态(RUNNABLE ) start方法
阻塞状态(BLOCKED ) 无法获得锁对象
等待状态( WAITING ) wait方法
计时等待(TIMED_WAITING ) sleep方法
结束状态( TERMINATED ) 全部代码运行完毕

五、线程安全问题

通过小练习了解线程安全:

需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票

public class ThreadDemo {
	public static void main(String[] args) {
		 //创建线程对象
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		MyThread t3 = new MyThread();
		
		//线程命名
		t1.setName("窗口一");
		t2.setName("窗口二");
		t3.setName("窗口三");
		
		//开启线程
		t1.start();
		t2.start();
		t3.start();
	}
}


//MyThread.java
public class MyThread extends Thread {
	// 表示这个类的对象都共享一个ticket对象
	static int ticket = 0;

	@Override
	public void run() {
		// 书写线程执行代码
		while (true) {
			if (ticket < 100) {
				try {
					Thread.sleep(100);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				ticket++;
				System.out.println(getName() + "正在卖第" + ticket + "张票");
			} else {
				break;
			}
		}
	}
}

我们发现会出现三个窗口卖同样的票或者超出票数的问题:

那么我们该如何解决呢?

 synchronized:

(1)可重入性

synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁;
synchronized是可重入锁,每部锁对象会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会-1,直到计数器的数量为0,就释放这个锁。

(2)不可中断性

一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断

 1. 同步代码块

  • 操作共享数据的代码锁起来

  • 特点1 : 锁默认打开,有一个线程进去了,锁自动关闭。
  • 特点2 :里面的代码全部执行完毕,线程出来,锁自动打开。 
public class MyThread extends Thread {
	// 表示这个类的对象都共享一个ticket对象
	static int ticket = 0;

	// 锁对象一定唯一
	static Object obj = new Object();

	@Override
	public void run() {
		// 书写线程执行代码
		while (true) {
			//锁对象是任意的
			synchronized(obj) {
				if (ticket < 100) {
					try {
						Thread.sleep(100);
					} catch (InterruptedException e) {
						// TODO Auto-generated catch block
						e.printStackTrace();
					}
					ticket++;
					System.out.println(getName() + "正在卖第" + ticket + "张票");
				} else {
					break;
				}
			}
		}
	}
}

2. 同步方法

  • 就是把synchronized关键字加到方法上。

  • 特点1  : 同步方法是锁住方法里面的所有代码
  • 特点2 : 锁对象不能自己指定。 
public class MyRunnable implements Runnable {
	int ticket = 0;

	@Override
	public void run() {
		// 1.循环
		while (true) {
			// 2.同步代码块(同步方法)
			if (method()) {
				break;

			}
		}
	}

    //this
	public synchronized boolean method() {
		// 3.判断共享数据是否到了末尾 如果到了末尾
		if (ticket == 100) {
			return true;
			// 4.判断共享数据是否到了末尾 如果没到末尾
		} else {
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			ticket++;
			System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票");
		}
		return false;
	}
}

3. lock锁

虽然我们可以理解同步代码块和同步方法的锁对象问题,

但是我们并没有直接地看到在哪里加上了锁以及在哪里释放了锁,

为了更清晰的表达如何加锁和释放锁,JDK5以后提供了一个新的锁对象Lock

Lock实现提供比使用synchronized方法和语句可以获得更广泛的锁定操作。

Lock中提供了获得锁和释放锁的方法:

成员方法 说明
void lock ( ) 获得锁
void unlock ( ) 释放锁

 Lock是接口不能直接实例化,这里采用它的实现类 ReentrantLock 实例化。

构造方法 说明
ReentrantLock ( ) 创建一个  ReentrantLock 的实例
public class MyRunnable implements Runnable {
	int ticket = 0;

	// 多个对象共享同一个锁
	static Lock lock = new ReentrantLock();

	@Override
	public void run() {
		// 1.循环
		while (true) {
			// 2.同步代码块(同步方法)
			// synchronized (MyThread.class) {
			lock.lock();

			try {
				// 3.判断共享数据是否到了末尾 如果到了末尾
				if (ticket == 100) {
					break;
					// 4.判断共享数据是否到了末尾 如果没到末尾
				} else {
					Thread.sleep(100);
					ticket++;
					System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票");
				}
			} catch (InterruptedException e) {
				e.printStackTrace();
			} finally {
				lock.unlock();
			}

		}
	}
}

六、死锁

  • 所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 
  • 死锁是一个错误,在以后的编程过程中要避免锁的嵌套
//MyThread.java
public class MyThread extends Thread {

	static Object objA = new Object();
	static Object objB = new Object();

	@Override
	public void run() {
		// 1.循环
		while (true) {
			if ("线程A".equals(getName())) {
				synchronized (objA) {
					System.out.println("线程A拿到了A锁,准备拿B锁");
					synchronized (objB) {
						System.out.println("线程A拿到了B锁,顺利执行完一轮");
					}
				}
			} else if ("线程B".equals(getName())) {
				if ("线程B".equals(getName())) {
					synchronized (objB) {
						System.out.println("线程B拿到了B锁,准备拿A锁");
						synchronized (objA) {
							System.out.println("线程B拿到了A锁,顺利执行完一轮");
						}
					}
				}

			}
		}
	}
}

public class ThreadDemo {
	public static void main(String[] args) {
		
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		
		t1.setName("线程A");
		t2.setName("线程B");
		
		t1.start();
		t2.start();
	}
}

运行结果:(卡死) 

七、生产者和消费者 ( 等待唤醒机制 )

生产者消费者模式是一种非常经典的多线程协作的模式。

常见方法:

成员方法 说明
void wait ( )  当前线程等待,直到被其他线程唤醒
void notify ( ) 所及唤醒单个线程
void notifyAll ( ) 唤醒所有线程

 举例:

 1.  消费者代码实现

//Desk.java
public class Desk {
	// 作用: 控制生产者和消费者的执行
	
	//判断桌子上是否有面条: 0:没有 ; 1:有
	public static int foodFlag = 0;
	
	//定义总个数
	public static int count = 10;
	
	//锁对象
	public static Object lock = new Object();
}


//Foodie.java
public class Foodie extends Thread {
	@Override

	public void run() {

		// 1.循环
		while (true) {
			// 同步代码块
			synchronized (Desk.lock) {
				if (Desk.count == 0) {
					break;
				} else {
					// 先判断桌子上是否有面条
					if (Desk.foodFlag == 0) {
						// 没有:等待
						try {
							Desk.lock.wait(); // 让当前线程与锁进行绑定
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					} else {
						// 把吃的总数- 1
					Desk.count--;
						// 有: 开吃
					System.out.println("吃货在吃面条,还能再吃" + Desk.count + "碗");
						// 吃完之后:唤醒厨师继续做
					Desk.lock.notifyAll();

						// 修改桌子的状态
					Desk.foodFlag = 0;
					}

				}
			}
		}
	}
}

2.  生产者代码实现

public class ThreadDemo {
	public static void main(String[] args) {
		// 创建线程对象
		Cook c = new Cook();
		Foodie f = new Foodie();

		// 线程命名
		c.setName("厨师");
		f.setName("吃货");
		
		//开启线程
		c.start();
		f.start();
	}
}

//Cook.java
public class Cook extends Thread{
	@Override
	public void run() {
		// 1.循环
		while (true) {
			// 同步代码块
			synchronized (Desk.lock) {
				if (Desk.count == 0) {
					break;
				} else {
					// 判断桌子上是否有食物
					if (Desk.foodFlag == 1) {
						// 如果有:就等待
						try {
							Desk.lock.wait();
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					} else {
						// 没有: 就制作食物
						System.out.println("厨师做了一碗面条");
						// 修改桌子上的食物状态
						Desk.foodFlag = 1;
						// 等待的消费者开吃
						Desk.lock.notifyAll();
					}
				}
			}
		}
	}
}

八、线程池

以前写多线程的弊端:

弊端一:用到线程的时候就要创建 弊端二:用完之后线程消失

 因此,我们我们引入线程池

线程池其实就是一种多线程处理形式,处理过程中可以将任务添加到队列中,然后在创建线程后自动启动这些任务。

  1. 创建一个池子,池子中是空的。
  2. 提交任务时,池子会创建新的线程对象,任务执行完毕,线程归还给池子;下次再次提交任务时,不需要创建新的的线程,直接复用已有的线程即可。
  3. 但是如果提交任务时,池子中没有空闲线程,也无法创建新的线程,任务就会排队等待。

 1. 线程池方法实现

Executors:线程池的工具类通过调用方法返回不同类型的线程池对象。

方法名称 说明
public static ExecutorService newCachedThreadPool ( ) 创建一个没有上限的线程池
public static ExecutorService newFixedThreadPool ( int nThreads ) 创建有上限的线程池
public class MyThreadPoolDemo {
	public static void main(String[] args) {
		// 1.获取线程池对象
		ExecutorService pool1 = Executors.newCachedThreadPool();
		ExecutorService pool2 = Executors.newFixedThreadPool(3);

		// 2.提交任务
		pool1.submit(new MyRunnable());
		pool1.submit(new MyRunnable());
		pool1.submit(new MyRunnable());
		pool1.submit(new MyRunnable());

		// pool2只能看到3个线程
		pool2.submit(new MyRunnable());
		pool2.submit(new MyRunnable());
		pool2.submit(new MyRunnable());
		pool2.submit(new MyRunnable());

		// 3.销毁任务
		pool1.shutdown();
	}
}

//MyRunnable.java
public class MyRunnable implements Runnable {

	@Override
	public void run() {
		for (int i = 0; i < 100; i++) {
			System.out.println(Thread.currentThread().getName() + " - - " + i);
		}
	}
}

2.自定义线程池

任务拒绝策略 说明
ThreadPoolExecutor.AbortPolicy 默认策略:丢弃任务并抛出RejectedExecutionException异常
ThreadPoolExecutor.DiscardPolicy 丢弃任务,但是不抛出异常这是不推荐的做法
ThreadPoolExecutor.DiscardoldestPolicy 抛弃队列中等待最久的任务然后把当前任务加入队列中
ThreadPoolExecutor.callerRunsPolicy 调用任务的run()方法绕过线程池直接执行
  • 核心元素一:核心线程的数量(不能小于0)
  • 核心元素二:线程池中最大线程的数量(最大数量>=核心线程数量)
  • 核心元素三:空闲时间(值)(不能小于0)
  • 核心元素四:空闲时间(单位)(用TimeUnit指定)
  • 核心元素五:堵塞队列(不能为null)
  • 核心元素六:创建线程的方式(不能为null)
  • 核心元素七:要执行的任务过多时的解决方案(不能为null)
public class MyThreadPoolDemo {
	public static void main(String[] args) {
		ThreadPoolExecutor pool1 = new ThreadPoolExecutor(3, // 核心线程数量,不能小于0
				6, // 最大线程数,不能小于0,最大数量 >= 核心线程数量
				60, // 空间线程最大存活时间
				TimeUnit.SECONDS, // 时间单位
				new LinkedBlockingQueue<>(3), // 任务队列
				Executors.defaultThreadFactory(), // 创建线程工厂
				new ThreadPoolExecutor.AbortPolicy() // 任务拒绝策略
		);
	}
}

不断的提交任务,会有以下三个临界点:

  1. 当核心线程满时,再提交任务就会排队。
  2. 当核心线程满,队伍满时,会创建临时线程。
  3. 当核心线程满,队伍满,临时线程满时,会触发任务拒绝策略。

3.最大并行数

CPU密集型运算

(读取文件操作比较少)

I/O密集型运算

(读取文件操作比较多)

public class MyThreadPoolDemo {
	public static void main(String[] args) {
		//向Java虚拟机返回可用处理器的数目
		int count = Runtime.getRuntime().availableProcessors();
		System.out.println(count); //12
	}
}

所以线程池多大合适呢?

示例:(4线8核通过计算公式)

 九、综合练习

1.  抢红包

抢红包也用到了多线程。
假设:100块,分成了3个包,现在有5个人去抢。

其中,红包是共享数据。
5个人是5条线程。
打印结果如下:
                 XXX抢到了XXX元

                 XXX抢到了XXX元

                 XXX抢到了XXX元

                 XXX没抢到
                 XXX没抢到

public class MyThread extends Thread {
	// 总金额
	static BigDecimal money = BigDecimal.valueOf(100.0);
	// 个数
	static int count = 6;
	// 最小抽奖金额
	static final BigDecimal MIN = BigDecimal.valueOf(0.01);

	@Override
	public void run() {
		synchronized (MyThread.class) {
			if (count == 0) {
				System.out.println(getName() + "没有抢到红包");
			}else {
				//中奖金额
				BigDecimal prize;
				if (count == 1) {
					prize = count;
				}else {
					//获取抽奖范围
					double bounds = money.subtract(BigDecimal.valueOf(count -1).multiply(MIN).doubleValue());
					Random r = new Random(); 
					//抽奖金额
					prize = BigDecimal.valueOf(r.nextDouble()bounds);
				}
				//设置抽中红包,小数点保留两位,四舍五入
				prize = prize.setScale(2,RoundingMode.HALF_UP);
				//在总金额中去掉对应的钱
				money = money.subtract(prize);
				//红包少了一个
				count--;
				//输出红包信息
				System.out.println(getName() + "抽中了" + prize + "元");
			}
		}
	}
}



//Test.java
public class Test {
	public static void main(String[] args) {
		// 创建线程对象
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		MyThread t3 = new MyThread();
		MyThread t4 = new MyThread();
		MyThread t5 = new MyThread();
		MyThread t6 = new MyThread();

		// 线程命名
		t1.setName("张三");
		t2.setName("李四");
		t3.setName("王五");
		t4.setName("赵六");
		t5.setName("钱七");
		t6.setName("孙八");

		// 线程启动
		t1.start();
		t2.start();
		t3.start();
		t4.start();
		t5.start();
		t6.start();
	}
}

2.  抽奖

有一个抽奖池,该抽奖池中存放了奖励的金额,该抽奖池中的奖项为{10,5,20,50,100,200,500,800,2,80,300,700};
创建两个抽奖箱(线程)设置线程名称分别为“抽奖箱1”,“抽奖箱2”

随机从抽奖池中获取奖项元素并打印在控制台上,格式如下:
                          每次抽出一个奖项就打印一个(随机)
                          抽奖箱1又产生了一个10元大奖

                          抽奖箱1又产生了一个100元大奖

                          抽奖箱1又产生了一个200元大奖

                          抽奖箱1又产生了一个800元大奖

                          抽奖箱2又产生了一个700元大奖
                          ...

public class MyThread extends Thread {
	ArrayList<Integer> list;

	public MyThread(ArrayList<Integer> list) {
		this.list = list;
	}

	@Override
	public void run() {
	  while (true) {
		synchronized (MyThread.class) {
			if (list.size() == 0) {
				break;
			} else {
				// 继续抽奖
				try {
					Thread.sleep(10);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				Collections.shuffle(list);
				int prize = list.remove(0);
				System.out.println(getName() + "又产生了一个" + prize + "元大奖");
			}
		}
	}
  }
}


//Test.java
public class Test {
	public static void main(String[] args) {
		//创建奖池
		ArrayList<Integer> list = new ArrayList<>();
		Collections.addAll(list, 10,5,20,50,100,200,500,800,2,80,300,700);
		
		
		// 创建线程对象
		MyThread t1 = new MyThread(list);
		MyThread t2 = new MyThread(list);

		// 线程命名
		t1.setName("抽奖箱1");
		t2.setName("抽奖箱2");

		// 线程启动
		t1.start();
		t2.start();

	}
}

猜你喜欢

转载自blog.csdn.net/hdakj22/article/details/129553187