目录
操作系统
操作系统是一个负责管理的软件.
操作系统对下,要管理硬件设备;对上,要给软件提供稳定的运行环境.
所以操作系统是软件,硬件,用户之间交互的媒介.
常见的操作系统:
Windows:Windows 98,xp,vista,win7,win10,win11
Linux:程序员必须掌握的系统,特别适合于进行开发和部署.
Mac:苹果电脑用的系统(和Linux是表兄弟).
Android:本质上是Linux.
IOS:和Mac同宗同源.
操作系统的定位
系统调用:操作系统给应用程序提供的api .
操作系统内核:操作系统的核心功能(管理着对上对下).
驱动程序:硬件设备种类繁多,厂商各异,硬件厂商在开发硬件的同时会提供驱动.电脑装好了驱动,才能让系统正确识别硬件设备.
比如:有个程序想要操作一下硬件设备,就需要先通过系统调用,把操作命令告诉给系统内核,内核调用驱动程序,进一步的操作硬件设备.
进程
一个跑起来的程序,就是一个"进程".
进程(process)也叫做任务(task)!
在电脑上有一个idea64.exe可执行程序.但是此时我们没有双击去运行它.
此时他不能叫做一个进程(没运行的就不是进程).没跑起来的,叫做"程序".
进程是一个重要的"软件资源",是由操作系统内核进行负责管理的.
如何来认识进程??
从描述和组织两个方面来解析进程.(描述:讲清楚都有哪些属性特征.组织:通过一定的数据结构,把多个这样的基本单位串起来).
描述:使用结构体(C语言的结构体)来描述进程的属性(操作系统基本上都是C/C++来写的).
用来描述进程的这个结构体起了个特殊的名字,叫做PCB(进程控制块)(PCB是一个结构体,不是硬件"PCB").
组织:通过双向链表,来把多个PCB给串到一起.(并不是一个单纯的双向链表).
创建一个进程,本质上就是创建一个PCB这样的结构体对象,把它插入到链表中.
销毁一个进程,本质上就是把链表上的指定PCB结点删除掉.
任务管理器查看进程链表,本质上就是遍历这个PCB链表.
PCB
PCB里面都是描述了进程的哪些特征??(此处描述的是一个进程里只有一个线程的情况).
1.pid
进程的身份标识符(唯一的数字)
2.内存指针
指向了说明自己的内存是哪些.
3.文件描述符表
硬盘上的文件等其他资源.
4.进程调度相关的属性
(操作系统里面有一个重要的模块:调度器,就是负责让有限的CPU来调度执行这么多的进程)
硬件资源,内存,硬盘,网卡都好分配,但是CPU资源不好分配,进程有上百个,CPU有几个??
打开设备管理器,查看处理器
现在的CPU都是多核CPU.(比如8核16线程,一个CPU分成8个核心,每个核心,又能一个顶两个,超线程技术,这种情况就视为16核就行了)
进程多,CPU资源少,属于是狼多肉少的局面.这些进程,是希望能够"同时运行"."分时复用".
并行:微观上同一时刻,两个核心上的进程,就是同时进行的.
并发:微观上,同一时刻,一个核心上只能运行同一个进程,但是它能够对进程快速的进行切换.(比如CPU这个核心上,先运行一下QQ音乐,在运行一下cctalk,在运行一下画图板..,只要切换速度足够快(2.5GHz,每秒运行25亿条指令)宏观上人感知不到,人看起来就好像是这几个进程同时运行).
并行和并发是内核负责处理的,应用程序层面和程序员层面是感知不到的,因此往往也把并行和并发,统称为并发!!!未来除非是显式声明,否则谈到并发,就是指并行+并发).
1) 进程的状态
就绪状态:随叫随到.进程随时准备好了去CPU上执行.
运行状态:正在进行的.
很多操作系统不会明确区分就绪和运行.
阻塞状态:短时间内无法到CPU上执行了,比如进程在进行密集的IO操作,读写数据.
2)优先级
先给谁排,后给谁排,给谁多排点,给谁排少点.
进程也是有优先级的,操作系统进行系统调度并不是一碗水端平.
3)上下文
操作系统在进行进程切换的时候,就需要把进程执行的"中间状态",记录下里,保存好.
下次这个进程再上CPU上运行的时候,就可以恢复上次的状态,以便于继续往下执行.
类比"存档,读档"
上下文本质上就是你存档的内容,进程的上下文本质上就是你存档的内容.
进程的上下文,就是CPU中的各个寄存器的值.(寄存器:CPU内置的存储数据的模块,保存的就是程序运行过程中的中间结果).
保存上下文:就是把这些CPU寄存器的值,记录保存到内存中(PCB中).
恢复上下文:就是把内存中的这些寄存值恢复进去.
4)记账信息
操作系统,统计每个进程在cpu上占用的时间和执行的指令数目.根据这个信息来决定下一阶段该如何调度.
PCB这里包含的属性非常多,以上是核心的属性!!
内存管理
虚拟地址空间
程序中所获取到的内存地址,并非真实的物理内存的地址,而是经过了一层抽象,虚拟出来的地址.
C语言中学过的指针,这里的内存地址,就是虚拟的内存地址,并非真实的物理内存地址.
物理地址
内存物理上是个内存条,可以存很多的数据.
内存可以想象成是一个大走廊,走廊非常长,有很多的房间,每个房间的大小是1Byte,每个房间还有编号,从0开始依次累加.
这个内存编号,就是"地址",这个地址也就认为是"物理地址".
内存有个特性,随机访问,访问内存上的任意地址的数据,速度都极快,时间上都差不多.
针对进程的内存空间
如果采用上述模式,如果代码不小心出bug了,就可能导致访问的内存就越界了(不小心的指针变量,变成了0x8000,明明是进程1的bug,却影响到了进程2,这里造成的影响非常严重).
所以,针对进程使用的内存空间,进行'隔离'引入了虚拟地址空间.
代码里不在直接使用真实的物理地址了,而是使用虚拟的地址.
真实的物理地址到虚拟地址的转换是由操作系统和专门的硬件设备负责进行的.
MMU硬件设备能够让转换更快一点,很多时候是集成在CPU里的.
一旦某个进程访问越界了,操作系统内核会发现当前这里的地址超出了进程的访问范围,此时就直接会向进程反馈一个错误(具体来说是发送一个SIGN SEGEMENT FAULT信号,来引起进程的崩溃).
这样,哪个进程出了bug,就只会引起当前进程崩溃,而不会影响到其他的进程.
进程间的通信
虽然进程隔离了,但是又引入了新的问题,有些情况下,进程之间也需要数据的交互.
怎么实现进程间的通信??
在隔离性的基础上,开个口子,进程间通信,实现方式有很多,但是核心思路是一致的.
需要开创一个多个进程都能访问到的"公共空间",基于这个公共空间来进行交互数据.
(使用文件,使用网络)
线程
引入进程这个概念,最主要的目的是为了解决"并发编程"这样的问题.
这是因为CPU进入了多核心的时代,要想进一步提高程序的执行速度,就需要充分的利用CPU的多核资源.(不是说CPU核心多了,程序一下跑的就快了,还需要程序代码能够把这些CPU核心给利用上)(防止出现"一核有难,多核围观"的情况).
其实,多进程编程,已经解决并发编程的问题了,已经可以利用起来cpu的多核资源.
但是,进程有个致命的缺点,重!!!(消耗的资源多并且速度慢)
创建一个进程,销毁一个进程,调度一个进程,开销都比较大.(说进程重,主要是重在"资源分配/回收"上).
因此,针对这个问题,线程应运而生,线程也叫作"轻量级进程".
线程在解决并发编程的前提下,让创建,销毁,调度的速度,更快了.
线程为什么''轻'',把申请资源/释放资源的操作给省去了.
线程和进程的关系
进程包含线程.
一个进程可以包含一个线程,也可以包含多个线程(但是不能没有线程).
只有第一个线程启动的时候,开销是比较大的,后续线程就小很多.
同一个进程里的多个线程之间,公用了进程的同一份资源(主要是指内存和文件描述符表).
公用内存:线程1 new的对象,在线程 2,3,4里都可以直接使用.
公用文件描述符表:线程1打开的文件,在线程2,3,4里都可以直接使用.
操作系统在进行实际调度的时候,是以线程为单位进行调度的.
如果每个进程有多个线程,每个线程是独立在CPU上调度的=>线程是操作系统调度执行的基本单位.
每个线程都有自己的执行逻辑(执行流).
一个线程是通过一个PCB来描述的,一个进程里面可能是对应一个PCB,也可能是多个了.
之前介绍的PCB里面的状态,上下文,优先级,记账信息,每个线程都有自己的,各自记录各自的.
但是同一个进程的PCB之间,pid是一样的,内存指针和文件描述符表也是一样的.
Thread类
Thread类是java中操作多线程最核心的类.
线程的创建
创建线程,是希望线程成为一个独立的执行流(执行一段代码)
如何指定一个线程所要执行的代码,方法有很多.
t.start();线程中的特殊方法,启动一个线程.
注意:start里面没有调用run,start只是创建了一个新的线程,由新的线程来执行run方法.
如果run方法执行完毕,新的线程就自然销毁了.
交叉打印hello main和hello world,两个线程同时工作.(并发)
mian每次都是比world先打印吗?
不一定谁先谁后,操作系统调度线程的时候,"抢占式执行",具体哪个线程先上,哪个线程后上,是不确定的,取决于操作系统的调度器具体实现策略.
虽然有优先级,但是在应用程序上无法修改,从应用程序(代码)的角度,看到的效果,就好像是线程之间的调度顺序是随机的一样(内核本身里并非是随机,但是干预的因素太多,并且应用程序这一层无法感知到细节,就只能认为是随机的了).
start和run的区别
start:是真正创建了一个线程(从系统这里创建的),线程是独立的执行流.
run:只是描述了线程要干的活是什么,如果直接在main中调用run,此时没有创建新的线程,全是main一个线程在工作.
查看当前运行的java进程
可以使用jdk自带的工具jconsole查看当前的java进程中的所有线程.
可以看到,当前进程中的线程,不只是一两个,有很多!!
创建线程的写法
1.继承Thread,重写run
2.实现Runnable接口
解耦合:目的就是为了让线程和线程要干的活之间分离开.
未来如果要改代码,不用多线程,使用多进程,或者线程池,或者协程,此时代码改动比较小.
3.使用匿名内部类,继承Thread
创建了一个Thread的子类(子类没有名字),所以才叫做"匿名".
创建了子类的实例,并且让t指向该实例.
4.使用匿名内部类,实现Runnable
Runnable匿名内部类的实例作为Thread类构造方法的参数.
5.使用Lambda表达式
最简单,推荐写法.
()->
把任务用Lambda表达式来描述,直接把lambda传给Thread构造方法.
Thread用法
可以看到我们起的名字出现在了列表中.
在这个列表中,可以看到,没有主线程了,那是主线程执行完了.
主线程执行完了start之后,紧接着结束了main方法,对于主线程来说,main方法完了,自己也就没了.
Thread的常见属性
getid()
获取线程的身份标识.
getName()
获取我们在构造方法里给线程起的名字.
getState()
获取线程状态(Java线程里的状态是要比操作系统原生的状态更丰富一些).
getPriority()
这个可以获取,也可以设置,但是设置了也没什么用.
isDaemon()
是否是守护线程(是否是"后台线程").
前台线程会阻止进程的结束,前台线程的工作没做完,进程是完不了的,后台线程,不会阻止进程结束,后台线程工作没做完,进程也是可以结束的.
代码里手动创建的线程,都默认是前台的,包括main默认也是前台的.
其他的jvm自带的线程都是后台的.
也可以手动的使用setDaemon 设置后台的线程.
是后台线程就是守护线程.
此时如果把t设置成后台线程,进程的结束就和t无关了,此时主线程就只有main,main结束了,进程就结束了.
isAlive()
isAlive 是在判断,当前系统里的这个线程是不是真的有了.
如果光是创建一个t变量,不调用start,系统的内核里是没有创建线程的.调用了start才是真正的开始做任务.
在真正调用start之前,调用t.isAlive就是false.
调用start之后,isAlive就是true.
另外,如果内核里线程把run干完了,此时线程销毁,pcb也随之释放,但是Thread t 这个对象还不一定被释放,此时isAlive也是false.
中断一个线程
中断的意思,不是让线程立即就停止,而是通知线程,你应该要停止了.是否是真的停止,取决于线程这里具体的代码写法.
1.使用标志位来控制线程是否要停止.
这个代码之所以能够起到修改flag,t线程就结束.完全取决于t线程内部的代码,代码里通过flag控制循环.因此,让这个线程结束,这个线程是否要结束,什么时候结束,都是线程内部自己的代码来决定.
2.使用Thread 自带的标志位来进行判定
这个东西是可以唤醒上面的sleep这样的方法的.
while (!Thread.currentThread().isInterrupted())中
Thread.currentThread()这是Thread类的静态方法,通过这个方法可以获取到当前线程.
哪个线程调用这个方法,就能得到哪个线程的对象引用(类似于this).
isInterrupted()
为true表示被终止,为false表示未被终止(要继续走).这个方法背后就相当于在判定一个boolean变量.
我们在条件里进行逻辑取反,表示:当isInterrupted()为true时,被终止,取反条件里就为false,循环就结束了.
我们运行这段代码:
会发现在打印之后,触发了异常,触发异常后,继续打印.
这是因为interrupt会做两件事:
1.把线程内部的标志位(boolean)设置成true
2.如果线程正在sleep,就会触发异常,把sleep唤醒,但是sleep在唤醒的时候,还会做一件事,就是把刚才设置的标志位在设置回false(清空了标志位).这就导致,当sleep的异常被catch完了之后,循环还要继续执行.
为什么sleep要清除标志位?
唤醒之后,线程到底是要终止,还是不要,到底是立即终止,还是稍后,就把选择权交给程序员了.
(程序猿可以通过改变t线程内部代码来实现是要终止还是不要,还是立即终止,还是稍后终止).
等待一个线程
线程是一个随机调度的过程.
等待线程,做的事情,就是在控制两个线程的结束顺序.
本身在执行完start之后,t线程和main线程就并发执行,分头行动.
main继续往下执行,t也会继续往下执行.
当main线程执行到t.join()时,发生阻塞(阻塞也是一个常见的术语,block).
main线程会一直阻塞到t线程结束,main线程才会从join阻塞状态中恢复回来,才能继续往下执行.
t线程肯定比main线程先结束.
线程的状态
线程的状态是针对当前的线程调度的情况来描述的.
在Java中对于线程的状态,进行了细化.
1.NEW
创建了Thread对象,但是还没有调用start(内核里还没有创建对应线程的PCB)
2.TERMINATED
表示内核中的PCB已经执行完了,但是new的Thread的对象还在.
3.RUNNABLE
可运行的(可以细分为正在CPU上运行的和在就绪队列里,随时可以去CPU上运行的)
4.WAITING
5.TIMED_WAITING
6.BLOCKED
4,5,6都是阻塞状态(都是表示线程PCB正在阻塞队列中,但是这几种阻塞是由于不同的原因导致的)
线程的状态转换图
对于TERMINATED 的理解
一旦内核里的PCB消亡了,此时代码中的t对象就失去了作用,之所以存在,也是迫不得已.JAVA中对象的生命周期,自有其规则.这个生命周期和系统内核里线程的并非时完全一致的.内核的线程释放的时候,无法保证Java代码中的t对象也立即被释放.
因此,势必会存在,内核中的PCB没了,但是代码中的t还存在这样的局面,此时就需要特定的状态,来把t对象标识成无效.(不能够重新start,一个线程,只能start一次).
通过这里的循环就可以看到,t执行过程中状态的切换了.
当前获取到的状态,到底是什么,完全取决于系统里的调度操作 .
多线程的意义:
写一个CPU密集型的代码来体会:
public class ThreadDemo12 {
public static void main(String[] args) {
// 假设当前有两个变量, 需要把两个变量各自自增 1000w 次. (典型的 CPU 密集型的场景)
// 可以一个线程, 先针对 a 自增, 然后再针对 b 自增
// 还可以两个线程, 分别对 a 和 b 自增.
serial();
//concurrency();
}
// 串行执行, 一个线程完成
public static void serial() {
// 为了衡量带没带的执行速度, 加上个计时的操作
// currentTimeMillis 获取到当前系统的 ms 级时间戳.
long beg = System.currentTimeMillis();
long a = 0;
for (long i = 0; i < 100_0000_0000L; i++) {
a++;
}
long b = 0;
for (long i = 0; i < 100_0000_0000L; i++) {
b++;
}
long end = System.currentTimeMillis();
System.out.println("执行时间: " + (end - beg) + " ms");
}
public static void concurrency() {
// 使用两个线程分别完成自增.
Thread t1 = new Thread(() -> {
long a = 0;
for(long i = 0; i < 100_0000_0000L; i++) {
a++;
}
});
Thread t2 = new Thread(() -> {
long b = 0;
for(long i = 0; i < 100_0000_0000L; i++) {
b++;
}
});
// 开始计时
long beg = System.currentTimeMillis();
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 结束计时
long end = System.currentTimeMillis();
System.out.println("并发执行时间: " + (end - beg) + " ms");
}
}
可以看到,此处使用两个线程的并发执行,时间缩短的很明显.
多线程可以更充分的利用到多核心CPU的资源.
多线程,在这种CPU密集型的任务中,有非常大的作用,可以充分利用CPU的多核资源,从而加快程序的运行效率.
但是不是说多线程,就能一定提高效率:
1.是否是多核
2.当前核心是否是空闲的(如果CPU的核心都已经满载了,这个时候启用再多的线程也不起作用).