一、多线程相关概念
1.1 进程
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。
- 进程就是用来加载指令、管理内存、管理IO的。
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
1.2 线程
1.2.1 什么是线程
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行。
- 一个进程之内可以分为一到多个线程。
进程和线程的区别:
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务。
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间。
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)。
1.2.2 线程状态
- 在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和限时等待六种。
- 当一个线程对象被创建,但还未调用start()时处于新建状态
NEW
。 - 调用了start(),就会由新建进入可运行状态
RUNNABLE
。如果线程内代码已经执行完毕,由可运行进入终结状态TERMINATED
。当然这些是一个线程正常执行情况。 - 如果线程获取锁失败后,由可运行进入Monitor的阻塞队列进入阻塞状态
BLOCKED
,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程再次进入可运行状态RUNNABLE
。 - 如果线程获取锁成功后,但由于条件不满足,调用了wait(),此时从可运行状态释放锁等待状态
WAITING
,当其它持锁线程调用notify()或notifyAll(),会恢复为可运行状态RUNNABLE
。 - 当线程中调用了sleep(long),也会从可运行状态进入限时等待状态
TIMED_WAITING
,不需要主动唤醒,超时时间到自然恢复为可运行状态RUNNABLE
。
1.3 并行与并发
1.3.1 单核CPU
- 单核CPU下线程实际是串行执行的。
- 操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。
- 总结为一句话就是: 微观串行,宏观并行。
- 一般会将这种线程轮流使用CPU的做法称为并发(concurrent)。
1.3.2 多核CPU
- 每个核(core)都可以调度运行线程,这时候线程可以是并行的。
- 并发:是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU。例如家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发。
- 并行:是同一时间动手做多件事情的能力。4核CPU同时执行4个线程。雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行。
- 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)。
二、线程的创建方式
2.1 继承Thread类
1. 定义子类MyThread继承Thread类,重写run()方法
package com.hkd.thread;
public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println("子线程执行输出"+ i);
}
}
}
2. main中创建MyThread类的对象,调用线程对象的start方法开启线程(启动后还是执行run方法)
package com.hkd.thread;
public class ThreadDemo1 {
public static void main(String[] args) {
// new一个新线程对象
Thread t = new MyThread();
// 调用start方法启动线程
t.start();
for (int i = 0; i < 5; i++) {
System.out.println("主线程执行输出"+ i);
}
}
}
疑问:
-
为什么不调用run(),而是调用start()开启线程?
直接调用run方法会当成普通方法执行,此时相当于还是单线程执行。只有调用start方法才是启动一个新的线程,调用run方法执行run方法中的逻辑代码。并且start方法只能被调用一次。
-
为什么子线程要放在主线程之前写?
如果主线程放在子线程之前了,主线程一直是先跑完的,相当于单线程了。
方法一的优缺点:
- 优点:编码简单。
- 缺点:线程类已经继承Thread类,无法继承其他类,不利于扩展。
2.2 实现Runnable接口
1. 定义一个线程任务类MyRunnable实现Runnable接口,重写run()方法
package com.hkd.thread;
public class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("子线程执行输出"+ i);
}
}
}
2. 创建MyRunnable任务对象,把MyRunnable任务对象交给Thread处理;调用线程对象的start方法开启线程。
package com.hkd.thread;
public class ThreadDemo1 {
public static void main(String[] args) {
// 创建一个任务对象
Runnable target = new MyRunnable();
// 把任务对象交给Thread处理
Thread t = new Thread(target);
// 开启线程
t.start();
for (int i = 0; i < 5; i++) {
System.out.println("主线程执行输出"+ i);
}
}
}
在创建线程的时候,不会立即执行run(),而是等到start()执行的时候,才会开始执行run()。
方式二的优缺点:
- 优点:线程任务类只是实现接口,可以继续继承类和实现接口,扩展性强。
- 缺点:编程多一层对象包装(任务对象),如果线程有执行结果是不可以直接返回的。run()不能抛异常,只能try/catch。
2.3 实现Runable的另一种写法(匿名内部类)
package com.hkd.thread;
public class ThreadDemo1 {
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println("子线程执行输出"+ i);
}
}).start();
// 把任务对象交给Thread处理
Thread t = new Thread(target);
for (int i = 0; i < 5; i++) {
System.out.println("主线程执行输出"+ i);
}
}
}
2.4 实现Callable接口
1. 定义MyCallable类实现Callable接口,重写call方法,封装要做的事情
package com.hkd.thread;
import java.util.concurrent.Callable;
// 需声明线程任务执行完毕后的结果数据类型
public class MyCallable implements Callable<String> {
private int n;
public MyCallable(int n){
this.n = n;
}
// 重写call方法
@Override
public String call() throws Exception {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return "子线程的执行结果:"+ sum;
}
}
2. 用FutureTask把Callable对象封装成线程任务对象,把这个对象交给Thread处理,调用start方法开启线程,执行任务;线程执行完毕后,通过FutureTask的get方法获取任务执行的结果
package com.hkd.thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo1 {
public static void main(String[] args) {
// 创建Callable对象
Callable<String> call1 = new MyCallable(100);
// 把Callable任务对象封装成FutureTask,它是Runnable对象(实现Runnable接口),可以交给Thread
// 可以在线程执行完毕之后通过get方法得到线程执行的结果
FutureTask<String> f1 = new FutureTask<>(call1);
// 交给线程处理
Thread t1 = new Thread(f1);
t1.start();
Callable<String> call2 = new MyCallable(200);
FutureTask<String> f2 = new FutureTask<>(call2);
Thread t2 = new Thread(f2);
t2.start();
// get方法获取任务执行的结果,如果上面代码没有执行完是不执行这里的
try {
String rs1 = f1.get();
System.out.println("线程1结果:"+ rs1);
} catch (Exception e) {
e.printStackTrace();
}
try {
String rs2 = f2.get();
System.out.println("线程2结果:"+ rs2);
} catch (Exception e) {
e.printStackTrace();
}
}
}
方法三的优缺点:
- 优点:线程任务类只是实现接口,可以继续继承类和实现接口,扩展性强。可以在线程执行完毕后去获取线程执行的结果。call()可以抛异常。
- 缺点:编码复杂一点。
三、线程提供的API
3.1 API
package com.hkd.thread.com.hkd.thread2;
public class ThreadMain {
public static void main(String[] args) throws Exception {
Thread t1 = new MyThread();
// 给当前线程命名
t1.setName("一号");
t1.start();
System.out.println(t1.getName());
Thread t2 = new MyThread();
t1.setName("二号");
t2.start();
System.out.println(t2.getName());
// 当前正在运行的线程对象
Thread m = Thread.currentThread();
System.out.println(m.getName());
for (int i = 0; i < 5; i++) {
System.out.println("main线程输出:" + i);
if(i==3)
// 让当前线程休眠
Thread.sleep(3000);
}
}
}
package com.hkd.thread.com.hkd.thread2;
public class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + "输出:" +i);
}
}
}
3.2 Java中wait()和sleep()的区别
3.2.1 共同点
wait()、wait(long)、sleep(long)的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态。
3.2.2 不同点
- 方法归属不同
- sleep(long)是Thread的静态方法。
- 而wait()、wait(long)都是Object的成员方法,每个对象都有。
- 醒来时机不同
- 执行sleep(long)和wait(long)的线程都会在等待相应毫秒后醒来。
- wait(long)和wait()还可以被notify()唤醒,wait()如果不唤醒就一直等下去。
- 它们都可以被打断唤醒。
- 锁特性不同(重点)
- wait()的调用必须先获取wait()对象的锁,而sleep则无此限制。
- wait()执行后会释放对象锁,允许其它线程获得该对象锁(我放弃cpu使用权,但你们还可以用)。
- 而sleep()如果在synchronized代码块中执行,并不会释放对象锁(我放弃cpu使用权,你们也用不了)。
四、线程安全
4.1 线程安全问题的原因
- 多个线程同时操作同一个共享资源的时候可能会出现业务安全问题,称为线程安全问题。
- 出现安全问题的原因:多线程并发,同时访问共享资源,存在修改共享资源。
4.2 解决方法:线程同步
- 让多个线程实现先后依次访问共享资源,这样就解决了安全问题。
- 线程同步的核心思想:加锁。把共享资源进行上锁,每次只能一个线程进入访问完毕以后解锁,然后其他线程才能进来。
4.2.1 synchronized同步代码块
4.2.1.1 synchronized锁的使用
- synchronized对象锁,采用互斥的方式,让同一时刻至多只有一个线程持有对象锁,其他线程再想获取这个对象锁的时候会被阻塞。当线程释放锁后,其他线程再争抢锁执行代码。
- 一般建议使用共享资源作为锁对象;对于实例方法建议使用
this
作为锁对象;对于静态方法建议使用字节码(类名.class)对象作为锁对象。
public void drawmoney(double money) {
// 1. 获取谁来取钱
String name = Thread.currentThread().getName();
// 同步代码块
synchronized (this) {
// 2. 判断账户余额够不够
if(this.money >= money){
System.out.println(name + "来取钱成功,吐出" + money);
this.money -= money;
System.out.println(name + "取钱后剩余" + this.money);
} else {
System.out.println(name + "取钱,余额不足");
}
}
}
4.2.1.2 synchronized锁的原理Monitor
- Monitor 被翻译为监视器,是由JVM提供,C++语言实现。
- 被monitorenter和monitorexit包围住的指令就是上锁的代码。
- 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁。
Monitor内部具体的存储结构:
- Owner:存储当前获取锁的线程的,只能有一个线程可以获取。
- EntryList:关联没有抢到锁的线程,处于Blocked状态的线程。
- WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程。
Monitor的执行流程:
- 代码进入synchorized代码块,先让对象锁lock关联到monitor,然后判断Owner是否有线程持有。(如何关联的在章节4.2.1.4讲解)
- 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功。
- 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)。
- 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待。
4.2.1.3 进阶:对象的内存组成
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充。
其中MarkWord部分的组成:
- hashcode:25位的对象标识Hash码。
- age:对象分代年龄占4位。
- biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁。
- thread:持有偏向锁的线程ID,占23位。
- epoch:偏向时间戳,占2位。
- ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位。
- ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位。
- 我们可以通过lock的标识,来判断是哪一种锁的等级。
- 后三位是001表示无锁。
- 后三位是101表示偏向锁。
- 后两位是00表示轻量级锁。
- 后两位是10表示重量级锁。
4.2.1.4 进阶:Monitor重量级锁
- 每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。
- 每个对象的对象头都可以设置monoitor的指针,让对象与monitor产生关联。
4.2.1.5 锁升级:轻量级锁
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替地执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
static final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized (obj) {
// 同步块 B
}
}
- 在正常程序执行的时候,对象处于无锁状态。
- 当程序执行到method1方法的时候,进入同步代码块,线程会在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
- 通过CAS指令将Lock Record的地址存储在对象头的MarkWord中(数据进行交换),如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
- 如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
- 解锁过程。遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
- 如果Lock Record的Mark Word不为null,则利用CAS指令将对象头的Mark Word恢复成为无锁状态。如果失败则膨胀为重量级锁。
4.2.1.6 锁升级:偏向锁
- 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
- Java 6中引入了偏向锁来做进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
三种锁的区别:
一旦锁发生了竞争,都会升级为重量级锁。
4.2.2 同步方法
- 作用:把出现线程安全问题的核心方法给上锁。
- 原理:每次只能一个线程进入,执行完毕以后自动解锁,其他线程才可以进来执行。
- 对于实例方法默认使用this作为锁对象。
- 对于静态方法默认使用类名.class对象作为锁对象。
//同步方法(锁起来)
public synchronized void drawmoney(double money) {
// 1. 获取谁来取钱
String name = Thread.currentThread().getName();
// 2. 判断账户余额够不够
if (this.money >= money) {
System.out.println(name + "来取钱成功,吐出" + money);
this.money -= money;
System.out.println(name + "取钱后剩余" + this.money);
} else {
System.out.println(name + "取钱,余额不足");
}
}
疑问:
同步代码块和同步方法哪个更好一些?
答:同步代码块锁的范围更小,同步方法锁的范围更大。
4.2.3 Lock锁
可以灵活地上锁和解锁。
public void drawmoney(double money) {
// 1. 获取谁来取钱
String name = Thread.currentThread().getName();
// 上锁
lock.lock();
try {
// 2. 判断账户余额够不够
if (this.money >= money) {
System.out.println(name + "来取钱成功,吐出" + money);
this.money -= money;
System.out.println(name + "取钱后剩余" + this.money);
} else {
System.out.println(name + "取钱,余额不足");
}
} finally {
//解锁
lock.unlock();
}
}
4.3 线程通信
- 什么是线程通信、如何实现?
- 所谓线程通信就是线程间相互发送数据。
- 线程通信常见形式
- 通过共享一个数据的方式实现。
- 根据共享数据的情况决定自己该怎么做,以及通知其他线程怎么做。
- 线程通信实际应用模型
- 生产者与消费者模型:生产者线程负责生产数据,消费者线程负责消费生产者产生的数据。
- 一般要求:生产者线程生产完数据后唤醒消费者,然后等待自己,消费者消费完该数据后唤醒生产者,然后等待自己。
4.4 wait()、notify()、notifyAll()的区别
- void wait()方法,当前线程等待,直到另一个线程调用notify()或者notifyAll()才能唤醒当前线程。
- void notify()方法,随机唤醒一个wait线程。
- void notifyAll()方法,唤醒所有wait线程。
4.5 JMM:Java内存模型
- Java内存模型定义了共享内存中,多线程程序读写操作的行为规范。通过这些规则来规范对内存的读写操作,从而保证指令的正确性。
- 首先,所有的共享变量,包括实例变量和类变量,都被存储在主内存中,也就是计算机的RAM。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。
- 其次,每个线程都有自己的工作内存,这里保留了线程所使用的变量的工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。
- 最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。
4.6 CAS的理解
4.6.1 CAS的工作原理
- CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
- 一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功。
- 线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中。
- 线程1操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 101 (a++)。
- 线程2操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 99(a–)。
- 线程1拿A的值与主内存V的值进行比较,判断是否相等。如果相等,则把B的值101更新到主内存中。
- 线程2拿A的值与主内存V的值进行比较,判断是否相等(目前不相等,因为线程1已更新V的值99),不相等,则线程2更新失败。
- 自旋锁操作。因为没有加锁,所以线程不会陷入阻塞,效率较高。如果竞争激烈,重试频繁发生,效率会受影响。
- 需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功。
4.6.2 CAS的底层实现
- CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令。
- 都是native修饰的方法,由系统提供的接口执行,并非Java代码实现,一般的思路也都是自旋锁实现。
- 在Java中比较常见使用有很多,比如ReentrantLock和Atomic开头的线程安全类,都调用了Unsafe中的方法。
4.7 乐观锁和悲观锁
- CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
4.8 volatile的理解
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:保证线程间的可见性、禁止进行指令重排序。
4.8.1 保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
- 上面这个代码中,开启了三个线程,线程1在sleep了100ms后对stop全局变量进行更改。
- 线程2在sleep了200ms后对stop变量进行打印。
- 线程3会进入while循环,当stop为false的时候开始循环,在变为true的时候结束循环。
- 但是最终,线程2正确地打印了stop变量为true,线程3并未结束循环。这是因为在JVM虚拟机中有一个JIT(即时编辑器)给代码做了优化。
- 解决方案:第一种在程序运行的时候加入vm参数-Xint表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用);第二种在修饰stop变量的时候加上volatile,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:static volatile boolean stop = false。
4.8.2 禁止进行指令重排序
五、线程池
5.1 什么是线程池
线程池就是一个可以复用线程的技术。
-
不使用线程池的问题
如果用户每发起一个请求,后台就创建一个新线程来处理,下次新任务来了又要创建新线程,而创建新线程的开销是很大的,这样会严重影响系统的性能。
5.2 谁创建线程池
JDK 5.0起提供了代表线程池的接口:ExecutorService
5.3 如何得到线程对象
使用ExecutorService的实现类ThreadPoolExecutor自创建一个线程池对象,
ThreadPoolExecutor构造器的参数说明
5.4 线程池面试题
1. 临时线程是什么时候创建的?
新任务提交时发现核心线程都在忙,任务队列也满了,并且还可以创建临时线程,此时才会创建临时线程。
2. 什么时候开始拒绝任务?
核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始任务拒绝。
5.5 线程池处理Runnable任务
使用ExecutorService的方法:void execute
(Runnable target)
package com.hkd.threadpool;
import java.util.concurrent.*;
/**
* 自定义线程池对象
*/
public class threadpooldemo {
public static void main(String[] args) {
/**
* public ThreadPoolExecutor(int corePoolSize,
* int maximumPoolSize,
* long keepAliveTime,
* TimeUnit unit,
* BlockingQueue<Runnable> workQueue,
* ThreadFactory threadFactory)
*/
ExecutorService pool = new ThreadPoolExecutor(3, 5, 6, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(5), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
// 给任务线程池,Runnable任务
Runnable target = new MyRunnable();
pool.execute(target);
pool.execute(target);
pool.execute(target);
}
}
5.6 线程池处理Callable任务
使用ExecutorService的方法:Future<T> submit
(Callable<T> command)
package com.hkd.threadpool;
import com.hkd.thread.MyCallable;
import java.util.concurrent.*;
/**
* 自定义线程池对象
*/
public class threadpooldemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService pool = new ThreadPoolExecutor(3, 5, 6, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(5), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
/**
* 处理Callable任务
*/
// 提交Callable任务,返回Future任务对象
Future<String> f1 = pool.submit(new MyCallable(100));
Future<String> f2 = pool.submit(new MyCallable(200));
Future<String> f3 = pool.submit(new MyCallable(300));
Future<String> f4 = pool.submit(new MyCallable(400));
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());
System.out.println(f4.get());
}
}
在线程池执行submit()方法的时候,假设第一个线程执行的时候,第二个线程是否会执行是看线程调度器决定的,如果线程池中有足够的空闲线程,那么第二个线程任务可以立马执行,但如果线程池中的所有线程都在执行其他任务,则第二个线程将会等待,直到有空闲线程可以使用。