请添加图片描述

✨个人主页:bit me👇
✨当前专栏:Java EE初阶👇
✨每日一语:知不足而奋进,望远山而前行。

💤一. 认识线程(Thread)

🍎1. 线程的引入

  • 根据我们前面学的进程,为什么要有多个进程呢?为了并发编程,他的 CPU 单个核心已经发挥到极致了,想要提升算力,就得使用多个核心。
  • 引入并发编程,最大的目的就是为了能够充分的利用好 CPU 的多核资源。使用多进程这种编程模型,是完全可以做到。并发编程,并且也能够使 CPU 多核被充分利用,但是在有些情急下,会存在问题,例如:需要频繁的创建 / 销毁进程,这个时候就会比较低效!!!
  • 例如,你写了个服务器程序,服务器要同一时刻给很多客户提供服务的,就需要用到并发编程,典型的做法,就是每个客户端给他分配一个进程,提供一对一服务。

创建 / 销毁进程,本身就是一个比较低效的操作
1. 创建 PCB
2. 分配系统资源(尤其是内存资源)分配资源,就比较消耗时间了!(这个是在系统内核资源管理模块进行一系列操作的...)
3. 把 PCB 加入到内核的双向链表中

为了提高这个场景下的效率,就引入了 " 线程 ",线程其实也叫做 " 轻量级进程 "

🍏2. 线程是什么?

一个线程其实是包含在进程中的(一个进程里面可以有很多个线程),每个线程其实也有自己的 PCB (一个进程里面可能就对应多个 PCB),同一个进程里面的多个线程之间,共用一份系统资源(意味着,新创建的线程,不必重新给他分配系统资源,只需要复用之前的即可!)

因此,创建线程只需要
1. 创建 PCB
2. 把 PCB 加入到内核的链表中

这就是线程相对于进程做出的重大改进!!!也就是线程更轻量的原因!

  • 创建线程比创建进程更快,开销更小.
  • 销毁线程比销毁进程更快,开销更小.
  • 调度线程比调度进程更快.

线程,是包含在进程内部的 " 逻辑执行流 "(线程可以执行一段单独的代码,多个线程之间,是并发执行的)

操作系统进行调度的时候,其实也是以 " 线程为单位 " 来进行调度的(系统内核不认进程 / 线程,只认 PCB)

如果把进程比作工厂,线程就是工厂内部的流水线

🍋3. 进程和线程之间的区别

  • 进程是包含线程的,线程是在进程内部的
  • 每个进程有独立的虚拟地址空间(进程之间的资源是独立的,进程的独立性),也有自己独立的文件描述符表;同一个进程(不同进程里面的不同线程,则没有共享的资源)的多个线程之间,则共用这一份虚拟地址空间和文件描述符表(线程之间系统资源是共享的)
  • 进程是操作系统中资源分配的基本单位,线程是操作系统中,调度执行的基本单位
  • 多个进程同时执行的时候,如果一个进程挂了,一般不会影响到别的进程;同一个进程内的多个线程之间,如果一个线程挂了,很可能把整个进程带走,其他同进程里的线程也就没了

 

💨二. 第一个多线程程序

🏐1. Java中线程的认识和基本操作

在 Java 中即使是一个最简单的 “hello”,其实在运行的时候也涉及到线程了,一个进程里至少会有一个线程

public static void main(String[] args) {
    System.out.println("hello");
}

运行上面的程序,操作系统就会创建一个 Java 进程,在这个 Java 进程里就会有一个线程(主线程)调用 main 方法;虽然在上述代码中,我们并没有手动的创建其他线程,但是 Java 进程在运行的时候,内部也会创建多个线程。

注:

谈到多进程的时候,经常会谈到 “父进程” “子进程”。进程 A 里面创建了进程 B,A 是 B 的父进程,B 是 A 的子进程。但是在多线程里面,没有 “父线程” “子线程” 这种说法,线程之间的地位是对等的!

创建一个线程,Java 中创建线程,离不开一个关键的类,Thread。一种比较朴素的创建线程的方式,是写一个子类,继承 Thread,重写其中的 run 方法。

