目录
四、为什么需要使用线程,为什么通常不是通过多进程的方式处理?
一、什么是进程
(1)什么是进程?
一个正在运行的程序,就成为进程。比如正在运行的QQ音乐,已经打开的idea编辑器。但是,如果没有运行该程序,那么他不是进程,仅仅是一个程序。每一个进程,都会消耗一定的资源。
(2)如何查看当前电脑上面的所有的进程?
使用快捷键:ctri+alt+delete,选择查看任务管理器:
在右侧,可以看到,每个进程都占用了一定的资源;比如 CPU,内存,磁盘。所以,如果当当前电脑比较卡顿的时候,可以看一下当前电脑是否有太多的进程,可能会影响运行的效率。
因而,还可以得出结论,进程是操作系统资源分配的基本单位。进程是一个重要的“软件资源”,由操作系统内核负责进行管理的。
(3)进程有哪些属性:
A.Pid:进程的身份标识符。
B.内存指针:指向了自己内存有哪些;
C.文件描述表:硬盘上的文件等其他资源。
(4)内存与进程
如图所示,为一个内存条,有10个大小的“门”,每个门的下标都是从0开始,这个内存编号,就是所谓的“物理地址”。每个房间的“门”的大小为1Byte
内存拥有一个比较特别的特性->访问任意地址的数据,速度都极快,时间上都差不多。因此,数组取下标的时间复杂度就是O(1)
所以,操作系统当中的各个进程,是按照数组的数据结构存储的
如图所示,在传统的操作系统当中,内存空间是相互不隔离的,这就导致有一些可能:比如当某一个进程出现bug的时候很有可能就会出现另外一个进程也出现问题。即:有可能指针越界,影响了其他的内存执行。
所以,需要对进程正在使用的空间,进行“隔离”,因此引入了“虚拟地址空间”。而不是真实的地址空间。如上图,进程1访问的空间为OX1000~OX1FFF,进程2访问的空间为0X8000~0X8FFF,这都是真实的地址空间。
进程的隔离:
虚拟地址空间:为了能够避免进程之间产生相互影响
由操作系统和专门的MMU硬件设备,负责进行虚拟地址到物理地址的转换。当进程开始运行的时候,CPU会为其在虚拟空间当中分配一块内存空间,后面通过操作系统的MMU硬件设备,会映射到真实的物理空间当中。
如果一个进程出现错误的时候,操作系统内核发现当前这里的地址超出该进程的访问范围,就会发送一个错误的信号,引起进程的崩溃。
二、什么是线程(Thread)
线程的定义
线程,其实是要针对于进程这个概念,来产生的。
线程是 操作系统能够进行运算调度的最小单位。每个进程至少包含一个线程。一个进程可以有很多线程,每条线程并行执行不同的任务。
每一个线程,在操作系统当中都对应一个PCB。一个进程当中的多个线程的pid相同。共用一块内存空间。每个线程都有自己的执行逻辑,这个逻辑称为(执行流)。一个进程当中的多个PCB,是使用双向链表的数据结构组织的。
每一个线程执行自己的任务的时候,就被操作系统调度到CPU上面执行自己的任务。
图解:
线程调度
线程可能存在上百个,但是,CPU是如何分配资源的呢?难道只有一个CPU负责运行上百个进程吗?
下面,就要引入一个概念:让大量的线程在少数的CPU上面同时运行,每一个线程都拥有自己的执行流,它们操作系统调度到CPU内核上面各自执行自己的任务,这样的一个过程,就是线程调度。
如何解决线的调度:
①并行:
微观上同一时刻,两个核心上的进程,是同时执行的。比如一个CPU上面运行QQ,另外一个CPU上面运行微信。二者同时运行,这就是并行。
②并发:
微观上,同一时刻,同一个核心只能运行一个线程,但是它可以对进程进行快速的切换。比如,在同一个CPU内核当中,先运行QQ音乐,再运行QQ,再运行微信......但是,他运行的时候,切换地特别快,让你无法感觉到一样。有多块?(2.5GHZ)即:每秒执行25亿次,如此之快的速度,肉眼怎样可以发掘呢?
因此,线程的调度,解决方案就是并行+并发,统一称之为并发。
线程调度的属性:
A.线程的状态:
了解了并发与并行之后,再了解一下各个进程的状态:即:哪些进程是在运行,哪些没有在运行,哪些进程在干什么?
(1)就绪状态,随时准备去CPU上面运行。通俗地讲,随叫随到,随时准备好去CPU上面运行。
(2) 运行状态,正在CPU上面运行的线程,它的状态就是运行状态。
(3) 阻塞状态,短时间内无法去CPU上面运行。
B.线程的优先级:
C.上下文:
本质上 :保存的就是程序运行过程中的中间结果。预防一个线程执行到一半,然后离开CPU,再回来时候发现不是原来的状态了。
比如一个线程当中有任务for(int i=0;i<100;i++){
System.out.println(i);
}
当执行到i=70的时候,这个线程突然被调度离开CPU了,那么当这个线程继续被调度到CPU上面执行的时候,仍然会从上一次执行的地方开始执行。因此将会继续输出i=71,72......
D.记账系统:
统计每个线程进行的次数,防止某个进程执行次数过多,或者其他进程执行次数过少。让每个线程尽量被调度到CPU上面执行自己的任务的时间比较平均。
四、为什么需要使用线程,为什么通常不是通过多进程的方式处理?
这里,需要重新回顾一下进程的相关知识:进程,是一个正在运行的程序,进程是操作系统资源分配的基本单位。
进程由于”太重了“,何为”太重了“?即:创建一个进程开销比较大,调度,销毁一个进程开销也比较大。因此,线程就产生了:线程,”也称为轻量级进程"。因此,为了在解决并发问题的前提下面,让创建,销毁,调度进行的更快一些,就引入了线程
的概念。
线程为什么更加轻量? (4个原因)
原因1:线程的创建时间比较快。因为进程在创建的过程当中,还需要涉及文件的管理、文件的打开与关闭操作等等,而线程只是共享;
原因2:线程的终止时间比较快,因为线程需要释放的资源比进程少;
原因3:同一进程下面的所有线程共享进程的文件。这也就意味着,线程之间在进行数据传递的时候,不需要经过内核,不需要系统调用,也就提高了效率。
原因4:一个进程当中的所有线程都拥有相同的虚拟地址空间。也就是,同一个进程当中的所有线程都共用进程的同一个页表,线程切换的时候,不需要切换页表。
切换页表也是开销比较大的
我们画一张图来理解一下:多进程与多线程。
如图所示,这是一个房间,里面正在运行一个程序:一个人要吃掉桌面上这一只鸡,按照传统的情况,他可以一个人吃完。如果分开两个房间。这一只鸡分开两半,那么,如果两个人同时吃,确实也可以提高吃的效率:
下面这张图,就是多进程的描述:
但是这样,就需要多申请一块内存空间,来执行吃这一只鸡的任务。也就是说,传统一个进程仅仅运行一个线程这种方式,比较消耗内存。因为,进程是操作系统进行资源分配的基本空间,每多开启一个进程,是需要消耗新的内存空间的。因此,引入了线程:
如图所示,还是在当前这一个房间内,吃一只鸡,这个时候引入了多个人,同时吃鸡,这就可以在不多消耗内存空间的前提下面,提高了”吃完这只鸡“的运行效率。因此,总的”吃完这只鸡“,可以划分成各个线程”每个人都一起吃鸡,直到大家都吃完“,吃完之后,当前的这个进程也就结束了。因此,多线程的一个目的,多线程是为了同步完成多项任务,为了提高资源使用效率来提高系统的效率。
多线程引发一些问题:
①如果一个房间内,人太多,同时“吃鸡”,那么,有可能导致正在“吃鸡”的人,大家相互推推攘攘,导致正在“吃”的人没有办法专心吃。对应在实际情况当中,就是,线程太多,核心数目有限,不少的开销会浪费在线程的调度上面。所以,多线程不一定可以提高程序运行的效率,在一些特定的场景下面,甚至有可能降低程序运行的效率
下面有一个业务场景,现在有一个任务:让其中一个变量int a+=5共count次,让另外一个变量int b自减count次。如果是单线程,写法如下:
public static int count=10000;
public static void main(String[] args) {
long start = System.currentTimeMillis();
//任务1:+=5共count次
int a = 0;
for (int i = 0; i < count; i++) {
a += 5;
}
//任务2:--b共count次
int b = 0;
for (int i = 0; i < count; i++) {
b--;
}
long end = System.currentTimeMillis();
System.out.println(end - start);
}
如果是多线程,把这一个任务拆解成两个子任务,其中一个线程执行自增count次的任务,另外一个线程执行执行自减count次的任务,代码如下:
public static int count=10000;
public static void main(String[] args) {
long start=System.currentTimeMillis();
//任务1:让一个线程单独自增count次
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
int a=0;
for(int i=0;i<count;i++){
a+=5;
}
}
});
//任务2:让另外一个线程单独自增count次
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
int b=0;
for(int i=0;i<count;i++){
b--;
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
//统计执行的时间
long end=System.currentTimeMillis();
System.out.println(end-start);
}
关于这两个不同的写法,在《Java并发编程的艺术》当中,有一个专门的测试统计数据:
循环次数 | 串行执行耗时/ms | 并发执行耗时/ms | 并发比串行快多少 |
1亿 | 130 | 77 | 约1倍 |
1千万 | 18 | 9 | 约1倍 |
1百万 | 5 | 5 | 差不多 |
10万 | 4 | 3 | 并发略快 |
1万 | <0 | 1 | 慢 |
可以看到,在数据量比较小的时候,串行执行反而比并发快。这就是因为,在两个线程并发执行当中,操作系统调度,线程上下文切换的,也是需要开销耗时的。
②还会引发一系列的线程安全问题,下一篇文章会介绍。
但是,多进程并行执行的情况下面就不会导致这种情况发生。因为一个进程只有一个线程,每个进程当中的唯一线程只会运行在一个CPU核心当中,只占用一块内存资源。每个进程不会共享内存空间。
总结一下线程和进程的区别,以及为什么要使用线程:
随着多核CPU时代的降临,并发编程成为“刚需”。
①线程的空间的利用效率得到提升:
每开辟一个进程,就需要多开辟一块内存空间,如果需要执行的任务过多,就有可能造成内存空间的浪费;但是同一个进程当中,可以拥有多个线程,多个线程共享当前线程开辟的内存空间。
②线程比进程更加轻量:线程的创建,销毁,调度,相比起进程,会耗时更少;
③线程是操作系统进行运算调度的基本单位,每一个线程都拥有自己的执行流,会被操作系统轮流调度到CPU内核上面执行自己的任务;进程是操作系统进行系统内存空间分配的基本单位,每一个进程都有自己独立的内存空间;
④进程和线程相比,进程的运行环境更加安全。因为每一个进程在操作系统当中都有一块自己的内存空间,各个进程之间的内存空间是相互隔离的;而每一个线程都会与自己对应的同一进程下的所有线程共享同一块内存空间。多个线程并发执行,会有线程安全问题。
⑤有一些特殊的业务场景,可能会比较耗时,比如"等待IO",比较耗时。为了让等待IO的时间可以去执行其他一些任务,于是有了线程。
五、Java当中,创建线程,有哪几种方式?分别有什么不同
①让一个类,继承自Thread类,并且重写Run方法。
在主方法中,让父类的指针指向子类的引用。并且产生的父类对象调用start方法。
其中,run方法具体的执行,就是由新创建的线程:thread来执行的。误区:run方法是在start方法内部调用的:错误!start方法只是创建一个线程,并没有直接调用run方法。run方法,是由新创建的线程去执行的。调用的start方法之后,相当于调用了操作系统的API,通过操作系统内核创建PCB。并且把要执行的指令交给了这个PCB。当PCB被调度到CPU上面执行的时候,也就执行了线程的run方法了。
start方法和run方法的区别?
start方法,相当于调用操作系统内核,创建新的PCB,创建新的线程。run方法,描述的是线程的执行的过程。
图解:
运行后:
控制台输出了"hello world"。这里有一个问题:
刚刚的代码当中,继承一个Thread类,重写了run方法,然后调用.start()方法启动线程,和直接在主方法输出"hello world"这两种方式有什么不同?
具体的不同就是,如果直接输出hello world,那么相当于只有1个线程在执行,即:主线程。如果创建一个新的线程,然后调用start方法的话,只相当于主线程创建了另外一个线程,也就是说,运行的时候,一共是2个线程 .新创建的线程去执行run方法。
体验一下:两个线程同时运行的效果:
class MyThread extends Thread{
/**
* 重写Run方法
*/
@Override
public void run() {
//主线程,调用t.start(),创建一个新的线程,
//新的线程,调用start方法。
while (true) {
System.out.println("hello world");
}
}
}
/**
* @author 25043
*/
public class ThreadDemo1 {
public static void main(String[] args) throws InterruptedException {
Thread thread=new MyThread();
thread.start();
//run,只是描述了线程需要干的活是什么
//如果紧急run,那就是相当于单个线程,并没有创建其他的线程。
while (true){
System.out.println("hello main");
}
}
}
运行:
可以看到,两个线程,即使在不同无限循环当中,也会重复执行。
如果是单线程的话,仅仅只会反复输出"hello world"或者“hello main"。
至于哪个线程先执行,哪个后执行,这个没有确定的,这是由操作系统负责调度时候决定。也就是说,操作系统安排哪个线程先到CPU上面运行,哪个线程就先输出语句。
在第一种方法当中:任务指的是执行"run"方法的内容,任务与新创建的线程”耦合“在一起,耦合度比较大。接下来,看一种耦合度比较小的方式
②让一个类,实现Runnable接口
/**
*
* Runnable的作用是描述一个需要执行的接口
* 描述一个要执行的任务
*/
class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("hello");
}
}
/**
* @author 25043
*/
public class ThreadDemo2 {
public static void main(String[] args) {
//描述了一个任务
Runnable runnable=new MyRunnable();
//把任务交给线程执行,解耦合
Thread thread=new Thread(runnable);
thread.start();
}
}
步骤:让一个类实现Runnable接口。在主线程当中,让Runnable 接口实现一个已经实现Ruaable接口的类。接着,把任务交给进程;即:Thread thread=new Thread(runnable);
经典面试问题,为什么第二种方式更加优胜?请说明原因:
答:继承Thread类,线程对象和线程任务耦合在一起。一旦创建Thread类的子类对象,既是线程对象,有又有线程任务。那个对应的线程任务就是去执行"run”方法。
再来了解一下Runnable接口,Runnable接口本质上是描述了一个任务,如果让一个类去实现Runnable接口然后重写run方法,这就意味着实现Runnable接口的这一个类相当于也描述了一个任务。接着,把这个runnable对象传给Thread的构造方法,相当于Runnable接口对线程对象和线程任务进行解耦。
③匿名内部类的方式
/**
* @author 25043
*/
public class ThreadDemo3 {
public static void main(String[] args) {
Thread thread=new Thread(){
@Override
public void run() {
System.out.println("hello thread");
}
};
thread.start();
}
}
以上操作,步骤为:先创建了一个Thread的子类,然后,让这个子类重写了run方法。本质和①的方式一样。都是“新建一个线程子类,让当前这个线程去执行run方法,执行它的任务”。
④使用匿名内部类的接口方式
/**
* @author 25043
*/
public class ThreadDemo4 {
public static void main(String[] args) {
//本质和2一样
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hello Thread");
}
});
thread.start();
}
}
这种方式,也是匿名内部类的实现方式,但是这个被“匿”掉名称的类,实现了Runnable接口,本质上是创建了一个任务,交给thread来执行。
⑤使用lambda表达式创建任务,相当于④的简写版
/**
* @author 25043
*/
public class ThreadDemo5 {
public static void main(String[] args) {
//用lambda表达式来描述,直接把lambda
//传给Thread方法。
Thread t=new Thread(()->
System.out.println("hello"));
t.start();
}
}