线程基础知识

线程和进程

进程:

  • 进程就是用来加载指令、管理内存、管理 IO 的 。
  • 操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是资源分配的最小单位。

    线程:

  • 线程是进程中的实体,一个进程可以拥有多个线程,一个线程必须有一个父进程。

  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
  • 线程,有时被称为轻量级进程(Lightweight Process,LWP),是操作系统调度(CPU调度)执行的最小单位。

    进程与线程的区别:

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集

  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPC(Inter-process communication)
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

    进程通信的方式

  1. 管道(pipe)及有名管道(named pipe):管道可用于具有亲缘关系的父子进程间的通信,有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
  2. 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。
  3. 消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。
  4. 共享内存(shared memory):可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。
  5. 信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段。
  6. 套接字(socket):这是一种更为一般得进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。

    线程的同步互斥

    线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。
    线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。
    四种线程同步互斥的控制方法
  • 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。(在一段时间内只允许一个线程访问的资源就称为临界资源)。
  • 互斥量:为协调共同对一个共享资源的单独访问而设计的。
  • 信号量:为控制一个具有有限数量用户资源而设计。
  • 事件:用来通知线程有一些事件已发生,从而启动后继任务的开始。

    上下文切换

  • 上下文切换是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。

  • 上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:
    1. 暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方
    2. 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它
    3. 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。
  • 上下文切换只能在内核模式下发生。
  • 上下文切换是多任务操作系统的一个基本特性。
  • 上下文切换通常是计算密集型的。就CPU时间而言,上下文切换对系统来说是一个巨大的成本,实际上,它可能是操作系统上成本最高的操作。因此,操作系统设计中的一个主要焦点是尽可能地避免不必要的上下文切换。

通过命令查看CPU上下文切换情况

  1. #可以看到整个操作系统每1秒CPU上下文切换的统计
  2. vmstat 1

image.png
其中cs列就是CPU上下文切换的统计。当然,CPU上下文切换不等价于线程切换,很多操作会造成CPU上下文切换:

  • 线程、进程切换
  • 系统调用
  • 中断

查看某一个线程\进程的上下文切换
使用pidstat命令

  1. 常用的参数:
  2. -u 默认参数,显示各个进程的 CPU 统计信息
  3. -r 显示各个进程的内存使用情况
  4. -d 显示各个进程的 IO 使用
  5. -w 显示各个进程的上下文
  6. # 显示进程5598每一秒的切换情况
  7. pidstat w p 5598 1

从进程的状态信息中查看

  1. cat /proc/1646988/status

image.png

操作系统层面线程生命周期

操作系统层面“五态模型”:初始化、可运行、运行、休眠、终止。
image.png

  • 初始状态:指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  • 可运行状态:指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
  • 运行状态:当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了运行状态。
  • 休眠状态:运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
  • 终止装填:线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

    查看进程线程的方法

  • linux

    • ps -ef 查看所有进程
    • ps -Tf -p 查看某个进程(PID)的所有线程
    • kill 杀死进程
    • top 按大写 H 切换是否显示线程
    • top -H -p 查看某个进程(PID)的所有线程
  • Java

    • jps 命令查看所有 Java 进程
    • jstack 查看某个 Java 进程(PID)的所有线程状态
    • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)

      Linux系统中线程实现方式

  • LinuxThreads linux/glibc包在2.3.2之前只实现了LinuxThreads

  • NPTL(Native POSIX Thread Library)

    Java线程详解

    Java线程的实现方式

  • 使用 Thread类或继承Thread类

  • 实现 Runnable 接口配合Thread
  • 使用有返回值的 Callable
  • 使用 lambda

本质上Java中实现线程只有一种方式,都是通过new Thread()创建线程,调用Thread#start启动线程最终都会调用Thread#run方法

Java线程实现原理

Java线程属于内核级线程

基于操作系统原生线程模型来实现。Sun JDK,它的Windows版本和Linux版本都使用一对一的线程模型实现,一条Java线程就映射到一条轻量级进程之中。
内核级线程:它们是依赖于内核的,即无论是用户进程中的线程,还是系统进程中的线程,它们的创建、撤消、切换都由内核实现。
用户级线程:操作系统内核不知道应用线程的存在。

协程

协程,英文Coroutines, 是一种基于线程之上,但又比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),具有对内核来说不可见的特性。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

  • 线程的切换由操作系统调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
  • 线程的默认stack大小是1M,而协程更轻量,接近1k。因此可以在相同的内存中开启更多的协程。
  • 不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

注意: 协程适用于被阻塞的,且需要大量并发的场景(网络io)。不适合大量计算的场景。

Java线程的调度机制

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式分两种,分别是协同式线程调度和抢占式线程调度。

