10分钟巩固多线程基础

2023年 9月 21日 53.5k 0

10分钟巩固多线程基础

前言

多线程是并发编程的基础,本篇文章就来聊聊多线程

我们先聊聊概念,比如进程与线程,串行、并行与并发

再去聊聊线程的状态、优先级、同步、通信、终止等知识

进程与线程

什么是进程?

操作系统将资源分配给进程,使用进程进行调度,但进程遇到阻塞任务时,为了提升CPU利用率,会进行切换进程

由于切换进程的成本太高,线程就诞生了

线程又被称为轻量级进程(LWP),线程是操作系统的基本调度单位,当线程被分到CPU给的时间片时就能够进行调度任务

当线程等待资源遇到阻塞时,为了提升CPU利用率会将线程进行挂起,等到后续资源准备好了又将线程恢复,分配到时间片后继续执行

为了安全起见,线程分为用户态和内核态,使用线程操作普通的任务时处于用户态就可以调度执行,要完成某些有关操作系统安全性相关的操作时,需要先切换到内核态再进行操作

线程的挂起、恢复就需要在用户态与内核态中进行切换,频繁的切换线程也会带来一定的开销

当我们点击打开浏览器时,浏览器程序可能会启动一个或多个进程

一个进程下有一个或多个线程,进程用于管理操作系统所分配的资源,线程用于进行调度,并且同一进程下所有线程能共享进程的资源,而线程中为了存储调度的任务运行情况,也会有自己私有的内存空间对其进行存储

用户态与内核态的线程模型实现分为三种:用户线程与内核线程一对一、多对一和多对多

一对一模型

一对一模型实现简单,一个用户线程映射一个内核线程,Java中采用的模型就是一对一

image.png

但如果线程使用不当,可能导致频繁切换内核态,带来大量开销

并且内核线程资源是有限的,因此一对一模型中线程资源有上限

多对一

在多对一模型中

image.png

由于多个用户线程映射同一内核线程,相比于一对一模型能够使用的用户线程更多

但是当发生阻塞时要切换到内核态进行阻塞,该内核线程对应的所有用户线程都会被阻塞,其实现也会变复杂

多对多

在多对多模型中

image.png

不仅解决一对一模型线程上限问题,还解决多对一模型中内核线程阻塞对应所有用户线程都阻塞的问题

但实现变得更加复杂

串行、并行与并发

为什么要用多线程?

随着硬件的发展,多数机器已经不在是单个核心CPU的机器,大量的机器都使用多核超线程技术

串行可以理解成排队执行,当线程分到CPU的资源时开始执行调度,线程可能进行IO任务的调度

此时会等待IO资源准备好才能进行调度,这段时间内CPU啥事也没干从而没有有效的利用CPU

image.png

为了提高CPU的利用率,在A线程等待IO资源时,可以将A线程先挂起,将CPU的资源分配给B线程

当A线程等待的IO资源准备好时,再将B线程挂起恢复A线程继续执行

两个线程在一段时间内看上去像在同时执行,实际上它们是交替执行,某个时刻上只有一个线程在执行

并发提升CPU的利用率,但也会带来线程上下文切换的开销

image.png

那什么又是并行呢?

上面说的串行、并发都在单线程下可以实现,但是并行的前提就是多核

并行指的是多个线程在某个时刻上也是同时执行,因此需要多核

image.png

那是不是多线程一定效率最快呢?

经过上面的分析,我们知道:线程挂起和恢复,上下文的切换会经过用户态、内核态的转换,会有性能开销

当线程太多、运行时频繁进行上下文切换,那么带来的性能开销甚至可能超过并发提升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++方法),启动线程执行任务

image.png

如果调用两次start则会抛出IllegalThreadStateException异常

线程状态

Java中的Thread的状态分为新建、运行、阻塞、等待、超时等待、终止

  public enum State {
      //新建
      NEW,
      //运行
      RUNNABLE,
      //阻塞
      BLOCKED,
      //等待
      WAITING,
      //超时等待
      TIMED_WAITING,
      //终止
      TERMINATED;
  }

