秦怀杂货店

General Store

  • 首页
  • 文章归档
  • 标签
  • 分类
  • 关于页面

  • 搜索
随便聊聊 数据结构 小游戏 数据库 Docker Springboot 系统设计 雪花算法 分布式 海量ip 最长回文子串 算法 面试题 线程池 多线程 线程 java学习 布隆过滤器 github 架构设计 docsify Git JVM LeetCode 杂货思考 设计模式 Lambda native isAssignableFrom 反射 剑指Offer mybatis SPI JDBC 编程工具 Java基础 集合

线程与线程池的那些事之线程篇

发表于 2021-05-20 | 分类于 并发与线程 | 0 | 阅读次数 424

本文关键字:

线程,线程池,单线程,多线程,线程池的好处,线程回收,创建方式,核心参数,底层机制,拒绝策略,参数设置,动态监控,线程隔离

线程和线程池相关的知识,是Java学习或者面试中一定会遇到的知识点,本篇我们会从线程和进程,并行与并发,单线程和多线程等,一直讲解到线程池,线程池的好处,创建方式,重要的核心参数,几个重要的方法,底层实现,拒绝策略,参数设置,动态调整,线程隔离等等。主要的大纲如下(本文只涉及线程部分,线程池下篇讲):

进程和线程

从线程到进程

要说线程池,就不得不先讲讲线程,什么是线程?

线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

那么问题来了,进程又是什么?

进程是操作系统中进行保护和资源分配的基本单位。

是不是有点懵,进程摸得着看得见么?具体怎么表现?打开Windows的任务管理器或者Mac的活动监视器,就可以看到,基本每一个打开的App就是一个进程,但是并不是一定的,一个应用程序可能存在多个进程。

比如下面的Typora就显示了两个进程,每个进程后面有一个PID是唯一的标识,也是由系统分配的。除此之外,每个进程都可以看到有多少个线程在执行,比如微信有32个线程在执行。**重要的一句话:**一个程序运行之后至少有一个进程,一个进程可以包含多个线程。

image-20210508225417275

为什么需要进程?

程序,就是指令的集合,指令的集合说白了就是文件,让程序跑起来,在执行的程序,才是进程。程序是静态的描述文本,而进程是程序的一次执行活动,是动态的。进程是拥有计算机分配的资源的运行程序。

我们不可能一个计算机只有一个进程,就跟我们全国不可能只有一个市或者一个部门,计算机是一个庞然大物,里面的运转需要有条理,就需要按照功能划分出比较独立的单位,分开管理。每个进程有自己的职责,也有自己的独立内存空间,不可能混着使用,要是所有的程序共用一个进程就会乱套。

**每个进程,都有各自独立的内存,进程之间内存地址隔离,进程的资源,比如:代码段,数据集,堆等等,还可能包括一些打开的文件或者信号量,这都是每个进程自己的数据。**同时,由于进程的隔离性,即使有一个程序的进程出现问题了,一般不会影响到其他的进程的使用。

进程在Linux系统中,进程有一个比较重要的东西,叫进程控制块(PCB),仅做了解:

PCB是进程的唯一标识,由链表实现,是为了动态的插入以及删除,创建进程的时候,生成一个PCB,进程结束的时候,回收这个PCB。PCB主要包括以下的信息:

  • 进程状态
  • 进程标识信息
  • 定时器
  • 用户可见的寄存器,控制状态寄存区,栈指针等等。

进程怎么切换的呢?

先明白计算机里面的一个事实:CPU运转得超级无敌快,快到其他的只有寄存器差不多能匹配它的速度,但是很多时候我们需要从磁盘或者内存读或者写数据,这些设备的速度太慢了,与之相差太远。(如果不特殊说明,默认是单核的CPU)

假设一个程序/进程的任务执行一段时间,要写磁盘,写磁盘不需要CUP进行计算,那CPU就空出来了,但是其他的程序也不能用,CPU就干等着,等到写完磁盘再接着执行。这多浪费,CPU又不是这个程序一家的,其他的应用也要使用。CPU你不用的时候,总有别人需要用。

所以CPU资源需要调度,程序A不用的时候,可以切出来,让程序B去使用,但是程序A切回来的时候怎么保证它能够接着之前的位置继续执行呢?这时候不得不提上下文的事。

