跳到主要内容

JUC复习篇(一)线程基础

· 阅读需 11 分钟

Java JUC 章节的知识点繁多,为了更好地整理我掌握的知识点,故此通过书写博客的方式,从底层原理开始梳理,总结出自己对并发编程的理解。

什么是并发编程

原因还得从计算机的发展历史说起。根据摩尔定律,CPU 算力随时间越来越高,但摩尔定律很快接近物理极限,单核心 CPU 算力遇到瓶颈。人们为了追求更高的算力,转向多核心 CPU 架构设计,多核心与超线程技术使得 CPU 算力大幅提高。原理相当于有多个人同时执行不同的指令,整体效率当然比一个人执行得更高。(注意是整体效率更高,不意味着使用多线程就一定比单线程应用执行指令效率更高)

为了更好地运用多核心 CPU 的计算资源(压榨 CPU),于是产生了多线程开发。虽然多线程在某些场景下确实提高了程序整体效率,但它并不是银弹:随之而来的还有线程安全、锁机制、死锁、线程上下文切换开销等一系列问题。

那么说了这么多线程的描述,到底什么是线程呢?

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务,而一个进程中至少有一个线程执行。

线程在 JVM 中的位置

通过 JVM 内存模型图,我们可以知晓线程 Thread 处于整个 JVM 中的具体位置。在 VM Stack 虚拟机栈空间中,每一个线程都有自己的线程栈,线程栈中存放着许多栈帧 Stack Frame,栈帧中又存放着 LVA、OS、FD 等信息。

LVA:局部变量数组(Local Variable Array)

  • 栈帧的局部变量部分是由从索引 0 开始的字节数组构成。
  • 它包含了所有的方法参数和局部变量。
  • 在数组中的每个插槽或条目大小是 4 个字节。
  • int、float 和引用类型在数组中全部占用一个条目或插槽,即 4 字节大小。
  • double 和 long 类型的数据在数组中占用 2 个连续条目,即总共 8 字节大小。
  • byte、short 和 char 类型在存储之前会被转化为 int 类型并占用 1 个插槽,即 4 字节大小。
  • 但是存储 boolean 类型值的方式在不同的 JVM 中有不同的实现。在大多数 JVM 的实现中,boolean 类型在局部变量数组中占用一个插槽。
  • 参数会首先被放置入局部变量数组中,顺序是它们在方法中声明的顺序。

OS:操作数栈(Operand Stack)

  • JVM 使用操作数栈作为运行的工作空间,或者说用来存储计算的中间结果。
  • 操作数栈也像局部变量数组一样组织为一个数组。但它不是使用索引来访问的,而是由一些指令来访问,这些指令可以把值推进栈中,也可以把值从栈中弹出并做一些我们需要的操作。

FD:栈帧数据区(Frame Data)

  • 栈帧数据区包含了所有的符号引用(常量池解析)和普通方法的 return 位置,该 return 与特定的方法关联,用来跳转回去。
  • 它也包含了一个到异常表的引用,可以在异常发生时提供 catch 代码块的信息。

在 JDK1.8 中,默认的线程栈空间为 1MB,可通过 -XX:ThreadStackSize=256k 调整线程栈内存空间大小。如果线程栈空间不足,则会抛出异常 StackOverflowError。

线程栈

**线程栈:**每次线程执行函数时都会压入一个栈帧,线程栈遵循先进后出,先进在栈底,后进在栈顶,从栈顶往下执行。例如:A 函数调用 B 函数,B 函数又调用 C 函数,栈结构如下图:

栈结构简单,且不需要垃圾回收器,因为栈帧执行完成后即刻销毁内存。

线程模型

一对一线程模型(即一个LWP对应一个KLT)

JDK1.8 中使用的就是这种模型。

**KLT:内核级线程(Kernel Level Thread)**这种线程实现是指直接由操作系统内核来完成线程支持、线程切换和操纵调度器进行调度,即线程映射到各个处理器。

**LWP:轻量级进程(Light Weight Process)**程序一般不会直接去使用内核线程,而是通过内核线程的一种高级接口 LWP 来调度操作内核线程。LWP 与普通进程的区别在于它只有一个最小的执行上下文和调度程序所需的统计信息,这也是它之所以被称为轻量级的原因,并且它与 KLT 之间的关系是 1:1 的存在。

优点:

1)实现简单,可适用大部分多线程场景,目前主流的 JVM 都使用此方案。

缺点:

1)用户线程的阻塞和唤醒直接映射到内核线程中,而且随着线程数量的增加,线程之间频繁切换导致 CPU 开销变大。JDK1.8 中引入了 CAS 算法来避免线程之间的频繁切换与线程加锁,确实大幅度提高了 JVM 的并发性能。

2)操作系统内核可以创建的线程数有限,应用程序创建过多的线程,会大幅度降低系统的性能,CPU 大部分时间片都会被过多的线程切换所消耗。

多对多线程模型(即在LWP上构建虚拟用户线程UT)

目前 GO 语言与 JDK19 版本所使用的就是这种模型。

