zoukankan      html  css  js  c++  java
  • 300 行代码带你秒懂 Java 多线程!

      线程的概念,百度是这样解说的:
      线程(英语:Thread)是操作体系可以进行运算调度的最小单位。它被包括在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一次序的操控流,一个进程中可以并发多个线程,每条线程并行履行不同的使命。在UnixSystemV及SunOS中也被称为轻量进程(LightweightProcesses),但轻量进程更多指内核线程(KernelThread),而把用户线程(UserThread)称为线程。
      1.1线程与进程的差异
      进程:指在体系中正在运转的一个运用程序;程序一旦运转便是进程;进程——资源分配的最小单位。
      线程:体系分配处理器时间资源的基本单元,或许说进程之内独立履行的一个单元履行流。线程——程序履行的最小单位。
      也便是,进程可以包括多个线程,而线程是程序履行的最小单位。
      1.2线程的状况
      NEW:线程刚创立
      RUNNABLE:在JVM中正在运转的线程,其间运转状况可以有运转中RUNNING和READY两种状况,由体系调度进行状况改变。
      BLOCKED:线程处于堵塞状况,等候监督锁,可以重新进行同步代码块中履行
      WAITING:等候状况
      TIMED_WAITING:调用sleepjoinwait办法或许导致线程处于等候状况
      TERMINATED:线程履行完毕,现已退出
      1.3Notify和Wait:
      Notify和Wait的效果
      首要看源码给出的解说,这里翻译了一下:
      Notify:唤醒一个正在等候这个目标的线程监控。假如有任何线程正在等候这个目标,那么它们中的一个被选择被唤醒。选择是任意的,发生在履行的酌情权。一个线程等候一个目标经过调用一个{@codewait}办法进行监督。
      Notify需求在同步办法或同步块中调用,即在调用前,线程也必须获得该目标的目标等级锁
      Wait:导致当时线程等候,直到另一个线程调用{@linkjava.lang.Object#notify}办法或{@linkjava.lang.Object#notifyAll}办法。
      换句话说,这个办法的行为就像它简略相同履行调用{@codewait(0)}。当时线程必须拥有该目标的监督器。
      线程开释此监督器的一切权,并等候另一个线程告诉等候该目标的监督器的线程,唤醒经过调用{@codenotify}办法或{@codenotifyAll}办法。然后线程等候,直到它可以重新获得监督器的一切权,然后持续履行。
      Wait的效果是使当时履行代码的线程进行等候,它是Object类的办法,该办法用来将当时线程置入预履行行列中,并且在Wait地点的代码行处中止履行,直到接到告诉或被中止为止。
      在调用Wait办法之前,线程必须获得该目标的目标等级锁,即只能在同步办法或同步块中调用Wait办法。
      Wait和Sleep的差异:
      它们最大本质的差异是,Sleep不开释同步锁,Wait开释同步锁。
      还有用法的上的不同是:Sleep(milliseconds)可以用时间指定来使他主动醒过来,假如时间不到你只能调用Interreput来强行打断;Wait可以用Notify直接唤起。
      这两个办法来自不同的类分别是Thread和Object
      最首要是Sleep办法没有开释锁,而Wait办法开释了锁,使得其他线程可以运用同步操控块或许办法。
      1.4Thread.sleep和Thread.yield的异同
      相同:Sleep和yield都会开释CPU。
      不同:Sleep使当时线程进入阻滞状况,所以履行Sleep的线程在指定的时间内必定不会履行;yield仅仅使当时线程重新回到可履行状况,所以履行yield的线程有或许在进入到可履行状况后立刻又被履行。Sleep可使优先级低的线程得到履行的时机,当然也可以让同优先级和高优先级的线程有履行的时机;yield只能使同优先级的线程有履行的时机。
      1.5弥补:死锁的概念
      死锁:指两个或两个以上的进程(或线程)在履行过程中,因抢夺资源而形成的一种互相等候的现象,若无外力效果,它们都将无法推动下去。此刻称体系处于死锁状况或体系发生了死锁,这些永远在互相等候的进程称为死锁进程。
      死锁发生的四个必要条件(缺一不可):
      互斥条件:望文生义,线程对资源的拜访是排他性,当该线程开释资源后下一线程才可进行占用。
      恳求和坚持:简略来说便是自己拿的不放手又等候新的资源到手。线程T1至少现已坚持了一个资源R1占用,但又提出对另一个资源R2恳求,而此刻,资源R2被其他线程T2占用,于是该线程T1也必须等候,但又对自己坚持的资源R1不开释。
      不可掠夺:在没有运用完资源时,其他线性不能进行掠夺。
      循环等候:一向等候对方线程开释资源。
      咱们可以根据死锁的四个必要条件损坏死锁的形成。
      1.6弥补:并发和并行的差异
      并发:是指在某个时间段内,多使命替换的履行使命。当有多个线程在操作时,把CPU运转时间划分成若干个时间段,再将时间段分配给各个线程履行。在一个时间段的线程代码运转时,其它线程处于挂起状。
      并行:是指同一时间一起处理多使命的才能。当有多个线程在操作时,CPU一起处理这些线程恳求的才能。
      差异就在于CPU是否能一起处理一切使命,并发不能,并行能。
      1.7弥补:线程安全三要素
      原子性:Atomic包、CAS算法、Synchronized、Lock。
      可见性:Synchronized、Volatile(不能确保原子性)。
      有序性:Happens-before规矩。
      1.8弥补:怎么完成线程安全
      互斥同步:Synchronized、Lock。
      非堵塞同步:CAS。
      无需同步的方案:假如一个办法原本就不涉及共享数据,那它天然就无需任何同步操作去确保正确性。
      1.9弥补:确保线程安全的机制:
      Synchronized关键字
      Lock
      CAS、原子变量
      ThreadLocl:简略来说便是让每个线程,对同一个变量,都有自己的独有副本,每个线程实际拜访的目标都是自己的,天然也就不存在线程安全问题了。
      Volatile
      CopyOnWrite写时复制
      随着CPU中心的增多以及互联网迅速发展,单线程的程序处理速度越来越跟不上发展速度和大数据量的增长速度,多线程应运而生,充分利用CPU资源的一起,极大进步了程序处理速度。
      创立线程的办法
      承继Thread类:
      publicclassThreadCreateTest{
      publicstaticvoidmain(String[]args){
      newMyThread.start;
      }
      }
      classMyThreadextendsThread{
      @Override
      publicvoidrun{
      System.out.println(Thread.currentThread.getName+"t"+Thread.currentThread.getId);
      }
      }
      完成Runable接口:
      publicclassRunableCreateTest{
      publicstaticvoidmain(String[]args){
      MyRunnablerunnable=newMyRunnable;
      newThread(runnable).start;
      }
      }
      classMyRunnableimplementsRunnable{
      @Override
      publicvoidrun{
      System.out.println(Thread.currentThread.getName+"t"+Thread.currentThread.getId);
      }
      }
      经过Callable和Future创立线程:
      publicclassCallableCreateTest{
      publicstaticvoidmain(String[]args)throwsException{
      //将Callable包装成FutureTask,FutureTask也是一种Runnable
      MyCallablecallable=newMyCallable;
      FutureTaskfutureTask=newFutureTask<>(callable);
      newThread(futureTask).start;
      //get办法会堵塞调用的线程
      Integersum=futureTask.get;
      System.out.println(Thread.currentThread.getName+Thread.currentThread.getId+"="+sum);
      }
      }
      classMyCallableimplementsCallable{
      @Override
      publicIntegercallthrowsException{
      System.out.println(Thread.currentThread.getName+"t"+Thread.currentThread.getId+"t"+newDate+"tstarting...");
      intsum=0;
      for(inti=0;i<=100000;i++){
      sum+=i;
      }
      Thread.sleep(5000);
      System.out.println(Thread.currentThread.getName+"t"+Thread.currentThread.getId+"t"+newDate+"tover...");
      returnsum;
      }
      }
      线程池办法创立:
      完成Runnable接口这种办法更受欢迎,由于这不需求承继Thread类。在运用规划中现已承继了其他目标的情况下,这需求多承继(而Java不支撑多承继,但可以多完成啊),只能完成接口。一起,线程池也是十分高效的,很容易完成和运用。
      实际开发中,阿里巴巴开发插件一向提倡运用线程池创立线程,原因在下方会解说,所以上面的代码我就只简写了一些Demo。
      2.1线程池创立线程
      线程池,望文生义,线程寄存的当地。和数据库连接池相同,存在的目的便是为了较少体系开销,首要由以下几个特点:
      下降资源耗费。经过重复利用已创立的线程下降线程创立和毁掉形成的耗费(首要)。
      进步响应速度。当使命抵达时,使命可以不需求比及线程创立就能立即履行。
      进步线程的可管理性。线程是稀缺资源,假如无限制地创立,不只会耗费体系资源,还会下降体系的稳定性。
      Java供给四种线程池创立办法:
      newCachedThreadPool创立一个可缓存线程池,假如线程池长度超越处理需求,可灵敏收回空闲线程,若无可收回,则新建线程。
      newFixedThreadPool创立一个定长线程池,可操控线程最大并发数,超出的线程会在行列中等候。
      newScheduledThreadPool创立一个定长线程池,支撑定时及周期性使命履行。
      newSingleThreadExecutor创立一个单线程化的线程池,它只会用仅有的作业线程来履行使命,确保一切使命按照指定次序(FIFO,LIFO,优先级)履行。
      经过源码咱们得知ThreadPoolExecutor承继自AbstractExecutorService,而AbstractExecutorService完成了ExecutorService。
      publicclassThreadPoolExecutorextendsAbstractExecutorService
      publicabstractclassAbstractExecutorServiceimplementsExecutorService
      2.2ThreadPoolExecutor介绍
      实际项目中,用的最多的便是ThreadPoolExecutor这个类,而《阿里巴巴Java开发手册》中强制线程池不允许运用Executors去创立,而是经过NewThreadPoolExecutor实例的办法,这样的处理办法让写的同学更加明确线程池的运转规矩,规避资源耗尽的危险。
      咱们从ThreadPoolExecutor下手多线程创立办法,先看一下线程池创立的最全参数。
      publicThreadPoolExecutor(intcorePoolSize,
      intmaximumPoolSize,
      longkeepAliveTime,
      TimeUnitunit,
      BlockingQueueworkQueue,
      ThreadFactorythreadFactory,
      RejectedExecutionHandlerhandler){
      if(corePoolSize<0||
      maximumPoolSize<=0||
      maximumPoolSize
      keepAliveTime<0)
      thrownewIllegalArgumentException;
      if(workQueue==null||threadFactory==null||handler==null)
      thrownewNullPointerException;
      this.corePoolSize=corePoolSize;
      this.maximumPoolSize=maximumPoolSize;
      this.workQueue=workQueue;
      this.keepAliveTime=unit.toNanos(keepAliveTime);
      this.threadFactory=threadFactory;
      this.handler=handler;
      }
      参数阐明如下:
      corePoolSize:线程池的中心线程数,即便线程池里没有任何使命,也会有corePoolSize个线程在候着等使命。
      maximumPoolSize:最大线程数,不论提交多少使命,线程池里最多作业线程数便是maximumPoolSize。
      keepAliveTime:线程的存活时间。当线程池里的线程数大于corePoolSize时,假如等了keepAliveTime时长还没有使命可履行,则线程退出。
      Unit:这个用来指定keepAliveTime的单位,比如秒:TimeUnit.SECONDS。
      BlockingQueue:一个堵塞行列,提交的使命将会被放到这个行列里。
      threadFactory:线程工厂,用来创立线程,首要是为了给线程起名字,默许工厂的线程名字:pool-1-thread-3。
      handler:回绝战略,当线程池里线程被耗尽,且行列也满了的时分会调用。
      2.2.1BlockingQueue
      对于BlockingQueue个人感觉还需求单独拿出来说一下。
      BlockingQueue:堵塞行列,有先进先出(重视公平性)和先进后出(重视时效性)两种,常见的有两种堵塞行列:ArrayBlockingQueue和LinkedBlockingQueue
      行列的数据结构大致如图:
      行列一端进入,一端输出。而当行列满时,堵塞。BlockingQueue中心办法:1.放入数据put2.获取数据take。常见的两种Queue:
      2.2.2ArrayBlockingQueue
      基于数组完成,在ArrayBlockingQueue内部,保护了一个定长数组,以便缓存行列中的数据目标,这是一个常用的堵塞行列,除了一个定长数组外,ArrayBlockingQueue内部还保存着两个整形变量,分别标识着行列的头部和尾部在数组中的位置。
      一段代码来验证一下:
      packagemap;
      importjava.util.concurrent.*;
      publicclassMyTestMap{
      //界说堵塞行列巨细
      privatestaticfinalintmaxSize=5;
      publicstaticvoidmain(String[]args){
      ArrayBlockingQueuequeue=newArrayBlockingQueue(maxSize);
      newThread(newProductor(queue)).start;
      newThread(newCustomer(queue)).start;
      }
      }
      classCustomerimplementsRunnable{
      privateBlockingQueuequeue;
      Customer(BlockingQueuequeue){
      this.queue=queue;
      }
      @Override
      publicvoidrun{
      this.cusume;
      }
      privatevoidcusume{
      while(true){
      try{
      intcount=(int)queue.take;
      System.out.println("customer正在消费第"+count+"个产品===");
      //仅仅为了便利观察输出成果
      Thread.sleep(10);
      }catch(InterruptedExceptione){
      e.printStackTrace;
      }
      }
      }
      }
      classProductorimplementsRunnable{
      privateBlockingQueuequeue;
      privateintcount=1;
      Productor(BlockingQueuequeue){
      this.queue=queue;
      }
      @Override
      publicvoidrun{
      this.product;
      }
      privatevoidproduct{
      while(true){
      try{
      queue.put(count);
      System.out.println("出产者正在出产第"+count+"个产品");
      count++;
      }catch(InterruptedExceptione){
      e.printStackTrace;
      }
      }
      }
      }
      //输出如下
      /**
      出产者正在出产第1个产品
      出产者正在出产第2个产品
      出产者正在出产第3个产品
      出产者正在出产第4个产品
      出产者正在出产第5个产品
      customer正在消费第1个产品===
      */
      2.2.3LinkedBlockingQueue
      基于链表的堵塞行列,内部也保护了一个数据缓冲行列。需求咱们留意的是假如结构一个LinkedBlockingQueue目标,而没有指定其容量巨细。
      LinkedBlockingQueue会默许一个类似无限巨细的容量(Integer.MAX_VALUE),这样的话,假如出产者的速度一旦大于顾客的速度,也许还没有比及行列满堵塞发生,体系内存就有或许已被耗费殆尽了。
      2.2.4LinkedBlockingQueue和ArrayBlockingQueue的首要差异
      ArrayBlockingQueue的初始化必须传入行列巨细,LinkedBlockingQueue则可以不传入。
      ArrayBlockingQueue用一把锁操控并发,LinkedBlockingQueue俩把锁操控并发,锁的细粒度更细。即前者出产者顾客进出都是一把锁,后者出产者出产进入是一把锁,顾客消费是另一把锁。
      ArrayBlockingQueue选用数组的办法存取,LinkedBlockingQueue用Node链表办法存取。
      2.2.5handler回绝战略
      Java供给了4种丢掉处理的办法,当然你也可以自己完成,首要是要完成接口:RejectedExecutionHandler中的办法。
      AbortPolicy:不处理,直接抛出反常。
      CallerRunsPolicy:只用调用者地点线程来运转使命,即提交使命的线程。
      DiscardOldestPolicy:LRU战略,丢掉行列里最近最久不运用的一个使命,并履行当时使命。
      DiscardPolicy:不处理,丢掉掉,不抛出反常。
      2.2.6线程池五种状况
      privatestaticfinalintRUNNING=-1<
      privatestaticfinalintSHUTDOWN=0<
      privatestaticfinalintSTOP=1<
      privatestaticfinalintTIDYING=2<
      privatestaticfinalintTERMINATED=3<
      RUNNING:在这个状况的线程池能判别承受新提交的使命,并且也能处理堵塞行列中的使命。
      SHUTDOWN:处于封闭的状况,该线程池不能承受新提交的使命,可是可以处理堵塞行列中现已保存的使命,在线程处于RUNNING状况,调用shutdown办法能切换为该状况。
      STOP:线程池处于该状况时既不能承受新的使命也不能处理堵塞行列中的使命,并且能中止现在线程中的使命。当线程处于RUNNING和SHUTDOWN状况,调用shutdownNow办法就可以使线程变为该状况。
      TIDYING:在SHUTDOWN状况下堵塞行列为空,且线程中的作业线程数量为0就会进入该状况,当在STOP状况下时,只要线程中的作业线程数量为0就会进入该状况。
      TERMINATED:在TIDYING状况下调用terminated办法就会进入该状况。可以以为该状况是最终的停止状况。
      回到线程池创立ThreadPoolExecutor,咱们了解了这些参数,再来看看ThreadPoolExecutor的内部作业原理:
      判别中心线程是否已满,是进入行列,否:创立线程
      判别等候行列是否已满,是:查看线程池是否已满,否:进入等候行列
      查看线程池是否已满,是:回绝,否创立线程
      2.3深入了解ThreadPoolExecutor
      进入Execute办法可以看到:
      publicvoidexecute(Runnablecommand){
      if(command==null)
      thrownewNullPointerException;
      intc=ctl.get;
      //判别当时活跃线程数是否小于corePoolSize,假如小于,则调用addWorker创立线程履行使命
      if(workerCountOf(c)
      if(addWorker(command,true))
      return;
      c=ctl.get;
      }
      //假如不小于corePoolSize,则将使命增加到workQueue行列。
      if(isRunning(c)&&workQueue.offer(command)){
      intrecheck=ctl.get;
      if(!isRunning(recheck)&&remove(command))
      reject(command);
      elseif(workerCountOf(recheck)==0)
      addWorker(null,false);
      }
      //假如放入workQueue失败,则创立线程履行使命,假如这时创立线程失败(当时线程数不小于maximumPoolSize时),就会调用reject(内部调用handler)回绝承受使命。
      elseif(!addWorker(command,false))
      reject(command);
      }
      AddWorker办法:
      创立Worker目标,一起也会实例化一个Thread目标。在创立Worker时会调用threadFactory来创立一个线程。
      然后启动这个线程。
      2.3.1线程池中CTL特点的效果是什么?
      看源码榜首反响便是这个CTL到底是个什么东东?有啥用?一番研究得出如下定论:
      CTL特点包括两个概念:
      privatefinalAtomicIntegerctl=newAtomicInteger(ctlOf(RUNNING,0));
      privatestaticintctlOf(intrs,intwc){returnrs|wc;}
      runState:即rs标明当时线程池的状况,是否处于Running,Shutdown,Stop,Tidying。
      workerCount:即wc标明当时有效的线程数。
      咱们点击workerCount即作业状况记载值,以RUNNING为例,RUNNING=-1<
      privatestaticfinalintCOUNT_BITS=Integer.SIZE-3;
      既然是29位那么便是Running的值为:
      11100000000000000000000000000000
      |||
      31~29位
      那低28位呢,便是记载当时线程的总线数啦:
      //Packingandunpackingctl
      privatestaticintrunStateOf(intc){returnc&~CAPACITY;}
      privatestaticintworkerCountOf(intc){returnc&CAPACITY;}
      privatestaticintctlOf(intrs,intwc){returnrs|wc;}
      从上述代码可以看到workerCountOf这个函数传入ctl之后,是经过CTL&CAPACITY操作来获取当时运转线程总数的。
      也便是RunningState|WorkCount&CAPACITY,算出来的便是低28位的值。由于CAPACITY得到的便是高3位(29-31位)位0,低28位(0-28位)都是1,所以得到的便是ctl中低28位的值。
      而runStateOf这个办法的话,算的便是RunningState|WorkCount&CAPACITY,高3位的值,由于CAPACITY是CAPACITY的取反,所以得到的便是高3位(29-31位)为1,低28位(0-28位)为0,所以经过&运算后,所得到的值便是高3为的值。
      简略来说便是ctl中是高3位作为状况值,低28位作为线程总数值来进行存储。
      2.3.2shutdownNow和shutdown的差异
      看源码发现有两种近乎相同的办法,shutdownNow和shutdown,规划者这么规划天然是有它的道理,那么这两个办法的差异在哪呢?
      shutdown会把线程池的状况改为SHUTDOWN,而shutdownNow把当时线程池状况改为STOP。
      shutdown只会中止一切空闲的线程,而shutdownNow会中止一切的线程。
      shutdown返回办法为空,会将当时使命行列中的一切使命履行完毕;而shutdownNow把使命行列中的一切使命都取出来返回。
      2.3.3线程复用原理
      finalvoidrunWorker(Workerw){
      Threadwt=Thread.currentThread;
      Runnabletask=w.firstTask;
      w.firstTask=null;
      w.unlock;//allowinterrupts
      booleancompletedAbruptly=true;
      try{
      while(task!=null||(task=getTask)!=null){
      w.lock;
      //Ifpoolisstopping,ensurethreadisinterrupted;
      //ifnot,ensurethreadisnotinterrupted.This
      //requiresarecheckinsecondcasetodealwith
      //shutdownNowracewhileclearinginterrupt
      if((runStateAtLeast(ctl.get,STOP)||
      (Thread.interrupted&&
      runStateAtLeast(ctl.get,STOP)))&&
      !wt.isInterrupted)
      wt.interrupt;
      try{
      beforeExecute(wt,task);
      Throwablethrown=null;
      try{
      task.run;
      }catch(RuntimeExceptionx){
      thrown=x;throwx;
      }catch(Errorx){
      thrown=x;throwx;
      }catch(Throwablex){
      thrown=x;thrownewError(x);
      }finally{
      afterExecute(task,thrown);
      }
      }finally{
      task=null;
      w.completedTasks++;
      w.unlock;
      }
      }
      completedAbruptly=false;
      }finally{
      processWorkerExit(w,completedAbruptly);
      }
      }
      便是使命在并不只履行创立时指定的firstTask榜首使命,还会从使命行列的中自己主动取使命履行,并且是有或许无时间限制的堵塞等候,以确保线程的存活。
      默许的是不允许。
      2.4CountDownLatch和CyclicBarrier差异
      countDownLatch是一个计数器,线程完成一个记载一个,计数器递减,只能只用一次。
      CyclicBarrier的计数器更像一个阀门,需求一切线程都抵达,然后持续履行,计数器递加,供给Reset功用,可以多次运用。
      3.多线程间通讯的几种办法
      提及多线程又不得不提及多线程通讯的机制。首要,要短信线程间通讯的模型有两种:共享内存和消息传递,以下办法都是基本这两种模型来完成的。咱们来基本一道面试常见的标题来分析:
      标题:有两个线程A、B,A线程向一个集合里面顺次增加元素"abc"字符串,一共增加十次,当增加到第五次的时分,希望B线程可以收到A线程的告诉,然后B线程履行相关的业务操作。
      3.1运用volatile关键字
      packagethread;
      /**
      *
      *@authorhxz
      *@deion多线程测验类
      *@version1.0
      *@data2020年2月15日上午9:10:09
      */
      publicclassMyThreadTest{
      publicstaticvoidmain(String[]args)throwsException{
      notifyThreadWithVolatile;
      }
      /**
      *界说一个测验
      */
      privatestaticvolatilebooleanflag=false;
      /**
      *计算I++,当I==5时,告诉线程B
      *@throwsException
      */
      privatestaticvoidnotifyThreadWithVolatilethrowsException{
      Threadthc=newThread("线程A"){
      @Override
      publicvoidrun{
      for(inti=0;i<10;i++){
      if(i==5){
      flag=true;
      try{
      Thread.sleep(500L);
      }catch(InterruptedExceptione){
      //TODOAuto-generatedcatchblock
      e.printStackTrace;
      }
      break;
      }
      System.out.println(Thread.currentThread.getName+"===="+i);
      }
      }
      };
      Threadthd=newThread("线程B"){
      @Override
      publicvoidrun{
      while(true){
      //防止伪唤醒所以运用了while
      while(flag){
      System.out.println(Thread.currentThread.getName+"收到告诉");
      System.out.println("dosomething");
      try{
      Thread.sleep(500L);
      }catch(InterruptedExceptione){
      //TODOAuto-generatedcatchblock
      e.printStackTrace;
      }
      return;
      }
      }
      }
      };
      thd.start;
      Thread.sleep(1000L);
      thc.start;
      }
      }
      个人以为这是基本上最好的通讯办法,由于A发出告诉B可以立马承受并DoSomething。

  • 相关阅读:
    目前流行前端几大UI框架排行榜
    vue nginx配置
    快速切换npm源
    vue项目打包部署生产环境
    VScoed Vue settings.json配置
    java获取远程图片分辨率
    Fegin的使用总结
    线程池核心参数
    mysqldump定时任务生成备份文件内容为空解决方法
    对汉字编码
  • 原文地址:https://www.cnblogs.com/hite/p/13066221.html
Copyright © 2011-2022 走看看