17-线程
一 线程概述
咱们来了解一下计算机中的这几个名词、程序、进程、多进程、线程、多线程
1.1 程序和进程的比较
- 定义上:
程序是一组指令集,这些指令共同描述了计算机如何执行特定的任务或者解决特定的问题。
进程是程序的一次动态执行过程,包含了程序计数器、寄存器、堆栈等运行时所需的资源。
-
- 存在形式上:
程序存储在磁盘等持久性存储介质上,以二进制文件或其他可执行格式存在。
进程是在内存中创建并运行,占用CPU、内存、磁盘IO等资源。
-
- 动静态上
程序是静态的,存在磁盘中。不会主动执行的。
进程是动态的,有生命周期,包括创建、就绪、运行、阻塞、挂起、终止等状态变化。
1.2 多进程
指的是计算机可以同时运行多个程序实例(进程)。每个进程在运行时都不会影响其他进程的运行,资源不共享
1.3 线程与多线程
线程是进程中的一部分,是进程的最小运行单元。一个进程至少包含一个线程,即主线程,也可以有多个线程。线程不能独立存在,必须依赖于进程。 每个线程都有自己的指令指针、堆栈和局部变量等,但它们共享进程的代码、数据和全局变量等资源。
简单点说,线程是进程中的一个任务执行流,从开始到结束,是一个运行流程。多线程就是进程中可以有多个任务执行流,这些个线程可以同步执行,也可以异步执行。
线程使用的场景:
- 当一个程序中需要同时完成多个任务的情况下,我们可以将每个任务定义成一个线程,使他们得以同时工作,
- 有些时候,虽然可以使用单一线程完成,但是使用多线程可以更快完成,如下载文件。
1.4 线程与进程的区别
- 进程是操作系统运行的一个任务,线程是进程中运行的一个任务
- 进程是资源分配的最小单位(相互独立),线程是进程执行的最小单位(cpu调度的基本单元)。
- 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。进程之间的通信需要以IPC进行
- 线程是轻量级的进程,同一个进程中可以包含多个线程。多线程共享进程中的数据,使用相同的地址空间,因此,线程之间的通信更方便,CPU切换(或创建)一个线程的开销远比进程要小很多。
- 一个进程结束,其内的所有线程都结束,但不会对另外一个进程造成影响。多线程程序,一个线程结束,有可能会造成其他线程结束
1.5 时间片与CPU核心
1.5.1 CPU核心
现在,一个CPU都是多内核(核心)的。
内核(处理器):一个CPU内核在一个时间片上可以执行一个线程
逻辑处理器:同时可以处理的线程数量。(超线程时使用)
1.5.2 时间片
在宏观上,我们可以同时打开多个应用程序,每个程序同时运行,互不打扰。但在微观上:由于只有一个CPU(一个内核而言),一次只能运行某一个进程的某一个线程。如何公平处理,一种方法就是引入时间片的概念,每个程序轮流执行。
CPU调度机制算法,会将时间划分成一个个时间片,时间片的大小从几ms到几百ms。
线程调度是计算机多线程操作系统中分配CPU时间给各个线程的过程。每个线程代表程序中的一个执行路径,操作系统通过线程调度器分配处理器时间,决定哪个线程将获得执行的机会,以及获得的时间长短。
现在大多数操作系统上的cup调度算法都是抢占式调度。
1.6 同步和异步的概念
多个线程“同时运行”只是我们感官上的一种表现。其实,线程是并发运行的。
操作系统将时间划分成很多时间片段,尽可能的均匀分配给每一个线程,获取时间片段的线程被CPU运行,而其他线程处于等待状态。所以这种微观上是走走停停,断断续续的,宏观上都在运行的现象叫并发。
但不是绝对意义上的“同时发生”。
二 线程的基本应用
2.1 线程的创建
线程(Thread)的常用构造器如下:
Thread(Runnable target) | 使用指定Runnable对象作为参数,创建一个Thread对象 |
---|---|
Thread(Runnable target, String name) | 使用指定Runnable对象作为参数,创建一个Thread对象,并指定对象名为name. |
Thread(String name) | 创建一个Thread对象,并指定名称为name |
方式1:使用Thread的子类型
该方式有两种写法形态。
- 自定义一个类型,继承Thread类型,重写run方法,然后创建自定义类型对象即可
- 使用匿名内部类,通过多态的形式,定义一个Thread类型的子类型对象,也可。
方式2:使用Runnable的子类型
该方法也有两种写法形态。
- 自定义一个类型,实现Runnable接口,重写run方法。然后创建对象,将其传入Thread的构造器中。
- 使用匿名内部类,定义一个Runnable接口的子类型对象,将其传入Thread的构造器中。
方式3:Callable和FutureTask组合
第三种使用Runnable功能更加强大的一个子类.这个子类是具有返回值类型的任务方法.
- 创建Callable接口的实现类对象,并实现call()方法。该call()方法将作为线程的执行体,该call()方法有返回值,
- 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
- 使用FutureTask对象作为Thread对象的target创建井启动新线程。
- 使用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
大部分人熟知的都是继承Thread类或实现Runnable接口,但上述两种方式来执行的线程其实都是没有返回值的,如果我们想要通过线程的执行获得一些有用的信息的话,那么通过继承Thread类或实现Runnable接口都是无法办到的。
比如说我们在实现一个商品的详情页的时候,可能需要获取商品的详细信息包括库存信息,商品详情,配送时效,商品评价等等一些信息的时候,如果我们在同一个线程中一次性查询这么多信息,那接口性能就可想而知了。 这个时候我们可能就会想到通过多线程的方式来并发的调用不同的接口,同时获取这些信息。这个时候,就该使用Callable接口来获取对应的返回值了。
当然,我们在真正的开发过程中一般也不会通过实现Callable接口的方式来实现功能,可能更多的是通过线程池来实现
调用start方法开启线程。start方法可以将该线程对象纳入可执行线程池中。切记,不是调用run方法
2.2 线程的生命周期
生命周期,指的是从出生到死亡的整个过程。而线程对象的生命周期,我们可以概括为五个状态,分别是新建、就绪、运行、阻塞、死亡这五个状态。
新建状态(New) | 尚未启动的线程处于此状态( 即还没有调用start()方法) |
---|---|
就绪状态(Runnable) | 也可称为Ready状态。指已经调用了start()方法,并获取了内存资源,但没有获取cpu的时间片。 |
运行状态(Running) | 线程获得CPU时间片,正在执行run()方法里的代码。 |
阻塞状态(Blocked) | 阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。可再细分三种:1. 等待阻塞:运行中的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。 2. 同步阻塞:运行中的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。 3. 其他阻塞:运行中的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。 |
死亡状态(Terminated) | 已经退出的线程处于此状态。退出的原因可能是run方法执行完毕、也可能是因为异常而中断了。 |
2.3 生命周期相关方法
void start() | 用来启动线程,使线程进入可运行状态,等待CPU分配时间片段 |
---|---|
static void sleep(long millis) | 线程睡眠方法,使线程转到阻塞状态。millis参数设定睡眠的毫秒数。当睡眠结束后,就转为就绪(Runnable)状态。sleep()方法平台移植性好。 |
static void yield() | 线程让步方法,暂停当前正在执行的线程对象,使之处于可运行状态,把执行机会让给相同或者更高优先级的线程 |
void join() | 线程加入方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态 |
void interrupt() | 线程打断方法。 打断哪个线程,就用哪个线程对象调用。 |
o.wait() o.notify() o.notifyAll() | 锁方法。使用锁对象调用,不是线程对象。 |
2.4 线程的其他方法
方法名 | 用途 |
---|---|
static Thread currentThread() | Thread类的静态方法,可以用于获取运行当前代码片段的线程对象 |
long getId() | 返回该线程的标识符 |
String getName() | 返回该线程的名称 |
int getPriority() | 优先级被划分为10级,值分别为1-10,其中1最低,10最高。线程提供了三个常量来表示最低,最高,以及默认优先级返回该线程的优先级。Thread.MIN_PRIORITY Thread.MAX_PRIORITY Thread.NORM_PRIORITY |
Thread.State getState() | 获取线程的状态 |
boolean isAlive() | 判断线程是否处于活动状态 |
boolean isInterrupted() | 判断线程是否已经中断 |
boolean isDaemon() | 判断线程是否为守护线程。守护线程的特点是,当进程中只剩下守护线程时,所有守护线程强制终止。GC就是运行在一个守护线程上的。 |
三 临界资源问题(*)
3.1 什么是临界资源
临界资源:在一个进程中,多个线程之间是资源共享的。如果一个资源同时被多个线程访问,这个资源就是一个临界资源。
当多个线程并发读写同一个临界资源时,可能会发生临界资源的安全隐患问题,也称为"线程并发安全问题"。
常见的临界资源:
- 多线程共享实例变量
- 多线程共享静态公共变量
如果想解决线程安全问题,需要将异步的操作变为同步操作。
异步操作:多线程并发的操作,相当于各干各的
同步操作:有先后顺序的操作,相当于你干完我再干。
3.2 线程锁机制
java提供了一种内置的锁机制来支持原子性,通过关键字synchronized来进行同步代码块。
同步代码块包含两部分:一个是锁对象;一个是由这个锁保护的代码块。
synchronized(同步监视器-锁对象引用){//代码块
}
每个Java对象都可以用作一个实现同步的锁。线程进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,无论是通过正常途径退出还是通过抛出异常退出都一样。获取内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
谁适合充当锁对象
我们只需要保证多线程访问同步代码块时,看到的是同一个锁对象即可。
- synchronized作用在方法内部时,可以使用使用成员属性、常量池中的对象或者this作为锁对象。
- synchronized作用在非静态方法上时,锁对象就是this。
- synchronized作用在静态方法上时,锁对象是类名.class对象。
合适的锁范围
在使用同步块时,应该尽量在允许的情况下减少同步范围,来提高并发的执行效率
3.3 单例模式的改进
懒汉式单例,在多线程的环境下,会出现问题。由于临界资源问题的存在,单例对象可能会被实例化多 次。解决方案,就是将对象的 null值判断和实例化,放到一个同步代码段中执行。
3.4 锁的API
Object类中有几个方法如下 :
wait()
- 等待,让当前的线程,释放自己持有的指定的锁标记,进入到等待队列。
- 等待队列中的线程,不参与CPU时间片的争抢,也不参与锁标记的争抢。
notify()
- 通知、唤醒。随机唤醒等待队列中的一个等待这个锁标记的线程。
- 被唤醒的线程,进入到锁池,开始争抢锁标记。
notifyAll()
- 通知、唤醒。唤醒等待队列中的所有的等待这个锁标记的线程。 。
- 被唤醒的线程,进入到锁池,开始争抢锁标记。
public class GDownloadShowDemo {public static void main(String[] args) {Object obj = new Object();/*** 创建一个下载线程download, 任务是下载图片,显示百分比*/Thread download = new Thread(){public void run(){for (int i = 1; i <=100 ; i++) {try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("图片已经下载:"+i+"%");}//下载成功后,通知等待状态的show线程synchronized (obj){obj.notify();}}};/*** 创建一个线程show ,任务是打开图片进行显示,显示百分比*/Thread show = new Thread(){public void run(){// 当线程show 运行任务体时,发现还没有下载完成,因此要处于等待状态try {synchronized (obj){obj.wait(); //释放了锁,进入了等待状态 ,如果被唤醒,会进行锁池状态,然后继续执行以下的逻辑}} catch (InterruptedException e) {e.printStackTrace();}for (int i = 1; i <=100 ; i++) {try {Thread.sleep(50);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("图片正在打开:"+i+"%");}}};download.start();show.start();}
}
无论是wait()方法,还是notity()/notifyAll()方法,在使用的时候要注意,一定要是自己持有的锁标记,才可以做这个操作。否则会出现 IllegalMonitorStateException 异常。
3.5 死锁概念
3.5.1 死锁的产生
死锁是指两个或多个线程无限期地等待对方持有的资源而导致的一种阻塞现象。 在 Java多线程编程中,死锁通常是由于多个线程在竞争资源时出现了相互等待的情况,导致所有线程都无法继续执行,从而产生死锁。
死锁发生的必要条件,包括以下四个条件:
- 互斥:即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可抢占 :资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 占有和等待 :一个线程本身占有资源(一种或多种)的同时还等待其他资源,而其他资源正被其他线程占有。
- 循环等待:即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
只有当这四个条件同时满足时,才可能发生死锁。如果其中任何一个条件不满足,就不会发生死锁。因此,避免死锁的关键是破坏这四个条件中的至少一个。
例如,可以通过使用同步机制来破坏请求和保持条件,避免进程在请求资源时阻塞并持有已获得的资源。同时,可以使用超时机制来破坏不剥夺条件,避免进程长时间持有资源而不释放。另外,可以使用资源分配图等工具来检测循环等待条件,从而避免出现循环等待的情况。
3.5.2 常见死锁原因
1)竞争同一把锁时发生死锁
如果一个线程对同一把锁,连续加了两次锁,并且该锁还是不可重入锁的时候,就会产生死锁。
2)多个锁的嵌套导致死锁
在 Java 中,如果多个线程在持有一个锁的情况下尝试获取另一个锁,并且在相互等待对方释放锁时,就会出现死锁。这种情况通常是由于多个线程在获取锁的顺序上存在差异,从而导致相互等待。
public class DeadLockDemo01{public static void main(String[] args){Runnable runnable1 = () -> {synchronized ("a") {System.out.println("线程A,持有了 a锁,在等待b锁 "); synchronized ("b") {System.out.println("线程A同时持有了a锁和b锁 ");}}};Runnable runnable2 = () -> {synchronized ("b") {System.out.println("线程B,持有了b锁,在等待a锁 "); synchronized ("a") {System.out.println("线程B同时持有了a锁和b锁 ");}}};new Thread(runnable1, "A").start();new Thread(runnable2, "B").start();}
}
在这个示例中,有两个线程分别获取两个锁,但是获取锁的顺序不同。这个代码会导致死锁的发生,因为每个线程都在等待另一个线程释放它需要的锁。
3.5.3 死锁的解决方案
1.避免使用多把锁
2.统一锁的获取顺序
在多线程中,如果不能避免使用多把锁,应该尽量保证所有线程获取锁的顺序是一致的。可以按照某种全局的规则来 确定锁的获取顺序,例如按照对象的 hash 值来获取锁。
3.使用超时等待锁
如果一个线程尝试获取锁时发现已经被其他线程占用,可以设置一个超时时间,超过这个时间就放弃获取锁。这样 可以避免线程一直阻塞等待锁而导致死锁。
4.检测死锁
可以定期检测系统中是否存在死锁,并且采取相应的措施来解决死锁问题。例如,可以使用 jstack 工具来查看死锁情 况,或者使用死锁检测算法来自动检测死锁。
1. 先使用jps 查询java进程PID
2. 再使用jstack [-l] PID
3.5.4 方案演示:
1)统一锁的获取顺序
Runnable runnable1 = () -> {synchronized ("a") {System.out.println("线程A,持有了 a锁,在等待b锁 "); synchronized ("b") {System.out.println("线程A同时持有了a锁和b锁 ");}}
};Runnable runnable2 = () -> {synchronized ("a") {System.out.println("线程B,持有了b锁,在等待a锁 "); synchronized ("b") {System.out.println("线程B同时持有了a锁和b锁 ");}}
};new Thread(runnable1, "A").start();
new Thread(runnable2, "B").start();
2)使用hash值统一锁的获取顺序
//..... // 根据对象的hashCode确定锁的顺序int fromHash = System.identityHashCode(fromAccount);int toHash = System.identityHashCode(toAccount);if (fromHash < toHash) {synchronized (fromAccount) {synchronized (toAccount) {new Helper().transfer();}}} else if (fromHash > toHash) {synchronized (toAccount) {synchronized (fromAccount) {new Helper().transfer();}}} else {// 处理hashCode相同的情况synchronized (tieLock) {synchronized (fromAccount) {synchronized (toAccount) {new Helper().transfer();}}}}}
}//......
3.6 ReentrantLock
3.6.1 可重入锁的简介
ReentrantLock 是 Java 并发包(java.util.concurrent.locks)中的一个可重入锁实现,它提供了比 synchronized 关键字更灵活、功能更丰富的线程同步机制。ReentrantLock类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync(非公平锁)与FairSync(公平锁)类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。
1)可重入性
ReentrantLock 是一种可重入锁。这意味着持有锁的线程可以再次获取该锁,而不会发生死锁。每次成功获取锁都会增加锁的持有计数,相应的释放锁操作会减少计数。当计数降至零时,锁才会真正释放给其他等待的线程。
2)公平性
ReentrantLock 提供了公平锁和非公平锁两种模式。 默认是非公平锁。在构造时可以通过传递布尔参数指定:
-
公平锁(true):
按照线程请求锁的顺序进行排队,先请求的线程优先获得锁。公平锁倾向于减少线程饥饿现象,但可能降低系统的整体吞吐量。
-
非公平锁(默认,false):
不保证按照线程请求锁的顺序分配锁,允许后来的线程“插队”获取锁。非公平锁在某些场景下可能提供更高的性能,但可能增加线程饥饿的风险。
3)显式锁操作
与 synchronized 关键字(隐式锁操作)不同,ReentrantLock 需要显式地调用方法来获取和释放锁:
lock() | 上锁方法,如果发现已经上锁,当前线程将被阻塞,直到获取锁 |
---|---|
unlock() | 解锁方法。必须确保在持有锁的线程中正确调用此方法,否则可能导致死锁或其他同步问题。 |
tryLock() | 尝试非阻塞地获取锁。如果锁可用,立即返回 true;否则返回 false。 |
tryLock(long timeout, TimeUnit unit) | 尝试在指定时间内获取锁。如果在超时时间内锁不可用,返回 false。 |
4)条件变量(Condition)
ReentrantLock 还支持条件变量,通过 newCondition() 方法创建 Condition 对象。条件变量允许线程在满足特定条件时等待,直到其他线程通知它们条件已发生变化。与 Object 类的 wait()、notify() 和 notifyAll() 方法相比,条件变量提供了更精细的线程同步控制:
- await():当前线程进入等待状态,释放锁,并在其他线程调用对应 Condition 对象的 signal() 或 signalAll() 方法时唤醒
- signal():唤醒一个等待在该 Condition 上的线程,但不释放锁。
- signalAll():唤醒所有等待在该 Condition 上的线程,但不释放锁。
四 生产者消费者设计模式
4.1 什么是生产者消费者模型
生产者-消费者模型(Producer-Consumer problem)是一个非常经典的多线程并发协作的模型。
比如某个模块负责生产数据,而另一个模块负责处理数据。产生数据的模块就形象地被称为生产者;而处理数据的模块,则被称为消费者。
生产者和消费者在同一段时间内共用同一个存储空间,生产者往存储空间中添加产品,消费者从存储空间中取走产品,当存储空间为空时,消费者阻塞,当存储空间满时,生产者阻塞。
4.2 生产者消费者模式的优点
1、解耦
由于有缓冲区的存在,生产者和消费者之间不直接依赖,耦合度降低。
2、支持并发
由于生产者与消费者是两个独立的并发体,它们之间是通过缓冲区作为桥梁连接,生产者只需要往缓冲区里丢数据,就可以继续生产下一个数据,而消费者只需要从缓冲区中拿数据接口,这样就不会因为彼此的处理速度而发生阻塞。(通过使用多个生产者和消费者线程,可以实现并发处理,提高系统的吞吐量和响应性)
3、支持忙闲不均
缓冲区还有另一个好处:当数据生产过快的时候,消费者来不及处理,未处理的数据可以暂时存在缓冲区中。等消费者处理掉其他数据时,再从缓存区中取数据来处理。(通过使用缓冲区可以平衡生产者与消费者之间的速度差异,以及处理能力的不匹配)
4.3 生产者消费者模式所遵循的规则
- 生产者仅仅在缓冲区未满时生产,缓冲区满则停止生产。
- 消费者仅仅在缓冲区有产品时才能消费,缓冲区为空则停止消费。
- 当消费者发现缓冲区没有可消费的产品时会通知生产者。
- 当生产者生产出可消费的产品时,应该通知等待的消费者去消费。
4.4 案例演示
import java.util.LinkedList;public class DesignModelTest {public static void main(String[] args) {//创建一个苹果架FruitStand fruitStand = new FruitStand(100,50);//创建四个生产者Thread p1 = new Producer(fruitStand,50,"小A");Thread p2 = new Producer(fruitStand,60,"小B");Thread p3 = new Producer(fruitStand,70,"小C");Thread p4 = new Producer(fruitStand,80,"小D");//创建留个消费者Thread c1 = new Customer(fruitStand,60,"小黑");Thread c2 = new Customer(fruitStand,40,"小白");Thread c3 = new Customer(fruitStand,50,"小二");Thread c4 = new Customer(fruitStand,30,"小三");Thread c5 = new Customer(fruitStand,50,"小黄");Thread c6 = new Customer(fruitStand,20,"小绿");//启动线程p1.start();p2.start();p3.start();p4.start();c1.start();c2.start();c3.start();c4.start();c5.start();c6.start();}
}
/*** 模拟消费者类型*/
class Customer extends Thread{//提供一个苹果架的属性private FruitStand fruitStand;//消费者要购买的数量private long number;public Customer(FruitStand fruitStand,long number,String name){super(name);this.fruitStand =fruitStand;this.number = number;}/*** 重写run方法,调用购买方法购买苹果*/public void run() {while (true){buy();}}/*** 购买方法*/public void buy(){fruitStand.take(number);}
}
/*** 模拟生产者类型*/
class Producer extends Thread{//提供一个苹果架的属性private FruitStand fruitStand;//生产者要生产的数量private long number;public Producer(FruitStand fruitStand,long number,String name){super(name);this.fruitStand = fruitStand;this.number = number;}/*** 重写run方法,调用生产方法*/public void run() {while (true){product();}}/*** 生产者的生产方法,要调用苹果架的存储方法*/public void product(){fruitStand.store(number);}
}
/*** 模拟仓库,也就是水果架*/
class FruitStand{//集合属性用于存储苹果private LinkedList<Apple> apples;//容量上限private long capacity;//用于初始化水果架的数据public FruitStand(long capacity,long initNum) {this.apples = new LinkedList<>();this.capacity = capacity;//初始化时,苹果架上有initNum个苹果for (int i = 0; i < initNum; i++) {apples.add(new Apple());}}/*** 存储方法,用于向集合属性中添加苹果* @param number 表示生产者要生产的数量*/public synchronized void store(long number){//如果生产的数量加上现有的数据大于容量,则进行阻塞状态while(number+apples.size()>capacity){try {this.wait();} catch (InterruptedException e) {e.printStackTrace();}}//生产数据for (int i = 0; i < number; i++) {apples.add(new Apple());}System.out.println(Thread.currentThread().getName()+"生产了"+number+"个苹果,现在的个数:"+apples.size());//唤醒其他等待线程notifyAll();}/*** 取走方法,用于消费者调用此方法消费* @param number 消费者要购买的数量*/public synchronized void take(long number){// 消费者需要的数量大于苹果架上现有的数据时,不能购买,要处于等待状态while(number>apples.size()){try {this.wait();} catch (InterruptedException e) {e.printStackTrace();}}//购买水果for (int i = 0; i < number; i++) {//取出队列中的第一个apples.pop();}System.out.println(Thread.currentThread().getName()+"购买了"+number+"个苹果,剩余的个数为:"+apples.size());this.notifyAll();}
}/*** 模拟苹果类型*/
class Apple{
}
五 线程池用法
5.1 线程池的简介
线程池,其实就是一个容器,里面存储了若干个线程。
使用线程池,最主要是解决 “复用” 的问题。之前的处理方式中,当我们使用到一个线程的时候,需要进行实例化,当这个线程使用结束之后,对这个线程进行销毁。对于需求实现来说没有问题,但是频繁的开辟、销毁线程,其实对CPU来说,是一种负荷。所以要尽量的优化这一点。
可以使用复用机制解决这个问题。当我们需要使用到一个线程的时候,不是直接实例化,而是先去线程池中查找是否有闲置的线程可以使用。如果有,直接拿来用;如果没有,再实例化一个新的线程。并且,当这个线程使用结束之后,并不是马上销毁,而是将其放入线程池中,以便下次使用。
5.2 线程池的工作原理
线程池中的所有线程,可以分为两部分:核心线程 和 临时线程。
-
核心线程:
常驻于线程池中的线程,这些线程,不会被线程池销毁,只有当线程池需要被销毁的时候, 他们才会被关闭销毁。
-
临时线程:
当遇到了高密度的线程需求量时,此时,就会临时的开辟一些线程,处理一些任务。当这些线程处理完自己需要处理的任务后,就会闲置。当闲置了指定的时间之后,这个临时线程就会被销毁。
5.3 线程池的应用
5.3.1 构造器参数解析
在Java中,使用 ThreadPoolExecutor 类来描述线程池,在这个类的对象实例化的时候,有几个常见的参数:
参数 | 含义 |
---|---|
int corePoolSize | 核心线程的数量 |
int maximumPoolSize | 线程池最大容量(包含了核心线程和临时线程) |
long keepAliveTime | 临时线程可以空闲的时间 |
TimeUnit unit | 临时线程保持存活的时间单位 |
BlockingQueue workQueue | 任务等待队列 |
RejectedExecutionHandler handler | 拒绝访问策略 |
-
BlockingQueue 有三个子类型
- ArrayBlockingQueue
- LinkedBlockingQueue
- SynchronouseQueue
-
RejectedExecutionHandler有四种
ThreadPoolExecutor.AbortPolicy 丢弃新的任务,并抛出异常 RejectedExecutionException ThreadPoolExecutor.DiscardPolicy 丢弃新的任务,但是不会抛出异常 ThreadPoolExecutor.DiscardOldestPolicy 丢弃等待队列中最早的任务 ThreadPoolExecutor.CallerRunsPolicy 不会开辟新的线程,由调用的线程来处理
5.3.2 常用方法
execute(Runnable runnable) | 将任务提交给线程池,由线程池分配线程来完成 |
---|---|
shutdown() | 向线程池发送一个停止信号,这个操作并不会立即停止线程池中的线程,而是在线程池中所有的任务都执行结束之后,结束线程和线程池。 |
shutdownNow() | 立即停止线程池中的所有的线程和线程池。 |
5.3.2 案例演示
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;/*** int corePoolSize : 核心线程数量,指的就是不会被销毁的线程数量* int maximumPoolSize, : 线程池中线程数量的最大上限 ,临时线程的数目= 最大上限- 核心数量* long keepAliveTime, : 临时线程在没有任务执行时,最多可以闲置的时间,时间一过,进行销毁* TimeUnit unit, : 时间单位,指的是临时线程的时间单位* BlockingQueue<Runnable> workQueue :阻塞队列,用于存储没有执行的任务* 三个子类:* ArrayBlockingQueue* LinkedBlockingQueue* SynchronouseQueue** 在使用时,应该设置队列的容量,默认是Integer.MAX_VALUE** RejectedExecutionHandler:线程拒绝策略,是一个接口。子类有四个,如下所示* AbortPolicy: 丢弃新的任务,并抛出异常 RejectedExecutionException* DiscardPolicy: 丢弃新的任务,但是不会抛出异常* CallerRunsPolicy: 不会开辟新的线程,由向线程池中添加的线程来处理多余的任务。* DiscardOldestPolicy:丢弃阻塞队列中最早的任务。**/
public class ThreadPoolDemo {public static void main(String[] args) {/*** 创建一个线程池*** 线程池的总结:* 内部维护着核心线程、阻塞队列、临时线程。** 任务数<=核心线程+阻塞队列容量时,不会创建临时线程,都是由核心线程进行处理。队列中的线程会依次被空闲下来的核心* 线程继续处理* 任务数>核心线程+阻塞队列容量时,并且任务数< 最大线程数量+阻塞队列容量时,* 会创建临时线程,临时线程处理的任务是加入不到阻塞队列中的那些任务。不会触发拒绝策略** 任务数>最大线程数量+阻塞队列容量时,会触发拒绝策略。*/ThreadPoolExecutor executor = new ThreadPoolExecutor(5,10,60,TimeUnit.SECONDS,new LinkedBlockingQueue<>(5),new ThreadPoolExecutor.CallerRunsPolicy());/*** execute(Runnable runnable):* 向线程池中添加任务,任务的分配由线程池来管理,无需开发人员操心*/for (int i =1 ; i <=16 ; i++) {executor.execute(new Task(i));}/*** 关闭线程池:* shutdown(): 等所有任务都执行完毕后,线程池中的所有线程都会被销毁*///executor.shutdown();/*** 核心线程和临时线程处理完任务后,不再处理队列中的任务而是立即销毁* shutdownNow():*/executor.shutdownNow();}}
class Task implements Runnable{private int num;public Task(int num){this.num =num;}@Overridepublic void run() {for (int i = 0; i <10 ; i++) {try {Thread.sleep(500);} catch (InterruptedException e) {e.printStackTrace();}System.out.println(Thread.currentThread().getName()+" :"+num);}}
}
5.5 线程池的工具类
Executors类是一个用来获取线程池对象的工具类,实际应用中,大部分场景下,可以不用前面的构造方法进行线程池的获取,而是用Executors工具类中的方法进行获取。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;/*** 线程池工具类的应用*/
public class ExecutorsDemo {public static void main(String[] args) {/*** newFixedThreadPool(int nThreads)* 创建有固定数量的线程池对象,固定数量指的是核心线程,不会有临时线程*///ExecutorService service = Executors.newFixedThreadPool(2);/*** newCachedThreadPool()* :内部没有维护核心线程,都是用的临时线程。临时线程闲置时间超过60秒,就会销毁*///ExecutorService service = Executors.newCachedThreadPool();/*** newSingleThreadExecutor()* : 内部只维护一个核心线程,没有临时线程。*/ExecutorService service = Executors.newSingleThreadExecutor();for (int i = 0; i < 10; i++) {service.submit(new Task(i));}}
}
六 练习题
1、Java的线程状态源码中为什么没有RUNNING状态
public class Thread implements Runnable {//省略...public enum State {NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED;}//省略...
}
JVM 中没有去区分这两种状态呢?
- 现在的时分多任务操作系统架构通常都是用所谓的“时间分片”方式进行抢占式轮转调度。这个时间分片通常是很小的,一个线程一次最多只能在 cpu 上运行比如10-20ms 的时间(此时处于 running状态),时间片用后就要被切换下来放入调度队列的末尾等待再次调度。这一切换的过程称为线程的上下文切换。
- 显然,10-20ms 对人而言是很快的,人几乎感知不到切换和停顿。也这正是单核 CPU 上实现所谓的“并发”的基本原理。如果是多核CPU,才有可能实现真正意义上的并发,这种情况通常也叫并行。
- 通常,Java的线程状态是服务于监控的,如果线程切换得是如此之快,那么区分 ready 与 running 就没什么太大意义。当你看到监控上显示是 running 时,对应的线程可能早就被多次切换了。现今主流的 JVM 实现都把 Java线程一一映射到操作系统底层的线程上,把调度委托给了操作系统,我们在虚拟机层面看到的状态实质是对底层状态的映射及包装。JVM 本身没有做什么实质的调度,把底层的 ready 及 running 状态映射上来也没多大意义,因此,统一成为runnable 状态
2、wait() 和 sleep() 的区别?
- sleep()方法,在休眠时间结束后,会自动的被唤醒。而wait()进入到的阻塞态,需要被 notify/notifyAll手动唤醒。
- wait()会释放自己持有的指定的锁标记,进入到阻塞态。 sleep()进入到阻塞态的时候,不会释 放自己持有的锁标记。
3、 创建线程的方式
请用两种不同的方式创建并启动线程,分别打印10次的"Hello from Thread-1"和"Hello from Thread-2"。
6、线程状态控制
实现一个程序,创建一个线程并演示线程的各种状态转换:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。
7、线程优先级和守护线程
创建5个不同优先级的线程,观察它们的执行顺序。另外创建一个守护线程,在主线程结束后自动终止。
8、线程安全问题演示
创建一个银行账户类 BankAccount,包含余额字段和存钱、取钱方法。创建多个线程同时操作同一个账户,演示线程安全问题。
9、死锁演示
创建两个资源对象和两个线程,故意造成死锁情况,然后分析死锁产生的原因并提供解决方案。
10、ReentrantLock和synchronized有什么不同(面试题)
- 第一点:
- ReentrantLock可以提供公平+非公平两种特性,当ReentrantLock构造方法中指定了参数为true的时候,这个锁被确定为公平锁。而synchronized无法提供公平锁的特性
- 第二点:
- ReentrantLock的加锁、解锁操作都是需要手动进行,而synchronized的话可以进行自动的加锁、解锁操作。synchronized可以有效避免加锁之后忘记解锁的情况。
- 当代码执行到synchronized修饰的代码块的时候,如果在同步代码块内部发生了异常,没有及时处理的话,会提前退出并且让线程释放锁。而ReentrantLock无法做到立刻解锁,因此,unLock()的解锁操作一定要在finally代码块当中,避免加锁之后忘记解锁的情况。
- 第三点:
- synchronized无法提供lock.tryLock()这样的尝试获取锁的特性,而ReentrantLock可以提供。
- 线程如果在指定的时间之内无法获取到锁,或者锁已经被占用了,那么lock.tryLock()可以有效减少线程阻塞等待的情况,或者减少阻塞等待的时间。而synchronized只会让无法获取到锁的线程"死等"。直到获取到锁的线程释放锁
- 第四点:
- ReentrantLock可以提供中断式加锁。 ReentrantLock在调用lock.lockInterruptibly()时候,可以让获取不到锁,进入阻塞等待的线程被提前"唤醒",但是synchronized不可以。具体的操作已经在上面解释了。
11、怎么停止一个线程
1.使用stop()方法终止线程 注意:它确实可以停止一个正在运行的线程,但是这个方法是不安全的,而且是已被废弃的方法
2.使用退出标志终止线程
3.使用 interrupt 方法 仅仅是在当前线程中打一个停止的标记,并不是真的停止线程。还要使用Thread.isInterrupted()
判断是否被中断(检查标志位),有则增加相应的中断处理代码