回顾
回顾上一篇的文章,我们主要介绍了现代计算机模型,CPU 的缓存一致性协议,CPU 和内存的工作原理,这些知识点都是为了更好的去学习我们的 Java 并发编程。
介绍
本文,我们来了解一个概念,什么是线程?
Java 中线程和计算机的线程有什么区别?
什么是线程
现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个 Java 程序、网页、软件应用等,操作系统就会创建一个进程。现代操作系统调度 CPU 的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的堆栈、局部变量、计数器等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让我们感觉到这些线程在同时并发执行。
进程是系统分配资源的基本单位,线程是调度 CPU 的基本单位,一个进程至少包含一个执行线程(main 线程),线程依附在进程当中,每个线程都有一组寄存器(保存当前线程的工作变量)、堆栈(记录执行历史,其中每一帧保存了一个已经调用但未返回的过程)、一个程序计数器(记录要执行的下一条操作指令)
CPU 会提供一个时间片来执行线程的代码块,然后快速的切换不同的线程,以达到并发的效果。
需要知道,我们的 JVM 中的 Thread 是无法直接操作 CPU 的,JVM 是依赖的底层的操作系统,因此会带来一个概念,线程类型。
操作系统空间
-
内核空间
- 系统核心,底层的进程
-
用户空间
- JVM
- eclipse 应用
- 视频播放器
线程类型
- 用户级线程(User-Level Thread)
- 内核线线程(Kernel-Level Thread)
CPU 级别
Intel 的 CPU 将特权级别分为 4 个级别:RING0,RING1,RING2,RING3。Windows 只使用其中的两个级别 RING0 和 RING3,RING0 只给操作系统用,RING3 谁都能用。如果普通应用程序企图执行 RING0 指令,则 Windows 会显示“非法指令”错误信息。在用户空间中,JVM 会创建一个 ULT 级别线程,只能拥有 Ring3 级别权限。
而 Ring0 级别 ULT 是无法去调用操作,至于为什么要这样划分?
出发是为了安全性考虑,假如 ULT 可以任意去操作 CPU,拥有 Ring0 级别,那 JVM 的线程去肆意的攻击,修改其他的进程的指令,数据。会导致安全性问题。如果不限制,那内核里面的指令可以被修改,病毒可以随意的植入。
线程调度
JVM 假如需要生成一个内核级线程的话,可以怎么操作?
可以通过调用内核空间提供的系统调用接口(JNI)去创建一个 KLT 级别线程。
创建了 KLT 级别线程之后,才可以去使用 CPU,才会被分配时间片。
用户线程
指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态 / 核心态切换(上下文切换),速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。
内核线程
线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。大多数市场上的操作系统,如 Windows,Linux 等都支持内核级线程。
我们看 KLT,每个进程中的线程,全部依附于内核中,在内核中都会有对一个线程表一一对应,可以理解为轻量级的小进程,对应于具体的那个用户空间的线程的具体任务,同时也拥有 Ring0 级别的 CPU 特权。
Java 中创建的是哪个级别的线程?
- 1.2 时,创建的是 ULT
- 1.2 之后,创建的是 KLT
private native void start0();
Java 线程与系统内核线程关系
JVM 创建线程之后,会去通过库调度器调用,在内核空间中生成一个内核线程,并在内核空间的线程表关系中,进行一一映射对应。
Java 创建线程
- new java.lang.Thread().start()
- 使用 JNI 将一个 native thread attach 到 JVM 中
针对 new java.lang.Thread().start()这种方式,只有调用 start()方法的时候,才会真正的在
JVM 中去创建线程,主要的生命周期步骤有
- 创建对应的 JavaThread 的 instance
- 创建对应的 OSThread 的 instance
- 创建实际的底层操作系统的 native thread
- 准备相应的 JVM 状态,比如 ThreadLocal 存储空间分配等
- 底层的 native thread 开始运行,调用 java.lang.Thread 生成的 Object 的 run()方法
- 当 java.lang.Thread 生成的 Object 的 run()方法执行完毕返回后, 或者抛出异常终止后,终止 native thread
- 释放 JVM 相关的 thread 的资源,清除对应的 JavaThread 和 OSThread
针对 JNI 将一个 native thread attach 到 JVM 中,主要的步骤有
- 通过 JNI call AttachCurrentThread 申请连接到执行的 JVM 实例
- JVM 创建相应的 JavaThread 和 OSThread 对象
- 创建相应的 java.lang.Thread 的对象
- 一旦 java.lang.Thread 的 Object 创建之后,JNI 就可以调用 Java 代码了
- 当通过 JNI call DetachCurrentThread 之后,JNI 就从 JVM 实例中断开连接
- JVM 清除相应的 JavaThread, OSThread, java.lang.Thread 对象
Java 线程的生命周期
如下图所示
为什么用到并发?并发会产生什么问题?
为什么用到并发
并发编程的本质其实就是利用多线程技术,在现代多核的 CPU 的背景下,催生了并发编程的趋势,通过并发编程的形式可以将多核 CPU 的计算能力发挥到极致,性能得到提升。除此之外,面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分。
即使是单核处理器也支持多线程执行代码,CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
并发不等于并行:并发指的是多个任务交替进行,而并行则是指真正意义上的“同时进行”。实际上,如果系统内只有一个 CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个 CPU 的系统中。
并发的优点
- 充分利用多核 CPU 的计算能力;
- 方便进行业务拆分,提升应用性能;
并发产生的问题
- 高并发场景下,导致频繁的上下文切换
- 临界区线程安全问题,容易出现死锁的,产生死锁就会造成系统功能不可用
- 其它
CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
线程上下文切换过程:
上下文切换
Linux 为内核代码和数据结构预留了几个页框,这些页永远不会被转出到磁盘上。从 0x00000000 到 0xc0000000(PAGE_OFFSET)的线性地址可由用户代码 和 内核代码进行引用(即用户空间)。从 0xc0000000(PAGE_OFFSET)到 0xFFFFFFFFF 的线性地址只能由
内核代码进行访问(即内核空间)。内核代码及其数据结构都必须位于这 1 GB 的地址空间中,但是对于此地址空间而言,更大的消费者是物理地址的虚拟映射。
这意味着在 4 GB 的内存空间中,只有 3 GB 可以用于用户应用程序。一个进程只能运行在用户方式(usermode)或内核方式(kernelmode)下。用户程序运行在用户方式下,而系统调用运行在内核方式下。在这两种方式下所用的堆栈不一样:用户方式下用的是一般的堆栈,而内核方式下用的是固定大小的堆栈(一般为一个内存页的大小)。
每个进程都有自己的 3 G 用户空间,它们共享 1GB 的内核空间。当一个进程从用户空间进入内核空间时,它就不再有自己的进程空间了。这也就是为什么我们经常说线程上下文切换会涉及到用户态到内核态的切换原因所在。
以上图为例,来介绍下,CPU 的上下文切换
第一步
线程 A 申请到了时间片 A,执行相关的业务逻辑,当时间到达之后,CPU 纸箱执行线程 B 的时间片 B
这个时候线程 A 需要把一个临时中间状态进行存储,以便之后继续执行。
会把执行的结果通过 CPU 寄存器 —> 缓存 — > 通过 bus 总线(缓存一致性协议)写回到主内存中。
中间的一些状态会存放到主内存中的内核空间,一个叫做 Tss 任务状态段
的地方,存储了程序指令、程序指针、中间数据等。
第二步
执行时间片 B,执行完之后继续指向线程 A 的时间片 A。
这个时候 CPU 需要重新想内存中 load
上一个时间片执行的中间结果程序指令、程序指针、中间数据。
然后重新继续执行线程 A 的逻辑。
小结
本文介绍了什么是线程,并发,上下文切换的相关知识,希望对你有所帮助。