多线程(1)

1. 线程(Thread)

1.1 概念

1.1.1 线程是什么

一个线程就是一个"执行流",每个线程之间都可以按照顺序执行自己的代码,多个线程之间"同时"执行着多份代码。

1.1.2 为什么要有线程

首先,"并发编程"成为"刚需"

  • 单核cpu的发展遇到了瓶颈,要想提高算力,就需要多核 cpu,而并发编程能更充分利用多核 cpu 资源
  • 有些任务场景需要"等待 IO",为了让等待 IO 的时间能够去做一些其他工作,也需要用到并发编程

其次,虽然多进程也能实现并发编程,但是线程比进程更轻量

  • 创建线程比创建进程更快(因为创建线程省去了"分配资源"的过程,一旦创建进程,同时也会创建第一个线程,此时就会分配资源,一旦后续创建更多线程,就不必重新再分配资源了)
  • 销毁线程比销毁进程更快(因为销毁线程省去了"释放资源"的过程)
  • 调度线程比调度进程更快

最后,人们不满足于线程的轻量,又有了"线程池(ThreadPool)"和 "协程(Coroutine)"

1.1.3 进程和线程的区别

  • 进程包含线程,每个进程至少有一个线程存在,即主线程
  • 进程是系统"资源分配"的基本单位,线程是系统"调度执行"的基本单位
  • 进程和进程之间不共享内存空间,同一个进程的线程之间共享同一份系统资源(包括内存、硬盘、网络宽带等;在编程中,多个线程是可以公用同一份变量的)
  • 线程是当下实现并发编程的主流方式,通过多线程就可以充分利用好多核cpu,但并不是线程数目越多越好,线程数目达到一定程度,把多个核心充分利用后,此时若继续增加线程,无法再提高效率,甚至可能会影响效率
  • 多个线程之间,可能会相互影响(线程安全问题),一个线程抛出异常,可能会把其他线程一起带走
  • 多个进程之间,一般不会相互影响,一个进程崩溃了,不会影响到其他进程,这一点称为"进程的隔离性"

1.1.4 Java的线程和操作系统线程的关系

线程是操作系统中的概念,操作系统内核实现了线程这样的机制,并且对用户层提供了一些API供用户使用(如 Linux 中的 pthread 库)

Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装

1.2 创建线程

方法1:继承 Thread 类

继承 Thread 来创建一个线程类

java 复制代码
//此处 Thread 类可以直接使用,不需要导入任何包,因为其包含于 java.lang
class MyThread extends Thread {
    //继承 Thread 的主要目的就是为了重写 run 方法
    @Override
    public void run() {
        //这里写的代码,就是即将创建出的线程要执行的逻辑
        System.out.println("hello Thread");
    }
}
public class Demo1 {
    public static void main(String[] args) {
        //创建 MyThread 类的实例
        MyThread t = new MyThread();

        //调用 start 方法
        //会在进程内部创建出一个新的线程
        //新的线程就会执行 run 里面的代码
        t.start();
    }
}

执行结果:


tip:上面 run 方法,用户手动定义了,但没有手动调用,最终这个方法被 系统/库/框架 进行调用了,此时这样的方法就称为"回调函数"(callback)

例如:

  • C 中的函数指针主要有两个用途:1. 作为回调函数;2. 实现转移表,降低代码的复杂程度
  • Java 数据结构中的优先级队列(堆),必须先定义好对象的"比较规则";如:Comparable 中的 compareTo 和 Comparator 中的 conpare,都是自己定义,但没有调用,此时都是由标准库本身内部的逻辑负责调用的

上面代码运行起来是一个进程,但是这个进程中包含了两个线程

调用main方法的线程称为"主线程";t.start(); 又手动创建了新的线程

主线程和新线程就会 并发/并行 的在cpu上执行


由于程序只执行一次,速度很快,看不出线程相关,所以给程序加上 while 循环:

