初见多线程并发编程
什么是进程?🥇
我们每次打开一个软件其实都是打开了一个exe后缀的文件,这些文件叫做可执行文件,这些可执行文件被双击之前都是静静的躺在硬盘上,此时的他们对我们的系统没有任何影响,一旦进行了双击的操作,操作系统就会把这个.exe加载到内存中,并且执行.exe内部的一些指令(.exe文件内部就存储了很多这个程序对应的二进制指令)
上述运行起来的可执行程序,就称为"进程".进程是操作系统进行资源分配的最基本单位
操作系统是如何管理进程的?👆
宏观上为2步:
- 描述进程
- 组织进程
-
描述进程(这里以单线程的进程为例)
每个进程会用一个pcb块进行描述,这个pcb块就包含了很多的属性
-
pid:线程自己的id号,每个线程的id都不一样,都有自己独一份的编号
-
内存指针:执行这个进程要执行的代码/指令子内存的哪个区域,以及这个进程执行过程中所依赖的数据在内存的哪个位置
-
文件描述符表:可以给他抽象理解成一个数据,一旦一个进程要对文件进行操作,就会在文件描述符表中新增一项,用以描述被操作文件的相关信息,并且我们应当知道,只要进程启动了,就会默认打开三个文件:标准输入,标准输出,标准错误,其中文件描述符表的各个位置的下标就是文件描述符.
-
专用于进程调度的属性
-
为什么要进程调度?
因为当代操作系统都是多任务操作系统,操作系统同时运行着多个进程,肯定是要考虑如何去调度这些进程的.
首先要知道并行和并发的区别:
- 并行:微观视角去看,是两个cpu核心同时跑两个任务的代码
- 并发:微观上看是一个cpu先执行一个任务的代码,再快速切换去执行第二个任务的代码,正是由于切换的足够的快,我们宏观上去看:这两个任务好像就是同时执行的一样
所以我们宏观上是无法区分并行并发的,所以同一用并发来进行概括
pcb专用于进程调度的属性😗
- 状态:一个pcb可能有两个状态:就绪状态和阻塞状态,前者表明该pcb随时都可以放到cpu上去跑,后者则不可以
- 优先级:进程之间的被操作系统调度,也是分优先级的,有的进程就是会先调度,甚至调度的时间还比较长
- 记账信息:统计每个进程分别都在cpu上执行了多久了,分别都执行了哪些个指令,没被执行的线程都分别等了多久了,依据这些统计数据,操作系统将更加合理的去调度进程,或者换句话说,记账信息给进程调度提供了指导信息.
- 上下文:某个进程被调度出cpu的时候,由于后续可能还要继续执行这个进程,所以先将关于该进程的寄存器中的数据先存到内存中,后续再次调度到这个进程的时候,cpu又可以把之前存档的内存中的相关信息进行恢复(读档),存档+读档就是所谓的上下文属性.
2.组织进程
典型的实现就是用一个双向链表把一个个的pcb个串起来(Linux)系统中就是如此
内存资源是如何分配的?☔️
以进程为基本单位进行分配,一个进程内的若干个线程共享该进程所分得的资源.
并且进程与进程之间通过虚拟地址空间的方式彼此分隔,这使得进程之间具备独立性,以致于操作系统不会由于一个进程挂了,而导致其他进程也挂.
但是进程之间在处理一些复杂业务的时候,也要借助多个进程进行交互才能完成,此时就有了一个能进行进程间"信息交换"的需求,主流操作系统中提供进程间通信机制又如下几种:
- 管道
- 共享内存
- 文件
- 网络
- 信号量
- 信号
什么是线程?😒
为什么要进行并发编程
- 单核cpu的算力达到了瓶颈,要想再提高算力,就需要多核cpu,而并发编程更能充分利用多核cpu资源
- 有些任务场景需要进行等待IO,为了让这个等待的时间能够去赶一下别的工作,也需要进行并发编程.
出现线程的原因:由于要进行并发编程,如果使用进程来开展,可行是可行,但是用进程来实现并发编程,会有很多问题,如:创建或者销毁一个进程就会涉及到资源的分配和释放,如果需要频繁进行进程调度,期间的资源申请和释放过程是一笔不小的开销.那如何解决这个多进程并发编程带来的问题,就有了如下的解决方案:
- 采用进程池:某进程不再执行,我们可以将该进程放进进程池中,后续又要使用该进程,直接无进程池中拿出来使用即可,这样就可以避免频繁的创建和销毁进程带来的资源开销.但是:闲置的进程放在那里也是要占用资源的,并且一旦进程池中的进程放的越来越多,这个闲置进程的资源开销也不小啊.所以采用进程池的方式来解决多进程并发编程的方法并不好
- 采用多线程:进程可以完成一个任务,我线程也可以,并且呢线程比进程来的更轻量,更轻量的原因有三:**创建和销毁一个线程相比较于创建和销毁一个进程要低很多;其次调度进程的成本也要比调度线程的成本高很多(线程是系统调度的最小单位).再之,一个进程中的若干线程共享同一份资源,所以除了第一次线程调度时,其所属进程需要进行资源分配,后续该进程中的线程再进行创建和销毁都不涉及资源的再申请和释放了,最后进程完全执行结束,最后再释放资源即可.**后来人们还不满足与多线程并发编程,然后又出现了线程池,和协程.
- 生活案例:一个工厂(一天能生产5000部手机)被下达一个任务,一天造出10000部手机,我可以有两条思路:1我再建一个工厂,两个工厂一起干;还有一个思路就是我在现有工厂里再加一条流水线,两个思路都能让任务达标,但后者显然付出的代价要更小.(进程是线程的容器)
多线程编程⛸
标准库中的线程类(Thread)🏒
操作系统提供了一组关于线程的API,JAVA基于这组API进行了进一步的封装,就成了Thread类
Thread的基本用法🍨
-
编写一个类继承Thread类,子类对父类的run()进行覆盖,调用start()后操作系统才会有这个线程,否则只有Thread对象
class MyThread extends Thread{ //此处的run方法中,就相当于日后你需要布置给这个线程要干的活 public void run(){ System.out.println("hello thread"); } } public class Demo1 { public static void main(String[] args) { Thread t=new MyThread(); t.start(); } }
-
创建一个类,实现Runnable接口,实例化这个子类并将其作为形参传给Thread进行构造
class MyThread1 implements Runnable{ @Override public void run() { System.out.println("hello thread1"); } } public class Demo1 { public static void main(String[] args) { Thread t=new Thread(new MyThread1()); t.start(); } }
-
借助匿名内部类的写法对1进行改写
public class Demo1 { public static void main(String[] args) { Thread t1=new Thread(){ @Override public void run() { System.out.println("hello t1"); } }; t1.start(); } }
-
借助匿名内部类对2进行改写
public class Demo1 { public static void main(String[] args) { Thread t2=new Thread(new Runnable() { @Override public void run() { System.out.println("hello t2"); } }); t2.start(); } }
-
查看Runnable接口可与发现该接口是一个函数式接口(一个接口中只有一个抽象方法),所以我们可以在这里使用Lambda表达式
public class Demo1 { public static void main(String[] args) { Thread t3=new Thread(()->{ System.out.println("hello t3"); }); t3.start(); } }
多线程并发案例📦
- 并发打印
public class Demo1 {
public static void main(String[] args) {
Thread t1=new Thread(()->{
while(true){
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
//main线程默认必须有,且不用主动打开
while(true){
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//打印结果:
hello main
hello thread
hello thread
hello main
hello main
hello thread
hello main
hello thread
hello thread
hello main
主线程上来就打开了t1线程,主线程和t1出现了抢占式执行,代码表示的是两个线程都是休眠1s然后再打印,但这个1s只能说明1s内不能被唤醒,没说1s后瞬间就会被唤醒,所以具体何时被唤醒取决于系统调度,最终出现了此处的随机打印结果.
2.使用多线程和不使用多线程时给两个数都自增10亿次,比较二者的时间差
public class Demo2 {
private static final int count=10_0000_0000;
public static void main(String[] args) {
long start=System.currentTimeMillis();
Thread t1=new Thread(()->{
long a=0;
for(long i=0;i<count;i++){
a++;
}
});
t1.start();
Thread t2=new Thread(()->{
long b=0;
for (long i=0;i<count;i++){
b++;
}
});
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end=System.currentTimeMillis();
System.out.println("开启多线程时,耗时:"+(end-start)+"ms");
}
}
//开启多线程时,耗时:274ms
public class Demo3{
private static long count=10_0000_0000;
public static void main(String[] args) {
long a=0;
long b=0;
long start=System.currentTimeMillis();
for(long i=0;i<count;i++){
a++;
}
for(long i=0;i<count;i++){
b++;
}
long end=System.currentTimeMillis();
System.out.println("未开启多线程时,耗时:"+(end-start)+"ms");
}
}
//未开启多线程时,耗时:486ms
可以看出,两个线程同时干活,比一个main线程干活的效率提升了将近一倍,之所以不是严格的一倍关系,是因为t1,t2线程的执行时而并发时而并行,因为cpu资源充足的时候,一个进程中的多个线程可能会被分配多个cpu资源,但cpu资源紧缺的情况下,一个进程可能只会被分配一个cpu资源,所以前者可以完全串行,也就是并行执行,而后者只能并发,但是这个cpu分配资源的过程人是控制不了的,取决于操作系统,所以我们只能判断出,使用两个线程要与只用一个线程的时间关系:0.5-1倍,最优的情况就是整个过程都在并行,那效率就能提升一倍咯.
其次,t1.join()的意思是,main在等t1结束!!!
再其次就是:如果count本身就比较小,我们如果还有多线程就做任务,就杀鸡用了牛刀了,此时的效果可能就是适得其反.所以说多线程也不是万能良药.多线程特别适合于cpu密集型程序(即程序要进行大量的计算,使用多线程就可以更充分的利用cpu的多核资源)
Thread的常见构造方法✌️
方法 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnable作为参数进行构造线程对象 |
Thread(String name) | 创建线程对象,并给对象一个名字 |
Thread(Runnable target,String name) | 使用Runnable创建对象,并且给线程对象一个名字 |
Thread(ThreadGroup group,Runnable target) | 线程分组,目前仅了解即可 |
给线程重命名,可以方便我们使用jconsole进行线程的调试,注意调试过程中,代码必须是正在运行.jconsole在我们安装的jdk的bin目录下.
Thread的常见属性🍰
属性 | 获取方法 |
---|---|
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否中断 | isInterrupted() |
- id是每个线程独一份的编号
- 名称常用于调试
- 状态java中细分为5种,后续会介绍
- 优先级更高的线程理论上说更容易会被调度到cpu上执行
- JVM会在一个进程的所有非后台线程都结束之后,才会结束运行,前面的我们创建的线程都默认是非后台线程
- 存活与否,主要看一个线程的run()是否执行完毕:线程对象创建好了,但是还没start,返回false,对象有了,start也调用了,并且run还没执行完毕,返回true,run()跑完了又会返回false,所以是是否存活看的是系统中的这个线程是否还存在.
- 线程中断问题后续会介绍
Thread中的重要方法🏭
-
start
先创建了线程对象,再调用start之后,系统中才会有这个线程,如果只是简单的调用run,那此时仅仅是属于父类引用调用子类中重写的方法所形成的多态了.
-
中断线程
- 可以手动设置标志位,但这个方法不好
- 使用标准库自带的判断是否中断的方法(isInterrupted()),非阻塞状态下的线程可以使用interupt进行中断线程
public class Demo4 { public static void main(String[] args) { Thread t=new Thread(()->{ while(!Thread.currentThread().isInterrupted()){ System.out.println("我还没中断哟!"); //打印完就睡个1s再继续嚣张 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); break; } } }); t.start(); //让主线程main先休息个3s吧,然后再去中断t线程 try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } t.interrupt();//interrupt如果是针对正在sleep的线程进行中断,会使得sleep抛出一个异常 } } //打印: 我还没中断哟! 我还没中断哟! 我还没中断哟! java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at Demo4.lambda$main$0(Demo4.java:15) at java.lang.Thread.run(Thread.java:748)
-
线程等待
如上述的main线程等t1,t2结束的案例中提到了,不再赘述.主要要说的就是,线程等待的目的主要还是控制各线程的结束先后顺序.还有就是等可以,但也不能死等,万一我们main()等的那个t1线程的run()一直不跑完,我的main应当不继续等了,所以join()中可以设置一个等待的时间上限,以ms为单位.
-
获取当前线程的引用
Thread.currentThread();//哪个线程调用的就会获取到哪个线程对象的引用
-
线程休眠
进程作为一个容器,里头装了很多的线程,每个线程都有一个pcb,其中pcb有一个字段较tgroupid,表明的是这个pcb属于哪个进程,所以说属于同一个进程的线程的tgroupid是一样的,在内存中就会有两个队列,分别是就绪队列和阻塞队列,分别是一个双向链表进行组织的pcb,当就绪队列的某个pcb执行了sleep,它就会被调度到阻塞队列中,睡眠时间到了,就会在合适的时机被重新调度回就绪队列中.(此处是针对Linux操作系统而言的)
总结:💯
-
进程/线程是什么,及其二者的区别
-
如何使用标准库的类去创建线程
-
线程的终止
-
线程的等待
-
线程对象的获取
-
线程休眠