当程序A(假设为单进程)放弃CPU的时候,需要保存当前的上下文,**何为上下文?**也就是除了CPU之外,寄存器或者其他的状态,就跟犯罪现场一样,需要拍个照,要不到时候别的程序执行完之后,怎么知道接下来怎么执行程序A,之前执行到哪一步了。总结一句话:保存当前程序的执行状态。

上下文切换一般还涉及缓存的开销,也就是缓存会失效,一般执行的时候,CPU会缓存一些数据方便下次更快的执行,一旦进行上下文切换,原来的缓存就失效了,需要重新缓存。

调度一般有两种(一般是按照线程维度来调度),CPU的时间被分为特别小的时间片:

  • 分时调度:每个线程或者进程轮流的使用CPU,平均时间分配到每个线程或者进程。
  • 抢占式调度:优先级高的线程/进程立即抢占下一个时间片,如果优先级相同,那么随机选择一个进程。

时间片超级短,CPU超级快,给我们无比丝滑的感觉,就像是多个任务在同时进行

我们现在操作系统或者其他的系统,基本都是抢占式调度,为什么?

因为如果使用分时调度,很难做到实时响应,当后台的聊天程序在进行网络传输的时候,分配予它的时间片还没有使用完,那我点击浏览器,是没有办法实时响应的。除此之外,如果前面的进程挂了,但是一直占有CPU,那么后面的任务将永远得不到执行。

由于CPU的处理能力超级快,就算是单核的CPU,运行着多个程序,多个进程,经过抢占式的调度,每一个程序使用的时候都像是独享了CPU一样顺滑。进程有效的提高了CPU的使用率,但是进程在上下文切换的时候是存在着一定的成本的。

线程和进程什么关系?

前面说了进程,那有了进程,为啥还要线程,多个应用程序,假设我们每个应用程序要做n件事,就用n个进程不行么?

可以,但是没必要。

进程一般由程序,数据集合和进程控制块组成,同一个应用程序一般是需要使用同一个数据空间的,要是一个应用程序搞很多个进程,就算有能力做到数据空间共享,进程的上下文切换都会消耗很多资源。(一般一个应用程序不会有很多进程,大多数一个,少数有几个)

进程的颗粒度比较大,每次执行都需要上下文切换,如果同一个程序里面的代码段A,B,C,做不一样的东西,如果分给多个进程去处理,那么每次执行都有切换进程上下文。这太惨了。一个应用程序的任务是一家人,住在同一个屋子下(同一个内存空间),有必要每个房间都当成每一户,去派出所登记成一个户口么?

进程缺点:

  • 信息共享难,空间独立
  • 切换需要fork(),切换上下文,开销大
  • 只能在一个时间点做一件事
  • 如果进程阻塞了,要等待网络传过来数据,那么其他不依赖这个数据的任务也做不了

但是有人会说,那我一个应用程序有很多事情要做,总不能只用一个进程,所有事情都等着它来处理啊?那不是会阻塞住么?

确实啊,单独一个进程处理不了问题,那么我们把进程分得更小,里面分成很多线程,一家人,每个人都有自己的事情做,那我们每个人就是一个线程,一家人就是一个进程,这样岂不是更好么?

进程是描述CPU时间片调度的时间片段,但是线程是更细小的时间片段,两者的颗粒度不一样。线程可以称为轻量级的进程。其实,线程也不是一开始就有的概念,而是随着计算机发展,对多个任务上下文切换要求越来越高,随之抽象出来的概念。
$$进程时间段 = CPU加载程序上下文的时间 + CPU执行时间 + CPU保存程序上下文的时间$$

$$
线程时间段 = CPU加载线程上下文的时间 + CPU执行时间 + CPU保存线程上下文的时间$$
最重要的是,进程切换上下文的时间远比线程切换上下文的时间成本要高,如果是同一个进程的不同线程之间抢占到CPU,切换成本会比较低,因为他们共享了进程的地址空间,线程间的通信容易很多,通过共享进程级全局变量即可实现。

况且,现在多核的处理器,让不同进程在不同核上跑,进程内的线程在同个核上做切换,尽量减少(不可以避免)进程的上下文切换,或者让不同线程跑在不同的处理器上,进一步提高效率。

进程和线程的模型如下:

image-20210509163642149