java 复制代码
class MyThread extends Thread {
    //继承 Thread 的主要目的就是为了重写 run 方法
    @Override
    public void run() {
        //这里写的代码,就是即将创建出的线程要执行的逻辑
        while(true) {
            System.out.println("hello Thread");
            //循环中加上休眠操作,让循环每循环一次都休息一会,避免 cpu 消耗过大
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class Demo1 {
    public static void main(String[] args) throws InterruptedException {
        //创建 MyThread 类的实例
        MyThread t = new MyThread();

        //调用 start 方法创建线程
        t.start();

        while(true) {
            System.out.println("hello main!");
            Thread.sleep(1000);
        }
    }
}

我们查看运行结果可发现:多个线程之间,谁先去 cpu 上调度执行,这个过程是"不确定的"(此处不是数学意义的随机)

这个调度顺序,取决于操作系统内核里"调度器"的实现

调度器里有一套规则,但是我们作为应用程序开发,没法进行干预,也感受不到,只能把这个过程近似的视为"随机"(抢占式执行),并不属于伪随机!


借助第三方工具,可以更直观的来看线程的情况

  1. 打开 jdk 的安装位置,找到bin目录底下的jconsole.exe 通过这个可以查看进程情况

可以看到一个 Java 进程中,不只有两个线程

这些线程起到了一些辅助作用:1. 垃圾回收(合适时机,释放不使用的对象);2. 统计信息/调试信息(比如现在通过 jconsole.exe能够查看到一个 Java 进程的详情

调用栈:描述了函数之间/方法之间的调用关系,当代码出现问题(抛出异常、进程终止...),此时查看对应的调用栈就能知道是哪个代码的哪一行语句出现了问题,以及这个代码是如何一层一层被调用过去的

  1. IDEA的调试器,也能看到类似的信息

可以手动切换到某个线程上,看需要关注的信息


方法2:实现 Runnable 接口

定义一个类(MyRunnable)来实现 Runnable 接口

java 复制代码
//实现 Runnable 接口
class MyRunnable implements Runnable {
    @Override
    public void run() {
        //描述了线程要完成的逻辑
        while(true) {
            System.out.println("hello thread!");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        //仍然需要创建 Thread 类实例,调用 Thread 的构造方法时,将 Runnable 对象作为 Thread 的参数
        MyRunnable runnable = new MyRunnable();
        Thread t = new Thread(runnable);
        
        t.start();

        while(true) {
            System.out.println("hello main!");
            Thread.sleep(1000);
        }
    }
}

上述代码中,Runnable 就是用来描述"要执行的任务",通过 Thread 创建线程,线程要执行的任务是通过 Runnable 来描述的,而不是通过 Thread 自己来描述

这种做法更有利于"解耦合"

Runnable 只是一个任务,并不是和"线程"这样的概念强相关,后续执行这个任务的载体可以是线程,也可以是 线程池、虚拟线程...


方法3:通过匿名内部类创建线程

匿名内部类创建 Thread 子类对象
java 复制代码
public class Demo3 {
    public static void main(String[] args) {
        Thread t = new Thread() {
            @Override
            public void run() {
                
            }
        };
    }
}
  1. 定义匿名内部类,这个类是 Thread 的子类

  2. 类的内部重写了父类的 run 方法

  3. 创建了一个子类的实例,并且把实例的引用赋值给 t

tip:匿名内部类一般就是"一次性"使用的类,此种方法内聚性更好一些


匿名内部类创建 Runnable 子类对象
java 复制代码
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                
            }
        });

lambda 表达式创建 线程
java 复制代码
        Thread t = new Thread(() -> {
            System.out.println("lambda 表达式创建线程");
        });

tip:lambda 表达式本质上就是匿名内部类更简化的写法,而很多时候,我们写"匿名内部类"目的不是为了写"类",而是为了写类里的方法,lambda 就是直接能够表示要写的 run 方法

2. Thread 类及常见方法

Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联

2.1 Thread 的常见构造方法

|--------------------------------------------|-------------------------|
| 方法 | 说明 |
| Thread() | 创建线程对象 |
| Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
| Thread(String name) | 创建线程对象并命名 |
| Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象并命名 |
| Thread(ThreadGroup group, Runnable target) | 线程可以被用来分组管理,分好的组即为线程组 |

tip:线程默认名为 Thread-0、Thread-1...,给线程命名不会影响到线程的执行结果,但是起一个合适的名字,有利于调试程序

ThreadGroup 线程组,把多个线程放到一组里,方便同意设置线程的一些属性

现在很少会使用线程组,线程相关属性用的也不太多,现在更多使用 线程池

2.2 Thread 的常见属性

|--------|-----------------|
| 属性 | 获取方法 |
| ID | getId() |
| 名称 | getName() |
| 状态 | getState() |
| 优先级 | getPriority() |
| 是否后台线程 | isDaemon() |
| 是否存活 | isAlive() |
| 是否被中断 | isInterrupted() |

  • ID 是 JVM 自动分配的,不能手动设置,是 Thread 对象的身份标识(通常情况下,一个 Thread 对象就对应到系统内部的一个线程(PCB),但也可能会存在一个情况:Thread 对象存在,但是系统内部的线程已经没了/还没创建...)
  • 名称是各种调试工具用到
  • 状态表示线程当前所处的一个情况(阻塞/就绪,Java 中把线程的状态分的更详细,后面细说)
  • 设置不同的优先级,会影响到系统的调度,这里的影响是基于"统计"规则的影响,直接肉眼很难观察到效果
  • 后台线程:JVM 会在一个进程的所有非后台线程结束后,才会结束运行
  • 是否存活可简单理解为:run 方法是否运行结束了

示例:

java 复制代码
public class Demo5 {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while (true) {
                System.out.println("hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();

        //获取一下线程的属性
        System.out.println("线程id:" + t.getId());
        System.out.println("线程名字:" + t.getName());
        System.out.println("线程状态:" + t.getState());
        System.out.println("线程优先级:" + t.getPriority());
    }
}

运行结果:


前后台线程

前台线程:如果某个线程在执行过程中,能够阻止进程结束,这个线程就是"前台线程"

后台线程:如果这个线程在执行过程中,不能组织进程结束(虽然线程在执行着,但是进程要结束了,此时这个线程也会随之被带走),这样的线程就是"后台线程"

一个进程中,前台线程可以有多个(创建的线程默认就是前台线程),必须所有的前台线程都结束,进程才结束

例如:


是否存活

isAlive() 为 true 表示内核的线程存在,为 false 表示内核的线程无了

代码中创建的 new Thread 对象的生命周期和内核中实际的线程不一定是一样的

可能会出现 Thread 对象任然存在,但是内核中的线程不存在了这种情况(如:1) 调用 start 之前,内核中还没创建线程;2) 线程的 run 执行完毕了,内核的线程就无了,但是 Thread 对象仍然存在)

但是肯定不会出现 Thread 对象不存在,线程还存在 这种情况

java 复制代码
public class Demo7 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("hello Thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        
        //创建线程之前
        System.out.println(t.isAlive());
        t.start();
        
        //创建线程之后
        System.out.println(t.isAlive());
        Thread.sleep(5000);//等待线程执行完毕
        
        //线程执行完后
        System.out.println(t.isAlive());
    }
}