在操作系统中将运行分为就绪、运行中状态,当线程创建好后等待CPU分配时间片的状态就是就绪状态,分配到时间片运行就是运行中状态

image.png

新建:线程刚创建和还未获取到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的描述可以查看这篇文章:15000字、6个代码案例、5个原理图让你彻底搞懂Synchronized

线程通信

等待wait / 通知 notify

使用synchronized时要去获取锁,获取锁后线程才能执行调度,当调度中不满足执行条件时,需要让出锁让其他线程执行

比如生产者/消费者模型,当生产者获取到锁要进行生产资源时,发现资源已经满了,它应该让出锁,等到消费者消费完时将它唤醒

这种等待/通知模式是实现线程通信的一种方式,Java提供wait、notify方法来实现等待/通知模式

使用wait、notify的前提是获取到锁

wait让当前线程释放锁进入等待模式,等待其他线程使用notify唤醒后竞争锁,竞争到锁才返回

wait(1)也可以携带等待的时间ms,当时间到达时自动唤醒,并开始竞争锁

notify 唤醒等待当前锁的某个线程

notifyAll 唤醒所有等待当前锁的线程

其具体实现可以查看15000字、6个代码案例、5个原理图让你彻底搞懂Synchronized 的锁升级中重量级锁那一小节

生产者消费者模型

生产者、消费者模型中常用等待与通知进行线程通信

生产者检查到生产的资源已满时就进入等待,等待消费者消费完来唤醒,生产完再去唤醒消费者

消费者检查到没有资源时就进入等待,等待生产者生产完来唤醒,消费完再去唤醒生产者

生产

 public void produce(int num) throws InterruptedException {
     synchronized (LOCK) {
         //如果生产 资源 已满 等待消费者消费
         while (queue.size() == 10) {
             System.out.println("队列满了,生产者等待");
             LOCK.wait();
         }
         
         //生产
         Message message = new Message(num);
         queue.add(message);
         System.out.println(Thread.currentThread().getName() + "生产了" + 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();
     }
 }

虚假唤醒

生产者、消费者会先判断是否满足条件,如果不满足条件则进行等待,直到被唤醒

在检查条件时如果使用if进行判断,可能会造成虚假唤醒问题

比如:生产者生产一个资源,唤醒所有消费者,这时消费者依次获取到锁后都会去消费,实际应该先检查是否满足条件

 public void consume() throws InterruptedException {
     synchronized (LOCK) {
         //如果队列为空 等待生产者生产
         if (queue.isEmpty()) {
             System.out.println("队列空了,消费者等待");
             LOCK.wait();
         }
         
         //消费
         Message message = queue.poll();
         System.out.println(Thread.currentThread().getName() + "消费了" + message);
         //唤醒 所有线程
         LOCK.notifyAll();
     }
 }

使用while循环判断取代if判断,避免出现虚假唤醒问题

sleep 睡眠

sleep 方法用于让线程睡眠一段时间ms

sleep与wait的区别:

  • wait方法是object的;sleep方法是Thread的

  • 使用wait需要先获取锁,被唤醒后也要获取锁才能返回;使用sleep时不需要先获取锁,因此sleep返回时不需要先获取锁

  • wait等待会释放锁;sleep睡眠时不会释放锁

  • join 等待

    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

    最后(不要白嫖,一键三连求求拉~)

    本篇文章被收入专栏 由点到线,由线到面,深入浅出构建Java并发编程知识体系,感兴趣的同学可以持续关注喔

    本篇文章笔记以及案例被收入 gitee-StudyJava、 github-StudyJava 感兴趣的同学可以stat下持续关注喔~

    案例地址:

    Gitee-JavaConcurrentProgramming/src/main/java/A_Thread

    Github-JavaConcurrentProgramming/src/main/java/A_Thread

    有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

    关注菜菜,分享更多干货,公众号:菜菜的后端私房菜

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论