多线程常见问题整理

目录

一.什么是进程?什么是线程?两者之间的区别?

二.Java中如何实现多线程?

三.start()和run()的区别?

四.volatile是什么?

五.线程的生命周期?

六.wait()

七.notify()和notifyAll()

八.生产者消费者模型

九.什么是线程池?为什么要使用线程池?

十.Java内存模型

十一.如何停止线程?

十二.什么是原子操作类?

十三.线程安全集合类

十四.阻塞队列

十五.悲观锁和乐观锁

十六.CountDownLatch


一.什么是进程?什么是线程?两者之间的区别?

进程是拥有一定功能的关于某个数据集合上的一次运动,是系统进行资源分配和调度的一个单位

线程是进程的实际运作单位,是一个进程中的执行场景,是CPU调度的最小单位

举个例子,使用谷歌浏览器,谷歌浏览器就是一个进程,而很多人用它上网,就是很多个线程

多线程提高了程序的使用率,多进程提高了CPU的使用率

区别:

1.线程是进程的子集,一个进程可以有很多线程,每条线程执行不同的任务

2.不同的进程使用不同的内存空间,而所有线程共享一片进程的内存区域(注意不要和栈搞混,每个线程都拥有单独的栈内存用来存储本地数据)

3.一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃掉整个进程都死掉,所以多进程比多线程健壮。但是进程切换时,消耗 的资源大,效率高,所以涉及到频繁的切换时,使用线程好于进程。如果要求同时进行并且又要共享某些变量的并发操作,只能用线程而不能用进程

二.Java中如何实现多线程?

1.继承Thread类,并重写里面的run()方法

2.实现Runnable接口,重写run()方法,作为参数传给Thread(更灵活,且可以多继承)

3.实现Callable接口,重写call()方法,通过FutureTask包装器来创建Thread线程(可以抛出其他异常且可以获得返回值)

4.定时器,可以定时的来执行某个任务

   创建Timer对象,调用schedule方法,将TimeTask对象作为参数传入方法中,然后重写run()方法

5.基于线程池的方式。使用Executors工具类创建线程池,execute()方法提交任务,用Runnable作为参数,重写run()方法

三.start()和run()的区别?

start()方法用于启动线程,且内部调用了run()方法,真正实现了多线程

run()称为线程体,它包含了要执行的这个线程的内容,run()运行结束,此线程终止,然后CPU再调度其它线程。如果不使用start(),那么程序仍然会执行,只是被当做普通方法了,这样就没有达到多线程的目的

四.volatile是什么?

volatile关键字是一个特殊的类型修饰符,只有成员变量才可以使用它。能确保本条指令不会因为编译器的优化而省略

在A线程内,当读取一个变量时,为了提高存取速度,编译器会把变量存到高速缓存区当中,以后再取变量值时,就直接从高速缓存区里面取,当变量在线程B中的值改变了,但是线程A中的高速缓存区并不知道,所以的值仍然不变,这样就会造成程序读取的数据和实际的变量不一致

而volatile的出现就保证了数据的可见性,编译器就对访问该变量的代码不再进行优化,从而达到稳定访问

五.线程的生命周期?

多线程常见问题整理_第1张图片

线程进入就绪状态之后,就有时间去抢夺CPU时间片,这个时间片就是执行权,抢到之后就进入了运行状态奶,当时间用完但是运行还没有结束时,就会又返回到就绪状态,继续抢夺时间片,然后继续未执行完的run(),直至方法执行完毕

六.wait()

让线程抢夺到时间片后,如果因为一些其他原因想让它暂停一会,那么调用wait()就可以使这个线程进入Wait Set休息室暂歇

多线程常见问题整理_第2张图片

但是需要注意,wait()是Object的方法,所以调用时obj对象进行调用,且要在锁内,如果没有获得对象,也就没有资格去调用,而且,使用wait()方法可能会抛出InterruptedException