运行结果:

特殊情况:

由于线程之间的调度顺序是不确定的,如果两个线程都是 sleep 3000 ,当时间到了,两个线程谁先执行,谁后执行都不一定(此处不一定,不是指双方概率均等,实际上这里两种情况的概率会随着系统的不同,随着代码运行环境的不同,都可能存在差异)

2.3 Thread 的核心操作

start

经典面试题:start 和 run 之间的区别

start:调用系统函数,真正在系统内核中创建线程(创建 PCB ,加入链表中),此处 start 会根据不同的系统,分别调用不同的 api(windows、linux、mac...)

run:描述了线程要执行的任务,也可以称为"线程的入口"

  • start 的执行速度一般是比较快的(创建线程,比较轻量)
  • 一旦 start 执行完毕,新线程就会开始执行,调用 start 的线程也会继续执行(main)
  • 调用 start 不一定非得是 main 线程,任何线程都可以创建其他线程
  • 一个 Thread 对象只能调用一次 start,如果多次调用 start 就会出现问题(一个 Thread 对象,只能对应系统中的一个线程)

线程的状态:由于 Java 中希望一个 Thread 对象只能对应到一个系统中的线程

因此就会在 start 中根据线程状态做出判定

如果 Thread 对象是没有 start,此时状态是 NEW 状态,接下来就可以顺利调用 start

