目录
码字不易,喜欢就点个关注❤,持续更新技术内容。相关资料请私信。
相关内容:第二篇:JUC并发编程高级篇(未完待续)-优快云博客
1 线程简介
1.1 进程
学习线程前先了解进程。
首先进程是程序的一次执行活动,程序是指令和数据的有序集合,静态存在于磁盘中。而进程是程序的一次动态地执行活动,是系统进行资源分配的独立单位或基本单位,而且在单核CPU的情况下还能并发执行多个进程。
所以说进程有以下三大特征:
动态性:进程是运行中的程序,要动态的占用内存,CPU和网络等资源。
独立性:进程与进程之间是相互独立的,彼此有自己的独立内存区域。
并发性:假如执行的进程超过了设备的多核数量,CPU会轮询执行进程,因为轮询切换的速度速度非常快,就像多个进程在同时进行。(并发发送在微观上的时间段,并行发生在同一时间点上)
1.2 线程
线程是包含在进程中的一系列的功能活动,线程共享进程的内存资源,线程拥有资源的使用而不拥有所有权。它是CPU进行运算调度的最小单位。
实际上多线程的概念是模拟出来的,真正的多线程是指多核运算。如果是模拟出的多线程,那就是针对于一个CPU,在很短的时间间隔内,CPU只能进行一段代码的运算,但由于CPU运算很快,它可以在这一小段时间运算完成后又进行其他的代码运算,所以CPU不断地切换代码进行运算,在人的感知下CPU就像同时进行多个代码进行运算一样。
所以线程也支持并发性,能提高程序的效率。能解决很多业务模型,是大型高并发技术的核心技术。
线程调度:
分时调度:所有线程轮流使用CPU,平均分配每个线程占用CPU的时间。
抢占式调度:优先让优先级高的线程使用CPU,如果线程的优先级相同,随机选择,Java使用的为抢占式调度。
线程状态:
线程五大状态:创建状态、就绪状态、运行状态、阻塞和等待状态、死亡状态。
创建状态:线程对象已经创建,但尚未启动。
就绪状态(可运行和运行):当调用start()方法启动线程,线程就进入了就绪状态,但不意味着CPU立即调度执行该线程。
运行状态:在Java虚拟机中执行,处于运行状态,线性才真正在执行线程体的代码块。
阻塞和等待状态:当调用sleep(),wait()时线程进入等待状态。sleep不会释放锁,时间到后继续执行,wait会释放锁,时间到后需重新获得锁。如果线程运行过程中未得到某个资源的锁时,就会让出CPU,进入该资源的阻塞队列。
线程死亡:线程执行完毕或出异常中断,进入死亡状态。
1.3 多线程
多线程的概念如线程中的描述,还有需要了解的是,CPU在进行多线程调度的情况下对同一资源进行操作时,需要加入并发控制。每个线程都拥有自己的工作内存空间,内存如果控制不当会造成数据不安全。下面讲解的多线程同步机制会增加理解。
2 线程实现
线程的创建有三种方式,分别是直接继承Thread类,实现Runnable接口,实现Callable接口。
子类继承Thread类重写run()方法定义执行方法,通过子类线程对象.start()方法,启动线程,不建议使用,避免面向对象单继承的局限性。
实现Runnable接口重写run()方法定义执行方法,通过实现类初始化线程任务对象,通过Thread构造器包装成线程对象,通过Thread线程对象.start()的方式启动线程,推荐使用,避免了面向对象单继承的局限性,也方便了同一个对象被多个线程使用。(一个线程任务对象可以通过多个代理变成多个不同的线程)。
实现Callable接口
2.1 继承Thread类
直接继承Thread类示例
public class ThreadDemo {
// 启动这个类,这个类就是进程,它自带一个主线程,
// 主线程的执行方法就是main方法
public static void main(String[] args) {
Thread t1 = new MyThread("自定义1号线程");
// 调用线程对象的start()方法开启线程,最终执行run()方法
// 1.底层其实是给CPU注册当前线程,并且触发run()方法执行
// 2.如果线程直接调用run()方法,相当于变成了普通类的执行,此时将只有主线程在执行
// 3.建议线程先创建子线程,主线程的任务放在之后。否则主线程永远是先执行完
t1.start();
Thread t2 = new MyThread("自定义2号线程");
t2.start();
Thread.currentThread().setName("主线程");
for(int i = 0 ; i < 100 ; i++ ) {
System.out.println(Thread.currentThread().getName()+" => "+i);
}
}
}
// 1.定义一个线程类继承Thread。线程类并不是线程对象,用来创建线程对象的。
class MyThread extends Thread{
public MyThread(String name) {
// public Thread(String name):父类的有参数构造器
super(name); // 调用父类的有参数构造器初始化当前线程对象的名称!
}
// 2.重写run()方法,线程最终的执行方法,方法的调用和变量的操作都在里面执行
// 变量和方法可以在线程类中定义,或者在外部类中定义然后传递进来直接传递和通过对象传递(降低耦合)。
@Override
public void run() {
for(int i = 0 ; i < 100 ; i++ ) {
System.out.println(Thread.currentThread().getName()+" => "+i);
}
}
}
通过运行结果可以发现程序是并发执行的,响应速度很快,主线程和自定义线程就像同时在执行。
继承Thread类的优缺点:
优点:编码简单。 缺点:线程类已经继承了Thread类无法继承其他类了,功能不能通过继承拓展。也就是单继承的局限性。
Thread线程类中常用方法
方法 | 说明 |
---|---|
currentThread() | 获取当前线程对象,类方法 |
sleep(long millis) | 在指定的毫秒数内让当前线程休眠,不释放锁。类方法。 |
join() | 等待调用的线程执行完 |
setName(String name) | 给当前线程设置名字,可以通过初始化构造器时传递设置 |
getName() | 获取当前线程的名字 |
setPriority(int newPriority) | 更改线程优先级 |
yield() | 暂停当前正在执行的线程对象,并执行其他线程 |
interrupter() | 中断线程,一般不用 |
isAlive() | 测试线程是否处于活动状态 |
线程类中还有很多方法:
线程休眠:调用sleep(毫秒数)指定所在的当前线程阻塞的毫秒数,时间达到后线程进入就绪状态。sleep可以模拟网络延时,倒计时等。另外每个线程都有一个锁,sleep不会释放锁。
线程礼让:是让当前正在执行的线程暂停,将线程从运行状态转为就绪状态,让CPU调度,所以礼让不一定成功,就是可能还是执行原来的线程。
线程合并:Join合并线程,待该线程执行完成后,再执行其他线程,其他线程阻塞。可以想象成合并线程插队。
线程终止:现在JDK已经不推荐使用stop()、destroy()方法,已废弃。建议使用一个标志位进行终止变量,当flag=false,则终止线程运行。最好让线程自己终止。
线程守护:线程分为用户线程和守护线程,虚拟机必须确保用户线程执行完毕,守护守护线程不用终止,如记录操作日志,监控内存,垃圾回收等等。
设置线程优先级:Java提供一个线程调度器来监控程序中启动后进入就绪状态的所以线程,线程调度器按照优先级决定应该调度哪个线程来执行。线程的优先级用数字表示,范围从1~10。调用以下方法改变或获取优先级:
Thread.currentThread().setPriority(int x);
Thread.currentThread().getPriority();
优先级的设定建议在调用start()启动线程前。优先级低只是意味着获得调度的概率低,并不是优先级低就不会被调用了,要看CPU的调度。
2.2 实现Runnable接口
实现Runnable接口示例
public class ThreadDemo {
public static void main(String[] args) {
// 创建一个线程任务对象(注意:线程任务对象不是线程对象)
// 使用匿名内部类简化写法
// 相对于子类初始化重写了方法的线程任务对象,再把线程任务对象包装成线程对象.且可以指定线程名称
new Thread(() -> {
for(int i = 0 ; i < 10 ; i++ ){
System.out.println(Thread.currentThread().getName()+"==>"+i);
}
}, "线程1").start();
for(int i = 0 ; i < 10 ; i++ ){
System.out.println(Thread.currentThread().getName()+"==>"+i);
}
}
}
Thread的构造器:
public Thread(){}
public Thread(String name){}
public Thread(Runnable target){}
public Thread(Runnable target,String name){}
实现Runnable接口创建线程的优缺点:
缺点:代码复杂一点。
优点:
线程任务类只是实现了Runnable接口,可以继续继承其他类,而且可以继续实现其他接口(避免了单继承的局限性)
同一个线程任务对象可以被包装成多个线程对象
适合多个多个线程去共享同一个资源(后面内容)
实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立。
线程池可以放入实现Runnable或Callable线程任务对象。(后面了解)
其实Thread类本身也是实现了Runnable接口的。
2.3 实现Callable接口(拓展)
实现Callable接口示例
-
定义一个线程任务类实现Callable接口 , 申明线程执行的结果类型。
-
重写线程任务类的call方法,这个方法可以直接返回执行的结果(在实现接口时通过泛型定义)。也可以使用匿名内部类写法直接重写方法并初始化出实现类对象,相当简洁。
-
创建一个Callable的线程任务对象。
-
把Callable的线程任务对象包装成一个未来任务对象。
-
把未来任务对象包装成线程对象。
-
调用线程的start()方法启动线程。
public class ThreadDemo {
public static void main(String[] args) {
// 1.通过匿名内部类重写call()方法并初始化出Callable实现类的线程任务对象。把Callable任务对象包装成一个可以执行未来任务对象。(就多了这一步而已,为了包装成Runnable对象和最后获得返回值线程执行法的返回值)
// public FutureTask(Callable<V> callable)
// 未来任务对象:
// FutureTask类实现了RunnableFuture接口,RunnableFuture接口又继承了Runnable接口,
// 所以未来任务对象其实就是一个Runnable对象:这样就可以被包装成线程对象!
// 未来任务对象可以在线程执行完毕之后去得到线程执行的结果。
FutureTask<String> task = new FutureTask<>(() -> {
// 需求:计算1-10的和返回
int sum = 0 ;
for(int i = 1 ; i <= 10 ; i++ ){
System.out.println(Thread.currentThread().getName()+" => " + i);
sum+=i;
}
return Thread.currentThread().getName()+"执行的结果是:"+sum;
});
// 3.把未来任务对象包装成线程对象
Thread t = new Thread(task, "线程1");
// 4.启动线程对象
t.start();
for(int i = 1 ; i <= 10 ; i++ ){
System.out.println(Thread.currentThread().getName()+" => " + i);
}
// 在最后去获取线程执行的结果,如果线程没有结果就会出异常,然后会让出CPU等线程执行完再来取结果
try {
// 获取call方法返回的结果(正常/异常结果)
String rs = task.get();
System.out.println(rs);
} catch (Exception e) {
e.printStackTrace();
}
}
}
优缺点:
优点:全是优点。
线程任务类只是实现了Callable接口,可以继续继承其他类,而且可以继续实现其他接口(避免了单继承的局限性)
同一个线程任务对象可以被包装成多个线程对象
适合多线程去共享资源
实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立。
线程池可以放入实现Runnable或Callable线程任务对象。
能直接得到线程执行的结果。
缺点:编码稍复杂。
2.4 总结
Runnable接口是实现线程的顶级接口:
Runnable接口中只有一个run()方法,该方法没有返回值。通过实现Runnable接口重写run()方法后,成为线程任务类。
Thread类:
Thread是已经实现了Runnable接口(run方法中只是做了简单判断,需要子类根据业务功能再重写),并定义了开启线程的start()方法以及线程常用的api,继承Thread类的子类可以直接调用start()方法注册开启线程,且可以使用Java写好提供的大量线程方法。
若直接实现Runnable接口,只是成为了一个线程任务类,需要通过Thread类构造器包装成Thread对象,才能通过调用start()方法开启线程对象。
JDK5带来Callable泛型接口:
因为Runnable中run()方法没有返回值且没有定义抛出异常,实现类的实现run()方法时不能抛出大于父类异常,所以实现类运行run()方法时不能抛出异常。
此时Callable就应运而生了,Callable接口中定义了call()方法,方法的返回值可以在实现接口时通过泛型传递。该方法还可以抛出异常:
V call() throws Exception;
实现Callable接口后,此时并没有实现run()方法使其成为线程任务类,所以经过Runnable接口的实现类未来任务FutureTask类包装后成为线程任务类,然后再通过Thread类构造器包装,初始化为Thread类对象后执行start()方法注册开启线程对象。
3 多线程同步机制
3.1 引入
虽然多线程的并发性提高了程序的响应速度,让我们放视频的同时听音乐。但多线程的并发性存在优点的同时,在另一方面会造成数据资源的安全性问题。
由于同一进程中线程共享相同的存储空间,也就是共享内存资源,多线程访问可能带来数据不一致性问题。多线程操作同一个共享资源的时候可能会出现线程安全问题(并发随机性)。需要控制对资源的顺序访问。
多线程操作同一个资源,即同一个对象被多个线程同时操作,就称为并发。处理多线程并发问题时,多个线程访问同一对象,因为并发随机性,其中一些线程会修改对象时会造成数据不一致,这时候我们就需要线程同步机制,线程同步机制就是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待队列,等待前面线程使用完毕,下一个线程再使用。所以控制对资源的顺序访问就是通过同步方法和同步代码块。
3.2 同步方法和同步代码块
为了保证数据在方法中被正确地访问,在访问时加入同步锁机制(synchronized),当一个线程获得对象的排他锁,独占资源,其他线程必须排队等待,使用后释放锁即可。同步锁机制:
一个线程持有锁会导致其他需要此锁的线程阻塞;
在多线程竞争下,加锁和释放锁会导致比较多的上下文切换和调度延长,性能降低;
如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题。
关键字synchronized具有同步锁机制,用来控制对资源访问的两种用法是synchronized同步方法和synchronized同步代码块。
要进入该方法或者代码块中需要获得该范围资源的锁,否则线程阻塞。方法或代码块一旦执行,线程就独占该锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行。
还有一点是,方法锁的范围大于代码块,而方法中不是所有的数据都需要同步访问,所以相对于块锁,方法锁可能会影响效率(如同一个人蹲坑把整个厕所都锁了)。
另外同步锁的使用方法,同步方法只需要在方法返回前面加上synchronized关键字即可,而同步代码块需要指定任意的唯一对象(同步监视器),实例方法中推荐当前对象本身(this),静态方法中推荐使用类名.class字节码对象作为锁对象。
线程1访问,锁定同步监视器,执行其中代码。
线程2访问,发现同步监视器被锁,无法进入访问。
线程1访问执行完毕后解锁同步监视器。
线程2访问,发现同步监视器没有锁,然后锁定并访问执行。
示例
定义一个账号类用来取钱:
public class Account {
private String cardID;
private double moeny;
public void drawMoney(double moeny) {
// 取钱逻辑
// 1.谁来取钱
String name = Thread.currentThread().getName();
// 2.判断余额是否足够
if(this.moeny >= moeny){
//进入判断,获得取钱的资格
System.out.println(name+"取出:"+moeny);
// 3.更新余额
this.moeny -= moeny;
System.out.println(name+"取出后剩余:"+ this.moeny);
}else{
System.out.println(name+"来取时余额不足!");
}
}
public Account(String cardID, double moeny) {
this.cardID = cardID;
this.moeny = moeny;
}
Getter and Setter...
}
创建王二和张三线程对同一个账户进行取钱:
public class ThreadDemo {
public static void main(String[] args) {
// a.初始化账户对象,并存入一万元
Account acc = new Account("012210" , 10000);
// b.创建2个线程对象进行取钱
Thread Wang = new DrawThread(acc, "王二", 10000);
Wang.start();
Thread Zhang = new DrawThread(acc, "张三", 10000);
Zhang.start();
}
}
// 定义取钱的线程类,传入要取钱的账户对象和取钱人。
class DrawThread extends Thread{
// 定义一个成员变量接收账户对象
private Account acc;
private double money;
public DrawThread(Account acc, String name, double money){
super(name);
this.acc = acc;
this.money = money;
}
@Override
public void run() {
// 取钱10000
acc.drawMoney(money);
}
}
未为操作账户的方法加同步锁时:(多线程操作同一个共享资源的时候出现了线程安全问题(并发随机性))
在操作账户的方法上加synchronized同步方法锁,或者在操作账户的代码块上加synchronized同步代码块锁:(一个线程持有锁会导致其他需要此锁的线程阻塞,直到前面先获得锁的线程执行完毕自动释放锁)
3.3 Lock锁
从JDK 5开始,Java提供了更广泛更强大的线程同步锁机制,通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当。每次只能有一个线程对Lock对象加锁,线程访问共享资源之前应先获得Lock对象的锁,锁提供了对共享资源的独占访问。
ReentrantLock类实现了Lock接口,在实现线程安全的控制中,比较常用,可以显式加锁、释放锁,它拥有与synchronized相同的并发性和内存语义。
class Test{
// 加final防止被撬
private final Lock lock = new ReentrantLock();
public void test(){
lock.lock();
try{
// 线程安全代码
}catch (Exception e) {
e.printStackTrace();
}finally{
// 在finally中释放锁,防止执行代码中出现异常导致一直不释放锁
lock.unlock();
}
}
}
Lock是显式锁(手动上锁和释放),synchronized是隐式锁,出了作用域自动释放。
Lock锁只有代码块锁,synchronized有代码块锁和方法锁。
Lock锁同步代码块,性能更好。且具有更好的扩展性(Lock有更多子类),上面使用的是ReentrantLock子类。
优先使用顺序:Lock > 同步代码块 > 同步方法。
3.4 总结
虽然同步锁机制控制了线程对资源的顺序访问,解决了多线程操作同一个共享资源的时候可能会出现线程安全问题(并发随机性)。有好的一面,自然也有坏的一面,由于控制了线程对资源的顺序访问,未获得锁的线程必须等待,这样导致了虽然线程安全,但是运行性能差。而线程不安全性能好。
所以开发中如果不存在多线程安全问题,建议不使用代码同步机制。但存在线程安全问题还是需要使用线程安全的设计。
4 线程池
4.1 介绍
引入:在并发的线程数量多情况下,经常创建和销毁占用内存空间而且消耗时间,降低程序执行效率。
线程池:提前创建好多个线程,放入线程池中,使用时从中提取,使用完放回池中,实现重复利用。这样可以避免频繁创建和销毁线程,导致消耗资源和时间。线程池的核心思想就是:线程复用,同一个线程可以被重复利用来处理多个任务。
线程池中提前创建好线程的好处:
提高响应速度(减少创建新线程的时间)
降低资源消耗(重复利用线程池中的线程,不需要每次都创建)
便于线程管理
corePoolSize:线程池核心线程数
maximumPoolSize:线程池最大线程数
keepAliveTime:线程没有任务时最多保持多长时间后销毁
JDK 5开始提供了线程池相关API:ExecutorService接口和Executors类:
ExecutorService:真正的线程池接口,常见子类ThreadPoolExecutor:
public ThreadPoolExecutor(int corePoolSize, // 核心线程数量,长久不死的线程
int maximumPoolSize, // 最大支持执行线程数
long keepAliveTime, // 超过核心线程数部分的空闲线程等待新任务的最大时间
TimeUnit unit, // 设置时间单位
// 可执行任务的阻塞队列
BlockingQueue<Runnable> workQueue)
线程执行器方法 | 说明 |
---|---|
void execute(Runnable command) | 一般用来执行Runnable线程任务对象,没有返回值 |
<T>Future<T> submit(Callable<T> task) | 提交一个Runnable线程任务对象或Callable对象再封装的线程任务对象给线程池执行 |
void shutdown() | 等待任务执行完毕后才关闭线程池 |
void shutdownNow() | 立即关闭线程池,无论任务是否执行完 |
Executors:线程池工具类,是用于创建并返回不同类型的线程池的工厂类。
public static ExecutorService newFixedThreadPool(int nThread):创建一个可重用固定线程数的线程池返回。底层还是创建返回了一个线程池执行器ThreadPoolExecutor。
总结
线程执行器的顶级接口是Executor,ExecutorService继承于它,最终真正线程执行器实现类是ThreadPoolExecutor:
Executor <- ExecutorService <- AbstractExecutorService <- ThreadPoolExecutor
Executors是线程池工具类,用于方便创建并返回不同类型的线程池的工厂类。有时也可以自己创建线程执行器,对线程池进行更精细的设置。
4.2 简单示例
以提交Runnable线程任务对象为例:
public class ThreadPoolsDemo02 {
public static void main(String[] args) {
// 1.创建一个线程池,指定线程的固定数量是3.
// 底层是:new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
ExecutorService pools = Executors.newFixedThreadPool(3);
// 2.创建线程的任务对象。
Runnable target = new MyRunnable();
// 3.把线程任务放入到线程池中去执行。
pools.submit(target); // 提交任务,取线程执行
pools.submit(target); // 提交任务,取线程执行
pools.submit(target); // 提交任务,取线程执行
pools.submit(target); // 不会再创建新线程,会复用之前的线程来处理这个任务
pools.submit(target); // 不会再创建新线程,会复用之前的线程来处理这个任务
// 等待任务执行完毕以后才会关闭线程池
pools.shutdown();
//pools.shutdownNow(); // 立即关闭线程池的代码,无论任务是否执行完毕
}
}
class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"执行任务");
}
}
同样也可以直接提交Callable对象,但和提交Runnable线程任务对象有所不同:(详细说明可以看线程实现的总结)
提交Runnable线程任务对象后会封装为一个未来任务对象返回,可以通过get()提取结果,但结果中没有线程执行方法的返回值;提交Callable对象后会将该对象封装变为未来任务对象(FutureTask类恰是Runnable接口的实现类),可以通过get()提取结果,结果中有有线程执行方法的返回值和异常。
5 死锁
5.1 介绍
死锁:多个线程因为竞争有限资源而同时阻塞。
从其中一个线程的角度来看,该线程占有自己资源,并且请求访问被别的线程占有的资源,然后阻塞死等人家释放。
同样的其他线程也是这种情况,保持并请求。最后形成闭环(循环等待),就发生了死锁。
产生死锁的四个必要条件:
条件 | 说明 |
---|---|
互斥使用 | 当前执行线程具有排他性 |
不可抢占 | 请求线程不能强制从执行线程手中剥夺资源 |
请求与保持 | 当前执行线程在请求其他资源的同时保持对原有资源的占有 |
循环等待 | 是上面的三个条件的产生结果,p1要p2的资源,p2要p1的资源,两线程抱一起就形成了循环等待。 |
互斥
请求与保持
不剥夺
循环等待
上面列出了死锁的四个必要条件,我们只需要破坏其中的一个或多个条件就可以避免死锁。
5.2 必然死锁案例
案例一:简单死锁
创建两个线程,声明两个资源(同步监视器),线程1占用资源1后,请求资源2;线程2占用资源后 ,请求资源1。
public class ThreadDead {
// 1.至少需要两份资源。
public static Object resources1 = new Object();
public static Object resources2 = new Object();
public static void main(String[] args) {
// 2.创建2个线程。
new Thread(new Runnable() {
@Override
public void run() {
// 线程1:占用资源1 ,请求资源2
synchronized (resources1){
System.out.println("线程1已经占用资源1,请求访问资源2");
try {
// 线程休眠,让线程2占用资源2后再请求访问资源2,最终产生必然死锁
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (resources2){
System.out.println("线程1已经占用资源2");
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
// 线程2:占用资源2 ,请求资源1
synchronized (resources2){
System.out.println("线程2已经占用资源2,请求访问资源1");
try {
// 线程休眠,让线程1占用资源1后再请求访问资源1,最终产生必然死锁
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (resources1){
System.out.println("线程2已经占用资源1");
}
}
}
}).start();
}
}
程序运行到此由于死锁不在进行下去,线程1与线程2都在互相死等对方释放资源(锁):
案例二:多线程多资源死锁(哲学家就餐问题)
OOA:面向对象分析
五个哲学家围坐一张桌子,左手边和右手边都分别有一双筷子,哲学家吃饭时需要从左右先后拿一支左筷和一支右筷,拿左筷时左边哲学家不能拿右筷。可以想象一个哲学家左手拽着左筷不放,右手准备拿右筷的情景。(互斥、不可抢夺和保持与请求)这样每个哲学家都拽着左筷,然后准备拿右筷,但发现别人都拽着,就在死等自己右边的哲学家放开筷子。(循环等待)
这样所有哲学家都占有左筷不放而又访问右筷,于是就产生了死锁。
OOD:面向对象设计
五双筷子是五份资源对象,五个哲学家是来争夺筷子的五个线程对象。五个哲学家分别都争夺在自己位置的左筷和右筷,即每个线程争夺两个连续资源对象,而最后一个哲学家争夺最后一个资源和第一个资源,这样形成如同现实中围坐一起的哲学家争夺筷子一样。
但是要产生必然死锁,需要在线程在抢到前一个资源时(左筷),休眠一定的时间直到其他线程都抢到了前一个资源(左筷),然后访问后一个资源(右筷)。这样就产生了死锁。
// 随便定义筷子类
public class Chopstick {
}
// 定义哲学家线程类
// 在线程执行方法中哲学家拽住左筷后又申请访问拽右筷
public class Philosopher extends Thread {
private Chopstick left, right;
private int index;
public Philosopher(String name, Chopstick left, Chopstick right, int index){
// super(name);
this.setName(name);//和调用父类构造器初始化一样为线程取名
this.left = left;
this.right = right;
this.index = index;
}
@Override
public void run(){
synchronized (left) {//左手边筷子需要和左边人抢,获得左手边筷子之后还需要右手边筷子,不放下左筷去访问右手边筷子👇。
System.out.println("哲学家"+Thread.currentThread().getName()+"占有左筷,准备抢右筷");
try {
Thread.sleep(1000+index*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (right) {//右手边筷子也需要和右边人抢
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(index+"号哲学家吃完了");
}
}
}
}
// 初始化五双筷子(五份资源),传递给五个哲学家线程抢夺(一个筷子对象可以被左边哲学家将其当作一支右筷,也可以被右边哲学家当作一支左筷)
// 开启五个哲学家线程,开始抢筷子准备吃饭
public class TestDemo {
public static void main(String[] args) {
Chopstick cs0 = new Chopstick();
Chopstick cs1 = new Chopstick();
Chopstick cs2 = new Chopstick();
Chopstick cs3 = new Chopstick();
Chopstick cs4 = new Chopstick();
// 用线程池不容易发生死锁
// ThreadPoolExecutor pool = new ThreadPoolExecutor(
// 3,//核心线程数量,不会死的线程
// 16,//线程池总大小
// 60,//空闲时间
// TimeUnit.SECONDS,//单位时间
// new ArrayBlockingQueue<>(2),//阻塞队列
// Executors.defaultThreadFactory(),//线程工厂
// new ThreadPoolExecutor.AbortPolicy()//拒绝策略抛出异常
// );
// 提示:虽然为线程类命名了,但通过提交线程任务对象,线程池中执行任务的线程对象有自己的命名,不会执行线程任务的命名。
// pool.submit(new Philosopher("p0",cs0,cs1,0));
// pool.submit(new Philosopher("p1",cs1,cs2,1));
// pool.submit(new Philosopher("p2",cs2,cs3,2));
// pool.submit(new Philosopher("p3",cs3,cs4,3));
// pool.submit(new Philosopher("p4",cs4,cs0,4));
// 用依次创建新线程的方式,让线程顺序执行,才会产生必然死锁
// 用线程池就不用自己注册创建线程了
new Philosopher("p0", cs0, cs1, 0).start();
new Philosopher("p1", cs1, cs2, 1).start();
new Philosopher("p2", cs2, cs3, 2).start();
new Philosopher("p3", cs3, cs4, 3).start();
new Philosopher("p4", cs4, cs0, 4).start();
}
}
每个线程都占有前一个资源(左筷)且不放,然后访问后一个资源(右筷),循环死等,导致死锁:
注意:如果不顺序注册开启线程,线程执行顺序不一样,线程拿到前左筷后休眠的时间不固定,就会拿到左筷,然后就不会产生必然死锁了。这就是为什么使用线程池执行任务就不会产生必然死锁了:
另:Thread类是Runnable接口的实现类,所有也可以直接提交到线程池执行。
6 线程通信
线程通信了解原理,代码几乎不用。以后有大型框架进行封装。
线程通信就是线程协作。多个线程包含在同一个进程中,在执行的过程中有时需要进行通信协作。应用场景:生产者与消费者问题,。这是一个线程同步问题,生产者和消费者共享一个资源,并且生产者与消费者之间相互依赖,互为条件。
在生产者消费者问题中,因为生产者和消费者共享一个资源,除了需要实现不同线程之间的消息传递以外,还需要保证线程安全。
6.1 通信协作方式
管程法(并发协作模型"生产者消费者模型"):
生产者:负责生成数据的模块(方法、对象、线程、进程)
消费者:负责处理数据的模块(方法、对象、线程、进程)
缓冲区:消费者不能直接使用生产者的数据,他们之间有个"缓冲区"。生产者将生成好的数据放入缓冲区,消费者从缓冲区拿出数据
信号灯法(并发协作模型"生产者消费者模型"):声明标志位,通过标志位的显示进行通知或生产。
6.2 线程协作模型中的方法
方法名 | 作用 |
---|---|
notifyAll() | 唤醒同一对象上所有调用wait()方法的线程,优先级高的线程优先调度执行 |
notify() | 唤醒一个处于等待状态的线程 |
wait() | 进入无限等待,直到其他线程通知,会释放锁等待其他线程唤醒再获取锁与sleep不同 |
wait(long timeout) | 进入指定毫秒数的等待,达到时间还没有其他线程唤醒自己醒过来进入就绪状态。时间根据业务决定。 |
以上方法均是Object类的方法,都只能在同步方法或同步代码块中使用,否则会抛出异常IllegalMonitorStateException。
6.3 生产者消费者模型示例
如下定义了一个便利店的类,成员变量有便利店序号以及泡面余量,成员方法是泡面进货和泡面取货。定义取货线程后,初始化并开启老王、老张两个线程不断来买该便利店的泡面(取货线程执行drawNoodles);定义进货线程后,初始化并开启服务员1-3三个线程不断来进货(进货线程执行addNoodles)。在方法中进行判断,操作完成后,通知唤醒其他线程,并等待自己。(生产者消费者模型中一个线程的整个过程就是运行,唤醒其他线程,等待,被唤醒,运行,唤醒其他线程,等待,被唤醒...)
public class MiniMart {
private String martNumber ;
private int noodles ; // 余量。
public MiniMart() {
}
public MiniMart(String martNumber, int noodles) {
this.martNumber = martNumber;
this.noodles = noodles;
}
// 服务员123
public synchronized void addNoodles(int noodles) {
try{
// 1.获取来进货的服务员
String name = Thread.currentThread().getName();
// 2.判断泡面是否足够
if(this.noodles > 0){
// 5.泡面足够,等待自己,唤醒老王和老张来买!
this.notifyAll();
this.wait();
}else{
// 3.泡面不够,进货
this.noodles += noodles;
System.out.println(name+"来进货,进了"+noodles+"包泡面,剩余:"+this.noodles+"包"+"\r\n");
// 4.等待自己,唤醒别人!
this.notifyAll();
this.wait();
}
}catch (Exception e){
e.printStackTrace();
}
}
// 老王 老张线程调用(通过传递便利店对象调用)
public synchronized void drawNoodles(int noodles) {
try{
// 1.知道是谁来取钱
String name = Thread.currentThread().getName();
// 2.判断余额是否足够
if(this.noodles > 0){
// 3.账户有钱,有钱可以取
this.noodles -= noodles;
System.out.println(name+"来买"+noodles+"包泡面,剩余:"+this.noodles+"包");
// 4.没钱,先唤醒别人,等待自己,。
this.notifyAll();
this.wait();
}else{
// 5.泡面不足,先唤醒服务员,等待自己。
this.notifyAll();
this.wait();
}
}catch (Exception e){
e.printStackTrace();
}
}
Setter and Getter...
}
首先定义精简的输出:(只输出进货或者取货线程的主要操作)
然后定义更详细的输出:(输出线程的所有操作,可以看到多线程在并发运行过程中的随机性,一个线程等待自己唤醒其他线程,四个线程随机获得锁执行操作,但是在同步机制下,数据是一致的)