线程和进程的区别或者优点

  • 线程是程序执行的最小单位,进程是操作系统分配资源的最小单位。
  • 一个应用可能多个进程,一个进程由一个或者多个线程组成
  • 进程相互独立,通信或者沟通成本高,在同一个进程下的线程共享进程的内存等,相互之间沟通或者协作成本低。
  • 线程切换上下文比进程切换上下文要快得多。

线程有哪些状态

现在我们所说的是Java中的线程Thread,一个线程在一个给定的时间点,只能处于一种状态,这些状态都是虚拟机的状态,不能反映任何操作系统的线程状态,一共有六种/七种状态:

  • NEW:创建了线程对象,但是还没有调用Start()方法,还没有启动的线程处于这种状态。

  • Running:运行状态,其实包含了两种状态,但是Java线程将就绪和运行中统称为可运行

    • Runnable:就绪状态:创建对象后,调用了start()方法,该状态的线程还位于可运行线程池中,等待调度,获取CPU的使用权
      • 只是有资格执行,不一定会执行
      • start()之后进入就绪状态,sleep()结束或者join()结束,线程获得对象锁等都会进入该状态。
      • CPU时间片结束或者主动调用yield()方法,也会进入该状态
    • Running :获取到CPU的使用权(获得CPU时间片),变成运行中
  • BLOCKED :阻塞,线程阻塞于锁,等待监视器锁,一般是Synchronize关键字修饰的方法或者代码块

  • WAITING :进入该状态,需要等待其他线程通知(notify)或者中断,一个线程无限期地等待另一个线程。

  • TIMED_WAITING :超时等待,在指定时间后自动唤醒,返回,不会一直等待

  • TERMINATED :线程执行完毕,已经退出。如果已终止再调用start(),将会抛出java.lang.IllegalThreadStateException异常。

image-20210509224848865

可以看到Thread.java里面有一个State枚举类,枚举了线程的各种状态(Java线程将就绪和运行中统称为可运行):


public enum State {
    /**
     * 尚未启动的线程的线程状态。
     */
    NEW,

    /**
     * 可运行线程的线程状态,一个处于可运行状态的线程正在Java虚拟机中执行,但它可能正在等待来自操作系统(如处理器)的其他资源。
     */
    RUNNABLE,

    /**
     * 等待监视器锁而阻塞的线程的线程状态。
     * 处于阻塞状态的线程正在等待一个监视器锁进入一个同步的块/方法,或者在调用Oject.wait()方法之后重新进入一个同步代码块
     */
    BLOCKED,

    /**
     * 等待线程的线程状态,线程由于调用其中一个线程而处于等待状态
     */
    WAITING,

    /**
     * 具有指定等待时间的等待线程的线程状态,线程由于调用其中一个线程而处于定时等待状态。
     */
    TIMED_WAITING,

    /**
     * 终止线程的线程状态,线程已经完成执行。
     */
    TERMINATED;
}

除此之外,Thread类还有一些属性是和线程对象有关的:

  • long tid:线程序号
  • char name[]:线程名称
  • int priority:线程优先级
  • boolean daemon:是否守护线程
  • Runnable target:线程需要执行的方法

介绍一下上面图中讲解到线程的几个重要方法,它们都会导致线程的状态发生一些变化:

  • Thread.sleep(long):调用之后,线程进入TIMED_WAITING状态,但是不会释放对象锁,到时间苏醒后进入Runnable就绪状态
  • Thread.yield():线程调用该方法,表示放弃获取的CPU时间片,但是不会释放锁资源,同样变成就绪状态,等待重新调度,不会阻塞,但是也不能保证一定会让出CPU,很可能又被重新选中。
  • thread.join(long):当前线程调用其他线程thread的join()方法,当前线程不会释放锁,会进入WAITING或者TIMED_WAITING状态,等待thread执行完毕或者时间到,当前线程进入就绪状态。
  • object.wait(long):当前线程调用对象的wait()方法,当前线程会释放获得的对象锁,进入等待队列,WAITING,等到时间到或者被唤醒。
  • object.notify():唤醒在该对象监视器上等待的线程,随机挑一个
  • object.notifyAll():唤醒在该对象监视器上等待的所有线程

单线程和多线程

单线程,就是只有一条线程在执行任务,串行的执行,而多线程,则是多条线程同时执行任务,所谓同时,并不是一定真的同时,如果在单核的机器上,就是假同时,只是看起来同时,实际上是轮流占据CPU时间片。

