转载

最适合初学者了解的Java多线程与并发基础

本文会介绍Java中多线程与并发的基础,适合初学者食用,如果想看关于多线程与并发稍微进阶一些的内容可以看我的另一篇博客—《锁》

线程与进程的区别

在计算机发展初期,每台计算机是串行地执行任务的,如果碰上需要IO的地方,还需要等待长时间的用户IO,后来经过一段时间有了批处理计算机,其可以批量 串行 地处理用户指令,但本质还是串行,还是不能并发执行。 如何解决并发执行的问题呢? 于是引入了 进程 的概念, 每个进程独占一份内存空间,进程是内存分配的最小单位,相互间运行互不干扰且可以相互切换 ,现在我们所看到的多个进程“同时"在运行,实际上是进程高速切换的效果。

那么有了线程之后,我们的计算机系统看似已经很完美了,为什么还要进入线程呢?如果一个进程有多个子任务,往往一个进程需要逐个去执行这些子任务,但往往这些子任务是不相互依赖的,可以并发执行,所以需要CPU进行更细粒度的切换。所以就引入了线程的概念,线程隶属于某一个进程,它共享进程的内存资源,相互间切换更快速。

进程与线程的区别:

1.进程是资源分配的最小单位,线程是CPU调度的最小单位。所有与进程相关的资源,均被记录在PCB中。

2.线程隶属于某一个进程,共享所属进程的资源。线程只由堆栈寄存器、程序计数器和TCB构成。

3.进程可以看作独立的应用,线程不能看作独立的应用。

4.进程有独立的地址空间,相互不影响,而线程只是进程的不同执行路径,如果线程挂了,进程也就挂了。所以多进程的程序比多线程程序健壮,但是切换消耗资源多。

Java中进程与线程的关系:

1.运行一个程序会产生一个进程,进程至少包含一个线程。

2.每个进程对应一个JVM实例,多个线程共享JVM中的堆。

3.Java采用单线程编程模型,程序会自动创建主线程 。

4.主线程可以创建子线程,原则上要后于子线程完成执行。

线程的start方法和run方法的区别

  • 区别

    Java中创建线程的方式有两种,不管使用继承Thread的方式还是实现Runnable接口的方式,都需要重写run方法。 调用start方法会创建一个新的线程并启动,run方法只是启动线程后的回调函数 ,如果调用run方法,那么执行run方法的线程不会是新创建的线程,而如果使用start方法,那么执行run方法的线程就是我们刚刚启动的那个线程。

  • 程序验证

    public class Main {
    	public static void main(String[] args) {
    		Thread thread = new Thread(new SubThread());
    		thread.run();
    		thread.start();
    	}
    	
    }
    class SubThread implements Runnable{
    
    	@Override
    	public void run() {
    		// TODO Auto-generated method stub
    		System.out.println("执行本方法的线程:"+Thread.currentThread().getName());
    	}
    	
    }
    复制代码
    最适合初学者了解的Java多线程与并发基础

Thread和Runnable的关系

  • Thread源码

    最适合初学者了解的Java多线程与并发基础
  • Runnable源码

    最适合初学者了解的Java多线程与并发基础
  • 区别

    通过上述源码图,不难看出,Thread是一个类,而Runnable是一个接口,Runnable接口中只有一个没有实现的run方法,可以得知,Runnable并不能独立开启一个线程,而是依赖Thread类去创建线程,执行自己的run方法,去执行相应的业务逻辑,才能让这个类具备多线程的特性。

  • 使用继承Thread方式和实现Runable接口方式分别创建子线程

    • 使用继承Thread类方式创建子线程

      public class Main extends Thread{
      	public static void main(String[] args) {
      		Main main = new Main();
      		main.start();
      	}
      	@Override
      	public void run() {
      		System.out.println("通过继承Thread接口方式创建子线程成功,当前线程名:"+Thread.currentThread().getName());
      	}
      	
      }
      复制代码

      运行结果:

      最适合初学者了解的Java多线程与并发基础
    • 使用实现Runnable接口方式创建子线程

      public class Main{
      	public static void main(String[] args) {
      		SubThread subThread = new SubThread();
      		Thread thread = new Thread(subThread);
      		thread.start();
      	}
      	
      }
      class SubThread implements Runnable{
      
      	@Override
      	public void run() {
      		// TODO Auto-generated method stub
      		System.out.println("通过实现Runnable接口创建子线程成功,当前线程名:"+Thread.currentThread().getName());
      	}
      	
      }
      复制代码

      运行结果:

      最适合初学者了解的Java多线程与并发基础
    • 使用匿名内部类方式创建子线程

      public class Main{
      	public static void main(String[] args) {
      		Thread thread = new Thread(new Runnable() {
      			@Override
      			public void run() {
      				// TODO Auto-generated method stub
      				System.out.println("使用匿名内部类方式创建线程成功,当前线程名:"+Thread.currentThread().getName());
      			}
      		});
      		thread.start();
      	}
      }
      复制代码

      运行结果:

      最适合初学者了解的Java多线程与并发基础
  • 关系

    1.Thread是实现了Runnable接口的类,使得run支持多线程。2

    2.因类的单一继承原则,推荐使用Runnable接口,可以使程序更加灵活。