public class WaitTest {
    static Object obj = new Object();
    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (obj) {
                System.out.println("执行了线程1的代码...");
                try {
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("结束了线程1的代码...");
            }
        }).start();
    }
}

如果没有人来唤醒被wait()的线程,那么它就会一直休息,但是wait()也有带参方法,带参的话,即参数就是等待的时间,如果线程超过等待时间还没有人去将它唤醒,线程就会自动离开休息室

七.notify()和notifyAll()

对于进入wait Set休息室的被wait()的线程,如果不想让它继续暂停,那么就需要将其进行唤醒,此时就可以使用上述两个方法

notify()是在Wait Set休息室中随机找一个线程,然后将其唤醒,然后被唤醒的线程重新竞争

notifyAll()则是将Wait Set里面的所有线程全部唤醒

wait()和notify()的典型应用有让线程顺序执行,生产者消费者模型等

八.生产者消费者模型

一边生产,一边消费,比如卖包子,一遍制作包子(生产者),一遍顾客买包子(消费者),两者的速度不一定匹配,比如生产者需要提前做好一部分包子,有一段时间买包子的速度也大于制作包子的速度;当然也不能无限生产包子,导致产品浪费。所以就需要先制作好一批包子,等消费者买走一部分再进行生产,所以中间需要一个容器存储这些已经生产好的包子

代码设计:利用wait()和notifyAll()实现

在第十四个知识整理,会有一个更高级的方式(阻塞队列)对生产者消费者模式进行实现

容器为一个集合,用于存放已生产的商品,容量为5

创建一个生产者线程,总数最多生产15个,如果当前生产量超过可以容纳的数量,则进入等待状态,只要生产就可以唤醒消费者

创建消费者线程,如果集合中的商品不为0,那么就可以进行消费,且消费了就可以唤醒生产者线程

import java.util.ArrayList;
import java.util.List;

public class ProducerAndConsumerTest {
    static List products = new ArrayList<>();