下面的每一个格子是一个时间片(每一个时间片实际上超级无敌短),不同的线程其实可以抢占不同的时间片,获得执行权。时间片分配的单位是线程,而不是进程,进程只是容器

image-20210511002923132

如何启动一个线程

其实Java的main()方法本质上就启动了一个线程,但是本质上不是只有一个线程,看结果的 5 就大致知道,其实一共有 5 个线程,主线程是第 5 个,大多是后台线程:

public class Test {
    public static void main(String[] args) {
        System.out.println(Thread.currentThread().toString());
    }
}

执行结果:

Thread[main,5,main]

可以看出上面的线程是main线程,但是要想创建出有别于main线程的方式,有四种:

  • 自定义类去实现Runnable接口
  • 继承Thread类,重写run()方法
  • 通过Callable和FutureTask创建线程
  • 线程池直接启动(本质上不算是)

实现Runnable接口

class MyThread implements Runnable{
    @Override
    public void run(){
        System.out.println("Hello world");
    }
}
public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyThread());
        thread.start();
        System.out.println("Main Thread");
    }
}

运行结果:

Main Thread
Hello world

如果看底层就可以看到,构造函数的时候,我们将Runnable的实现类对象传递进入,会将Runnable实现类对象保存下来:

    public Thread(Runnable target) {
        this(null, target, "Thread-" + nextThreadNum(), 0);
    }

然后再调用start()方法的时候,会调用原生的start0()方法,原生方法是由c或者c++写的,这里看不到具体的实现:

    public synchronized void start() {
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        group.add(this);
        boolean started = false;
        try {
          	// 正式的调用native原生方法
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
            }
        }
    }

Start0()在底层确实调用了run()方法,并且不是直接调用的,而是启用了另外一个线程进行调用的,这一点在代码注释里面写得比较清楚,在这里我们就不展开讲,我们将关注点放到run()方法上,调用的就是刚刚那个Runnable实现类的对象的run()方法:

    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }

继承Thread类

由于Thread类本身就实现了Runnable接口,所以我们只要继承它就可以了:

class Thread implements Runnable {
}

继承之后重写run()方法即可:

class MyThread extends Thread{
    @Override
    public void run(){
        System.out.println("Hello world");
    }
}
public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyThread());
        thread.start();
        System.out.println("Main Thread");
    }
}

执行结果和上面的一样,其实两种方式本质上都是一样的,一个是实现了Runnable接口,另外一个是继承了实现了Runnable接口的Thread类。两种都没有返回值,因为run()方法的返回值是void。

Callable和FutureTask创建线程

要使用该方式,按照以下步骤:

  • 创建Callable接口的实现类,实现call()方法
  • 创建Callable实现类的对象实例,用FutureTask包装Callable的实现类实例,包装成FutureTask的实例,FutureTask的实例封装了Callable对象的Call()方法的返回值
  • 使用FutureTask对象作为Thread对象的target创建并启动线程,FutureTask实现了RunnableFuture,RunnableFuture继承了Runnable
  • 调用FutureTask对象的get()来获取子线程执行结束的返回值
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class CallableTest {
    public static void main(String[] args) throws Exception{

        Callable<String> callable = new MyCallable<String>();
        FutureTask<String> task = new FutureTask<String>(callable);

        Thread thread = new Thread(task);
        thread.start();

        System.out.println(Thread.currentThread().getName());
        System.out.println(task.get());

    }
}

class MyCallable<String> implements Callable<String> {
    @Override
    public String call() throws Exception {
        System.out.println(
                Thread.currentThread().getName() +
                        " Callable Thread");
        return (String) "Hello";
    }
}

执行结果:

main
Thread-0 Callable Thread
Hello

其实这种方式本质上也是Runnable接口来实现的,只不过做了一系列的封装,但是不同的是,它可以实现返回值,如果我们期待一件事情可以通过另外一个线程来获取结果,但是可能需要消耗一些时间,比如异步网络请求,其实可以考虑这种方式。

Callable和FutureTask是后面才加入的功能,是为了适应多种并发场景,Callable和Runnable的区别如下:

  • Callable 定义方法是call(),Runnable定义的方法是run()
  • Callable的call()方法有返回值,Runnable的run()方法没有返回值
  • Callable的call()方法可以抛出异常,Runnable的run()方法不能抛出异常

线程池启动线程

本质上也是通过实现Runnable接口,然后放到线程池中进行执行:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " : hello world");
    }
}

