回顾
回顾上一篇的文章,我们主要介绍了现代计算机模型,CPU的缓存一致性协议,CPU和内存的工作原理,这些知识点都是为了更好的去学习我们的Java并发编程。
介绍
本文,我们来了解一个概念,什么是线程?
Java中线程和计算机的线程有什么区别?
什么是线程
现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序、网页、软件应用等,操作系统就会创建一个进程。现代操作系统调度CPU的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的堆栈、局部变量、计数器等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让我们感觉到这些线程在同时并发执行。
进程是系统分配资源的基本单位,线程是调度CPU的基本单位,一个进程至少包含一个执行线程(main线程),线程依附在进程当中,每个线程都有一组寄存器(保存当前线程的工作变量)、堆栈(记录执行历史,其中每一帧保存了一个已经调用但未返回的过程)、一个程序计数器(记录要执行的下一条操作指令)
CPU会提供一个时间片来执行线程的代码块,然后快速的切换不同的线程,以达到并发的效果。
需要知道,我们的JVM中的Thread是无法直接操作CPU的,JVM是依赖的底层的操作系统,因此会带来一个概念,线程类型。
操作系统空间
- 内核空间系统核心,底层的进程
- 用户空间JVMeclipse应用视频播放器
线程类型
- 用户级线程(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的逻辑。
小结
本文介绍了什么是线程,并发,上下文切换的相关知识,希望对你有所帮助。