多线程是并发编程的基础,本篇文章就来聊聊多线程
我们先聊聊概念,比如进程与线程,串行、并行与并发
再去聊聊线程的状态、优先级、同步、通信、终止等知识
什么是进程?
操作系统将资源分配给进程,使用进程进行调度,但进程遇到阻塞任务时,为了提升CPU利用率,会进行切换进程
由于切换进程的成本太高,线程就诞生了
线程又被称为轻量级进程(LWP),线程是操作系统的基本调度单位,当线程被分到CPU给的时间片时就能够进行调度任务
当线程等待资源遇到阻塞时,为了提升CPU利用率会将线程进行挂起,等到后续资源准备好了又将线程恢复,分配到时间片后继续执行
为了安全起见,线程分为用户态和内核态,使用线程操作普通的任务时处于用户态就可以调度执行,要完成某些有关操作系统安全性相关的操作时,需要先切换到内核态再进行操作
线程的挂起、恢复就需要在用户态与内核态中进行切换,频繁的切换线程也会带来一定的开销
当我们点击打开浏览器时,浏览器程序可能会启动一个或多个进程
一个进程下有一个或多个线程,进程用于管理操作系统所分配的资源,线程用于进行调度,并且同一进程下所有线程能共享进程的资源,而线程中为了存储调度的任务运行情况,也会有自己私有的内存空间对其进行存储
用户态与内核态的线程模型实现分为三种:用户线程与内核线程一对一、多对一和多对多
一对一模型
一对一模型实现简单,一个用户线程映射一个内核线程,Java中采用的模型就是一对一
但如果线程使用不当,可能导致频繁切换内核态,带来大量开销
并且内核线程资源是有限的,因此一对一模型中线程资源有上限
多对一
在多对一模型中
由于多个用户线程映射同一内核线程,相比于一对一模型能够使用的用户线程更多
但是当发生阻塞时要切换到内核态进行阻塞,该内核线程对应的所有用户线程都会被阻塞,其实现也会变复杂
多对多
在多对多模型中
不仅解决一对一模型线程上限问题,还解决多对一模型中内核线程阻塞对应所有用户线程都阻塞的问题
但实现变得更加复杂
为什么要用多线程?
随着硬件的发展,多数机器已经不在是单个核心CPU的机器,大量的机器都使用多核超线程技术
串行可以理解成排队执行,当线程分到CPU的资源时开始执行调度,线程可能进行IO任务的调度
此时会等待IO资源准备好才能进行调度,这段时间内CPU啥事也没干从而没有有效的利用CPU
为了提高CPU的利用率,在A线程等待IO资源时,可以将A线程先挂起,将CPU的资源分配给B线程
当A线程等待的IO资源准备好时,再将B线程挂起恢复A线程继续执行
两个线程在一段时间内看上去像在同时执行,实际上它们是交替执行,某个时刻上只有一个线程在执行
并发提升CPU的利用率,但也会带来线程上下文切换的开销
那什么又是并行呢?
上面说的串行、并发都在单线程下可以实现,但是并行的前提就是多核
并行指的是多个线程在某个时刻上也是同时执行,因此需要多核
那是不是多线程一定效率最快呢?
经过上面的分析,我们知道:线程挂起和恢复,上下文的切换会经过用户态、内核态的转换,会有性能开销
当线程太多、运行时频繁进行上下文切换,那么带来的性能开销甚至可能超过并发提升CPU利用率带来的收益
JDK中为我们提供的线程类是java.lang.Thread
,它实现Runnable接口,用构造接受Runnable的实现
public class Thread implements Runnable { private Runnable target; }
Runnable接口是函数式接口,其中只有run方法,run方法中的实现表示该线程启动后要去执行的任务
public interface Runnable { public abstract void run(); }
Java中创建线程的方式只有一种:创建Thread对象,再去调用start方法,启动线程
我们可以通过构造器创建线程的同时设置线程的名称,并设置要实现的任务(打印线程名称 + hello)
public void test(){ Thread a = new Thread(() -> { //线程A hello System.out.println(Thread.currentThread().getName() + " hello"); }, "线程A"); //main hello a.run(); a.start(); }
当主线程中调用run方法时,实际上是主线程去执行runnable接口的任务
前文我们说过,Java中的线程模型是一对一模型,一个线程对应一个内核线程
只有调用start方法时,才去调用本地方法(C++方法),启动线程执行任务
如果调用两次start则会抛出IllegalThreadStateException
异常
Java中的Thread的状态分为新建、运行、阻塞、等待、超时等待、终止
public enum State { //新建 NEW, //运行 RUNNABLE, //阻塞 BLOCKED, //等待 WAITING, //超时等待 TIMED_WAITING, //终止 TERMINATED; }
在操作系统中将运行分为就绪、运行中状态,当线程创建好后等待CPU分配时间片的状态就是就绪状态,分配到时间片运行就是运行中状态
新建:线程刚创建和还未获取到CPU分配的时间片
运行:线程获取到CPU分配的时间片,进行任务调度
阻塞:线程调度过程中,因无法获取共享资源导致进入阻塞状态(比如被synchronized阻塞)
等待:线程调度过程中,执行wait、join等方法进入等待状态,等待其他线程唤醒
超时等待:线程调度过程中,执行sleep(1)、wait(1)、join(1)等设置等待时间的方法时进入超时等待状态
终止:线程执行完调度任务或者异常执行进入终止状态
线程需要调度任务的前提是获取CPU资源(CPU分配的时间片)
在Java中提供setPriority
方法来设置获取CPU资源的优先级,范围是1~10,默认为5
//最小 public final static int MIN_PRIORITY = 1; //默认 public final static int NORM_PRIORITY = 5; //最大 public final static int MAX_PRIORITY = 10;
但设置的优先级只是Java层面的,映射到操作系统的优先级又是不同的
比如在Java设置优先级5或6,可能映射到操作系统的优先级处于同一级别
什么是守护线程?
可以把守护线程理解成后台线程,当程序中所有非守护线程执行完任务时,程序会结束
简而言之,无论守护线程是否执行完,只要非守护线程执行完,程序就会结束
因此守护线程可以用来做一些检查资源的后台操作
使用setDaemon(true)
方法让线程变成守护线程
当多线程需要使用共享资源时,由于共享资源数量有限,它们不能同时获取
每时刻只能有一个线程获取,其他未获取到共享资源的线程就需要被阻塞
如果多线程同时使用共享资源可能会造成逻辑错误
在Java中常用synchronized关键字使用加锁的方式来保证同步(只有一个线程能够访问共享资源)
synchronized (object){ System.out.println(object); }
其中object就是加锁的共享资源
使用synchronized时要去获取锁,获取锁后线程才能执行调度,当调度中不满足执行条件时,需要让出锁让其他线程执行
比如生产者/消费者模型,当生产者获取到锁要进行生产资源时,发现资源已经满了,它应该让出锁,等到消费者消费完时将它唤醒
这种等待/通知模式是实现线程通信的一种方式,Java提供wait、notify方法来实现等待/通知模式
使用wait、notify的前提是获取到锁
wait让当前线程释放锁进入等待模式,等待其他线程使用notify唤醒
wait(1)也可以携带等待的时间ms,当时间到达时自动唤醒,并开始竞争锁
notify 唤醒等待当前锁的某个线程
notifyAll 唤醒所有等待当前锁的线程
生产者、消费者模型中常用等待与通知进行线程通信
生产者检查到生产的资源已满时就进入等待,等待消费者消费完来唤醒,生产完再去唤醒消费者
消费者检查到没有资源时就进入等待,等待生产者生产完来唤醒,消费完再去唤醒生产者
生产
public void produce(int num) throws InterruptedException { synchronized (LOCK) { //如果生产 资源 已满 等待消费者消费 while (queue.size() == 10) { System.out.println("队列满了,生产者等待"); LOCK.wait(); } Message message = new Message(num); System.out.println(Thread.currentThread().getName() + "生产了" + message); queue.add(message); //唤醒 所有线程 LOCK.notifyAll(); } }
消费
public void consume() throws InterruptedException { synchronized (LOCK) { //如果队列为空 等待生产者生产 while (queue.isEmpty()) { System.out.println("队列空了,消费者等待"); LOCK.wait(); } Message message = queue.poll(); System.out.println(Thread.currentThread().getName() + "消费了" + message); //唤醒 所有线程 LOCK.notifyAll(); } }
sleep 方法用于让线程睡眠一段时间ms
与wait的区别是sleep睡眠时不会释放锁、并且使用sleep时不需要先获取锁
join方法用于等待某个线程执行完
比如,在主线程上调用thread.join()
就需要等待thread线程执行完,join方法才会返回
同时join也支持设置等待时间ms,超时自动返回
终止线程一般使用安全的终止方式:中断线程
线程运行时会保存一个标记位,默认为false,表示没有其他线程对其进行中断
当想要某个线程停止时,可以对其进行中断,比如线程A.interrupt()
: 对线程A执行中断操作 ,此时线程A的中断标识为true
当线程调度任务期间,轮询到中断标识为true时就会停止,可以使用线程A.isInterrupted()
: 查看线程A的中断标记
当线程进入等待状态时,被其他线程中断会发生中断异常,会清楚标志位并抛出中断异常;可以在catch块中捕获处理进行清理资源或资源的释放
当在根据中断标识循环执行时,还可以自己中断自己停止继续执行
Thread thread = new Thread(() -> { //中断标识为false就循环执行任务 while (!Thread.currentThread().isInterrupted()) { try { //执行任务 System.out.println(" "); //假设等待资源 TimeUnit.SECONDS.sleep(1); //获得资源后执行 } catch (InterruptedException e) { //等待时中断线程会在抛出异常前恢复标志位 //捕获异常时,重新中断标志(自己中断) Thread.currentThread().interrupt(); //结束前处理其他资源 } } // true System.out.println(" 中断标识位:" + Thread.currentThread().isInterrupted()); });
还有一种检测中断的方式Thread.interrupted()
: 查看当前线程的中断标记,并清除当前线程的中断标记,中断标记恢复为false