前言

本文主要内容:

  • 介绍实现线程的方式及java实现线程的方案
  • 介绍线程调度方式
  • 介绍java中的线程状态及转换
  • 介绍Java内存模型

    正文

操作系统基础

进程与线程的区别

在linux内核中,进程与线程都被视为任务,由链表结构进行表示与管理。如何区分线程与进程呢?该链表结构会记录当前线程id,主线程id,主线程地址。当主线程id等于当前线程id时,就表明当前是进程,所以,在内核中的存储结构这一方面,它们没有什么区别,主要区别在于资源占用方面。首先明确下它们的定位
进程:资源拥有的基本单位
线程:调度的基本单位
所以,可以认为进程是给多个线程提供资源的。看一下进程的虚拟内存结构
点击查看【processon】
用户空间是进程私有的,当进程切换时,用户空间中的信息需要保存,寄存器中保存的信息也要保存,而统一个进程中,不同线程切换时,只需要保存栈和寄存器中的信息,这资源占用是大大减少的

线程的实现方式

实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现 -深入理解java虚拟机

使用内核线程实现

进程线程都由同一种链表结构管理,存储在内核空间中,顶多是在用户空间中间中进行优化下,线程少占用些资源
程序一般通过轻量级进程(Light Weight Process,LWP)来使用KLT.LWP:KLT = 1:1
缺点:

  • 代价较高。基于内核线程实现,线程的使用过程中,会频繁的在用户态和内核态间来回切换
  • 线程数量较少。内核空间小,可以创建的线程数量不多

点击查看【processon】

使用用户线程实现

完全建立在用户空间上的线程,就是将线程的管理表交由进程,在用户空间实现。好处很多,因为用户空间更大,内核空间:用户空间是1:3,可以创建更多的线程,二是因为线程不使用内核空间,因此当线程切换时,就不需要进行内核态与用户态之间的切换,更快,更节省资源。但是缺点也突出,因为是由一个内核线程或者称为进程,模拟出来的多个线程,所以在内核感知中,其实还是一个进程,也就是粒度太大,内核进行管理时,将这一大群线程,统一视为一个线程,如果进行阻塞的系统调用,那么这一个进程中的所有线程都要停止
点击查看【processon】
优点:

  • 消耗资源较少
  • 可创建线程数量较多

缺点:

  • 所有线程的操作都需要程序自己完成
  • 对于设计底层的功能(处理阻塞,处理线程映射到其它处理器),实现困难或不能实现

使用用户线程加轻量级进程混合实现

字面意思,就是以上两种方式的结合
用户线程数:轻量级进程数 = N:M
点击查看【processon】

java实现线程的方式

Java线程在JDK1.2之前,基于被称为“绿色线程”(Green Threads) 用户线程实现的,而在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现的

也就是说,在JDK1.2之前,使用的使用用户线程方案,在JDK1.2及以后,java实现线程的方式依赖操作系统,操作系统支持怎样的线程模型,java就是什么样的线程模型。如果操作系统支持多种方案,则可以通过设置jvm参数来选择实现线程的方案。例如在Solaris平台,操作系统支持一对一(通过Bound Threads 或 Alternate Libthread实现)及多对多(通过 LWP/Thread Based Synchronization 实现)
多对多:-XX: +UseLWPSynchronization (默认)
一对多:-XX: +UseBoundThreads

线程调度方式

主要方式有两种:

  • 协同式线程调度(Cooperative Threads-Scheduling)
  • 抢占式线程调度(Preemptive Threads-Scheduling)

    协同式线程调度

    线程执行时间由线程本身来控制,任务执行完后,通知系统切换线程
    优点:实现简单
    缺点:线程执行时间不可控制,容易造成阻塞

    抢占式线程调度

    线程执行时间由系统来分配,线程的切换不由线程本身来决定。如果Java中的线程优先级能与运行的系统中的线程有限级相对应,则可以通过设置线程优先级来让线程被优先调度,从而尽可能的增加运行时间(当然,在编码实现中,所应用的锁应该是非公平锁才可以)
    java线程优先级:1/Thread.MIN_PRIORITY、2、3、4、5/Thread.NORM_PRIORITY)、6、7、8、9、10/Thread.MAX_PRIORITY
    提高线程优先级只是可能会被优先调用,还得考虑操作系统的因素
    优点:线程执行时间可控,避免一个线程导致整个进程阻塞的问题

    Java线程状态及转换

    状态

    网上有说五种的,有说六种的,还有七种,具体的说法也很多,这里就以 openjdk 14源码为准好了。
    image.png

  • NEW:创建后未使用

  • RUNNABLE:此时可能正在运行,也可能在等待被调度
  • BLOCKED:阻塞,等待获取互斥锁
  • WAITING:需要被显式唤醒
  • TIMED_WAITING:无需被显式唤醒,在约定时间过后会自动唤醒
  • TERMINATED:线程终止

    转换

    这里也以 openjdk 14 的源码为准,注释写得蛮清楚的。注释中没有记载的,我会进行注明

    NEW -> RUNNABLE

    (注释中未说明)
    **
    这里注释并未说明如何转换,我觉得应该是调用 start() 方法后,进入 RUNNABLE 状态

    BLOCKED <-> RUNNABLE

    注释中的内容如下:
    image.png
    大概是说,在等待获取监视器锁时进入 synchroized 修饰的代码块或者方法中。这是 BLOCKED -> RUNNABLE。如何从 RUNNABLE -> BLOCKED,注释到没有说,不过可以猜想是获取监视器锁(monitor lock)后就转换为了 RUNNABLE

    WAITING <-> RUNNABLE

    注释如下:
    image.png
    RUNNABLE -> WAITING

  • 调用 Object.wait()方法

  • 调用 Thread对象的 join() 方法
  • 调用 LockSupport.park()

