目录
一、计算机的组成
1、CPU:中央处理器,一台电脑最核心的部分,通过指令来完成各种算术运算和逻辑判断,是人类科技巅峰之作。
补充:
cpu虽然技术门槛非常高,但是很便宜,这是因为cpu每年都在更新换代,并且每一代cpu的提升幅度都比较大,因此并不保值。intel的创始人之一提出了一个摩尔定律:集成电路上可容纳的晶体管数量每隔大约18至24个月就会翻倍,从而导致芯片性提升接近一倍,同时成本也下降一半。
在cpu中还有寄存器,用于暂时存储指令,操作数和中间结果,寄存器的存储空间更小,访问速度更快(相比于内存),大概相差3-4个数量级,因此为了协调工作,cpu又引入了“缓存”来协调 寄存器 和 内存 之间的速度。
2、存储器:分为内存和外存。
内存:速度快,空间小,成本高,不能持久保存
外存:速度慢,空间大,成本小,可持久保存
这里说的快和慢是内存和外存相对而言的~
3、输入设备:键盘、鼠标、摄像头......
4、输出设备:显示器、打印机、扬声器......
二、操作系统
0x00 什么是操作系统
市面上存在着许多的操作系统,如:Windows11,linux,mac os, android,ios........
这些操作系统,需要对下管理硬件设备,对上提供稳定的运行环境
由于操作系统毕竟只是一个软件,不可能识别所有硬件设备,不过硬件设备就这么几个大类,因此操作系统只要知道每个大类中有什么功能,然后硬件产商在开发硬件的同时开发一个驱动程序(软件),让操作系统通过这个驱动程序完成对硬件设备的控制。可以这么理解:
0x01 进程/任务
一个运行起来的程序就叫做进程。每个进程要想运行,就需要消耗一定的系统资源。因此进程是系统资源分配的基本单位。
0x02 管理进程
一个操作系统上可能跑了很多进程,那如何对这些进程管理呢?两方面:描述、组织
1、使用类来描述一个进程,把管理的每个对象都用属性表示出来
2、使用数据结构组织这些进程对象,类似于双向链表,方便创建、销毁
在系统中专门有一个结构体(操作系统内核是c/c++写的,java中结构体相当于类)来描述进程的属性,这个结构体叫做“进程控制块”PCB。
PCB时一个非常庞大的结构体,包含很多的属性,在这就讨论一些比较重要的属性。
1、pid:
进程的身份标识,每一个进程都会有一个pid,同一时刻,不同进程之间的pid是不同的。
2、内存指针
描述了进程持有的“内存资源“,每个进程运行的时候,都会分配一定的内存空间。这个进程的内存空间,具体在哪,分配的内存空间有哪些部分,每个部分是干啥的,有这么一组指针来进行区分。
3、文件描述符
描述了进程持有的“硬盘资源”,类似于顺序表这样的数据结构,有很多元素。如果一个进程涉及到了文件操作,保存数据啥的,就需要与硬盘挂钩,于是需要通过文件读写的方式来操作,将硬盘上的数据修改/读取。
我们都知道一个程序的运行离不开cpu的运转,也就是说每个进程都需要消耗cpu资源,那怎么体现cpu资源呢?答案:进程的调度。
早期的操作系统,是一个“单任务操作系统”,同一时刻只有一个进程能运行,运行下一个进程就会退出当前进程,此时就不需要考虑调度问题,但随着发展,后面都支持多任务了,那如何运行这多个进程呢?——分时复用!
假设cpu是单核,如果使用分时复用的方法,就是将1s分成许多份,每个进程获取其中的一份或者多份,cpu在这一份的时间内执行这个进程。这样的执行方式叫作并发执行,只要我们时间轮转的够快(分成的份数够多),此时看起来就像是同时执行一样(宏观上是同时执行的,微观上是分时复用)。
现代的cpu都是多个核心了,此时我们可以在同一时刻使用两个核心执行进程,这种执行方式叫做并行执行,也就是说在微观上也是同时执行的。
不过在应用层,我们感知不到是并发还是并行,其内部都是通过系统调度来选择的。平时普通的程序员也不会去具体区分是并发还是并行,因此常常使用“并发”来代指并行和并发。
那操作系统如何进行进程调度呢?通过如下属性~
4、进程的状态
表示进程的状态,如就绪、运行、阻塞等,以指导操作系统的调度决策。
就绪状态:进程准备好运行,只等待操作系统分配处理器时间。
阻塞状态:进程某种执行条件不具备,导致这个进程暂时无法参与cpu的调度执行。例如:进程等待用户输入......
运行状态:进程当前正在cpu上执行其指令,正在占用cpu资源,以完成其所需的计算和任务。
5、进程的优先级
操作系统在调度多个进程的时候并非一视同仁,有的进程会给更高的优先级,优先执行。例如:接受QQ消息,晚个几秒钟也没啥关系。
6、进程的上下文
进程从cpu离开之前,需要保存当前数据,把当前cpu中寄存器的状态都记录到内存中,等到下次进程回到cpu上执行的时候,再把保存的值恢复回去,进程就沿着上次执行到的位置继续执行,类似于游戏打了一半,然后去吃饭了,等下次继续的时候,读取数据恢复到上次的关卡。
补充:
CPU中有些寄存器没有特殊含义,只是用来保存运算的中间结果,还有些寄存器是有特定含义的。
1、程序计数器
存储的是一个内存地址,用来保存当前执行到哪个指令(可以理解为指针)。一个进程在运行的时候,操作系统会将里面的指令和数据加载到内存中并形成地址,然后CPU就会从内存中通过查询地址的方式找到指令并执行指令。
2、维护栈相关的寄存器
程序在调用函数的时候会创建栈帧,而栈是一块空间,通过维护头和尾就可以知道个空间的位置,大小。如:ebp寄存器是存储的地址是栈底,esp寄存器存储的是栈顶,通过修改esp的值就可以实现“入栈”,”出栈“操作。
3、其他的通用寄存器
一般是用来保存计算的中间结果。如:当前有一个表达式, 10+20+30+40,假设现在算完了前面两项的和,但还没来得及算后面的,此时进程调度走了,就需要将保存计算的前面两项的和的寄存器的值备份到上下文中。
7、进程的记账信息
通过优先级的机制,对不同的进程分配了不同权重的资源,不过有可能会出现极端的情况,将所有资源都分配给了某个进程,其他进程没有分配到,通过记账信息记录当前进程在cpu的执行情况(执行时间),然后操作系统可以参考记账信息来进行下一次的调度进程。
0x03 虚拟内存空间
早期的操作系统,程序运行时分配的内存就是“物理内存”,这样就会导致,我的A程序通过某种方式访问到B程序的内存空间,然后进行修改,这就有可能会使B程序崩溃,为了防止这种情况,就引入了“虚拟内存空间”。
操作系统通过使用“虚拟内存地址”,不直接分配物理内存,而是分配虚拟的内存空间,类似哈希的思想,通过某个key来寻找value。
此时进程A想要操作某个内存中的数据,先通过虚拟内存地址告诉操作系统,然后操作系统将虚拟内存翻译成物理内存地址,进行修改,在翻译的过程中,操作系统就可以进行校验操作,如果是非法访问就可以处理,不会危害到别的进程了,保持了进程的独立性,为进程提供稳定的运行环境。
三、线程
当今时代大部分电脑的cpu都已经是多核心的了,因此我们可以充分利用这个特点,进行并发执行多个任务,从而提高程序的执行效率。
进行并发编程可以从两个方面:多进程、多线程。
0x00 多进程
通过多进程实现并发编程效果非常理想,但是多进程模型会有明显的缺点:太重量、效率不高。(相比多线程)
这是因为进程是资源分配的基本单位,每个进程都有自己的内存空间和系统资源,这就导致创建一个进程需要去申请资源,销毁一个进程需要去释放资源。如果频繁的进行创建/销毁进程,这个时候,开销就不能忽视了~
0x01 多线程
在Java中,更加鼓励使用多线程编程,这样就能很好的解决上述问题。这是因为线程是“轻量级进程”,创建/销毁比进程更快。
线程不能独立存在,而是依附于进程。一个进程可以包含一个线程,也可以包含多个线程。一个进程在最开始的时候,至少要有一个进程,也可以根据需要创建多个线程,从而实现并发编程。
一个进程中,可以有多个线程,每个线程都是可以独立的进行调度的,而调度是根据pcb属性来进行的,因此一个拥有多个线程的进程需要使用多个pcb(pid、内存指针、文件描述符表共用一份)来进行维护。
同一个进程的多个线程之间,由于共用一份内存空间和文件资源,创建线程的时候,不需要再重新申请资源了,直接复用之前操作系统分配给进程的资源,省去了资源分配的开销,提高了效率。
总得来说就是:
1、一个进程拥有一个或多个线程
2、每个线程使用一个pcb来表示,每个线程都可以独立的去CPU上调度执行,所以pcb中的状态、上下文、优先级、记账信息是独立的,线程是调度执行的基本单位。
3、pcb中的pid、内存指针、文件描述符表共用一份,所以创建线程的时候不需要再申请资源,创建的效率更高。
0x02 多线程的问题
1、我们知道可以通过创建额外的线程来帮我们提升效率,假设我们的线程创建的非常多,此时还能进一步提高效率吗?肯定是不能的,反而会因为要调度的线程太多了,导致调度的开销太大,进而降低了效率。
2、如果两个线程同时去争抢某个资源,这就会引起冲突,导致线程不安全。
3、如果某个线程生气了,出现了异常,此时如果没有妥善的处理,就容易使整个进程崩溃。例如:在程序中越界了,你却没有catch。
0x03 进程与线程的区别
1、进程包含线程,一个进程可以有一个或多个线程。
2、同一个进程的线程之间共用一份资源,不用再申请资源。
3、线程是资源分配的基本单位,进程是调度执行的基本单位。
4、进程和线程都是用来实现并发编程的,但是线程比进程更轻量、高效。
5、进程拥有独立性,一个进程挂了不会影响到别的进程。但一个进程内的线程挂了可能会影响到其他线程。
四、多线程编程
如何使用Java进行多线程编程?
线程是操作系统的概念,操作系统提供了一些API,可以用来操作线程,Java针对操作系统的API进行了封装,因此Java程序员只需要掌握一套API就可以到处运行了~~
0x00 如何创建线程
在Java中通过创建Thread类对象,来进行多线程操作。如果我们要想自定义一个线程的话,我们需要继承Thread。
class MyThread extends Thread{
}
一个线程跑起来,是从它的入口方法,也就是从run方法开始执行的,类似于一个Java程序的入口是main方法一样,只不过运行一个Java进程,会创建一个主线程,而主线程的入口是main方法。
我们观察源码会发现,Thread类实现了Runnable接口,而这个接口内部就定义了一个抽象的run方法。
所以,我们只需要重写一下run方法,在里面写线程的代码逻辑。不过重写了run方法只是定义了方法,要想真正启动一个线程,我们还需要调用start方法(在Thread类中定义了)。
class MyThread extends Thread{
@Override
public void run() {
//当前线程执行的入口
System.out.println("线程 启动!");
}
}
public class Test {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
}
}
小结:
start、run方法都是Thread的成员方法。run方法只是描述了线程的入口,该执行什么代码。start方法则是真正的调用了系统API,在系统中创建了线程,再让线程调用run方法。
0x01 线程如何执行
现有如下代码:
class MyThread extends Thread {
@Override
public void run() {
//当前线程执行的入口
while(true){
System.out.println("myThread线程在执行");
try {
sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class Test {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
System.out.println("main线程在执行");
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
运行结果如下:
通过观察,我们发现,在主线程开启一个线程的时候,主线程并不过阻塞,依旧会往下执行,而且线程执行的顺序也不是完全按照线程调用顺序,而是根据调度来决定。
如果将调用start方法,改成调用run方法呢?
由于没有使用start方法,程序不会创建一个线程,而是直接去调用run方法,直到调用完run方法后,才会回来继续往下执行main方法。
多线程运行的时候,可以使用jconsole来观察到进程里的多线程情况。jconsole是jdk中的一个工具。
0x02 线程的创建方式
创建一个线程其实很简单,只需要你重写run方法(写自己的代码逻辑),和调用start方法。而重写run方法可以通过继承Thread、实现Runnable接口、通过匿名内部类、使用lambda表达式........
1、继承Thread类
class MyThread extends Thread {
@Override
public void run() {
}
}
public class Test {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
}
}
2、实现Runnable接口
class MyThread implements Runnable {
@Override
public void run() {
}
}
public class Test {
public static void main(String[] args) {
MyThread myThread = new MyThread();
//由于Runnable接口没有start方法,所以得通过Thread的构造方法来创建。
Thread thread = new Thread(myThread);
thread.start();
}
}
3、通过匿名内部类
public class Test {
public static void main(String[] args) {
Thread thread = new Thread(){
@Override
public void run() {
//程序代码
}
};
thread.start();
}
}
4、基于lambda表达式
public class Test {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
//程序代码
});
thread.start();
}
}
五、Thread类及常用的方法
0x00 Thread类的构造方法
方法名 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnable对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用Runnable对象创建线程对象,并命名 |
Thread(ThreadGroup grop, Runnable target) | 将线程分组管理 |
0x01 Thread的几个常用的属性
属性 | 获取方法 | 说明 |
---|---|---|
ID | getId() | 获取线程的身份标识符ID |
名称 | getName() | 获取线程的名称 |
状态 | getState() | 获取线程的状态 |
优先级 | getPriority() | 获取线程的优先级 |
是否是后台线程 | isDaemoin() | 判断是否是后台线程 |
是否存活 | isAlive() | 判断是否存活 |
是否被中断 | isInterrupte() | 判断是否中断 |
补充:
1、ID是线程的身份标识,标识一个进程中的唯一线程,这个ID是Java给你分配的,并不是系统api提供的线程ID。
2、优先级,在理论上可以使优先级高的线程更容易被调度到,但站在应用程序的角度,很难察觉到。
3、后台线程,也叫做守护线程,与之对应的是前台线程。
前台线程:当所有的前台线程执行结束后,进程才会结束执行
后台线程:后台线程的结束与否不会影响进程的结束。
4、判断是否存活,是判断内核线程中是否还有,而不是判断Thread对象。Thread对象的生命周期往往比线程长,线程没了,Thread对象还在。
0x02 中断一个线程
在C++中可以直接终止一个正在运行的线程,但是在Java中是不可以的,因此设计思路就是让这个线程中的run方法尽快的结束。如下:
1、通过共享的标记,让线程退出
2、通过interrupt来中断线程
示例1:
public class Test2 {
private static boolean Quit = false;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while(Quit){
return
}
});
thread.start();
//手动设置退出
Quit = true;
}
}
上述方式是通过修改一个共享的变量,进行控制线程的运行的。但有如下缺点:
1、需要手动创建变量,比较麻烦。
2、当修改了变量,理应让线程立马去执行完剩下的代码,但如果线程此时sleep了,会到导致这个线程还要再睡一会,不符合我们的设计初衷(让线程尽快的执行结束)。
示例2:
public class Test2 {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
//currentThread() 返回的是 哪个线程调用,就返回哪个线程对象
//isInterrupted() 判断是否中断
while(!Thread.currentThread().isInterrupted()){
System.out.println("线程正在工作");
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
Thread.sleep(500);
//让线程中断
thread.interrupt();
}
}
运行结果如下:
抛出了一个异常,这是因为这个线程在sleep,然后interrupt()唤醒了线程,这就触发了sleep的异常。但我明明是让线程中断的,但是线程依然在工作,这是为什么呢?
解释:
sleep的线程被interrupt唤醒后,此时sleep方法会抛出异常,同时将"终止标识符"清除(在Thread对象内部有一个终止标识符,interrupt方法本质是使用过设置终止标识符来退出程序的)。
为什么要这样设定?是因为Java期望当线程收到终止信号的时候,能够自己决定接下来如何处理。也就是说如果在catch中捕获到异常,程序员可以自己决定接下来要干嘛,如果没有捕获到,run方法尽快结束就行。
0x03 线程等待
用来控制线程结束的顺序,让一个线程等待另一个线程执行结束,再继续执行。
方法 | 说明 |
---|---|
join() | 等待线程结束 |
join(long millis) | 等待线程结束,最多等 millis 毫秒 |
join(long millis, int nanos) | 等待线程结束,精度可以达到纳秒 |
说明:
thread.join() 工作过程:如果thread线程正在运行,那调用join的线程就会阻塞,一直阻塞到thread线程执行结束,如果thread线程已经执行结束了,调用join的线程就不会阻塞,直接继续执行。
0x04 线程状态
状态 | 说明 |
---|---|
NEW | Thread对象创建了,但还没调用start方法 |
TERMINATED | Thread对象还在,但内核中的线程已经没了 |
RUNNABLE | 可运行的,线程已经在cpu上执行了/线程正在排队等待上cpu执行 |
TIME_WAITING | 阻塞,由于sleep这种固定时间的方式产生阻塞 |
WAITING | 阻塞, 由于wait这种不固定时间的方式成的阻塞 |
BLOCKED | 阻塞,由于锁竞争导致的阻塞 |
0x05 线程安全
线程安全是多线程中最重要最复杂的部分。可能用一份代码在单线程的环境下执行是正确的,但在多线程环境中就不一定了。
示例:
public class Test4 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 10000; i++){
count++;
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 10000; i++){
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count: " + count);
}
}
在逻辑上,count应该自增了2w次,最终count的值应该为2w,然而结果却不是2w,而且几乎每次运行的结果都不相同。
解释:
这是因为count++ 操作,本质上是分成三步的:
1、load 把数据从内存中读到cpu寄存器中
2、add 把寄存器中的数据进行+1
3、save 把寄存器中的数据,保存到内存中。
如果是多个线程执行的话,由于线程之间的调度顺序是随机的,并不确定,就会导致出现问题。
如:当第一个线程正在进行第一个操作的load的时候,第二个线程已经完成了第二、三、四的操作,此时第一个线程再进行第一个操作的add的时候,从寄存器中读取到的数据是0,而非3,因此就会出现错误。
总结:
产生线程安全问题的原因:
1、操作系统中,线程的调度顺序是随机的(抢占式执行)
2、不同线程,最对同一个变量进行修改
3、修改操作,不是原子的,即某个操作必须一起全部完成。
4、内存可见性问题
5、指令重排序问题
那要如何保证代码一定准确呢?答案是加锁!
synchronized(对象名){
}
注意:
() 中需要表示一个用来加锁的对象,这个对象是啥不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。如果两个线程是在针对同一个对象加锁,就会有锁竞争,如果不是针对同一对象加锁,就不会有锁竞争,而此时的并发程度最高,但是不能保证正确。
{}内的代码就是要执行的内容了。
当一个线程拿到了这把对象锁之后,另外一个线程就得阻塞,等待上一个线程释放锁,之后再进行竞争这把锁。