进程、线程
前言:程序是如何跑起来的
通常来说,像我们苦逼码农写的软件都是文本格式的代码,这些代码不能直接被计算机识别执行,需要使用编译器编译成操作系统或者虚拟机可以运行的代码(可执行代码),它们都被存储在文件系统中。
要想让程序处理数据,完成计算任务,必须把程序从外部设备加载到内存中,并在操作系统的管理调度下交给 CPU 去执行,去运行起来,才能真正发挥软件的作用,程序运行起来以后,被称作进程。
进程除了包含可执行的程序代码,还包括进程在运行期使用的内存堆空间、栈空间、供操作系统管理用的数据结构。
进程(process)
程序在系统上的一次执行过程
每个进程有独立的地址空间,进程切换时需要切换进程页表,以及切换运行环境(寄存器等)
早期内存分配机制
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?
举个栗子,某台计算机总的内存大小是 128M ,现在同时运行两个程序 A 和 B , A 需占用内存 10M , B 需占用内存 110M 。计算机在给程序分配内存时会采取这样的方法:先将内存中的前 10M 分配给程序 A ,接着再从内存中剩余的 118M 中划分出 110M 分配给程序 B 。

这种分配方法可以保证程序 A 和程序 B 都能运行,但是这种简单的内存分配策略问题很多。
安全的问题 :进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有 bug 的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。
效率的问题 :内存使用效率低。在 A 和 B 都运行的情况下,如果用户又运行了程序 C,而程序 C 需要 20M 大小的内存才能运行,而此时系统只剩下 8M 的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上(知识点:外存硬盘属于I/O设备),释放出部分空间来供程序 C 使用,然后再将程序 C 的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。
地址随机性的问题:程序运行的地址不确定。当内存中的剩余空间可以满足程序 C 的要求后,操作系统会在剩余空间中随机分配一段连续的 20M 大小的空间给程序 C 使用,因为是随机分配的,所以程序运行的地址是不确定的。
内存分段机制
虚内存:内存地址不是真正的物理地址,而是一个虚拟地址(通过映射计算)。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。
当创建一个进程时,操作系统会为该进程分配一个 4GB 大小的虚拟进程地址空间。之所以是 4GB ,是因为在 32 位的操作系统中,一个指针长度是 4 字节,而 4 字节指针的寻址能力是从 0x00000000~`0xFFFFFFFF,最大值 0xFFFFFFFF` 表示的即为 4GB 大小的容量。
当进程创建时,每个进程都会有一个自己的 4GB 虚拟地址空间。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。
再举个例子。
假设有两个进程 A 和 B ,进程 A 所需内存大小为 10M ,其虚拟地址空间分布在 0x00000000 到 0x00A00000 ,进程 B 所需内存为 100M ,其虚拟地址空间分布为 0x00000000 到 0x06400000 。那么按照分段的映射方法,进程 A 在物理内存上映射区域为 0x00100000 到 0x00B00000 ,进程 B 在物理内存上映射区域为0x00C00000 到 0x07000000 。于是进程 A 和进程 B 分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程 A 的地址空间就是分布在 0x00000000 到 0x00A00000
在 Windows 系统下,这个虚拟地址空间被分成了 4 部分: NULL 指针区、用户区、 64KB 禁入区、内核区。应用程序能使用的只是用户区而已,大约 2GB 左右 ( 最大可以调整到 3GB) 。内核区为 2GB ,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。
这种分段的映射方法虽然解决了上述中的安全问题和地址随机性的问题,但并没能解决内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序, 这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。基于此情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页 (Paging) 。
内存分页机制
分页的基本方法是,将地址空间分成许多的页。每页的大小由 CPU 决定,然后由操作系统选择页的大小。目前 Inter 系列的 CPU 支持 4KB 或 4MB 的页大小,而 PC上目前都选择使用 4KB 。按这种选择, 4GB 虚拟地址空间共可以分成 1048576(1024*1024) 页, 512M 的物理内存可以分为 131072 个页。显然虚拟空间的页数要比物理空间的页数多得多。
例如:银行存钱,张三:存1号箱,李四:存1号箱,但是两个1号箱并不是同一个,有一个特殊的对应表来查询实际的物理位置
在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。
线程(Thread)
定义:进程真正的执行过程是线程,每个进程在创立时会缺省的创立一个主线程,当然,进程还可以创建更多的线程,所有的线程属于这个进程,进程销毁时,进程下所有线程也销毁,同一个进程里面 切换线程时 **不需要切换页表**(节约时间),需要切换运行时环境(寄存器等)
线程一定属于进程
每个线程都有独立的运行时空间
同一个进程里面 切换线程时 不需要切换页表(节约时间),需要切换运行时环境(寄存器等)
一个进程多个线程并发
Java中的线程生命周期
线程是一个动态执行的过程,它也有一个从产生到死亡的过程。Java中的线程生命周期大体可以分为五种状态:
1、初始化状态(NEW)
- 此时JVM为其分配内存,并初始化其成员变量的值;
- 此时线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体;
2、可运行/运行状态(RUNNABLE)
线程对象调用了start()方法之后,该线程处于 就绪状态,此时JVM会为其创建方法调用栈和程序计数器
该状态的线程一直处于 线程就绪队列(尽管是采用队列形式,事实上,把它称为可运行池而不是可运行队列。因为CPU的调度不一定是按照先进先出的顺序来调度的),线程并没有开始运行;
- 此时线程 等待系统为其分配CPU时间片,并不是说执行了start()方法就立即执行;
- 调用start()方法来启动线程,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体;
- 需要指出的是,调用了线程的run()方法之后,该线程已经不再处于新建状态,不要再次调用线程对象的start()方法。只能对处于新建状态的线程调用start()方法,否则将引发IllegaIThreadStateExccption异常;
3、运行状态(RUNNING)
当CPU开始调度处于 就绪状态 的线程时,此时线程获得了CPU时间片才得以真正开始执行run()方法的线程执行体,则该线程处于 运行状态。
- 如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态;
- 如果在一个多处理器的机器上,将会有多个线程并行执行,处于运行状态;
- 当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象;
线程不可能一直处于运行状态,其在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略(抢占式or协作式)。
4、阻塞状态(BLOCKED)
处于运行状态的线程在某些情况下,让出CPU并暂时停止自己的运行,进入 阻塞状态。
当发生如下情况时,线程将会进入阻塞状态:
- 线程调用sleep()方法,主动放弃所占用的处理器资源,暂时进入中断状态(不会释放持有的对象锁),时间到后等待系统分配CPU继续执行;
- 线程调用一个阻塞式IO方法,在该方法返回之前,该线程被阻塞;
- 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有;
- 程序调用了线程的suspend方法将线程挂起;
- 线程调用wait,等待notify/notifyAll唤醒时(会释放持有的对象锁);
阻塞状态分类:
- 等待阻塞:运行状态中的 线程执行wait()方法,使本线程进入到等待阻塞状态;
- 同步阻塞:线程在 获取synchronized同步锁失败(因为锁被其它线程占用),它会进入到同步阻塞状态;
- 其他阻塞:通过调用线程的 sleep()或join()或发出I/O请求 时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕 时,线程重新转入就绪状态;
在阻塞状态的线程只能进入就绪状态,无法直接进入运行状态。而就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定。当处于就绪状态的线程获得处理器资源时,该线程进入运行状态;当处于运行状态的线程失去处理器资源时,该线程进入就绪状态。
但有一个方法例外,调用yield()方法可以让运行状态的线程转入就绪状态。
等待状态分类:无限制等待状态(WAITING)、时限等待状态(TIMED_WAITING)
当线程进入了一个 时限等待状态,如:
**sleep(3000)**,等待3秒后线程重新进行 就绪(RUNNABLE)状态 继续运行。
5、终止状态(TERMINATED)
线程会以如下3种方式结束,结束后就处于 死亡状态:
- run()或call()方法执行完成,线程正常结束;
- 线程抛出一个未捕获的Exception或Error;
- 直接调用该线程stop()方法来结束该线程—该方法容易导致死锁,通常不推荐使用;
处于死亡状态的线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦死亡,就不能复生。 如果在一个死去的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。
所以,需要注意的是:
一旦线程通过start()方法启动后就再也不能回到新建(NEW)状态,线程终止后也不能再回到就绪(RUNNABLE)状态。
需要重点理解的是:虽然Java语言中线程的状态比较多,但是,其实在操作系统层面,Java线程中的阻塞状态(BLOCKED)、无时限等待状态(WAITING)、有时限等待状态(TIMED_WAITING)都是一种状态,即通用线程生命周期中的休眠状态。也就是说,只要Java中的线程处于这三种状态时,那么,这个线程就没有CPU的使用权。
下面的图表示Java中线程的生命周期。

参考博文
https://juejin.cn/post/6844903558433734669
https://developer.huawei.com/consumer/cn/forum/topic/0202779877806640557?fid=0101592429757310384