**UT:用户线程(User Thread)**建立在用户空间,系统内核不能感知用户线程的存在,线程创建、销毁、切换开销小。

多对多模型,又叫作两级线程模型,它充分吸收各线程模型的优点且尽量规避它们的缺点。在此模型下用户线程与内核线程是多对多(M : N,通常 M >= N)的映射模型。

优点:

1)应用程序自身去管理 UT 的相互切换,而底层 CPU 不必频繁地切换线程,可以更连续地执行当前线程,从而提高程序效率。

2)UT 的调度优先级可以通过程序自身把控。

3)极大提高并发量,通过此模型开启 UT 过万也可以很好地提供服务,天生适合高并发场景。

缺点:

1)实现比较复杂。比如 Go 语言采用的 GMP 线程模型就是这种多对多的方式实现的,这也是使用 goroutine 可以实现高并发的原因之一。目前 Java 的 Loom 项目也在这方面进行探索,在 JDK19 中已经实现,大家可以尝试使用。JEP 425:虚拟线程(预览版) (openjdk.org)

线程的5种状态

1) 新建(New)

线程对象被创建后,就进入了新建状态。

2) 就绪(Runnable)

也被称为"可执行状态"。线程对象被创建后,其它线程调用了该对象的 start() 方法,从而来启动该线程,例如 thread.start()。处于就绪状态的线程,随时可能被 CPU 调度执行,但具体什么时候执行此线程由 CPU 自身决定。

3) 运行(Running)

线程获取 CPU 权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。

4) 阻塞(Blocked)

  • (01) 等待阻塞 -- 通过调用线程的 wait() 方法,让线程等待某工作的完成。可通过 notify() 方法唤醒该线程,唤醒之后线程进入就绪状态。
  • (02) 同步阻塞 -- 线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用)时,它会进入同步阻塞状态。
  • (03) 其他阻塞 -- 通过调用线程的 sleep() 或 join() 或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。

5) 死亡(Dead)

线程执行完了或者因异常退出了 run() 方法,该线程结束生命周期。

Java线程池的应用

从上面的线程状态可以得知,不停地创建和销毁线程,将会在新建与死亡两个状态中消耗更多的计算资源。为了更好地使用线程,于是便出现了管理线程资源的线程池。在线程池中我们会先创建好指定数量的线程,待需要使用的时候,从中取出线程运行,运行完对应任务后又扔回线程池中,这样就很好地避免了重复的创建和销毁步骤,从而提高程序的效率。

优点:

1)更好地管理应用程序中的线程数量、任务数量。

2)加快了任务执行速度,即缩短了请求响应时间。(没有创建和销毁步骤)

3)可使用多线程并行计算提高效率,以及其他的增强功能。

4)复用线程,降低了系统计算资源的开销。

线程池的创建方式

  • 通过 ThreadPoolExecutor 手动创建线程池。
  • 通过 Executors 执行器自动创建线程池。

7种创建线程池方式

1)Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待。 2)Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。 3)Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序。 4)Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池。 5)Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池。 6)Executors.newWorkStealingPool:创建一个抢占式执行的线程池(任务执行顺序不确定)。 7)ThreadPoolExecutor:手动创建线程池的方式,它创建时最多可以设置 7 个参数:核心数,线程最大数,阻塞队列类型(有界,无界),存活时间,时间单位,线程工厂,拒绝策略

int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程的空闲存活时间
TimeUnit unit, // 存活时间的单位
BlockingQueue<Runnable> workQueue, // 阻塞队列(有界 / 无界)
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
提示

线程池的创建推荐使用最后一种 ThreadPoolExecutor 的方式,因为使用它可以明确线程池的运行规则,规避资源耗尽的风险。

拒绝策略

拒绝策略共有 4 种:

1)ThreadPoolExecutor.AbortPolicy:默认拒绝策略,拒绝任务并抛出异常。 2)ThreadPoolExecutor.CallerRunsPolicy:使用调用线程直接运行任务。 3)ThreadPoolExecutor.DiscardPolicy:直接拒绝任务,不抛出错误。 4)ThreadPoolExecutor.DiscardOldestPolicy:触发拒绝策略后,只要还有任务新增,就一直丢弃阻塞队列中最老的任务,并将新的任务加入。

线程最大执行任务数 = 线程数 + 队列长度。

如果线程池最大线程为 10 个线程,队列长度为 20,则表明该线程池最大同时接受 30 个任务,超出的任务则会使用对应的拒绝策略。当然也可以自己实现接口 RejectedExecutionHandler 来处理拒绝流程。

小结

这一篇从 JVM 内存模型入手,梳理了线程栈的内部结构、一对一与多对多两种线程模型的取舍,以及线程的五种状态。理解了线程创建与销毁的开销,也就理解了线程池存在的意义。实际开发中建议用 ThreadPoolExecutor 手动创建线程池,明确核心参数与拒绝策略,避免 Executors 默认配置带来的资源风险。这些基础概念是后续理解锁机制与 JUC 各类同步器的前提。

评论 / COMMENTS