【修炼内功】[JVM] 细说线程

本文已收录【修炼内功】跃迁之路

细说JVM线程状态.png

自从踏入程序猿这条不归路,便摆脱不了(进程)线程这只粘人的小妖精,尤其在硬件资源“过剩”的今天

不论你在使用c、C++、.Net,还是Java、Python、Golang,都免不了要踏过这一关,即使使用以“单线程”著称的Node.js,也要借助pm2类似的进程管理工具fork一批进程,来榨干机器资源

早些年使用c编写多线程时,需要使用宏定义来兼容多平台下不同库的函数,而Java从一开始便宣称的"Write Once, Run Anywhere"从虚拟机层面帮我们屏蔽了众多平台差异,那,Java线程与OS线程间有什么关系?

系统架构

细说JVM线程状态001.png

以*nix类系统为例,其系统体系架构主要分为用户态(user context)内核态(kernel context)

内核,本质上讲是一种较为底层的控制计算机硬件资源的软件

用户态,即上层应用程序的活动空间,应用程序的执行依托于内核提供的资源,为了使上层资源访问内核资源,内核提供系统调用接口以供上层应用访问

系统调用,可以看作是操作系统的最小功能单元,一种不能再简化的操作,而函数库则是对一组系统调用的封装,以降低应用程序调用内核的复杂度

用户态与内核态切换

在*nix类系统中,为了有效减少内核资源的访问及冲突,对不同的操作赋予了不同的执行等级,越是与系统相关的关键操作,越是需要高特权来执行

linux操作系统中主要采用了0和3两个特权等级,分别对应于内核态及用户态,运行于用户态的进程可以执行的操作及访问的资源会受到很大的限制,而运行在内核态的进程则可以执行任何操作,并且在资源的访问上也不会受到任何限制

一般应用程序一开始运行时都会处于用户态,当一些操作需要在内核权限下才能执行时,则会涉及一次从用户态到内核态的切换过程,当该操作执行完毕后,又会涉及一次从内核态到用户态的切换过程

细说JVM线程状态002.png

线程模型

回过头来,从系统层面聊一聊线程的实现模型

用户线程 v.s. 内核线程

简单来讲

  • 用户线程

    由应用程序创建、调度、撤销,不需要内核的支持(内核不感知)

    • 由于不需要内核的支持,便不涉及用户态/内核态的切换,消耗的资源较少,速度也较快
    • 由于需要应用程序控制线程的轮换调度,当有一个用户线程被阻塞时,整个所属进程便会被阻塞,同时在多核处理器下只能在一个核内分时复用,不能充分利用多核优势
  • 内核线程

    由内核创建、调用、撤销,并由内核维护线程的上下文信息及线程切换

    • 由于内核线程由内核进行维护,当一个内核线程被阻塞时,不会影响其他线程的正常运行,并且多核处理器下,一个进程内的多个线程可以充分利用多核的优势同时执行
    • 由于需要内核进行维护,在线程创建、切换过程中便会涉及用户态/内核态的切换,增加系统消耗

轻量级进程 LWP

在linux操作系统中,往往都是通过fork函数创建一个子进程来代表内核中的线程,在fork完一个子进程后,还需要将父进程中大部分的上下文信息复制到子进程中,消耗大量cpu时间用来初始化内存空间,产生大量冗余数据

为了避免上述情况,轻量级进程(Light Weight Process, LWP)便出现了,其使用clone系统调用创建子进程,过程中只将部分父进程数据进行复制,没有被复制的资源可以通过指针进行数据共享,这样一来LWP的运行单元更小、运行速度更快

LWP与内核线程一一映射,每个LWP都由一个内核线程支持

1:1 线程模型

1:1 模型,即每一个用户线程都对应一个内核线程,每个线程的创建、调度、销毁都需要内核的支持,每次线程的创建、切换都会设计用户状态/内核状态的切换,性能开销比较大,并且单个进程能够创建的LWP的数量是有限的,但能够充分里用多核的优势

细说线程状态.003.jpeg

N:1 线程模型

N:1模型,即所有的用户线程都会对应到一个内核线程中,该模型可以在用户空间完成线程的创建、调度、销毁,不需要内核的支持,同样也就不涉及用户状态/内核状态的切换,线程的操作较快且消耗较低,并且线程数量不受操作系统的限制,但不能发挥多核的优势,只能在一个核中分时复用,并且由于内核不能感知用户态的线程,在某一线程被阻塞时,会导致整个所属进程阻塞

