前言
通过本文我们将会了解到基本的多线程的知识。
一、线程安全的问题
在了解线程的安全问题前,我们先来看一个需求:
某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟电影院卖票。
分析:
有三个窗口,窗口各自都是独立的,可以将这3个窗口当作3个线程,在线程中执行的是卖票的代码。
1.创建一个类,继承
Thread
类2.定义变量
int ticked
,表示票数3.在
run()
方法中执行循环,当票数小于100时,票数自减,继续循环,直到票数卖完,循环结束。若是根据上述分析,最后的结果是三个线程,每个线程都卖了100张票,总共卖了300张票,并不是正确的需求
那么把变量
ticked
类型改为static int
就可以让三个线程共享一个数据但此时运行代码会发现,三个线程在执行的时候会有重复出现,甚至还有超出100范围的,这不是我们想要的,还是存在问题。
package com.practice.threaddemo1; /** * @Author YJ * @Date 2023/7/21 19:07 * Description:卖票线程代码 */ public class MyThread extends Thread { //通过静态变量实现三个线程共享 static int ticked = 0; @Override public void run() { while (true) { if (ticked < 100) { //每次卖票前睡一会 try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } ticked++; System.out.println(getName() + "正在卖第" + ticked + "张票!"); } else { break; } } } }
package com.practice.threaddemo1; /** * @Author YJ * @Date 2023/7/21 19:02 * Description:模拟电影院卖票 */ public class MyThreadDemo { public static void main(String[] args) { //1.创建线程对象 MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); MyThread t3 = new MyThread(); //2.设置线程名字 t1.setName("窗口1"); t2.setName("窗口2"); t3.setName("窗口3"); //3.开启线程 t1.start(); t2.start(); t3.start(); } }
通过上面的买票需求,我们发现线程存在的安全问题,会出现重复和超出范围的情况,为什么会出现这种情况呢,我们可以通过线程的执行结合代码分析:
原因分析:
1.票数重复:
在线程开启的时候,三个线程都在抢夺CPU的资源,假设线程一在开始抢到了CPU的执行权,线程一就会继续往下执行,进入线程后,满足判断条件,接着会立马睡10毫秒(自定义的),此时线程一不会抢夺CPU的执行权,线程二和线程三一定会有一个抢到CPU的执行权,,假设是线程二抢到了,它也会继续往下执行,通过判断条件,也同样会睡10毫秒,此时CPU的执行权一定会被其他线程抢到,所以线程三也会睡10毫秒,于是当线程各自醒来后,继续抢夺CPU执行权,假设是线程一抢到了,
ticked
会自增变成1,此时线程一还没来得及打印,线程二就抢到了CPU的执行权,ticked
又自增变成了2,同样的线程三也会抢到CPU执行权,ticked
自增到3,接下来无论是哪个线程继续往下打印,ticked
结果都是3,这样就出现了重复的情况。2.票数超出范围:
当票数到达99张时,三个线程还是在抢夺CPU执行权,线程一抢到后进入循环睡10毫秒,线程二抢到同样睡10毫秒,线程三同样进来睡10毫秒,睡完后陆续醒来继续执行下面的代码,线程一醒来后
ticked
自增变为100,还没来得及打印,线程二醒来执行代码ticked
子增变为101,还没来得及打印,线程三醒来执行代码ticked
子增变为了102,接下来无论哪个线程打印,结果都是102,票数超出了范围。上述根本原因是:线程执行时有随机性
解决方案:
将要执行的循环语句锁起来,这样当第一个线程抢到了CPU执行权,若是线程一的循环还没有执行完,线程二抢到了CPU执行权,由于循环是被锁住的,线程二就必须等待线程一执行完后才能进入循环。
二、同步代码块
2.1同步代码块实现方式
格式:
synchronized(锁对象){操作共享数据的代码}
特点1:锁默认打开,有一个线程进去了,锁自动关闭
特点2:里面的代码全部执行完毕,线程出来,锁自动打开
锁对象一定要是唯一的
通过锁来解决线程安全问题被叫做同步代码块
package com.practice.threaddemo1; /** * @Author YJ * @Date 2023/7/21 19:07 * Description:同步代码块 */ public class MyThread extends Thread { static int ticked = 0; //锁对象要唯一 static Object obj = new Object(); @Override public void run() { synchronized (obj) { while (true) { if (ticked < 100) { //每次卖票前睡一会 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticked++; System.out.println(getName() + "正在卖第" + ticked + "张票!"); } else { break; } } } } }
2.1同步代码块实现细节
分析上述同步代码块实现卖票的方式,结果只有一个线程卖完了所有的票,也就是说,一个线程抢到CPU执行权后进入循环,直到这个线程执行完所有的代码后循环结束,票数也增加到了100,后面的线程再进入循环时已经不符合循环条件,所以循环直接结束。
所以要注意的是,
synchronized
锁应该放在循环里面。既然锁对象是唯一的,我们可以直接将当前类的字节码对象作为唯一的锁对象,字节码对象一定是唯一的。
package com.practice.threaddemo1; /** * @Author YJ * @Date 2023/7/21 19:07 * Description:同步代码块 */ public class MyThread extends Thread { static int ticked = 0; @Override public void run() { while (true) { synchronized (MyThread.class) { if (ticked < 100) { //每次卖票前睡一会 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticked++; System.out.println(getName() + "正在卖第" + ticked + "张票!"); } else { break; } } } } }
结果:
二、同步方法
同步方法:就是将
synchronized
直接加在方法上格式:
修饰符 synchronized 返回值类型 方法名(方法参数){...}
**特点1:**同步方法是锁住方法里面所有的代码
**特点2:**锁对象不能自己指定(非静态的:this静态的:当前的字节码对象)
我们可以通过同步方法实现上述卖票需求:
package com.practice.threaddemo2; /** * @Author YJ * @Date 2023/7/21 21:00 * Description:同步方法 */ public class MyRunnable implements Runnable { //只创建一次,不需要static修饰 int ticket = 0; @Override public void run() { //1.循环 //2.同步代码块 //3.判断共享数据是否到了末尾,如果到了末尾 //4.判断共享数据是否到了末尾,如果没有到末尾 while (true) { //同步方法 if (method()) break; } } private synchronized boolean method() { if(ticket==100) { return true; } else { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票!!!"); } return false; } }
StringBuffer
线程安全的原因是它的所有方法都有synchronized
修饰而StringBuilder
没有synchronized
修饰,这就是同步方法保证线程安全的原因。
三、Lock锁
虽然我们可以理解同步代码块和同步方法的锁对象问题,但是我们并没有直接看到在哪里加上了锁,在哪里释放了锁,为了更清晰的表达如何加锁和释放锁,JDK5之后提供了一个新的锁对象Lock锁
Lock实现提供比使用
synchronized
方法和语句获得更广阔的锁定操作Lock中提供了获得锁和释放锁的方法
void lock()
:获得锁
void unlock()
:释放锁手动上锁,手动释放锁
Lock是接口,不能实例化,这里采用它的实现类
ReentrantLock
来实例化
ReentrantLock
的构造方法
ReentrantLock()
:创建一个ReentrantLock
的实例
package com.practice.threaddemo3; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * @Author YJ * @Date 2023/7/21 19:07 * Description:Lock锁 */ public class MyThread extends Thread { static int ticked = 0; static Lock lock = new ReentrantLock(); @Override public void run() { while (true) { // synchronized (MyThread.class) { lock.lock(); try { if (ticked == 100) { break; } else { //每次卖票前睡一会 Thread.sleep(10); ticked++; System.out.println(getName() + "正在卖第" + ticked + "张票!"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } //} } } }
注意:在多线程使用锁的时候,不能让两个锁嵌套起来,两个锁嵌套有可能导致死锁的产生。
四、生产者和消费者(等待唤醒机制)
生产者消费者模式是一个十分经典的多线程协作的模式。
void wait()
:当前线程等待,直到被其他线程唤醒void notify()
:随机唤醒单个线程void notifyAll()
:唤醒所有线程
4.1生产者和消费者的思路分析
- 假设有一个吃货线程表示消费者,厨师线程表示生产者,有一个桌子,桌子上有面条,吃货线程执行吃,厨师线程负责等,桌子上没有面条,吃货就负责等,厨师生产面条。
- 生产者和消费者的理想情况:
- 厨师线程生产了一碗面条,放到桌子上,吃货线程吃一碗面条,相当于厨师做一碗面条,吃货吃一碗面条。
但是线程执行具有随机性,并不一定会是这种理想情况。生产者和消费者(消费者等待):
当两个线程启动时,若是消费者线程先抢到CPU执行权,但发现并没有任务要执行,这时消费者线程就需要等待
wait
,此时CPU执行权一定会被生产者线程抢到,生产者开始布置任务,布置完成后,消费者线程还是处于等待状态的,此时生产者线程就需要告诉消费者线程可以执行任务了,这个动作叫做唤醒notify
。
- 消费者(消费数据):
- 1.判断桌子上是否有食物
- 2.如果没有就等待
- 生产者(生产数据):
- 1.制作食物
- 2.把食物放在桌子上
- 3.叫醒等待的消费者开吃
生产者和消费者(生产者等待):
当两个线程启动时,生产者抢到了CPU执行权,没有任务要执行,生产者开始布置任务,布置完成后,即使没有消费者在等待,仍然可以执行唤醒
notify
操作,而在下一步还是生产者抢到了CPU执行权,但此时已经有任务了,生产者就不能再去布置任务了,所以生产者就要等待wait
。
- 消费者(消费数据):
- 1.判断桌子上是否有食物
- 2.如果没有就等待
- 生产者(生产数据):
- 1.判断桌子上是否有食物
- 2.有:等待
- 3.没有:制作食物
- 4.制作食物
- 5.把食物放在桌子上
- 6.叫醒等待的消费者开吃
4.2生产者和消费者的代码实现
- 桌子:
package com.practice.waitandnotify; /** * @Author YJ * @Date 2023/7/22 8:12 * Description:控制生产者和消费者的执行(桌子) */ public class Desk { //是否有面条:0.没有 1.有 public static int foodFlag = 0; //总个数 public static int count = 10; //锁对象 public static Object lock = new Object(); }
- 生产者(厨师):
package com.practice.waitandnotify; /** * @Author YJ * @Date 2023/7/22 8:11 * Description:生产者(厨师) */ public class Cook extends Thread{ @Override public void run() { /** * 1.循环 * 2.同步代码块 * 3.判断共享数据是否到了末尾(到了末尾) * 4.判断共享数据是否到了末尾(没到末尾) */ while (true) { synchronized (Desk.lock) { if(Desk.count == 0) { break; } else{ //1.判断桌子上是否有食物 if(Desk.foodFlag == 1) { //2.有:等待 try { Desk.lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } else { //3.没有:制作食物 System.out.println("厨师做了一碗面条"); //4.修改食物状态 Desk.foodFlag = 1; //5.唤醒等待的消费者 Desk.lock.notifyAll(); } } } } } }
- 消费者(吃货):
package com.practice.waitandnotify; /** * @Author YJ * @Date 2023/7/22 8:11 * Description:消费者(吃货) */ public class Foodie extends Thread{ @Override public void run() { /** * 1.循环 * 2.同步代码块 * 3.判断共享数据是否到了末尾(到了末尾) * 4.判断共享数据是否到了末尾(没到末尾) */ while (true) { synchronized(Desk.lock) { if(Desk.count == 0) { System.out.println("已经吃不下了~~"); break; } else { //判断桌子上是否有面条 if(Desk.foodFlag == 0) { //没有:等待 try { Desk.lock.wait();//让当前锁跟这个线程绑定 } catch (InterruptedException e) { e.printStackTrace(); } } else { //把吃的总数-1 Desk.count--; //有:开吃 System.out.println("正在吃,还能再吃" + Desk.count + "碗~"); //吃完了:唤醒厨师 Desk.lock.notifyAll(); //修改桌子状态 Desk.foodFlag = 0; } } } } } }
- 代码运行:**
package com.practice.waitandnotify; /** * @Author YJ * @Date 2023/7/22 8:34 * Description:运行 */ public class ThreadDemo { public static void main(String[] args) { //创建线程对象 Cook cook = new Cook(); Foodie foodie = new Foodie(); cook.setName("厨师"); foodie.setName("吃货"); cook.start(); foodie.start(); } }
- 结果:
4.3等待唤醒机制(阻塞队列方式实现)
- 阻塞队列的继承结构:
Iterable
Collection
Queue
BlockingQueue
- 实现类:
ArrayBlockingQueue
:底层是数据,有界,必须指定长度LinkedBlockingQueue
:底层是链表,无界,但不是真正的无界,最大为int
的最大值
代码实现:
细节:生产者和消费者必须使用同一个阻塞队列。
package com.practice.waitandnotifyblockingqueue; import java.util.concurrent.ArrayBlockingQueue; /** * @Author YJ * @Date 2023/7/22 9:13 * Description:生产者 */ public class Cook extends Thread{ ArrayBlockingQueue<String> queue; public Cook(ArrayBlockingQueue<String> queue) { this.queue = queue; } @Override public void run() { while (true) { try { queue.put("面条"); System.out.println("厨师放了一碗面条~"); } catch (InterruptedException e) { e.printStackTrace(); } } } }
package com.practice.waitandnotifyblockingqueue; import java.util.concurrent.ArrayBlockingQueue; /** * @Author YJ * @Date 2023/7/22 9:14 * Description:消费者 */ public class Foodie extends Thread{ ArrayBlockingQueue<String> queue; public Foodie(ArrayBlockingQueue<String> queue) { this.queue = queue; } @Override public void run() { while (true) { try { String food = queue.take(); System.out.println(food); } catch (InterruptedException e) { e.printStackTrace(); } } } }
package com.practice.waitandnotifyblockingqueue; import java.util.concurrent.ArrayBlockingQueue; /** * @Author YJ * @Date 2023/7/22 9:13 * Description:阻塞队列方式实现 */ public class ThreadDemo { public static void main(String[] args) { //1.创建阻塞队列对象 ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1); //2.创建线程对象,并把阻塞队列传递过去 Cook cook = new Cook(queue); Foodie foodie = new Foodie(queue); //3.开启线程 cook.start(); foodie.start(); } }
4.4线程的状态
五、线程池
以前写多线程的弊端:
- 1.用到线程的时候就创建(效率低)
- 2.用完后线程消失(浪费资源)
改进:
我们可以准备一个容器,用来存放线程,这个容器就叫做线程池,刚开始,容器中是空的,当给线程池提交一个任务时,线程池会自动地创建一个线程,用这个线程执行任务,执行完后,把线程返回给容器,等到下次再执行任务时,就不需要重新创建线程了。
特殊情况:
当第二个任务执行时,第一个任务还没有执行结束,线程池就要再创建一个新的线程,用这个新的线程执行任务,再来任务,继续创建线程,执行完后,都返回给线程池。
线程池中的线程创建是有上限的,可以自己定义最大线程数量,当任务过多,线程创建也达到上限时,未获取线程的任务只能排队等待。
核心原理:
- 1.创建一个池子,池子中是空的
- 2.提交任务时,池子会创建新的线程对象,任务执行完毕,线程归还给池子,下次再次提交任务时,不需要创建新的线程,直接复用已有的线程即可
- 3.如果提交任务时,池子中没有空闲的线程,也无法创建新的线程,任务就会排队等待。
代码实现:
- 1.创建线程池
- 2.提交任务
- 3.所有任务执行完毕,关闭线程池
Excutors
:线程池的工具类,通过调用方法返回不同类型的线程池对象。public static ExcutorService newCachedThreadPool()
:创建一个没有上限的线程池public static ExcutorService newFixedThreadPool()
:创建有上限的线程池
package com.practice.mythreadpool; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * @Author YJ * @Date 2023/7/22 10:20 * Description:创建没有上限的线程池 */ public class MyThreadPoolDemo1 { public static void main(String[] args) throws InterruptedException { //1.获取线程池对象 ExecutorService pool1 = Executors.newCachedThreadPool(); Thread.sleep(1000); //2.提交任务 pool1.submit(new MyRunnable()); Thread.sleep(1000); pool1.submit(new MyRunnable()); Thread.sleep(1000); pool1.submit(new MyRunnable()); Thread.sleep(1000); pool1.submit(new MyRunnable()); Thread.sleep(1000); pool1.submit(new MyRunnable()); //3.销毁线程池 //pool1.shutdown(); } }
package com.practice.mythreadpool; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * @Author YJ * @Date 2023/7/22 10:20 * Description:创建有上限的线程池 */ public class MyThreadPoolDemo2 { public static void main(String[] args) throws InterruptedException { //1.获取线程池对象 ExecutorService pool1 = Executors.newFixedThreadPool(3); Thread.sleep(100); //2.提交任务 pool1.submit(new MyRunnable()); Thread.sleep(100); pool1.submit(new MyRunnable()); Thread.sleep(100); pool1.submit(new MyRunnable()); Thread.sleep(100); pool1.submit(new MyRunnable()); Thread.sleep(100); pool1.submit(new MyRunnable()); //3.销毁线程池 //pool1.shutdown(); } }
六、自定义线程池
核心参数:
- 1.核心线程的数量(不能小于0)
- 2.线程池中最大线程数量(最大数量>=核心线程数量)
- 3.空闲时间(值),如60(不能小于0)
- 4.空闲时间(单位),如s(用TimeUnit指定)
- 5.阻塞队列(不能为null)
- 6.创建线程的方式(不能为null)
- 7.要执行的任务过多时的解决方案(不能为null)
注意:
自定义线程池可以创建核心线程和临时线程。
假设核心线程有3个,临时线程是3个,队伍长度为3个,表示线程池中最多有6个线程可用,而且其中3个临时线程只有在队伍满的情况下又来了任务才会创建并执行,先提交的任务不一定先执行。
若有8个任务要执行,3个核心线程执行3个任务,三个任务在队伍中等待,此时还有两个任务,那么此时就要创建2个临时线程执行两个任务,队伍中还有3个任务在等待。
若是任务过多,线程池满了,队伍也满了,还是有任务,这时就会触发任务拒绝策略:
ThreadPoolExcutor.AbortPolicy
:默认策略:丢弃任务并抛出RejectedExecutionException
异常ThreadPoolExcutor.DiscardPolicy
:丢弃任务,但不抛出异常,不推荐ThreadPoolExcutor.DiscarOldestPolicy
:抛弃队列中等待最持久的任务,然后把当前任务加入队列中ThreadPoolExcutor.CallerRunsPolicy
:调用任务的run()
方法绕过线程池直接执行
package com.practice.mythreadpool2; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * @Author YJ * @Date 2023/7/22 10:50 * Description:创建自定义线程池 */ public class MyThreadPoolDemo1 { public static void main(String[] args) throws InterruptedException { //创建自定义线程池对象 ThreadPoolExecutor pool = new ThreadPoolExecutor( 3,//核心线程的数量(不能小于0) 6,//线程池中最大线程数量(最大数量>=核心线程数量) 60,//空闲时间(值)(不能小于0) TimeUnit.SECONDS,//空闲时间(单位),如s(用TimeUnit指定) new ArrayBlockingQueue<>(3),//阻塞队列(不能为null) Executors.defaultThreadFactory(),//创建线程的方式(不能为null) // -- Executors.defaultThreadFactory()底层就是new了一个Thread new ThreadPoolExecutor.AbortPolicy()//任务拒绝策略 ); //提交任务 //... } }
6.1、最大并行数
以4核8线程为例:
4核表示的是电脑有4个大脑,利用超线程技术,就可以把原本的4个大脑虚拟成8个,也就是8线程。
可以在设备管理器或任务管理器中看到自己电脑的最大并行数:
也可通过Java虚拟机用代码查看:
package com.practice.mythreadpool2; /** * @Author YJ * @Date 2023/7/22 11:50 * Description:获取电脑最大并行数 */ public class MyThreadPoolDemo2 { public static void main(String[] args) throws InterruptedException { int count = Runtime.getRuntime().availableProcessors(); System.out.println(count); } }
6.2、线程池多大合适
CPU密集型运算: 最大并行数+1
I/O密集型运算:(读取本地文件较多、读取数据库文件较多)
最大并行数 * 期望CPU利用率 * (总时间(CPU计算时间+等待时间)) / CPU计算时间
总结
关于多线程的学习其实还有很多,目前介绍学习的是我们平时会用到的,希望会有帮助,我会继续学习并记录博客的学习笔记,欢迎大家关注+点赞!!!