Java基础学习(十六):多线程
- Java基础学习(十六):多线程
- 概念
- 多线程的实现方式
- 常见成员方法
- 线程安全问题
- 同步代码块
- 同步方法
- Lock 锁
- 生产者消费者模式(等待唤醒机制)
- 线程池
本文为个人学习记录,内容学习自 黑马程序员
概念
- 进程:程序的基本执行实体
- 线程:操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位
- 并发:在同一时刻,有多个指令在单个 CPU 上交替执行
- 并行:在同一时刻,有多个指令在多个 CPU 上同时执行
- JUC:
java.util.concurrent
包的缩写
多线程的实现方式
在 Java 中,多线程有三种实现方式:
-
继承 Thread 类的方式进行实现
步骤:① 自己定义一个类继承 Thread 类 ② 重写 run 方法 ③ 创建子类的对象 ④ 启动线程
public class Test {public static void main(String[] args) {// 3.创建对象MyThread t = new MyThread();// 4.使用start方法启动线程t.start();} }// 1.继承Thread类 class MyThread extends Thread {// 2.重写run方法@Overridepublic void run() {// 线程具体执行代码} }
-
实现 Runnable 接口的方式进行实现
步骤:① 自己定义一个类实现 Runnable 接口 ② 重写 run 方法 ③ 创建自己类的对象 ④ 创建 Thread 类的对象 ⑤ 启动线程
public class Test {public static void main(String[] args) {// 3.创建自己的类对象MyRun r = new MyRun();// 4.创建Thread类对象,并将自己的类作为参数Thread t = new Thread(r);// 5.使用start方法启动线程t.start();} }// 1.实现Runnable接口 class MyRun implements Runnable {// 2.重写run方法@Overridepublic void run() {// 线程具体执行代码} }
-
利用 Callable 接口和 Future 接口方式实现
特点:有返回值,即可以获取到多线程运行的结果
步骤:① 创建一个类实现 Callable 接口 ② 重写 call 方法 ③ 创建自己类的对象 ④ 创建 FutureTask 类的对象 ⑤ 创建 Thread 类的对象 ⑥ 启动线程 ⑦ (可选)通过FutureTask类的get()方法得到返回值
public class Test {public static void main(String[] args) throws ExecutionException, InterruptedException {// 3.创建自己类的对象MyCallable mc = new MyCallable();// 4.创建FutureTask类的对象,并将自己类的对象作为参数FutureTask<Integer> ft = new FutureTask<>(mc);// 5.创建Thread类的对象,并将FutureTask类的对象作为参数Thread t = new Thread(ft);// 6.启动线程t.start();// 7.(可选)通过FutureTask类的get()方法得到返回值Integer result = ft.get();} }// 1.实现Callable接口,泛型为返回值类型 class MyCallable implements Callable<Integer> {// 2.重写call方法,方法体为要在线程中执行的内容,此处为求和计算,并将求和结果进行返回@Overridepublic Integer call() {int sum = 0;for (int i = 0; i < 10; i++) {sum += i;}return sum;} }
三种实现方式的对比:
优点 | 缺点 | |
---|---|---|
继承 Thread 类 | 编程简单,可以直接使用 Thread 类中的方法 | 无法再继承其他的类,扩展性差 |
实现 Runnable 接口 | 可以继承其他的类,扩展性较强 | 编程相对复杂,无法直接使用 Thread 类中的方法 |
实现 Callable 接口 | 可以继承其他的类,还能得到线程返回值,扩展性强 | 编程相对复杂,无法直接使用 Thread 类中的方法 |
常见成员方法
方法名 | 说明 |
---|---|
String getName() | 返回此线程的名称 |
void setName(String name) | 设置线程的名字(构造方法也可以设置名字) |
static Thread currentThread() | 获取当前线程的对象 |
static void sleep(long time) | 让线程休眠指定的时间,单位为毫秒 |
setPriority(int newPriority) | 设置线程的优先级 |
final int getPriority() | 获取线程的优先级 |
final void setDaemon(boolean on) | 设置为守护线程 |
public static void yield() | 出让线程/礼让线程 |
public void join() | 插入线程/插队线程 |
注意事项:
-
线程默认命名格式:Thread-X(X 为序号,从 0 开始,第一个线程为 Thread-0,第二个为 Thread-1,依此类推)
public class Test {public static void main(String[] args) {MyThread mt = new MyThread();// 手动设置线程名字mt.setName("123");mt.start();} }class MyThread extends Thread {@Overridepublic void run() {// 获取线程名字System.out.println(getName());} }
-
有两个方式设置线程名字:1. set() 方法 2. 构造方法
但要注意:使用构造方法设置线程名字时,由于构造方法不能继承,因此需要在子类的构造方法中调用父类的构造方法
class MyThread extends Thread {// 无参构造public MyThread() {}// 其中一个有参构造public MyThread(String name) {super(name);}@Overridepublic void run() {System.out.println(getName());} }
-
哪条线程执行到 sleep() 方法,就会停留对应时间,只有等待时间到了之后才会继续执行后续代码
-
线程的优先级:最小是 1,最大是 10,默认是 5
-
CPU 的调度方式分成抢占式调度和非抢占式调度,前者由多个线程随机抢占 CPU 使用权,后者由多个线程轮流使用 CPU,在 Java 中采用抢占式调度,优先级越高的线程抢占到 CPU 使用权的概率就越高。因此对于相同的任务,如果有两个线程同时执行该任务,优先级高的线程通常能更快地执行完任务
-
守护线程:当所有的非守护线程执行完毕后,守护线程中的内容就算还没执行完,短时间内也会陆续终止
public class Test {public static void main(String[] args) {MyThread1 mt1 = new MyThread1();MyThread2 mt2 = new MyThread2();// 将线程2设置为守护线程mt2.setDaemon(true);mt1.start();mt2.start();} }class MyThread1 extends Thread {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(getName() + "@" + i);}} }class MyThread2 extends Thread {@Overridepublic void run() {for (int i = 0; i < 100; i++) {System.out.println(getName() + "@" + i);}} }
-
出让线程:出让当前线程的 CPU 控制权,重新进行控制权的抢夺,可以使得 CPU 控制权的分布尽可能均匀
public class Test {public static void main(String[] args) {MyThread t1 = new MyThread();MyThread t2 = new MyThread();t1.start();t2.start();} }class MyThread extends Thread {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(getName() + "@" + i);// 每执行完一次就出让控制权Thread.yield();}} }
-
插入线程:可以将某一线程插入到当前线程之前,等待某一线程全部执行完毕后才继续执行当前线程
public class Test {public static void main(String[] args) throws InterruptedException {MyThread t = new MyThread();t.start();// 将t线程插入到当前线程之前执行t.join();for (int i = 0; i < 10; i++) {System.out.println("main线程" + i);}} }class MyThread extends Thread {@Overridepublic void run() {for (int i = 0; i < 10; i++) {System.out.println(getName() + "@" + i);}} }
-
线程的生命周期:
线程安全问题
由于多个线程的执行具有随机性,CPU 的控制权随时可能会被其他线程抢走,因此可能存在静态/共享数据操作到一半时控制权被抢走,导致效果有误的安全问题
同步代码块
-
作用:把操作共享数据的代码锁起来,解决线程的安全问题
-
格式:
synchronized (锁) {操作共享数据的代码 }
-
特点:
特点1:锁默认打开,有一个线程进去了,锁自动关闭
特点2:锁里面的代码全部执行完毕,线程出来,锁自动打开
-
示例:使用同步代码块实现多个窗口同时卖票
public class Test {public static void main(String[] args) {MyThread t1 = new MyThread("窗口1");MyThread t2 = new MyThread("窗口2");MyThread t3 = new MyThread("窗口3");t1.start();t2.start();t3.start();} }class MyThread extends Thread {public MyThread() {}public MyThread(String name) {super(name);}static int ticket = 0;// 锁对象可以是任意的对象,但必须保证唯一,因此需要使用static修饰static Object lock = new Object();@Overridepublic void run() {while (true) {// 同步代码块synchronized (lock) {if (ticket < 100) {ticket++;System.out.println(getName() + "正在卖第" + ticket + "张票");} else {break;}}}} }
细节1:要实现多个线程卖票,必须将同步代码块放在循环内,否则会出现只有一个线程将所有票卖完的情况
细节2:锁对象必须是唯一的,如果锁对象不唯一就失去了上锁的功能了,通常使用
synchronized (MyThread.class)
作为锁对象
同步方法
-
定义:把 synchronized 关键字加到方法上,称之为同步方法
-
格式:
修饰符 synchronized 返回值类型 方法名(方法参数) {...}
-
特点:
特点1:同步方法锁住方法里面的所有代码
特点2:锁对象不能自己指定,对于非静态方法,锁对象为 this,对于静态方法,锁对象为当前类的字节码文件对象(例如 MyThread 类的字节码文件对象就为 MyThread.class)
-
同步方法的写法:可以先写同步代码块,再将同步代码块抽取成同步方法(选中代码块再 Ctrl + Alt + M)
public class Test {public static void main(String[] args) {MyRunnable mr = new MyRunnable();Thread t1 = new Thread(mr, "窗口1");Thread t2 = new Thread(mr, "窗口2");Thread t3 = new Thread(mr, "窗口3");t1.start();t2.start();t3.start();} }class MyRunnable implements Runnable {// 小细节:采用实现Runnable接口的方式实现多线程时,共享数据不需要使用static修饰,因为MyRunnable的对象mr是唯一的int ticket = 0;@Overridepublic void run() {while (true) {if (method()) break;}}private synchronized boolean method() {if (ticket == 100) {return true;} else {ticket++;System.out.println(Thread.currentThread().getName() + ": " + ticket);}return false;} }
Lock 锁
-
作用:同步代码块和同步方法无法手动上锁和解锁,为了更清晰地表达如何上锁和解锁,JDK5 后提供了新的锁对象 Lock
-
Lock 实现了更广泛的锁定操作,提供了获得锁和释放锁的方法
方法名 说明 void lock() 手动上锁 void unlock() 手动释放锁 -
Lock 是接口,不能直接实例化,一般采用它的实现类 ReentrantLock 来实例化
-
注意:使用 Lock 锁手动操作锁时,一定要注意锁的释放,否则可能出现其他线程一直在等待解锁,导致程序无法终止
-
示例:错误使用方法
class MyThread extends Thread {static int ticket = 0;// 实例化对象static Lock lock = new ReentrantLock();@Overridepublic void run() {while (true) {lock.lock();if (ticket == 100) {// 执行break后直接退出循环,不会执行lock.unlock(),导致上锁了但未解锁break;} else {ticket++;System.out.println(getName() + " : " + ticket);}lock.unlock();}} }
-
示例:正确使用方法,使用 try-catch-finally 语句确保锁的释放
class MyThread extends Thread {static int ticket = 0;static Lock lock = new ReentrantLock();@Overridepublic void run() {while (true) {lock.lock();try {if (ticket == 100) {break;} else {ticket++;System.out.println(getName() + " : " + ticket);}} catch (Exception e) {e.printStackTrace();} finally {// finally语句块中的内容就算在try中退出了循环也会执行lock.unlock();}}} }
生产者消费者模式(等待唤醒机制)
-
生产者消费者模式是一个十分经典的多线程协作的模式
-
常见方法
方法名 说明 void wait() 当前线程等待,直到被其他线程唤醒 void notify() 随机唤醒单个线程 void notifyAll() 唤醒所有线程 -
示例:生产者——厨师生产食物,消费者——顾客消费食物
public class Test {public static void main(String[] args) {Cook cook = new Cook();Foodie foodie = new Foodie();cook.start();foodie.start();} }// 生产者——厨师 class Cook extends Thread {@Overridepublic void run() {// 1.大循环while (true) {// 2.同步代码块synchronized (Desk.lock) {// 3.业务逻辑if (Desk.count == 0) {break;} else {if (Desk.foodFlag == 0) {System.out.println("做食物");Desk.foodFlag = 1;// 唤醒和这把锁绑定的所有线程Desk.lock.notifyAll();} else {try {// 让当前线程和锁绑定,等待被唤醒,释放锁Desk.lock.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}}}}}} }// 消费者——顾客 class Foodie extends Thread {@Overridepublic void run() {// 1.大循环while (true) {// 2.同步代码块synchronized (Desk.lock) {// 3.业务逻辑if (Desk.count == 0) {break;} else {if (Desk.foodFlag == 0) {try {// 让当前线程和锁绑定,等待被唤醒,释放锁Desk.lock.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}} else {System.out.println("吃食物");// 唤醒和这把锁绑定的所有线程Desk.lock.notifyAll();Desk.count--;Desk.foodFlag = 0;}}}}} }// 生产者和消费者以外的第三方,用于记录数据 class Desk {// 记录当前是否有食物public static int foodFlag = 0;// 记录当前剩余食物(线程结束条件)public static int count = 10;// 锁对象public static Object lock = new Object(); }
-
阻塞队列实现等待唤醒机制
-
阻塞队列一共实现了 4 个接口:Iterable,Collection,Queue,BlockingQueue,主要使用其中的两个实现类对象 ArrayBlockingQueue 和 LinkedBlockingQueue
-
ArrayBlockingQueue:底层由数组实现,有界
-
LinkedBlockingQueue:底层由链表实现,无界(不是真正的无界,最大值为 int 的最大值)
-
示例:
public class Test {public static void main(String[] args) {// 阻塞队列,生产者和消费者必须共用一个阻塞队列,此处定义队列容量为1,数据类型为StringArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1);Cook cook = new Cook(queue);Foodie foodie = new Foodie(queue);cook.start();foodie.start();} }class Cook extends Thread {ArrayBlockingQueue<String> queue;public Cook(ArrayBlockingQueue<String> queue) {this.queue = queue;}@Overridepublic void run() {while (true) {// 使用queue.put()向阻塞队列中添加数据,如果队列已满则等待// 需要注意queue.put()的底层存在上锁和解锁,无需使用同步语句块包围try {queue.put("面条");} catch (InterruptedException e) {throw new RuntimeException(e);}}} }class Foodie extends Thread {ArrayBlockingQueue<String> queue;public Foodie(ArrayBlockingQueue<String> queue) {this.queue = queue;}@Overridepublic void run() {while (true) {// 使用queue.take()从阻塞队列中获取数据,如果队列为空则等待// 需要注意queue.take()的底层存在上锁和解锁,无需使用同步语句块包围try {String food = queue.take();System.out.println(food);} catch (InterruptedException e) {throw new RuntimeException(e);}}} }
-
-
线程的状态
Java 中为线程规定了六种状态:新建状态(NEW),就绪状态(RUNNABLE),阻塞状态(BLOCKED),等待状态(WAITING),计时等待状态(TIMED_WAITING),结束状态(TERMINATED)
如下图所示,但不包括运行状态,因为运行时的控制权已经交由操作系统了
线程池
-
核心原理:①创建一个空的线程池 ②提交任务时,线程池会创建新的线程对象,任务执行完毕后线程归还给线程池,下一次提交任务时不需要创建新的线程,而是直接复用已有的线程 ③如果提交任务时,线程池中没有空闲线程,也无法再创建新的线程,就排队等待
-
采用工具类创建线程池
Executors
:线程池的工具类,通过调用方法返回不同类型的线程池对象方法名 说明 public static ExecutorService newCachedThreadPool() 创建一个没有上限的线程池(最大为 int 的大小) public static ExecutorService newFixedThreadPool(int nThreads) 创建一个有上限的线程池 public class Test {public static void main(String[] args) {// 1.创建对象ExecutorService pool1 = Executors.newCachedThreadPool();// 2.提交任务(submit() 方法的参数可以是 Runnable 的实现类或者是 Callable 的实现类)pool1.submit(new MyRunnable());// 3.销毁对象pool1.shutdown();} }class MyRunnable implements Runnable {@Overridepublic void run() {// 线程具体代码} }
-
自定义线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(可以有七个参数); /* 参数一:核心线程数量 不能小于0 参数二:最大线程数量 不能小于0,最大线程数量>=核心线程数量 参数三:临时线程最大空闲时间(值) 不能小于0 参数四:临时线程最大空闲时间(单位) 用TimeUnit指定 参数五:任务队列 不能为null 参数六:创建线程的方式 不能为null 参数七:要执行的任务过多时的任务拒绝策略 不能为null */
向自定义线程池中提交任务时,存在以下三个临界点:
- 当核心线程满时,再提交任务就会排队
- 当核心线程满,队伍满时,再提交任务会创建临时线程
- 当核心线程满,队伍满,临时线程满时,再提交任务会触发任务拒绝策略
举例:假设线程池的核心线程数为3,临时线程数为3,队列长度为3,那么当提交10个任务时,1、2、3号任务交由核心线程执行,4、5、6号任务进入队列等待,7、8、9号任务交由临时线程执行(只有核心线程分配完并且任务队列排满时才会创建临时线程),10号任务根据拒绝策略进行处理
任务拒绝策略(静态内部类) 说明 ThreadPoolExecutor.AbortPolicy 默认策略,丢弃任务并抛出异常 ThreadPoolExecutor.DiscardPolicy 丢弃任务,但不抛出异常 ThreadPoolExecutor.DiscardOldestPolicy 丢弃队列中最早的任务,将当前任务添加进队列中 ThreadPoolExecutor.CallerRunsPolicy 调用任务的run()方法绕过线程池直接执行 示例:
public class Test {public static void main(String[] args) {// 1.创建对象ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(3, // 核心线程数量6, // 最大线程数量60, // 临时线程最大空闲时间(值)TimeUnit.SECONDS, // 临时线程最大空闲时间(单位)new ArrayBlockingQueue<>(3), // 任务队列Executors.defaultThreadFactory(), // 创建线程的方式new ThreadPoolExecutor.AbortPolicy() // 任务的拒绝策略);// 2.提交任务(submit() 方法的参数可以是 Runnable 的实现类或者是 Callable 的实现类)threadPoolExecutor.submit(new MyRunnable());// 3.销毁对象threadPoolExecutor.shutdown();} }
-
线程池大小设置
最大并行数:取决于 CPU 型号,4 核 8 线程的 CPU 的最大并行数为 8
对于 CPU 密集型运算(CPU 运算比较多)的任务,一般设置线程池大小 = 最大并行数 + 1
对于 I/O 密集型运算(文件读取比较多)的任务,一般设置线程池大小 = 最大并行数 * 期望 CPU 利用率 * (CPU 计算时间 + 等待时间) / CPU 计算时间