class MyTread extends Thread {
    @Override
    public void run() {
        System.out.println("hello Thread!");
    }
}

这个 run 方法重写的目的,是为了明确咱们新创建出来的线程,要干啥活儿

光创建了这个类,还不算创建线程,还得创建实例,在主函数中写如下代码

Thread t = new MyTread();
t.start();
  • System.out.println(“hello Thread!”); --> 先把新员工要做的任务安排好
  • Thread t = new MyTread(); --> 招聘进来一个新员工,把任务交给他
  • t.start(); --> 告诉新员工,你开始干活!

t.start(); --> 这才是真正开始创建线程(在操作系统内核中,创建出对应线程的 PCB,然后让这个 PCB 加入到系统链表中,参与调度)

Thread t = new MyTread();
t.start();

System.out.println("hello main!");

在这里插入图片描述
在这个代码中,虽然先启动的线程,后打印的 hello main,但是实际执行的时候,看到的却是先打印了 hello main 后打印了 hello thread。

  1. 每个线程是独立的执行流!main 对应的线程是一个执行流,MyThread 是另一个执行流,这两个执行流之间是并发(并发+并行)的执行关系!
  2. 此时两个线程执行的先后顺序,取决于操作系统调度具体实现(程序猿可以把这里的调度规则简单的视为是 “随机调度”)

因此执行的时候看到,是先打印 hello main 还是先打印 hello thread 是不能确定的。无论反复运行多少次看起来好像都是先打印 hello main,但是顺序仍然是不能确定的,当前看到的先打印 main,大概率是受到创建线程自身的开销影响的。

编写多线程代码的时候,一定要注意到!!!默认情况下,多个线程的执行顺序,是 “无序”,是 "随机调度"的。

我们还有一些手段能影响到线程执行的先后顺序的,但是调度器自身的行为修改不了,调度器仍然是随机调度,咱们最多是让某个线程先等待,等待另一个线程执行完了自己再执行

在这里插入图片描述

  • Process 进程
  • exit code 0 进程的退出码

操作系统中用 进程的退出码 来表示 “进程的运行结果”

  • 使用 0 表示进程执行完毕,结果正确
  • 使用非 0 表示进程执行完毕,结果不正确
  • 还有个情况 main 还没返回,进程就崩溃,此时返回的值很可能是一个随机值

要是我们不想要这个程序结束的这么快咋办?
就可以想办法让这个进程别结束这么快,好让咱们看下这里都有啥线程

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

把他们都套上一个死循环之后看到打印结果是 hello main! 和 hello Thread! 交替打印

🥎2. 线程的查看

(注意不要关闭上述程序运行,查看线程需要)此时就可以查看当前 Java 进程里的线程有哪些(打开任务管理器可以观看到程序运行状态)

在这里插入图片描述

  1. 找出 JDK 安装的目录,找到 bin 文件里,在 JDK 里提供了 jconsole这样的工具,可以看到 Java 进程里的线程详情

在这里插入图片描述

  1. 点击运行(如果运行界面没有这样的选项,我们在打开的时候需要以管理员方式打开!)

在这里插入图片描述

  1. 点击进去即可(不用害怕hhh)

在这里插入图片描述

  1. 打开线程所在窗口

在这里插入图片描述

  1. 找到左下角,会有显示具体的线程

在这里插入图片描述

这个列表就是当前 Java 进程中的线程信息
其他的线程则是 JVM 中的辅助功能,有的线程负责垃圾回收,有的线程负责处理调试信息,比如咱们使用的 jconsole 能够连上这个 JVM ,也是靠对应的线程提供的服务

  1. 点击某个线程的时候,右侧会显示线程的详细情况

在这里插入图片描述

在这里插入图片描述

上面两张表里都描述了当前线程这里的各种方法之间调用的关联关系

这里的调用栈非常有用,未来调试一个 “卡死” 的程序的时候,就可以看下面每个线程的调用栈是啥,就可以初步的确认卡死的原因。

上面的死循环代码打印的太快太多,有时候我们不希望它这么快,不利于我们观察,于是用 sleep 来让线程适当休息一下