如果已经调用过 start,就会进入到其他状态,只要不是 NEW 状态,接下来执行 start 都会抛出异常

2.4 终止线程

假设当前有 A、B 两个线程,B 正在运行,A 想让 B 结束

其核心就是 A 要想办法让 B 的 run 方法执行完毕,此时 B 自然就结束了,而不是说 B 的 run 执行一半,A 直接把 B 强制结束了

目前常见的两种方式:

2.4.1 使用自定义变量作为标志位

java 复制代码
public class Demo8 {
    private static boolean isQuit = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
           while (!isQuit) {
               System.out.println("hello Thread");
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
            System.out.println("t 线程执行结束");
        });

        t.start();

        Thread.sleep(2000);
        //修改 isQuit 变量,就能使 t 线程结束
        System.out.println("main 线程尝试终止 t 线程");
        isQuit = true;
    }
}

运行结果:

引出一个问题:如果把这个 isQuit 改为 main 方法的局部变量,这个代码是否仍然可以正常执行?

答案是不可以的,发生了变量捕获

当把该变量写成成员变量之后,就可以了,是因为此时的语法是"内部类访问外部类的成员变量",该语法是没问题的,和"变量捕获"无关了

lambda 表达式本质上是一个"函数式接口"产生的"匿名内部类",内部类本来就可以访问外部类的成员

2.4.2 调用interrupt() 方法来通知

使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位(Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记)

|-------------------------------------|----------------------|
| 方法 | 说明 |
| public void interrupt() | 中断对象关联的线程,如果线程正在阻塞,则 |
| public static boolean interrupted() | 判断当前线程的中断标志位是否设置,调用后 |
| public boolean isInterrupted() | 判断对象关联的线程的标志位是否设置,调用 |


若是直接使用 t 调用 isInterrupted 方法:

当程序运行后,发现其抛出了异常

分析:

由于判定 isInterrupted() 和执行打印 这俩操作太快了,因此整个循环主要的时间都是花在 sleep 1000 上

main 调用 Interrupt 的时候,t 线程大概率正处于 sleep 状态

此处 Interrupt 不仅仅能设置标志位,还能把刚才这个 sleep 操作给唤醒

比如 sleep 此时刚睡了 100ms,还剩 900ms,此时 Interrupt 被调用了,sleep 就会直接被唤醒,并抛出 InterruptedException 异常

又因为 catch 中默认代码再次抛出异常,而再次抛出的异常就没人 catch 了,最终就到了 JVM 这一层,进程抛出 RuntimeException 异常终止了

我们将catch中的默认代码修改:

此时调用 Interrupt 把 sleep 唤醒了,触发异常被 catch捕获,但是循环还在继续执行

看起来好像 while 中设置的标志位不存在一样


原因:

首先,Interrupt 肯定会设置这个标志位,其次,当 sleep 等阻塞的函数被唤醒之后,就会先清空刚才设置的 Interrupted 标志位,因此,想要结束循环,结束进程,就需要在 catch 中加上 return / break


有此设定的原因:

Java中的终止进程是一个"温柔"的过程,不是强行终止的