细说线程状态.004.jpeg

N:M 线程模型

N:M 模型是基于以上两种模型的一种混合实现,多个用户线程对应于多个内核线程,即解决了1:1模型中性能开销及线程数量的问题,也解决了N:1模型中阻塞问题,同时也能充分利用CPU的多核优势,这也是大部分协程实现的基础

细说线程状态.005.jpeg

Java在1.2之前基于用户线程实现(N:1线程模型),在1.2之后windows及linux平台下采用1:1线程模型,在solaris平台使用1:1或N:M线程模型实现(可配置)

线程状态

以下以linux平台为例

linux平台下,JVM采用1:1的线程模型,那Java中的线程状态与OS的线程状态是否也是一一对应的?

系统线程状态&生命周期

细说线程状态.006.jpeg

linux系统的线程状态及生命周期如上图,每种状态的详细解释不再一一赘述,这里简单介绍下RUNNABLERUNNING

  • RUNNABLE

    线程处于可运行的状态,但还没有被系统调度器选中,即还没有分配到CPU时间片

  • RUNNING

    线程处于运行状态,即线程分配到了时间片,正在执行机器指令

Java线程状态&生命周期

Java中的线程状态并没有使用系统线程状态一一对应的方式,而是提供了与之不同的6种状态

以下,linux系统线程状态会使用 斜体 加以区分

细说线程状态.007.jpeg

linux系统中的RUNNABLERUNNING被Java合并成了RUNNABLE一种状态,而linux系统中的BLOCKED被Java细化成了WAITINGTIMED_WAITINGBLOCKED三种状态

Java中的线程状态与系统中的线程状态大体相似,但又略有不同,最明显的一点是,如果由于I/O阻塞会使Java线程进入BLOCKED状态么?NO!I/O阻塞在系统层面会使线程进入BLOCKED状态,但在Java里线程状态依然是RUNNABLE

系统中的RUNNABLE表示线程正在等待CPU资源,在在Java中被认为同样是在运行中,只是在排队等待而已,故Java中将系统的RUNNABLERUNNING合并成了RUNNABLE一种状态

而对于系统中I/O阻塞引起的BLOCKED状态,在Java中被认为同样是在等待一种资源,故也认为是RUNNABLE的一种情况

Java线程的状态在Thread.State枚举中可以查看,其每种状态的释义写的非常清楚,这里不再一一解释

  • NEW

    Thread state for a thread which has not yet started.
  • RUNNABLE

    Thread state for a runnable thread. A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.
  • BLOCKED

    Thread state for a thread blocked waiting for a monitor lock. A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling Object.wait.
  • WAITING

    Thread state for a waiting thread. A thread is in the waiting state due to calling one of the following methods:

    • Object.wait with no timeout
    • Thread.join with no timeout
    • LockSupport.park

    A thread in the waiting state is waiting for another thread to perform a particular action. For example, a thread that has called Object.wait() on an object is waiting for another thread to call Object.notify() or Object.notifyAll() on that object. A thread that has called Thread.join() is waiting for a specified thread to terminate.

  • TIMED_WAITING

    Thread state for a waiting thread with a specified waiting time. A thread is in the timed waiting state due to calling one of the following methods with a specified positive waiting time:

    • Thread.sleep
    • Object.wait with timeout
    • Thread.join with timeout
    • LockSupport.parkNanos
    • LockSupport.parkUntil
  • TERMINATED

    Thread state for a terminated thread. The thread has completed execution.

上下文切换与调优

上下文切换涉及到进程间上下文切换与线程间上下文切换

用户态与内核态的每一次切换都会导致进程间上限文的切换,比如java中在使用重量级锁的时候会依赖系统底层的mutex lock,而该系统操作会导致用户态/内核态的切换,进而引起进程间的上下文切换

这里重点讨论下线程间的上下文切换

什么情况会触发线程间上下文切换

一个线程由RUNNING转为BLOCKED时(线程暂停),系统会保存线程的上下文信息

当该线程由BLOCKED转为RUNNABLE时(线程唤醒),系统会获取上次的上下文信息以保证线程能够继续执行

以上的一个过程线程上下文的一次切换过程

同样,一个线程由RUNNING转为RUNNABLE,再由RUNNABLE转为RUNNING时也会发生线程间的上下文切换

