一)Volitaile关键字的作用:
volatile的使用:常常用于一写多读的情况下,解决内存可见性和指令重排序
JAVA内存的JMM模型:主要是用来屏蔽不同硬件和操作系统的内存访问差异的,在不同的硬件和不同的操作系统内存的访问是有差异的,这种差异会导致相同的代码在不同的硬件和操作系统会有不同的行为,JMM内存模型就是为了解决这个差异,统一相同代码在不同硬件和不同操作系统的差异的
JAVA内存模型规定:所有的变量(包括普通成员变量和静态成员变量),都是必须存储在主内存里面,每一个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行,线程是不可以直接读写主内存的变量
但是Java的内存模型会带来一个新的问题,那就是说当某一线程修改了主内存共享变量的值之后,那么其他线程可能就不会感知到此值被修改了,它会一直使用工作内存的旧值,这样程序的执行就不会符合我们的预期了
内存可见性:指的是多个线程同时进行操作同一个变量,其中某一个线程修改了变量的值之后,其他线程无法进行感知变量的修改,这就是内存可见性问题
关键字volitaile和synchronized就可以强制保证接下来的操作是在操作内存,在生成的java字节码中强制插入一些内存屏障的指令,这些指令的效果,就是强制刷新内存,同步更新主内存和工作内存中的内容,在牺牲效率的时候,保证了准确性
synchronized,双重if,volatile
指令重排序是指编译器或者CPU优化程序的一种手段,调整指令执行的先后顺序,提高程序的执行性能,但是在多线程情况下会出现问题
1)之前咱们在说volatile的时候是说,此处的volatile是为了保证让其他线程修改了这里面的instance之后,保证后面的线程可以及时感知到修改,因为其他线程不也是加上synchronized来进行修改的吗?
2)当我们去执行instance=new instance()的时候,我们本质上干了三件事情
2.1)创建内存
2.2)针对内存空间进行初始化
2.3)把内存的地址赋值给引用
3)上面的这三个步骤可能会触发指令重排序,也就是说乱序执行,这里的执行顺序,可能是1,2,3,也可能是1,3,2,可能就是说把地址空间赋给引用了,然后再进行初始化;
咱们加上了volatile就可以保证这里面的指令就是按照1,2,3的顺序来进行执行的,保证其他线程拿到的实例也是一个完整的实例
private Singleton(){}; private static Singleton singleton=null; public static Singleton GetInstance(){ if(singleton==null){ synchronized(Object.class){ if(singleton==null){ singleton=new Singleton(); } } } return singleton; } }
单例模式适用于经常被访问的对象
或者是创建和销毁需要需要进行调用大量资源和时间的对象
1)创建一个私有的构造方法:防止外部直接new破坏单例模式
2)创建一个私有变量static保存该单例对象
3)提供公开的static方法返回单例对象
饿汉模式:在类加载的时候直接创建并进行初始化对象,在程序启动的时候只进行加载一次
实现简单,不存在线程安全问题,但是因为类加载的时候就创建了该对象
创建之后如果没有进行使用,那么就造成了资源浪费,依赖的是classLoader机制
懒汉模式:延迟加载,只有被使用的时候,才会被初始化
枚举:在第一次被使用的时候,才可以被JAVA虚拟机进行加载并初始化,所以他也是线程安全,并且是懒加载
enum TestEnum{//不要加class RED,Blue;//加上分号 public static TestEnum GetInstance(){//返回类型是你自定义的类名,不是enum return RED; } }
二)synchronized的底层实现原理:
synchronized底层是通过JVM内置的监视器锁来实现的,而监视器锁有是依靠于操作系统的底层mutex互斥量来实现的,进入到synchronized修饰的代码,相当于加了moniterenter,结束synchronized修饰的代码,相当于是moniterexit
监视器:监视器是一种机制,用来进行保障任何时候,都只有一个线程来进行执行指定区域的代码
1)一个监视器就类似于一个建筑,建筑里面有一个特殊的房间,这个房间同一时刻只能被一个线程所占有,一个线程从进入到该房间到离开该房间,可以全程占有该房间的所有数据;
2)进入该建筑叫做进入监视器,进入该房间叫做获得监视器,独自占有该房间叫做拥有监视器,离开该房间叫做释放监视器,离开该建筑叫做退出监视器
synchronized修饰的代码块,进入到代码块被moniterenter,然后退出代码块moniterexit
监视器锁就是类似于一个房间,同一时刻只会允许一个人进来,在任何时候都是只能有一个人进来,是依靠ObjectMoniter实现的
1)_recursions是某一个线程某一次重复获取到锁的次数,可重入锁代表某一个线程可以重复的获取锁,因为synchronized是可重入锁,线程是可以重复的获取到这把锁,那么某一个线程每一次获取到锁的时候,计数器就会记录该线程和获取到锁的次数,每获取到一次锁,进入到这个房间,_recursions++,每当离开这个房间一次,那么这个计数器就--,当_recursions=0的时候,说明此时这个监视器是没有人的,就放开房间让其他线程进入
2)count记录每一个线程获取到锁的次数,就是前前后后这个这个线程一共获取这把锁多少次
3)_owner:The Owner的拥有者,是持有该ObjectMonitor监视器对象的线程;
4)_EntryList:EntryList监控集合,存放的是处于阻塞状态的线程队列,在多线程情况下,竞争失败的线程会进入到EntryList阻塞队列;
5)WaitSet:存放的是处于wait状态的线程队列,当线程拥有监视器锁得时候调用到了wait()方法之后,会自动释放监视器锁,this.owner=null,释放监视器锁的线程会进入到waitSet队列,
监视器的执行流程如下:
1)线程通过CAS(对比并进行替换)尝试获取该锁,如果获取成功,那么将owner字段设置成当前线程,表明该线程已经持有这把锁,并将_recursions冲入次数的属性+1,如果获取失败就先通过自旋CAS来进行获取该锁,如果还是失败那么就把当前线程放入到EntryList监测队列,进入到阻塞状态;
2)当拥有锁的线程执行了wait方法之后,调用wait的线程释放锁,将owner变量设置成null状态,同时把该线程放入到waitSet带授权队列中等待被唤醒;
3)当调用某一个拥有监视器锁的线程调用notify方法时,随机唤醒WaitSet队列中的某一个线程来尝试获取锁,等待拥有监视器锁的调用notify的线程释放锁后,当调用notifyAll时随机唤醒所有WaitSet的队列的线程尝试获取该锁;
4)当拥有监视器的线程执行完了释放锁之后,会唤醒EntryList中所有线程尝试获取到该锁;
wait方法也是可以指定休眠时间的,比如说现在有两个线程,线程1进入到了synchronized修饰的方法之后,调用wait方法的那一刻,线程1会放弃synchronzied的那把锁,线程1从进入到waitting状态,线程2获取到了同一把锁,然后执行对象的notifyAll方法,执行完线程2的synchronized方法之后线程2释放锁,然后去尝试唤醒所有wait的线程,然后所有的wait的线程都去尝试争夺这同一把锁,但是如果是线程2调用的是notify方法,然后其他wait的线程只会被唤醒一个,然后尝试获取到锁执行;
三)说一说synchronized锁升级的流程:
偏向锁,指的是偏向某一个线程,指的是所有的线程来了之后会进行判断,对象头中的头部保存当前拥有的锁的线程ID,判断当前线程ID是否等于_owner的线程ID,等于说明你拥有这个线程,就可以进入执行
1)无锁:刚一开始的时候,没有线程访问synchronized修饰的代码,说明此时是处于无锁状态
2)偏向锁:当某一个线程第一次访问同步代码块并获取到这把锁的时候,锁的对象头里面将线程的ID记录下来,下一次再有线程过来的时候,程序会直接判断对象头中的线程ID(第一次访问锁的线程ID)和实际访问程序的线程ID是否相同,如果是同一个,那么程序会继续向下访问,如果不相同,说明有两个线程以上进行争夺锁,于是尝试通过CAS获取到这把锁,如果获取不到,就升级成轻量级锁
3)轻量级锁:这个还没有放弃挣扎,还会通过自旋的方式尝试得到锁,如果通过一定的次数得不到锁,因为synchronized是自适应自旋锁,synchronized是根据上一次自旋的结果来去决定这一次自旋的次数的,如果这个线程是通过上一次自旋来获取到锁的话,那么会有极大的大概率这一次也是可能通过自旋的方式来获取到锁的,如果上一次获取次数也比较少,那么这一次自旋的次数也会变少,如果一定的自旋次数获取不到锁,直接阻塞到EntryList
4)重量级锁:升级成重量级锁
四)synchronized是固定自旋次数吗?
synchronized本身是一个自适应自旋锁,自适应自旋锁指的是线程尝试获取到锁的次数不是一个固定值而是一个动态变化的值,这个值会根据前一次线程自旋的次数获取到锁的状态来决定此次自选的次数,比如说上一次通过自选成功的获取到了锁,那么synchronized会自动判断通过这一次自旋获取到锁的概率也会大一些,那么这一次自旋的次数就会多一些,如果通过上一次自旋没有成功获取到锁,那么这一次成功获取到锁的概率也会变得非常低,所以为了避免资源的浪费,就会少循环或者是不循环,简单来说就是如果这一次自旋成功了,下一次自旋的次数会多一些,否则下一次自选的次数会少一些
五)线程通讯的方法都有哪些?
线程通讯指的是多个线程之间通过某一种机制进行协调和交互,例如线程等待和通知机制就是线程通讯的主要手段之一,就是一个线程休眠了,另外一个线程进行唤醒,每一个等待唤醒的手段都是有着不同的应用场景,下一个唤醒手段就是上一个唤醒手段的补充
1)wait和notify使用必须和synchronized搭配一起使用,况且wait会主动释放锁;
2)可以唤醒加了同一把锁下面的两个不同的线程组,Condition可以有更多的分支,能唤醒的更加精准,每一组线程都可以使用一个Condition来进行等待和唤醒,生产者不要唤醒生产者消费者不要唤醒消费者,在生产者里面可以调用消费者的Condition2进行唤醒
3)可以指定某一个线程来唤醒,LockSupport.park()休眠当前线程,park和unpark本身就是静态方法,LockSupport.unpark(线程对象),LockSupport可以不搭配synchronized和lock来结合使用,这里面得park方法那个线程调用LockSupport.park()方法,拿一个线程就会阻塞
2)一个lock可以创建多个Conidtion此时就可以调用Condition的await()方法和signal()方法
一个Lock可以创建多个Condition对象,搞一个Condition叫做生产者,再Condition搞一个叫做消费者,可以有更多的分支,唤醒就变的更加的精准,每一组线程可以使用一个Condition来进行等待和唤醒的操作,分两组绑定Condition;
2.1)一堆生产者可以使用一个Condtion对象1来进行唤醒,可以使用Condition对象1调用await()方法进行休眠生产者,如果想要唤醒生产者,就可以调用Condition对象1的signal来唤醒生产者
2.2)一堆消费者可以使用一个Condtion对象2来进行唤醒,可以使用Condition对象2调用await()方法进行休眠消费者,如果想要唤醒消费者,就可以调用Condition对象1的signal来唤醒消费者
2.3)但是生产者和消费者加的都是同一把锁,这样使用Condition类就可以唤醒加了同一把锁的两组线程进行唤醒了,可以指定的某一组线程中的某一个线程进行唤醒
但是两堆生产者和消费者都是加的同一把锁,所以就可以根据哪一个Condition对象来唤醒的是生产者还是消费者,也是随机唤醒,但是也是可以指定唤醒那一组,是生产者还是消费者,但是wait和notify一个锁,一个对象只能有一组,同时生产者也是可以调用消费者的一个Condition进行唤醒了
1)现在有一个生产者消费者模型,生产者会产生一些任务存放到任务队列中,消费者是从任务队列中取出任务进行消费执行,生产者和消费者都是一组线程;
2)没有任务,生产者休眠,为了保证资源不被浪费,消息队列没有任务,消费者也会休眠,假设生产者线程组的某一个生产者有任务开始就开始被唤醒将任务放到消息队列里面,此时被唤醒的生产者将任务推动到消息队列里面,第二步就是休眠唤醒消费者去消费任务,如果此时使用的是Object中的唤醒机制,是将加了锁的线程随机唤醒,此时就会发生严重的问题,此时可能唤醒的是生产者和消费者,因为生产者和消费者加的是同一把锁,如果是唤醒的是生产者,此时会浪费资源,可能会导致消费者永远也不会消费消息队列中的元素
public class DemoWorld { public static void main(String[] args) throws InterruptedException { Thread t1=new Thread(()->{ System.out.println("线程1开始阻塞"); LockSupport.park(); System.out.println("线程1继续执行"); }); Thread t2=new Thread(()->{ System.out.println("线程2开始阻塞"); LockSupport.park(); System.out.println("线程2继续执行"); }); Thread t3=new Thread(()->{ LockSupport.unpark(t1); }); t1.start(); t2.start(); Thread.sleep(3000); t3.start(); } }
六)读写锁:创建读写锁,提高程序的执行性能,适用于读多写少
读写锁是将一把锁分成两部分,读锁和写锁,读锁是允许多个线程同时获得的,因为读操本身就是线程安全的,而写锁是互斥锁,是不允许多个线程同时获得些写锁的,况且写操作和读操作也是互斥的,读读不互斥,写写互斥,读写互斥
1)提高了程序执行的性能,多个读锁可以同时进行,相对于普通锁来说在任何情况下都要排队执行来说,读写锁提高了并发程序的执行性能
2)避免读到临时数据,读锁和写锁是互斥排队执行的,这样就保证了读取操作不会读到写一半的临时数据
多个线程获取到读锁,称之为读读不互斥,一个线程不能同时获取到读锁和写锁,写锁和写锁之间进行互斥
1)读读不互斥
public static void main(String[] args) throws InterruptedException { final ReentrantReadWriteLock commonLock=new ReentrantReadWriteLock(); final ReentrantReadWriteLock.ReadLock readLock=commonLock.readLock();//获取到读写锁中的读锁 final ReentrantReadWriteLock.WriteLock writeLock= commonLock.writeLock();//获取到读写锁中的写锁 Thread t1=new Thread(()->{ try { readLock.lock(); System.out.println("线程1获取到了读锁"); }finally { readLock.unlock(); System.out.println("线程1释放了读锁"); } }); Thread t2=new Thread(()->{ try { readLock.lock(); System.out.println("线程2获取到了读锁"); }finally { readLock.unlock(); System.out.println("线程2释放了读锁"); } }); t1.start(); t2.start(); }
2)读写互斥,可以看到一个线程不能同时获取到读写锁中的读锁和写锁
public static void main(String[] args) throws InterruptedException { final ReentrantReadWriteLock commonLock=new ReentrantReadWriteLock(); final ReentrantReadWriteLock.ReadLock readLock=commonLock.readLock();//获取到读写锁中的读锁 final ReentrantReadWriteLock.WriteLock writeLock= commonLock.writeLock();//获取到读写锁中的写锁 Thread t1=new Thread(()->{ try { writeLock.lock(); System.out.println("线程1获取到了写锁"); }finally { writeLock.unlock(); System.out.println("线程1释放了写锁"); } }); Thread t2=new Thread(()->{ try { readLock.lock(); System.out.println("线程2获取到了读锁"); }finally { readLock.unlock(); System.out.println("线程2释放了读锁"); } }); t1.start(); t2.start(); }
public class DemoWorld { public static void main(String[] args) throws InterruptedException { final ReentrantReadWriteLock commonLock=new ReentrantReadWriteLock(); final ReentrantReadWriteLock.ReadLock readLock=commonLock.readLock();//获取到读写锁中的读锁 final ReentrantReadWriteLock.WriteLock writeLock= commonLock.writeLock();//获取到读写锁中的写锁 Thread t1=new Thread(()->{ try { writeLock.lock(); System.out.println("线程1获取到了写锁"); }finally { writeLock.unlock(); System.out.println("线程1释放了写锁"); } }); Thread t2=new Thread(()->{ try { writeLock.lock(); System.out.println("线程2获取到了写锁"); }finally { writeLock.unlock(); System.out.println("线程2释放了写锁"); } }); t1.start(); t2.start(); } }
七)公平锁和非公平锁有什么区别?
公平锁:每一个线程获取到锁的的顺序总是按照线程访问锁的先后顺序来进行获取的,最前面访问锁的那个线程总是能最先获取到锁
非公平锁:每一个线程获取锁的顺序是随机的,并不会遵循先来后到的规则,所有线程会竞争并获取锁
公平锁的运行原理:
3.1)获取到锁的时候,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会先唤醒等待队列的队首的线程去获取到锁,锁的使用顺序就是队列中的先后顺序,在整个过程中,线程会从运行状态切换成休眠状态,再从休眠状态变成运行状态
3.2)在整个过程中,线程每一次休眠和恢复都需要进行用户态和内核态的切换,这个状态的转换是比较慢的,更注重的是资源的平均分配,但是按序唤醒线程的开销比较大,所以公平锁执行效率比较慢
非公平锁的运行原理:
4.1)当线程尝试获取到锁的时候,会先通过CAS来进行尝试获取到锁,如果获取到锁就直接拥有锁,如果锁获取失败就进入到阻塞队列,等待下一次获取到锁,获取到锁不用遵循先来后到的规则,避免线程恢复和休眠的操作,加速了程序的执行效率,不用遵循先来先到的规则
4.2)非公平锁的吞吐率(单位时间内获取到锁的速率)要比公平锁的概率更高,但是可能会出现线程饿死的情况,资源分配随机性比较强
八)JUC包下面的Exchange交换器:实现两个线程之间的数据交换的
1)exchange(V x):等待另一个线程到达此交换点,然后将对象传输给另一个线程,并从另一个线程中得到交换的对象,如果另一个线程未到达交换点,那么调用exchange得线程会一直进行休眠除非遇到了线程中断;
2)exchange(V x,long timeout,Timeunit unit):等待另一个线程到达交换点,然后将这个对象传输给另一个线程了,并从另一个线程中得到要交换的对象,如果说另一个线程未达到次交换点,那么此线程会一直进行休眠,直到遇到了线程中断,或者等待的时间超过了设定的时间,那么会直接抛出异常;
3)也就是说exchange方法到达了一个交换点之后,线程会在这个交换点进行休眠等待,直到另一个线程也调用了exchange方法,他们会进行相互交换数据,然后会执行后续的代码
4)Exchange是用来实现两个线程之间的数据交换的,它可以进行传输任意类型的数据,只需要在进行创建的时候定义泛型类型就可以了,它的核心方法是exchange方法
当线程执行到这个方法之后,当前线程会执行休眠操作,会进行等待另一个线程进行这个交换点,如果说另一个线程进入到了交换点,那么两者会进行交换数据,并执行接下来的流程
class Person{ public String username; public String desc; } public class DemoWorld { public static void main(String[] args) throws InterruptedException { Exchanger<Person> exchanger=new Exchanger<>(); Thread t1=new Thread(()->{ Person person1=new Person(); person1.username="线程1"; person1.desc="我是在t1线程创建的,现在过得很好"; try { Thread.sleep(1000); Person person=exchanger.exchange(person1);//此时交换完成之后获取到了线程2的person2 Thread.sleep(1000); System.out.println("当前打印的线程是"+Thread.currentThread().getName()+person.username+person.desc); } catch (InterruptedException e) { e.printStackTrace(); } },"线程1"); Thread t2=new Thread(()->{ Person person2=new Person(); person2.username="线程1"; person2.desc="我是在t1线程创建的,现在过得很好"; try { Thread.sleep(1000); Person person=exchanger.exchange(person2);//此时交换之后获取到了线程1中的person1 Thread.sleep(1000); System.out.println("当前打印的线程是"+Thread.currentThread().getName()+person.username+person.desc); } catch (InterruptedException e) { e.printStackTrace(); } },"线程2"); t1.start(); t2.start(); } }
九)进程和线程有什么区别?
上下文,状态,优先级,记账信息不共享,操作系统的调度器会非常频繁的进行线程切换,哪怕某个进程做某个工作做了一半,也有可能被打断;
单个CPU已经达到极限了,多核CPU代替单核CPU
在代码执行任务的时候,先把任务进行拆分,又有多个CPU来并发式的执行
什么情况下会造成线程从用户态到内核态的切换呢?
1)首先,如果在程序运行过程中发生中断或者异常,系统将自动切换到内核态来运行中断或异常处理机制
2)此外,程序进行系统调用也会从用户态切换到内核态
1)进程包含线程,如果将进程比作工厂,那么线程就是工厂中的若干流水线
2)创建线程比创建进程更轻量,销毁线程比销毁进程更轻量,调度线程比调度进程更轻量
3)切换速度不同:线程切换上下文速度是很快的,但是进程的上下文切换速度比较慢
4)操作系统创建进程,要给进程分配资源,进程是操作系统进行资源分配的最小单位,操作系统创建的线程,是要在CPU上面进行调度执行,线程是操作系统进行调度执行的最小单位
5)进程具有独立性,进程与进程之间资源不共享,每一个进程都有自己的虚拟地址空间,同一个进程的多个线程之间,共用这一块虚拟地址空间,一个进程挂了,不会影响到其他进程,但是同一个进程的多个线程,是在用同一个虚拟内存空间,一个线程挂了,是可能影响到其他线程的,甚至可能会导致整个进程崩溃
十)start和run方法有什么区别?
run只是一个普通的方法,描述了任务的内容,start是一个特殊的方法,会在系统中创建线程
1)方法性质不同:调用start方法可以直接启动线程,并使线程进入就绪,当run方法执行完了,线程,也就结束了,但是如果直接执行run方法,会当作普通方法来调用,还是在main方法进行的,不会创建一个新线程;
2)执行速度不同:run方法也叫作线程体,它里面包含了具体要执行的业务代码,当进行调用run方法的时候,会立即执行run方法的代码,但是当我们调用start方法的时候,本质上是启动了一个线程并将这个线程的状态设置为就绪状态,也就是说调用start()方法,程序不会立即执行
3)调用次数不同:run方法是普通方法,普通方法是可以被调用多次,但是start方法是创建新线程执行任务,而start方法只能调用一次,否则就会出现IllegalThreadStateException非法线程状态
为什么start方法只能调用一次呢?
原因是当start代码实现的第一行,会先进行判断当前的状态是不是0,也就是说是否是新建状态,如果不是新建状态NEW,那么就会抛出IllegalThreadStateException非法线程状态异常
当线程调用了第一个start方法之后,线程的状态就会由新建状态NEW变成RUNNABLE状态,此时再次调用start方法,JVM就会判断当前线程已经不等于新建状态了,从而会抛出IllegalThreadStateException异常,所以线程状态是不可逆的;
十二)synchronized的三种用法
1)修饰普通方法:加在访问修饰限定符,方法返回值之间
public synchronized void method(){};修饰普通方法,作用的对象是调用这个方法的对象
2)修饰静态方法:public static synchronized void staticMethod{};,当synchronized修饰静态方法的时候,锁的是类对象,这个锁对于所有调用这个锁的对象都是互斥的:注意,当修饰静态方法的时候,所有调用这个静态方法的对象都是互斥的,但是普通方法是指对对象级别的,不同的对象有着不同的锁
3)修饰代码块:在我们的日常开发中,最常用的是给代码块加锁,而不是给方法进行加锁,因为给方法进行加锁,相当于是给整个方法全部进行加锁,这样的话锁的粒度就太大了,程序的执行性能就会受到影响,加锁的对象常用this或者xxx.class这样的形式来进行表示
十三)线程的中断:
线程的中断,核心就是让线程的入口函数也就是run方法执行完毕,它指的是内存中的线程结束了,而不一定是Thread对象销毁;
十四)wait和sleep有什么区别?
wait方法和sleep方法都是用来将线程进入到休眠状态的,并且咱们的sleep方法和wait方法都是可以响应interrupt中断,也就是说在线程进行休眠的过程中,如果收到interrupt的中断信号,都可以进行响应并进行中断,并且都可以抛出InterruptedException异常
1)wait 方法属于 Object 类的方法,而 sleep 属于 Thread 类的方法
2)语法使用不同,wait必须和synchronized一起进行搭配使用,否则就会抛出IIIegalMonitorStateException异常,而sleep无需和synchronized一起使用;
3)wait会自动进行释放锁,调用wait的线程会主动进入到waitset队列里面,但是sleep不会主动释放锁,sleep在休眠状态并不会释放锁;
4)调用sleep方法会自动进入到time-waitting状态,但是调用wait方法会进入到waitting状态
5)等待机制:sleep是指定一个固定的时间去进行阻塞等待,wait既可以指定时间,又可以无限进行等待
6)唤醒机制:wait唤醒是可以通过notify机制或者interrupt或者时间到来进行唤醒,sleep通过时间到或者interrupt来唤醒;
7)方法设计初衷:wait的作用主要是为了协调线程之间的先后顺序,这样的场景并不适合sleep,sleep只是为了让线程休眠,并不会涉及到多个线程之间的配合