例如:A 线程希望 B 线程终止,B 收到这样的请求之后,B 需要自行决定 是否要终止/是否立即/稍后 终止(B 线程内部的代码由自己决定,其他线程无权干涉)

  1. 如果 B 线程想无视 A,直接在 catch 中不执行任何操作,B 线程仍然会继续执行(sleep 清除标志位,就可以使 B 能够做出这种选择,如果 sleep 不清除标志位的话,B 一定会结束,无法写出让线程继续执行的代码了)

  2. 如果 B 线程想立即结束,就直接在 catch 中写上 return 或者 break

  3. 如果 B 线程想稍后再结束,就可以在 catch 中写上一些其他的逻辑(如:释放资源、清理一些数据、提交一些结果...收尾工作),这些逻辑完成之后,再进行 return / break

如此便给了我们更多的操作空间

tip:

  1. Interrupt 方法能够设置标志位,也能唤醒 sleep 等阻塞方法

  2. sleep 被唤醒之后,又能清空标志位

2.5 线程等待 ------ join()

由于操作系统针对多个线程的执行是一个"随机调度,抢占式执行"的过程,我们无法确定两个线程调度执行的顺序,但是可以控制 谁先结束,谁后结束,线程等待,就是在确定两个线程的"结束顺序"

原理就是让 后结束 的线程等待 先结束 的线程,此时后结束的线程就会进入阻塞,一直到先结束的线程真的结束了,阻塞才解除

例如:现在有两个线程 a , b

在 a 线程中调用

b.join()

意思就是让 a 线程等待 b 线程先结束,然后 a 再继续执行

|------------------------------------------|----------------------|
| 方法 | 说明 |
| public void join() | 等待线程结束 |
| public void join(long millis) | 等待线程结束,最多等 millis 毫秒 |
| public void join(long millis, int nanos) | 同上,但可以更高精度(纳秒级) |

情况一:main 先执行 join 等待 t 执行

情况二:让 t 先结束,main 再执行 join

此时 join 并没有发生阻塞,因为 t 线程已经结束了

join 就是确保被等待的线程能够先结束,如果已经结束了,join 就不会再等了

tip:任何线程之间都是可以相互等待的,线程等待也不一定是两个线程之间,一个线程可以同时等待多个别的线程,或者若干线程之间也能相互等待

上面使用的 join 是无参版本的,无参数,意思是"死等""不见不散",被等待的线程只要不执行完,这里就会持续阻塞

2.6 获取当前线程引用

|--------------------------------------|-------------|
| 方法 | 说明 |
| public static Thread currentThread() | 返回当前线程对象的引用 |

java 复制代码
public class Demo {
    public static void main(String[] args) {
        Thread t = Thread.currentThread();
        System.out.println(t.getName());
    }
}

任何线程中,都可以通过这样的操作拿到线程的引用

2.7 休眠当前线程

|------------------------------------------------------------------------------|--------|
| 方法 | 说明 |
| public static void sleep(long millis) throws InterruptedException | 休眠当前线程 |
| public static void sleep(long millis, int nanos) throws InterruptedException | 更高精度 |

线程执行 sleep 就会使这个线程不参与 cpu 调度,从而把 cpu 资源让出来给别人用

又称 sleep 这样的操作为"放权",放弃使用 cpu 的权利

相关推荐
2401_857439692 小时前
SSM 架构下 Vue 电脑测评系统:为电脑性能评估赋能
开发语言·php
SoraLuna3 小时前
「Mac畅玩鸿蒙与硬件47」UI互动应用篇24 - 虚拟音乐控制台
开发语言·macos·ui·华为·harmonyos
xlsw_3 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹4 小时前
基于java的改良版超级玛丽小游戏
java
Dream_Snowar4 小时前
速通Python 第三节
开发语言·python
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭4 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫4 小时前
泛型(2)
java
超爱吃士力架4 小时前
邀请逻辑
java·linux·后端
南宫生4 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石4 小时前
12/21java基础
java