如何实现处理多线程的返回值

通过刚才的学习,我们知道多线程的逻辑需要放到run方法中去执行,而run方法是没有返回值的,那么遇到需要返回值的状况就不好解决,那么如何实现子线程返回值呢?

  • 主线程等待法

    通过让主线程等待,直到子线程运行完毕为止。

    实现方式:

    public class Main{
    	static String str;
    	public static void main(String[] args) {
    		Thread thread = new Thread(new Runnable() {
    			@Override
    			public void run() {
    				str="子线程执行完毕";
    			}
    		});
    		thread.start();
    		//如果子线程还未对str进行赋值,则一直轮转
    		while(str==null) {}
    		System.out.println(str);
    	}
    }
    复制代码
  • 使用Thread中的join()方法

    join()方法可以阻塞当前线程以等待子线程处理完毕。

    实现方式:

    public class Main{
    	static String str;
    	public static void main(String[] args) {
    		Thread thread = new Thread(new Runnable() {
    			@Override
    			public void run() {
    				str="子线程执行完毕";
    			}
    		});
    		thread.start();
    		//如果子线程还未对str进行赋值,则一直轮转
    		try {
    			thread.join();
    		} catch (InterruptedException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    		System.out.println(str);
    	}
    }
    复制代码

    join方法能做到比主线程等待法更 精准 的控制,但是join方法的 控制粒度并不够细 。比如,我需要控制子线程将字符串赋一个特定的值时,再执行主线程,这种操作join方法是没有办法做到的。

  • 通过Callable接口实现:通过FutureTask或者线程池获取

    在JDK1.5之前,线程是没有返回值的,通常程序猿需要获取子线程返回值颇费周折,现在Java有了自己的返回值线程,即 实现了Callable接口 的线程,执行了实现Callable接口的线程之后,可以获得一个Future对象,在该对象上调用一个get方法,就可以执行子线程的逻辑并获取返回的Object。

    实现方式1(直接获取 该方式为错误方式):

    public class Main implements Callable<String>{
    
    	@Override
    	public String call() throws Exception {
    		// TODO Auto-generated method stub
    		String str = "我是带返回值的子线程";
    		return str;
    	}
    	public static void main(String[] args) {
    		Main main = new Main();
    		try {
    			String str = main.call();
    			/*这种方式为什么是错误方式?
                           和上文说的一样,run()方法和start()方法的区别就在于
                           run()方法是线程启动后的回调方法,如果直接调用,相当于没有创建这个线程
                           还是由主线程去执行。
                           所以这里的call也一样,如果直接调用call,并没有子线程被创建,
                           而是相当于直接调用了类中的实例方法,获取了返回值,
                           从头到尾并没有子线程的存在。*/
    			System.out.println(str);
    		} catch (Exception e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    复制代码

    运行结果:

    最适合初学者了解的Java多线程与并发基础

    实现方式2(使用FutureTask):

    public class Main implements Callable<String>{
    
    	@Override
    	public String call() throws Exception {
    		// TODO Auto-generated method stub
    		String str = "我是带返回值的子线程";
    		return str;
    	}
    	public static void main(String[] args) {
    		FutureTask<String> task = new FutureTask<String>(new Main());
    		new Thread(task).start();
    		try {
    			if(!task.isDone()) {
    				System.out.println("任务没有执行完成");
    			}
    			System.out.println("等待中...");
    			Thread.sleep(3000);
    			System.out.println(task.get());
    			
    		} catch (InterruptedException | ExecutionException e) {
    			// TODO Auto-generated catch block
    			e.printStackTrace();
    		}
    	}
    }
    复制代码

    运行结果:

    最适合初学者了解的Java多线程与并发基础

    实现方法3(使用线程池配合Future获取):

    public class Main implements Callable<String>{
    
    	@Override
    	public String call() throws Exception {
    		// TODO Auto-generated method stub
    		String str = "我是带返回值的子线程";
    		return str;
    	}
    	public static void main(String[] args) throws InterruptedException, ExecutionException {
    		ExecutorService newCacheThreadPool = Executors.newCachedThreadPool(); 
    		Future<String> future = newCacheThreadPool.submit(new Main());
    		if(!future.isDone()) {
    			System.out.println("线程尚未执行结束");
    		}
    		System.out.println("等待中");
    		Thread.sleep(300);
    		System.out.println(future.get());
            newCacheThreadPool.shutdown();
    	}
    }
    复制代码

    运行结果:

    最适合初学者了解的Java多线程与并发基础

线程的状态

Java线程主要分为以下六个状态: 新建态(new)运行态(Runnable)无限期等待(Waiting)限期等待(TimeWaiting)阻塞态(Blocked)结束(Terminated)

  • 新建(new)

    新建态是线程处于 已被创建但没有被启动 的状态,在该状态下的线程只是被创建出来了,但并没有开始执行其内部逻辑。

  • 运行(Runnable)

    运行态分为 ReadyRunning ,当线程调用start方法后,并不会立即执行,而是去争夺CPU,当线程没有开始执行时,其状态就是Ready,而当线程获取CPU时间片后,从Ready态转为Running态。

  • 等待(Waiting)

    处于等待状态的线程不会自动苏醒,而只有等待被其它线程唤醒,在等待状态中该线程不会被CPU分配时间,将一直被阻塞。以下操作会造成线程的等待:

    1.没有设置timeout参数的Object.wait()方法。

    2.没有设置timeout参数的Thread.join()方法。

    3.LockSupport.park()方法( 实际上park方法并不是LockSupport提供的,而是在Unsafe中,LockSupport只是对其做了一层封装,可以看我的另一篇博客《锁》,里面对于ReentrantLock的源码解析有提到这个方法 )。

  • 限期等待(TimeWaiting)

    处于限期等待的线程,CPU同样不会分配时间片,但存在于限期等待的线程无需被其它线程显式唤醒,而是在等待时间结束后,系统自动唤醒。以下操作会造成线程限时等待:

    1.Thread.sleep()方法。

    2.设置了timeout参数的Object.wait()方法。

    3.设置了timeout参数的Thread.join()方法。

    4.LockSupport.parkNanos()方法。

    5.LockSupport.parkUntil()方法。

  • 阻塞(Blocked)

    当多个线程进入同一块共享区域时,例如Synchronized块、ReentrantLock控制的区域等,会去整夺锁,成功获取锁的线程继续往下执行,而没有获取锁的线程将进入阻塞状态,等待获取锁。

  • 结束(Terminated)

    已终止线程的线程状态,线程已结束执行。

Sleep和Wait的区别

Sleep和Wait者两个方法都可以使线程进入 限期等待 的状态,那么这两个方法有什么区别呢?

1.sleep方法由Thread提供,而wait方法由Object提供。

2.sleep方法可以在任何地方使用,而wait方法只能在synchronized块或synchronized方法中使用(因为必须获wait方法会释放锁,只有获取锁了才能释放锁)。

3.sleep方法只会让出CPU,不会释放锁,而wait方法不仅会让出CPU,还会释放锁。

测试代码:

public class Main{
	public static void main(String[] args) {
		Thread threadA = new Thread(new ThreadA());
		Thread threadB = new Thread(new ThreadB());
		
		threadA.setName("threadA");
		threadB.setName("threadB");
		
		threadA.start();
		threadB.start();
	}

	public static synchronized void print() {
		System.out.println("当前线程:"+Thread.currentThread().getName()+"执行Sleep");
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println("当前线程:"+Thread.currentThread().getName()+"执行Wait");
		try {
			Main.class.wait(1000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		System.out.println("当前线程:"+Thread.currentThread().getName()+"执行完毕");
	}
}
class ThreadA implements Runnable{
	@Override
	public void run() {
		// TODO Auto-generated method stub
		Main.print();
	}
	
}
class ThreadB implements Runnable{
	@Override
	public void run() {
		// TODO Auto-generated method stub
		Main.print();
	}
	
}
复制代码

执行结果:

最适合初学者了解的Java多线程与并发基础

从上面的结果可以分析出:当线程A执行sleep后,等待一秒被唤醒后继续持有锁,执行之后的代码,而执行wait之后,立即释放了锁,不仅让出了CPU还让出了锁,而后线程B立即持有锁开始执行,和线程A执行了同样的步骤,当线程B执行wait方法之后,释放锁,然后线程A拿到锁打印了第一个执行完毕,然后线程B打印执行完毕。

notify和notifyAll的区别

  • notify

    notify可以唤醒一个处于等待状态的线程,上代码:

    public class Main{
    	public static void main(String[] args) {
    		Object lock = new Object();
    		Thread threadA = new Thread(new Runnable() {
    			
    			@Override
    			public void run() {
    				synchronized (lock) {
    					try {
    						lock.wait();
    					} catch (InterruptedException e) {
    						// TODO Auto-generated catch block
    						e.printStackTrace();
    					}
    					print();
    					
    				}
    			}
    		});
    		Thread threadB = new Thread(new Runnable() {
    			
    			@Override
    			public void run() {
    				synchronized (lock) {
    					print();
    					lock.notify();
    				}
    				
    			}
    		});
    		
    		threadA.setName("threadA");
    		threadB.setName("threadB");
    		
    		threadA.start();
    		threadB.start();
    	}
    
    	public static void print() {
    			System.out.println("当前线程:"+Thread.currentThread().getName()+"执行print");
    			try {
    				Thread.sleep(1000);
    			} catch (InterruptedException e) {
    				// TODO Auto-generated catch block
    				e.printStackTrace();
    			}
    			System.out.println("当前线程:"+Thread.currentThread().getName()+"执行完毕");
    		
    	}
    }
    复制代码

    执行结果:

    最适合初学者了解的Java多线程与并发基础

    代码解释:线程A在开始执行时立即调用wait进入无限等待状态,如果没有别的线程来唤醒它,它将一直等待下去,所以此时B持有锁开始执行,并且在执行完毕时调用了notify方法,该方法可以唤醒wait状态的A线程,于是A线程苏醒,开始执行剩下的代码。

  • notifyAll

    notifyAll可以用于唤醒所有等待的线程,使所有处于等待状态的线程都变为ready状态,去重新争夺锁。

    public class Main{
    	public static void main(String[] args) {
    		Object lock = new Object();
    		Thread threadA = new Thread(new Runnable() {
    			
    			@Override
    			public void run() {
    				synchronized (lock) {
    					try {
    						lock.wait();
    					} catch (InterruptedException e) {
    						// TODO Auto-generated catch block
    						e.printStackTrace();
    					}
    					print();
    					
    				}
    			}
    		});
    		Thread threadB = new Thread(new Runnable() {
    			
    			@Override
    			public void run() {
    				synchronized (lock) {
    					print();
    					lock.notifyAll();
    				}
    				
    			}
    		});
    		
    		threadA.setName("threadA");
    		threadB.setName("threadB");
    		
    		threadA.start();
    		threadB.start();
    	}
    
    	public static void print() {
    			System.out.println("当前线程:"+Thread.currentThread().getName()+"执行print");
    			try {
    				Thread.sleep(1000);
    			} catch (InterruptedException e) {
    				// TODO Auto-generated catch block
    				e.printStackTrace();
    			}
    			System.out.println("当前线程:"+Thread.currentThread().getName()+"执行完毕");
    		
    	}
    }
    
    复制代码

    执行结果:

    最适合初学者了解的Java多线程与并发基础

    要唤醒前一个例子中的线程A,不光notify方法可以做到,调用notifyAll方法同样也可以做到,那么两者有什么区别呢?

  • 区别

    要说清楚他们的区别,首先要简单的说一下Java synchronized的一些原理,在openjdk中查看java的源码可以看到,java对象中存在monitor锁,monitor对象中包含 锁池等待池 (这部分的详细内容在另一篇文章《锁》中有详细介绍,这里就简单说一说)

    锁池,假设有多个对象进入synchronized块争夺锁,而此时已经有一个对象获取到了锁,那么剩余争夺锁的对象将直接进入锁池中。

    等待池,假设某个线程调用了对象的wait方法,那么这个线程将直接进入等待池,而等待池中的对象不会去争夺锁,而是等待被唤醒。

    下面可以说notify和notifyAll的区别了:

    notifyAll会让所有处于等待池中的线程全部进入锁池去争夺锁,而notify只会随机让其中一个线程去争夺锁。

yield方法

  • 概念

    /**
         * A hint to the scheduler that the current thread is willing to yield
         * its current use of a processor. The scheduler is free to ignore this
         * hint.
         *
         * <p> Yield is a heuristic attempt to improve relative progression
         * between threads that would otherwise over-utilise a CPU. Its use
         * should be combined with detailed profiling and benchmarking to
         * ensure that it actually has the desired effect.
         *
         * <p> It is rarely appropriate to use this method. It may be useful
         * for debugging or testing purposes, where it may help to reproduce
         * bugs due to race conditions. It may also be useful when designing
         * concurrency control constructs such as the ones in the
         * {@link java.util.concurrent.locks} package.
         */
        public static native void yield();
    复制代码

    yield源码上有一段长长的注释,其大意是说: 当前线程调用yield方法时,会给当前线程调度器一个暗示,当前线程愿意让出CPU的使用,但是它的作用应结合详细的分析和测试来确保已经达到了预期的效果,因为调度器可能会无视这个暗示,使用这个方法是不那么合适的,或许在测试环境中使用它会比较好

    测试:

    public class Main{
    	public static void main(String[] args) {
    		Thread threadA = new Thread(new Runnable() {
    			
    			@Override
    			public void run() {
    				System.out.println("ThreadA正在执行yield");
    				Thread.yield();
    				System.out.println("ThreadA执行yield方法完成");
    			}
    		});
    		Thread threadB = new Thread(new Runnable() {
    			
    			@Override
    			public void run() {
    				System.out.println("ThreadB正在执行yield");
    				Thread.yield();
    				System.out.println("ThreadB执行yield方法完成");
    				
    			}
    		});
    		
    		threadA.setName("threadA");
    		threadB.setName("threadB");
    		
    		threadA.start();
    		threadB.start();
    	}
    复制代码

    测试结果:

    最适合初学者了解的Java多线程与并发基础
    最适合初学者了解的Java多线程与并发基础

    可以看出,存在不同的测试结果,这里选出两张。

    第一种结果:线程A执行完yield方法,让出cpu给线程B执行。然后两个线程继续执行剩下的代码。

    第二种结果:线程A执行yield方法,让出cpu给线程B执行,但是线程B执行yield方法后并没有让出cpu,而是继续往下执行,此时就是 系统无视了这个暗示

interrupt方法

  • 中止线程

    interrupt函数可以 中断一个线程 ,在interrupt之前,通常使用stop方法来终止一个线程,但是stop方法过于暴力,它的特点是,不论被中断的线程之前处于一个什么样的状态,都无条件中断,这会导致被中断的线程后续的一些清理工作无法顺利完成,引发一些不必要的异常和隐患,还有可能引发数据不同步的问题。

  • 温柔的interrupt方法

    interrupt方法的原理与stop方法相比就显得温柔的多,当调用interrupt方法去终止一个线程时,它并不会暴力地强制终止线程,而是通知这个线程应该要被中断了,和yield一样,这也是一种暗示,至于是否应该中断,由被中断的线程自己去决定。当对一个线程调用interrupt方法时:

    1.如果该线程处于被阻塞状态,则立即退出阻塞状态,抛出InterruptedException异常。

    2.如果该线程处于running状态,则将该线程的中断标志位设置为true,被设置的线程继续运行,不受影响,当运行结束时由线程决定是否被中断。

线程池

线程池的引入是用来解决在日常开发的多线程开发中,如果开发者需要使用到非常多的线程,那么这些线程在被频繁的创建和销毁时,会对系统造成一定的影响,有可能系统在创建和销毁这些线程所耗费的时间会比完成实际需求的时间还要长。另外,在线程很多的状况下,对线程的管理就形成了一个很大的问题,开发者通常要将注意力从功能上转移到对杂乱无章的线程进行管理上,这项动作实际上是非常耗费精力的。

  • 利用Executors创建不同的线程池满足不同场景的需求

    • newFixThreadPool(int nThreads)

      指定工作线程数量的线程池。

    • newCachedThreadPool()

      处理大量中断事件工作任务的线程池,

      1.试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程。

      2.如果线程闲置的时间超过阈值,则会被终止并移出缓存。

      3.系统长时间闲置的时候,不会消耗什么资源。

    • newSingleThreadExecutor()

      创建唯一的工作线程来执行任务,如果线程异常结束,会有另一个线程取代它。可保证顺序执行任务。

    • newSingleThreadScheduledExecutor()与newScheduledThreadPool(int corePoolSize)

      定时或周期性工作调度,两者的区别在于前者是单一工作线程,后者是多线程

    • newWorkStealingPool()

      内部构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序。

      **Fork/Join框架:**把大任务分割称若干个小任务并行执行,最终汇总每个小任务后得到大任务结果的框架。

  • 为什么要使用线程池

    线程是稀缺资源,如果无限制地创建线程,会消耗系统资源,而线程池可以代替开发者管理线程,一个线程在结束运行后,不会销毁线程,而是将线程归还线程池,由线程池再进行管理,这样就可以对线程进行复用。

    所以线程池不但可以降低资源的消耗,还可以提高线程的可管理性。

  • 使用线程池启动线程

    public class Main{
    	public static void main(String[] args) {
    		ExecutorService newFixThreadPool = Executors.newFixedThreadPool(10);
    		newFixThreadPool.execute(new Runnable() {
    			
    			@Override
    			public void run() {
    				// TODO Auto-generated method stub
    				System.out.println("通过线程池启动线程成功");
    			}
    		});
    		newFixThreadPool.shutdown();
    	}
    }
    复制代码
  • 新任务execute执行后的判断

    要知道这个点首先要先说说ThreadPoolExecutor的构造函数,其中有几个参数:

    1.corePoolSize:核心线程数量。

    2.maximumPoolSize:线程不够用时能创建的最大线程数。

    3.workQueue:等待队列。

    那么新任务提交后会执行下列判断:

    1.如果运行的线程少于corePoolSize,则创建新线程来处理任务,即时线程池中的其它线程是空闲的。

    2.如果线程池中的数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时,才创建新的线程去处理任务。

    3.如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池大小是固定的,如果此时有新任务提交,若workQueue未满,则放入workQueue,等待被处理。

    4.如果运行的线程数大于等于maximumPoolSize,maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务。

  • handler 线程池饱和策略

    AbortPolicy:直接抛出异常,默认。

    CallerRunsPolicy:用调用者所在的线程来执行任务。

    DiscardOldestPolicy:丢弃队列中靠最前的任务,并执行当前任务。

    DiscardPolicy:直接丢弃任务

    自定义。

  • 线程池的大小如何选定

    这个问题并不是什么秘密,在网上各大技术网站均有文章说明,我就拿一个最受认可的写上吧

    CPU密集型:线程数 = 核心数或者核心数+1

    IO密集型:线程数 = CPU核数*(1+平均等待时间/平均工作时间)

    当然这个也不能完全依赖这个公式,更多的是要依赖平时的经验来操作,这个公式也只是仅供参考而已。

原文  https://juejin.im/post/5da193c6f265da5b74150770
正文到此结束
Loading...