使用 Thread.sleep 的方式进行休眠,sleep 是Thread 的静态成员方法,sleep 的参数是一个时间,单位 ms

在这里插入图片描述

这个异常在多线程中经常能见到,interrupted 中断

通过 try-catch 抓捕异常后的运行结果

在这里插入图片描述

加上 sleep 之后,无序调度这个事情就被更加清楚的观察到了。

在这里插入图片描述

一个经典面试题:
谈谈 Thread 的 run 和 start 的区别:

  • 使用 start 可以看到两个线程并发的执行,两组打印交替出现
  • 使用 run 可以看到只是在打印 thread,没有打印 main
     

直接调用 run 并没有创建新的线程,而只是在之前的线程中,执行了 run 里的内容;使用 start,则是创建新的线程,新的线程里面会调用 run,新线程和旧线程之间是并发执行的关系

上述总的代码:

class MyTread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println("hello Thread!");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Demo1 {
    public static void main(String[] args) {
        //创建一个线程
        //Java 中创建线程,离不开一个关键的类,Thread
        //一种比较朴素的创建线程的方式,是写一个子类,继承 Thread,重写其中的 run 方法。

        Thread t = new MyTread();
        //t.start();
        t.run();

        while (true){
            System.out.println("hello main!");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

 

🎶三. 创建线程的几种常见写法

  1. 创建一个类继承 Thread,重写 run。(上面已经写过)

这个写法,线程和任务内容是绑定在一起的!

  1. 创建一个类,实现 Runnable 接口,重写 run。
Runnable runnable = new MyRunnable();
Thread t = new Thread(runnable);
t.start();
  • 此处创建的 Runnable,相当于是定义了一个 "任务"(代码要干啥)
  • 需要 Thread 实例,把任务交给 Thread
  • 还是 Thread.start 来创建具体的线程
  • 这个写法,线程和任务是分离开的(更好的解耦合)(耦合的意思和表面差不多,模块 / 代码之间,关联关系越紧密,就认为耦合性越高,关联关系越不紧密,认为耦合性越低)
  • 写代码的时候追求 “高耦合” “低内聚”,让每个模块之间耦合性降低, 好处就是一个模块出了问题对另外的模块影响不大
  • 这种写法把任务内容和线程本身给分离开了,就把耦合性降低了(任务的本身和线程关系不大),假设这个任务不想通过多线程执行了,而是换成别的方式执行,这个时候代码的改动也不大。

一个问题:

为啥刚才使用 Thread,Runnable,interruptedException 都不需要 import ??啥样的类,不需要 import 就能直接使用??

  • 要么是同一个类中,要么这个类是在 java.lang 中(String)
  1. 仍然是使用继承 Thread 类,但是不再显式继承,而是使用 “匿名内部类”。
public class Demo3 {
    public static void main(String[] args) {
        Thread t = new Thread() {
            @Override
            public void run() {
                while(true){
                    System.out.println("hello Thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t.start();
    }
}
Thread t = new Thread() {

此处就是在创建了一个匿名内部类(没有名字),这个匿名内部类是 Thread 的子类,同时前面 new 关键字,就给这个匿名内部类创建出了一个实例

这一套操作实现了继承,方法重写,实例化

在 start 之前,线程只是准备好了,并没有真正被创建出来,执行了 start 方法,才真正在操作系统中创建了线程!!

Thread 实例是 Java 中对于线程的表示,实际上想要真正的跑起来,还是需要操作系统里面的线程,创建好了 Thread,此时系统里面还没有线程,直到调用 start 方法,操作系统才真的创建了线程(1.创建 PCB;2.把 PCB 加入到链表里)并进行运行。

  1. 使用 Runnable,是匿名内部类的方式使用
  • 方法一:
public class Demo4 {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                while(true){
                    System.out.println("hello thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        Thread t = new Thread(runnable);
        t.start();
    }
}
  • 方法二:(推荐)
public class Demo4 {
    public static void main(String[] args) {
	Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true){
                    System.out.println("hello Thread");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        t.start();
    }
}

中间的匿名内部类的实例,是 Thread 的构造方法的参数!

  1. 使用 lambda 表达式,来定义任务。(推荐做法)
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) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    }
}

使用 lambda 表达式,其实是更简单的写法,也是推荐写法,形如 lambda 表达式这样的,能够简化代码编写的语法规则,称为 “语法糖”。

lambda 表达式本质上就仅仅是一个 “匿名函数”(没有名字的,只使用一次的函数)

实际上,线程还有其他的创建方式,后续介绍后面的创建方式!

  • 6.基于 Callable / FutureTask 的方式创建
  • 7.基于线程池的方式创建

至少有七种创建方式~

 

💦四. 多线程的优势

  1. 单个线程,串行的,完成 20 亿次自增。
public class Demo6 {
    private static final long COUNT = 20_0000_0000;

    private static void serial(){
        //需要把方法执行的时间给记录下来
        //记录当前的毫秒级时间戳
        long beg = System.currentTimeMillis();

        int a = 0;
        for(long i = 0; i < COUNT; i++){
            a++;
        }
        a = 0;
        for(long i = 0; i < COUNT; i++){
            a++;
        }

        long end = System.currentTimeMillis();
        System.out.println("单线程消耗的时间:" + (end - beg) + " ms");
    }

    public static void main(String[] args) {
        serial();
    }
}

运行结果:

在这里插入图片描述

使用一个线程串执行花了大约 1200 ms

  1. 两个线程,并发的,完成 20 亿次自增
public class Demo6 {
    private static void concurrency(){
        long beg = System.currentTimeMillis();

        Thread t1 = new Thread(()->{
            int a = 0;
            for(long i = 0; i < COUNT; i++){
                a++;
            }
        });

        Thread t2 = new Thread(()->{
            int a = 0;
            for(long i = 0; i < COUNT; i++){
                a++;
            }
        });

        t1.start();
        t2.start();

        long end = System.currentTimeMillis();
        System.out.println("并发执行的时间:" + (end - beg) + " ms");
    }


    public static void main(String[] args) {
        concurrency();
    }
}

这个代码涉及到三个线程 t1,t2,main(调用 concurrency 方法的线程),三个线程都是并发执行的

t1.start(); t2.start(); 表示 t1,t2 是会开始执行的,同时,不等 t1,t2 执行完毕,main 线程就往下走了,于是就结束计时。此处的计时,是为了衡量 t1,t2 的执行时间,正确的做法应该是等到 t1,t2 都执行完,才停止计时。

在上述代码 t1.start(); t2.start(); 之后加上如下代码即可

try {
    t1.join();
    t2.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

t1.join(); t2.join(); --> join 是等待线程结束(等待线程把自己的 run 方法执行完),在主线程中调用 t1.join,意思就是让 main 线程等待 t1 执行完,这两个 join 操作谁先谁后不影响。

运行结果:

在这里插入图片描述

如果没有 join 的限制,main 和 t1,t2 都是同时往下走的!多个线程并发执行的(同时在往下走),走的过程中,调度顺序不确定!调度在这个代码执行过程中不是只有一次,会有很多次,即使是上面的简单代码,也可能会出现成千上万次的调度!绝对不是说一个线程执行完了再调度给第二个线程!!!

上面的结果相较之下对比体现出两个线程并发执行确实快了不少,但是为什么两个线程并发执行不是单个线程串行执行的耗时一半呢?

  • 创建线程自身,也是有开销的
  • 两个线程在 CPU 上不一定是纯并行,也可能是并发(一部分时间并行了,一部分时间并发了)
  • 线程的调度,也是有开销的

 

❄️五. 多线程的使用场景

  1. 在 CPU 密集型

代码中大部分工作,都是在使用 CPU 进行运算(就像上面的反复++),使用多线程,就可以更好的利用 CPU 多核计算资源,从而提高效率!

  1. 在 IO 密集型场景

input output ,读写硬盘,读写网卡…这些操作都算 IO,像这些 IO 操作,几乎都是不消耗 CPU 就能完成快速读写数据的操作,既然 CPU 在摸鱼,就可以给他找点活儿干,也可以使用多线程,避免 CPU 过于闲置。

更多推荐