10分钟巩固多线程基础

10分钟巩固多线程基础

前言

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

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

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

进程与线程

什么是进程?

操作系统将资源分配给进程,使用进程进行调度,但进程遇到阻塞任务时,为了提升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的实现

java 复制代码
   public class Thread implements Runnable {
       private Runnable target;
   }

Runnable接口是函数式接口,其中只有run方法,run方法中的实现表示该线程启动后要去执行的任务

java 复制代码
   public interface Runnable {
       public abstract void run();
   }

Java中创建线程的方式只有一种:创建Thread对象,再去调用start方法,启动线程

我们可以通过构造器创建线程的同时设置线程的名称,并设置要实现的任务(打印线程名称 + hello)

java 复制代码
       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的状态分为新建、运行、阻塞、等待、超时等待、终止

java 复制代码
  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

java 复制代码
   //最小
   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关键字使用加锁的方式来保证同步(只有一个线程能够访问共享资源)

java 复制代码
          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 的锁升级中重量级锁那一小节

生产者消费者模型

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

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

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

生产

java 复制代码
 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();
     }
 }

消费

java 复制代码
 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进行判断,可能会造成虚假唤醒问题

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

java 复制代码
 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的区别:

  1. wait方法是object的;sleep方法是Thread的

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

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

join 等待

join方法用于等待某个线程执行完

比如,在主线程上调用thread.join()就需要等待thread线程执行完,join方法才会返回

同时join也支持设置等待时间ms,超时自动返回

终止线程

终止线程一般使用安全的终止方式:中断线程

线程运行时会保存一个标记位,默认为false,表示没有其他线程对其进行中断

当想要某个线程停止时,可以对其进行中断,比如线程A.interrupt(): 对线程A执行中断操作 ,此时线程A的中断标识为true

当线程调度任务期间,轮询到中断标识为true时就会停止,可以使用线程A.isInterrupted(): 查看线程A的中断标记

当线程进入等待状态时,被其他线程中断会发生中断异常,会清楚标志位并抛出中断异常;可以在catch块中捕获处理进行清理资源或资源的释放

当在根据中断标识循环执行时,还可以自己中断自己停止继续执行

java 复制代码
          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-StudyJavagithub-StudyJava 感兴趣的同学可以stat下持续关注喔~

案例地址:

Gitee-JavaConcurrentProgramming/src/main/java/A_Thread

Github-JavaConcurrentProgramming/src/main/java/A_Thread

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

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

相关推荐
爱上语文19 分钟前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people22 分钟前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
qmx_071 小时前
HTB-Jerry(tomcat war文件、msfvenom)
java·web安全·网络安全·tomcat
为风而战1 小时前
IIS+Ngnix+Tomcat 部署网站 用IIS实现反向代理
java·tomcat
技术无疆3 小时前
快速开发与维护:探索 AndroidAnnotations
android·java·android studio·android-studio·androidx·代码注入
罗政6 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
架构文摘JGWZ6 小时前
Java 23 的12 个新特性!!
java·开发语言·学习
拾光师7 小时前
spring获取当前request
java·后端·spring
aPurpleBerry7 小时前
neo4j安装启动教程+对应的jdk配置
java·neo4j
我是苏苏7 小时前
Web开发:ABP框架2——入门级别的增删改查Demo
java·开发语言