public class Test {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10; i++) {
            MyThread thread = new MyThread();
            executorService.execute(thread);
        }
        executorService.shutdown();
    }
}

执行结果如下,可以看到五个核心线程一直在执行,没有规律,循环十次,但是并没有创建出十个线程,这和线程池的设计以及参数有关,后面会讲解:

pool-1-thread-5 : hello world
pool-1-thread-4 : hello world
pool-1-thread-5 : hello world
pool-1-thread-3 : hello world
pool-1-thread-2 : hello world
pool-1-thread-1 : hello world
pool-1-thread-2 : hello world
pool-1-thread-3 : hello world
pool-1-thread-5 : hello world
pool-1-thread-4 : hello world

总结一下,启动一个线程,其实本质上都离不开Runnable接口,不管是继承还是实现接口。

多线程可能带来的问题

  • 消耗资源:上下文切换,或者创建以及销毁线程,都是比较消耗资源的。
  • 竞态条件:多线程访问或者修改同一个对象,假设自增操作num++,操作分为三步,读取num,num加1,写回num,并非原子操作,那么多个线程之间交叉运行,就会产生不如预期的结果。
  • 内存的可见性:每个线程都有自己的内存(缓存),一般修改的值都放在自己线程的缓存上,到刷新至主内存有一定的时间,所以可能一个线程更新了,但是另外一个线程获取到的还是久的值,这就是不可见的问题。
  • 执行顺序难预知:线程先start()不一定先执行,是由系统决定的,会导致共享的变量或者执行结果错乱

并发与并行

并发是指两个或多个事件在同一时间间隔发生,比如在同1s中内计算机不仅计算数据1,同时也计算了数据2。但是两件事情可能在某一个时刻,不是真的同时进行,很可能是抢占时间片就执行,抢不到就别人执行,但是由于时间片很短,所以在1s中内,看似是同时执行完成了。当然前面说的是单核的机器,并发不是真的同时执行,但是多核的机器上,并发也可能是真的在同时执行,只是有可能,这个时候的并发也叫做并行。

image-20210511012516227

并行是指在同一时刻,有多条指令在多个处理器上同时执行,真正的在同时执行。

image-20210511012723433

如果是单核的机器,最多只能并发,不可能并行处理,只能把CPU运行时间分片,分配给各个线程执行,执行不同的线程任务的时候需要上下文切换。而多核机器,可以做到真的并行,同时在多个核上计算,运行。并行操作一定是并发的,但是并发的操作不一定是并行的。

关于作者

秦怀,公众号【秦怀杂货店】作者,技术之路不在一时,山高水长,纵使缓慢,驰而不息。个人写作方向:Java源码解析,JDBC,Mybatis,Spring,redis,分布式,剑指Offer,LeetCode等,认真写好每一篇文章,不喜欢标题党,不喜欢花里胡哨,大多写系列文章,不能保证我写的都完全正确,但是我保证所写的均经过实践或者查找资料。遗漏或者错误之处,还望指正。

2020年我写了什么?

开源编程笔记

150页的剑指Offer PDF领取

  • 本文作者: 秦怀杂货店
  • 本文链接: http://aphysia.cn/archives/thread1
  • 版权声明: 本博客所有文章除特别声明外,均采用CC BY-NC-SA 3.0 许可协议。转载请注明出处!
# 随便聊聊 # 数据结构 # 小游戏 # 数据库 # Docker # Springboot # 系统设计 # 雪花算法 # 分布式 # 海量ip # 最长回文子串 # 算法 # 面试题 # 线程池 # 多线程 # 线程 # java学习 # 布隆过滤器 # github # 架构设计 # docsify # Git # JVM # LeetCode # 杂货思考 # 设计模式 # Lambda # native # isAssignableFrom # 反射 # 剑指Offer # mybatis # SPI # JDBC # 编程工具 # Java基础 # 集合
【实战问题】-- 布隆过滤器的三种实践:手写,Redission以及Guava(2)
线程与线程池的那些事之线程池篇(万字长文)
  • 文章目录
  • 站点概览
秦怀杂货店

秦怀杂货店

纵然缓慢,驰而不息。

145 日志
19 分类
37 标签
Github E-mail
Creative Commons
0%
© 2022 秦怀杂货店
由 Halo 强力驱动
|
主题 - NexT.Pisces v5.1.4