协同式线程调度

线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。最大好处是实现简单,且切换操作对线程自己是可知的,没啥线程同步问题。坏处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里。

抢占式线程调度

每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java中,Thread.yield()可以让出执行时间,但无法获取执行时间)。线程执行时间系统可控,也不会有一个线程导致整个进程阻塞。
Java线程调度就是抢占式调度
在两线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行。但优先级并不是很靠谱,因为Java线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统。

Java线程的生命周期

Java 语言中线程共有六种状态,分别是:

  • NEW(初始化状态)
  • RUNNABLE(可运行状态+运行状态)
  • BLOCKED(阻塞状态)
  • WAITING(无时限等待)
  • TIMED_WAITING(有时限等待)
  • TERMINATED(终止状态)

在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。

image.png

Thread常用方法

sleep方法

  • 调用 sleep 会让当前线程从 Running 进入TIMED_WAITING状态,不会释放对象锁
  • 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出InterruptedException,并且会清除中断标志
  • 睡眠结束后的线程未必会立刻得到执行
  • sleep当传入参数为0时,和yield相同

    yield方法

  • yield会释放CPU资源,让当前线程从 Running 进入 Runnable状态,让优先级更高(至少是相同)的线程获得执行机会,不会释放对象锁

  • 假设当前进程只有main线程,当调用yield之后,main线程会继续运行,因为没有比它优先级更高的线程;
  • 具体的实现依赖于操作系统的任务调度器

    join方法

    等待调用join方法的线程结束之后,程序再继续执行,一般用于等待异步线程执行完结果之后才能继续运行的场景。

    stop方法

    stop()方法已经被jdk废弃,原因就是stop()方法太过于暴力,强行把执行到一半的线程终止。
    stop会释放对象锁,可能会造成数据不一致。

    Java线程的中断机制

    Java没有提供一种安全、直接的方法来停止某个线程,而是提供了中断机制。中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理。被中断的线程拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。

    API的使用

  • interrupt(): 将线程的中断标志位设置为true,不会停止线程

  • isInterrupted(): 判断当前线程的中断标志位是否为true,不会清除中断标志位
  • Thread.interrupted():判断当前线程的中断标志位是否为true,并清除中断标志位,重置为fasle

注意:使用中断机制时一定要注意是否存在中断标志位被清除的情况
sleep 期间能否感受到中断
处于休眠中的线程被中断,线程是可以感受到中断信号的,并且会抛出一个InterruptedException 异常,同时清除中断信号,将中断标记位设置成 false。

  • sleep可以被中断 抛出中断异常:sleep interrupted, 清除中断标志位
  • wait可以被中断 抛出中断异常:InterruptedException, 清除中断标志位

    Java线程间通信

    volatile

    volatile有两大特性,一是可见性,二是有序性,禁止指令重排序,其中可见性就是可以让线程之间进行通信。

    等待唤醒机制

    等待唤醒机制可以基于wait和notify方法来实现,在一个线程内调用该线程锁对象的wait方法,线程将进入等待队列进行等待直到被唤醒。
    LockSupport是JDK中用来实现线程阻塞和唤醒的工具,线程调用park则等待“许可”,调用unpark则为指定线程提供“许可”。使用它可以在任何场合使线程阻塞,可以指定任何线程进行唤醒,并且不用担心阻塞和唤醒操作的顺序,但要注意连续多次唤醒的效果和一次唤醒是一样的。

    管道输入输出流

    管道输入/输出流主要包括了如下4种具体实现:
    PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。
    1. public class Piped {
    2. public static void main(String[] args) throws IOException {
    3. PipedWriter out = new PipedWriter();
    4. PipedReader in = new PipedReader();
    5. out.connect(in);
    6. new Thread(() -> {
    7. try {
    8. out.write("tongtong");
    9. } catch (IOException e) {
    10. e.printStackTrace();
    11. } finally {
    12. try {
    13. out.close();
    14. } catch (IOException e) {
    15. e.printStackTrace();
    16. }
    17. }
    18. }).start();
    19. new Thread(() -> {
    20. try {
    21. int reveive;
    22. while ((reveive = in.read()) != -1) {
    23. System.out.print((char) reveive);
    24. }
    25. } catch (IOException e) {
    26. e.printStackTrace();
    27. }
    28. }).start();
    29. }
    30. }

    Thread.join

    join可以理解成是线程合并,当在一个线程调用另一个线程的join方法时,当前线程阻塞等待被调用join方法的线程执行完毕才能继续执行,所以join的好处能够保证线程的执行顺序,但是如果调用线程的join方法其实已经失去了并行的意义,虽然存在多个线程,但是本质上还是串行的,最后join的实现其实是基于等待通知机制的。