    public static void main(String[] args) {
        //生产者
        new Thread(() -> {
            for (int i = 1; i <= 15; i++) {
                synchronized (products) {
                    //容量不能超过5,否则等待
                    while (products.size() == 5) {
                        try {
                            products.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    Product p = new Product(i);
                    products.add(p);
                    products.notifyAll();
                    System.out.println(Thread.currentThread().getName() + "生产了产品" + p);
                    //添加商品之后,说明有商品了,可以唤醒消费者
                    products.notifyAll();
                }

            }
        }).start();

        //消费者1
        new Thread(() -> {
            for (int i = 1; i <= 8; i++) {
                synchronized (products) {
                    while (products.size() == 0) {
                        try {
                            products.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    Product p = products.remove(0);
                    System.out.println(Thread.currentThread().getName() + "消费了产品" + p);
                    //消费了产品之后,说明不再是最大容量,所以可以唤醒生产者继续生产
                    products.notifyAll();
                }
            }
        }).start();

        //消费者2
        new Thread(() -> {
            for(int i = 1; i <= 7; i++) {
                synchronized (products) {
                    while (products.size() == 0) {
                        try {
                            products.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    Product p = products.remove(0);
                    System.out.println(Thread.currentThread().getName() + "消费了产品" + p);
                    //消费了产品之后,说明不再是最大容量,所以可以唤醒生产者继续生产
                    products.notifyAll();
                }
            }
        }).start();
    }
}

class Product {
    private int i;

    Product(int i) {
        this.i = i;
    }

    @Override
    public String toString() {
        return "Product{" +
                "i=" + i +
                '}';
    }
}

九.什么是线程池?为什么要使用线程池?

加入有一个服务器,所有用户都可以进来做增删改查操作,加入有1000个用户要进行操作,那我们当然是不能让他们排队等候进行操作(等到头秃了),而是希望他们能够并行执行,所以就要创建1000个线程,每个线程服务一个用户。但是这样真的合理吗?答案显然不是的,线程也是需要占用系统资源的,系统不能无限制的创建线程,这是一种严重的资源浪费!

举个例子:餐馆有100个顾客,我们要对他们进行服务,但是我们不可能雇佣100个服务员分别对这些顾客服务(估计发不起工资),而是雇佣有限个服务员,当某个服务员有空闲了,再为下一个顾客进行服务,这其实就是线程池的思想。也是享元模式的一个重要体现

具体我们使用代码演示线程池的例子

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolTest {
    public static void main(String[] args) {
        //创建固定大小为3的线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(3);
        for(int i = 0; i < 6; i++) {
            //submit将任务提交给线程池,然后线程池等待空闲线程执行
            //使用lambda表达式,submit()里面自动实现了run()
            //使用线程池,不用我们去创建线程,start()也不需要我们自己去调用,线程池已经为我们做好了
            //当然,如果想要返回值,也可以将Callable作为参数传入submit()中
            threadPool.submit(() -> {
                System.out.println(Thread.currentThread().getName() + "任务执行");
            });
        }
    }
}

执行结果

多线程常见问题整理_第3张图片

其中有一个核心的ExecutorService的实现类ThreadPoolExecutor,有5个参数

int corePoolSize   核心线程数目, 也就是最少有几个线程

int maximumPoolSize     可以容纳的最多的线程数

long keepAliveTime      针对救急线程最多生存时间

TimeUnit  unit      时间单位    秒

BlockingQueue workQueue    阻塞队列   如何任务超过了核心线程数,进入队列进行排队,直到有空闲的线程

其中对于不同的场景我们需要创建不同类型的线程池

newFIxedThreadPool();    创建固定大小的线程池。其核心线程数=最大线程数,阻塞队列无界,可以放任意数量的任务。适用于执行多个长时间运行的任务

newCachedThread;     缓冲线程池。 没有核心线程数,即所有线程都是救急线程,最大线程数的最大值可以为int类型的最大值,可以认为是救急线程可以无线创建,而参数队列是来一个线程就会创建一个新线程。适用于任务书密集但每个任务执行时间较短

 newSingleThreadExecutor();     创建单线程线程池,创建以后值不能修改。即线程是串行执行的。适用于希望多个任务排队执行的场景

newScheduleThreadPool();      带有日程功能安排的线程池,即不是立刻执行的,而是可以设置在未来的某个时间执行(schedule())。也可以让线程按照某个周期执行(scheduleAtFixedRated())

十.Java内存模型

在说Java内存模型之前,我们先了解Java的内存结构,即运行时区域,参考https://blog.csdn.net/szy2333/article/details/88723095

而Java内存模型的主要目标是定义程序中各个变量的访问规则,即JVM中将变量存储到内存和从内存中取出变量这样的细节。

此处的变量有所不同步,它包含了实例字段,静态字段和构成数组对象的元素,但不包含局部变量和方法参数,因为后者是线程是私有的,不会共享,不存在数据竞争问题。为了获得较高的性能,Java内存模型并没有限制执行引起使用处理器的特定寄存器或者缓冲和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施

JMM规定了所有变量都储存在主内存(Main Memory)中,每个线程还有自己的工作内存(Working Memory),线程的工作内存保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方内存中的变量,线程之间值的传递都需要通过主内存来完成

多线程常见问题整理_第4张图片

线程1和线程2要进行数据的交换一般要经过两个步骤

1.线程1把工作内存1中更新过的共享变量刷新到主内存中去

2.线程2到主内存中读取线程1刷新过的共享变量,然后复制一份到工作内存2中去

Java内存模型则主要是围绕三个特征建立的,即原子性可见性有序性

原子性一个操作不能被打断,要么完全执行完毕,要么不执行(和事务原子性类似)

可见性一个线程对共享变量做了修改之后,其他线程立即能够看到变量的变化

a.可以使用volatile关键字实现可见性,volatile的特殊规则保证了变量修改后的新值立刻同步到工作内存中

b.使用synchronized关键字,在同步方法/同步块时,使用共享变量时会从主内存中刷新变量值到工作内存中,在结束时,会将工作内存中的变量值同步到主内存中去

c.使用Lock接口,常用ReentrantLock(重入锁)实现可见性。在方法开始位置执行lock.lock(),和synchronized开始位置一样

d.final关键字:被final修饰的变量,在构造方法一旦初始化完成,并且在构造方法中没有把this引用传递出去,那么其他线程就可以看到final的值(注意:this引用逃匿很危险。其他线程很可能只通过该引用访问到只初始化一般的对象)

有序性

对于一个线程内的代码而言,代码执行顺序是依次执行的,但是多线程时,程序的执行就会出现乱序(即指令重排现象和工作内存和主内存同步延迟现象)

Java提供了两个关键字volatile和synchronized来保证多线程之间的有序性。volatile关键字是本身通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现

十一.如何停止线程?

1正常情况下,run()和call()方法执行完的时候线程会自动结束

2.设置终止标志位,使线程正常退出,一般会配合while循环使用

3.使用stop()立即结束线程。但是由于线程无论执行到哪里,是否加锁,stop()都会立即杀死线程,无法保证原子操作能够顺利完成,存在数据错误的风险;同时,有可能对锁定的对象进行了解锁,导致数据得不到同步的处理,出现数据不一致的问题

4.suspend()将线程挂起,停止线程的运行,并未杀死线程。但挂起之后不会释放锁,这样,如果有其他多个线程在等待该锁,程序就会发生死锁现象

5.interrupt()终止线程。使用这种方法分为两种情况:线程处于阻塞状态,如使用了sleep(),或者使用while( ! isInterruptted())  {...}来判断线程是否中断

十二.什么是原子操作类?

在并发问题时,我们可以用两个线程共享一个变量,一个线程对其自增,一个对其自减,由于两个操作是多条指令,而不是一个原子性的动作,所以会造成多线程安全问题。我们之前使用加锁方式可以解决这个问题,但其实,我们也可以通过原子操作类来解决,即自增的指令看做是一个原子,自减也看做是一个原子,就不会发生指令交错

原子操作类有整型的,布尔的等等,我们利用整型原子做一个示范

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadConcurrentTest {
    //volatile static int count = 0;   //volatile是不能解决多个线程对一个数据的交错操作问题
    static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                //count++;
                count.getAndIncrement();
                //System.out.println(Thread.currentThread() + " : " + count);
            }
        });

        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                //count--;
                count.getAndDecrement();
                //System.out.println(Thread.currentThread() + " : " + count);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

操作结果输出总是0,说明确实做到了线程安全

十三.线程安全集合类

StringBuffer   线程安全

String     不可变类,都是线程安全的

Random     随机数类,加锁安全

Vector     实现了List,并且线程安全

HashTable     实现了Map,并且线程安全

5.0之后新增加、的安全集合类

CopyOnWriteArrayList    实现了List,线程安全且比Vector效率更高(下面会进行浅析)

ConcurrentHashMap   实现了Map,线程安全并且比HashTable效率更高(下面会进行浅析)

ConcurrentSkipListMap     实现了Map,线程安全并且可排序,和TreeMap类似,都是对key值进行排序

BlockingQueue      阻塞队列,实现了Queue,也是线程安全的(下一条进行详解)

CopyOnWriteArrayList

之前Vector也是线程安全的,但是由于它对读和写都是加锁的,导致了效率十分低下

而CopyOnWriteArrayList则是只对写(增删改)操作进行了加锁,而对于读不加锁,因此效率大大提高

那么它是怎么实现边读边写可以同时进行的呢?在这里,做了一个巧妙的处理,即修改的时候会将原来的旧数组copy一份,成为一个新的数组,如果同时有读和写两个操作,那么会在旧数组中进行读取操作,而在新数组中进行写的工作,写的工作完成后,抛弃旧数组。但是注意,同时进行写的操作是一定会加锁的,比如下图中线程3和线程4就是需要对新数组进行加锁操作

从实现原理上我们也不难理解,这种方式会在读操作多于写操作会大大提升效率,如果都是写操作,那么效率和Vector差不多

多线程常见问题整理_第5张图片

ConcurrentHashMap

首先,我们要知道HashMap的底层结构,即数组+链表,如下图,每次用hashcode定位到数组下标,下标相同则可以看成一个桶,每次put()和get()都需要先用hashcode先定位出桶的位置,再在桶中进行操作

HashTable也是基于Map实现的一个线程安全类,但是由于它是锁住了整个集合,造成了性能比较低,所以提出的所谓更高效率ConcurrentHashMap又是怎么样去实现的?

其实,ConcurrentHashMap相对于HashTable只是缩小了加锁的范围,HashTable是在整个集合中加了一把大锁,只要put()或者get(),都会将集合锁住,也就是所有操作都是串行执行的,所以效率低下。而ConcurrentHashMap则是给不同的桶进行加锁,比如线程1定位到了桶1,线程2定位到了桶3,那么这两个桶是各自执行桶内的操作,互不影响,即可以并发执行。只有在多个线程同时定位到一个桶中进行操作,才会对该桶进行加锁,所以,效率提高了很多!

多线程常见问题整理_第6张图片

十四.阻塞队列

首先了解队列,队列(Queue)是一种数据结构,遵循着先进先出的规则

阻塞队列(BlockingQueue),顾名思义,是队列的一种,但是在队列的基础上又支持了两个附加操作的队列,它们分别是:

支持阻塞的插入方法:队列满时,队列会阻塞插入元素的线程,直到队列不满

支持阻塞的移除方法:队列空时,获取元素的线程会等待队列变为非空

那么根据这种特性,我们就可以联想到一种模式:生产者消费者模式

为什么呢?在之前我们对于生产者消费者模式使用wait()和notifyAll()进行了一次实现。我们知道生产者等到生产达到饱和时,就会进入等待状态,直至有消费者进行了消费进行唤醒;同样的,消费者如果将生产的商品消费为空时,就会进入等待状态,直至有生产者生产了才会被唤醒。这个方式使用阻塞队列刚好,因为put()方法是达到阻塞队列要求的容量之后就会阻塞元素的插入,只会队列不为满,而take()则是当队列为空时阻塞,直至不为空

接下来我们对于代码进行一下具体实现

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

/*
    使用阻塞队列实现生产者消费者模型
* */
public class ProducerAndConsumerTest2 {
    static BlockingQueue queue = new LinkedBlockingQueue<>(5);
    public static void main(String[] args) {
        new Thread(() -> {
            for(int i = 1; i <= 15; i++) {
                Product p = new Product(i);
                try {
                    queue.put(p);
                    System.out.println(Thread.currentThread().getName() + "生产了商品" + p);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

        new Thread(() -> {
            for(int i = 1; i <= 7; i++) {
                try {
                    Product p = queue.take();
                    System.out.println(Thread.currentThread().getName() + "消费了商品" + p);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();

        new Thread(() -> {
            for(int i = 1; i <= 8; i++) {
                try {
                    Product p = queue.take();
                    System.out.println(Thread.currentThread().getName() + "消费了商品" + p);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

执行结果

多线程常见问题整理_第7张图片

十五.悲观锁和乐观锁

什么是悲观锁?

悲观锁就是总是假设最坏的情况,总觉得每次拿数据的时候会被别人修改,所以不管什么情况先上个锁再说,这样的话,只要别人想修改数据就只有等到他拿到锁才可以。之前学过的synchronized就是悲观锁,对于synchronized不再讨论

什么是乐观锁?

与悲观锁相反,它总是把别人都想的很好,每次去拿数据的时候别人不会修改自己的数据,所以不会上锁。而是拿数据之前先判断别人有没有对这个数据进行修改,如果修改了,那就会重新获取最新值,然后继续判断,直至确定当前值没有被修改再去操作。CAS体现的就是乐观锁

什么是CAS机制?

CAS就是compare and swap(没有使用synchronized),它不会给共享资源加锁,而是做一个尝试:先拿到旧值,查看旧值是否和共享区域的值相等,如果不等,那么说明别的线程改动了共享区域的值,我的操作失败;如果相等,那么就让我的操作成功。那么操作失败了怎么办?没关系,重新尝试就可以。之前用过的原子变量类里面的getAndSetInt()就使用的是这种CAS机制

获取源码并进行浅析

多线程常见问题整理_第8张图片

int var5;
//操作失败,没有关系,重新进行尝试
do {
    //获得共享区域内的最新值
    var5 = this.getIntVolatile(var1, var2);
    //比较并交换,如果比较后值相等,取反为false,跳出循环,并获得最新值var5,否则继续尝试
} while(!this.compareAndSwapInt(var1, var2, var5, var4));
return var5;

两种锁的使用场景

对于两种锁的介绍,我们可以看到两种各自有各自的好处。乐观锁适用于多读场景(即冲突真的很少发生的时候),这样就可以大大减少锁的开销,加大了系统的整个吞吐量。但是如果是多写的情况,一般就会经常产生冲突,这就会导致上层应用不断进行重新尝试,反而降低了性能,所以在多写的情况下,悲观锁就更适合使用一些

十六.CountDownLatch

CountDownLatch是一个同步工具类,它允许一个或者多个线程一直等待,直到其他线程执行完再执行

原理是通过一个计数器实现的,计数器的初始化值为线程的数量,每当一个线程完成来了自己的任务之后,计数器的值就相应减一。当计数器到达0时,表示所有线程都已完成任务,然后在闭锁上等待的线程就可以恢复执行任务

构造方法需要传一个count(int类型)参数,是用等待线程数量来进行初始化

计数器count是闭锁需要等待的线程数量,只能被设置一次,且CountDownLatch没有提供任何机制去重新设置计数器count

与CountDownLatch的第一次交互是主线程等待其他线程。主线程必须在其他线程启动后立即调用CountDownLatch.wait(),这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务

其他线程必须引用CountDownLatch闭锁对象,因为它们需要通知CountDownLatch对象。这种通知机制是通过CountDownLatch.countDown()来完成的,每调用一次,count的值减一,因此其它线程都调用这个方法,直到count为0,主线程就可以通过await()恢复自己的任务

实际应用:比如10个玩家进行王者荣耀时,为了保证游戏的公平性,就需要等待10个玩家都加载好以后才能进入游戏,那么可以用主线程来进行控制等待,十个玩家(十个线程)都进行了countDown()之后,主线程恢复运行,即可以开始游戏

代码实现

import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.CountDownLatch;

public class CountDownLatchTest {
    public static void main(String[] args) throws InterruptedException {
        //十个玩家,所以是十个计数
        CountDownLatch latch = new CountDownLatch(10);
        System.out.println("玩家准备");
        String[] str = new String[10];
        Random random = new Random();
        for(int i = 0; i <10; i++) {
            //因为匿名内部类只能调用外部被final修饰的常量,所以将i赋值给x
            final int x = i;
            //共创建十个线程
            new Thread(() -> {
                for(int j = 0; j <= 100; j++) {
                    try {
                        //睡眠时间使用随机数,实现游戏加载的不确定性
                        Thread.sleep(random.nextInt(100));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //锁住数组对象
                    synchronized (str) {
                        str[x] = (j + "%");
                        System.out.print("\r" + Arrays.toString(str));   //使用\r,跳到表头
                    }
                }
                //计数减一
                latch.countDown();   
            }).start();
        }

        latch.await();
        System.out.println("\n进入游戏");
    }
}

测试结果

多线程常见问题整理_第9张图片

 

 

你可能感兴趣的:(笔试整理)