WAITING -> RUNNABLE

  • 如果是因为调用 Object.wait() 转为了 WAITING,那么可以等待其其它线程调用 Object.notify() / Object.notifyAll() 进行唤醒
  • 如果是因为调用 Thread对象的 join() 方法转为了 WAITING,那么就等待那个线程结束后才可以继续进行
  • 如果是因为调用 LockSupport.part(),等待 LockSupport.unpart() 进行唤醒(注释中未写)

    TIMED_WAITING <-> RUNNABLE

    注释如下:
    image.png
    调用以下方法进入定时等待

  • Thread.sleep 单位为毫秒

  • Object.wait(long) 单位为毫秒
  • Thread对象的 join(long) 单位为毫秒
  • LockSupport.parkNanos 单位为纳秒
  • LockSupport.parkUntil 单位为毫秒

时间结束后,转为RUNNABLE

_RUNNABLE -> _TERMINATED

线程执行完成,或者提前终止,进入 TERMINATED

图示

这个图示蛮清楚的,直接看这个好了~来源:https://www.uml-diagrams.org/java-thread-uml-state-machine-diagram-example.html
不过这个图我觉得也有问题.从 等待被唤醒后,并不会进入阻塞态,应该进入Runnable 状态。从openjdk 14中关于进入阻塞的态的注释可以知道,当等待获取 monitor lock 且进入 synchronized 修饰的方法或者代码块时,才会进入阻塞。
再加点注释,READY 与 RUNNING 并未在 Thread 的状态枚举类中表现出,也就是这两个状态是检测不出来的,只有一个 RUNNABLE。但是在理论中,这是存在的,当线程调用 start后,进入 Ready 状态,等待 CPU 分配时间片,进行调用;被调用后进入 Running。这两个状态也是可以互相转换的,处于 Running 状态的线程调用 yield() 方法后会让出时间片,重新进入 Ready 状态。
Java线程的实现基础 - 图5

阻塞与等待的异同

这里阻塞指的是 BLOCKED 状态,等待指的是 WAITING 与 TIMED_WAITING 状态。写着写着发现阻塞与等待好像,于是记录下问题。
参考

共同点

  • 此时两者都无时间片

    不同点

  • 阻塞态的唤醒由 JVM 决定,被动;而等待状态什么时候结束可以由开发者自己决定,主动

    wait 与 sleep

    调用后,都进入阻塞态。但是wait会释放锁,sleep 不释放锁

    yield 与 sleep(0)的异同

    共同点

    两者都实现了让出 CPU 调度的效果

    不同点

    这里有些争议。网上大部分文章都提到,yield让出 cpu 调度后会考虑线程的优先级,只有与当前线程同级或者更高优先级的线程才可以被调度,而 sleep 则不考虑调度。但是,一是,openjdk 14及openjdk 8中的注释并未提到这点(两个注释是一样的)
    (openjdk 14)
    image.png
    (openjdk 8)
    image.png
    并且,有大佬从源码角度分析了,链接如下 https://juejin.im/post/6844903971463626766。我对这篇文章的理解是,

  • 只要有等待cpu调度的线程,yield就会让出调度,并不会考虑优先级问题。

  • 当 sleep 的等待时间为 0时,是可以从 sleep 转为 yield(虽然默认是不进行转换)

java内存模型

一种抽象出的概念,屏蔽各种硬件和操作系统的内存访问差异,实现在各种平台下都有一致的内存访问效果。由主内存和工作内存组成,通过八个操作来进行内存间的交互。

主内存与工作内存

主内存:

  • 主内存占用的是虚拟机内存(或者说主内存是虚拟机内存的一部分)
  • Java模型规定所有变量都存储在主内存

工作内存:

  • 每个线程都有独属于自己的工作内存,不同线程之间无法访问各自的工作内存
  • 工作线程中保存着主内存中对应的变量的副本,线程对变量的所有操作都需要在工作内存中进行

    交互

    Java线程的实现基础 - 图8
    这里的变量指的是线程共享的内容,例如:实例字段,静态字段。而局部变量,方法参数等线程私有的,并不包含在内。所有的变量存储在主内存中,然后线程使用时拷贝到工作内存。
    举个例子,某线程对需要使用一个全局变量
    该线程先lock,锁定主内存中的变量,标识为线程独占的状态,然后read,传出主内存的变量到线程的工作内存中,之后load,将从主内存中得到的变量值载入到工作区中的一个变量副本中。之后就是修改,use,将工作区中的变量副本的值传入到执行引擎,执行引擎进行操作,然后assign,把执行引擎使用过后的该变量值传给工作区中对应的变量副本。然后store,将变量副本传入主内存,接着write,将从工作内存中得到的变量值放入主内存变量中。最后使用完毕unlock,结束占用。

    参考

  • 深入理解Java虚拟机

  • 一文让你明白CPU上下文切换
  • 现代操作系统(第四版)
  • Java线程中wait状态和block状态的区别?
  • Java Thread States and Life Cycle
  • 链接