即,多线程的上下文切换实际上就是由多线程两个运行状态的互相切换导致的

那,什么情况下会触发 RUNNINGBLOCKEDRUNNABLE (对应Java中 RUNNABLEBLOCKED/WAITING/TIMED_WAITINGRUNNABLE) 的状态转变呢?

一种为程序本身触发,一种为操作系统或虚拟机触发

程序本身触发很容易理解,所有会导致 RUNNABLEBLOCKED/WAITING/TIMED_WAITING 的逻辑均会触发线程间上下文切换,如synchronizedwaitjoinparksleep

操作系统触发,最常见的比如线程时间片的分配

虚拟机触发,最常见的在于进行垃圾回收时的 'stop the world'

如何优化

既然所有会导致 RUNNABLEBLOCKED/WAITING/TIMED_WAITING 的逻辑均会触发线程间上下文切换,那便从诱因入手

锁竞争

锁其实并不是性能开销的根源,竞争锁才是

  1. 减少锁的持有时间

    锁的持有时间越长,就意味着可能有越多的线程在等待锁的释放,如果是同步锁,除了会造成线程间上下文切换外,还会有进程间的上下文切换 (mutex lock)

    优化方法有很多,比如将synchronized关键字从方法修饰移到方法体内,将synchronized修饰的代码块中无关的逻辑移到synchronized代码块外,等等

  2. 降低锁的粒度

    • 锁分离

      对于读操作大于写操作的逻辑,可以将传统的同步锁拆分为读写锁,即读锁与写锁,在多线程中,只有读写与写写是互斥的,避免读读情况下锁的竞争

    • 锁分段

      对于大集合或者大对象的锁操作,可以考虑将锁进一步分离,将大集合或者大对象分隔成多个段,对每一个段分别上锁,以避免对不同段进行操作时锁的竞争,如ConcurrentHashMap中对锁的实现

  3. 非阻塞乐观锁代替竞争锁

    • 使用volatile

      volatile 的读写操作不会导致上下文切换,开销较小,但volatile只保证可见性,不保证原子性

    • 使用CAS

      CAS 是一个原子的 if-then-act 操作,可以在我外部锁的情况下来保证读写操作的一致性,如Atomic包中的算法

    • 其它非阻塞乐观锁

wait/notify优化

  • 使用notify()代替notifyAll()

    众所周知,notifyAll会唤醒所有相关的线程,而notify则会唤醒指定线程,以减少过多不相关线程的上下文切换

  • 使用Lock+Condition组合的方式替代wait/notify

    synchronized是基于系统层面实现的,而Lock则是应用程序层面实现的,不会造成用户态/内核态的切换

    Condition会避免类似notifyAll提前唤醒过多无关线程的问题

合理设置线程池大小

线程池数量不宜设置过大,线程池数量设置过大容易导致大量线程处于等待CPU时间片的状态(RUNNABLE),同时也会导致过多的上下文切换

使用协程实现非阻塞等待

协程可以看做是一种轻量级线程

前文介绍到,Java线程使用1:1线程模型,每个用户线程都会映射到一个系统线程,线程由内核来管理

协程则使用N:M线程模型,协程完全由应用程序来管理,避免了众多的上下文切换

(协程不等于没有系统线程,只是会大大减少系统线程上下文切换的次数)

总结

  • 操作系统体系架构主要分为用户态(user context)内核态(kernel context)
  • 由于系统操作分不同的执行等级,应用程序在执行一些高等级操作时会发生用户态/内核态的切换
  • 用户线程由应用程序创建、调度、撤销,不需要内核的支持
  • 内核线程由内核创建、调用、撤销,并由内核维护线程的上下文信息及线程切换
  • 线程模型分为1:1N:1N:M三种,Java在window及linux上采用1:1线程模型,即每个用户线程都会对应一个内核线程
  • Java中的线程状态并没有使用系统线程状态一一对应的方式,而是使用NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六种状态
  • 用户态/内核态的切换会导致进程间上下文切换
  • 多线程两个运行状态的互相切换会导致线程间的上下文切换,诸如synchronized wait join park sleep 等常见操作均会引起线程间的上下文切换
  • 理解线程上下文切换的原因,合理优化程序,减少上下文切换,减轻系统负担

订阅号

阅读 4.1k

推荐阅读
林中小舍
用户专栏

工作中的坑点及经验

53 人关注
41 篇文章
专栏主页