一、线程的引入
上节,我们介绍了进程的概念,以及操作系统内核是如何管理进程的(描述+组织),PCB中的核心属性有哪些,
引入进程这个概念,最主要的目的,就是为了解决“并发编程”这样的问题。
为什么我们需要并发编程?
这是因为CPU进入了多核心时代,为了进一步提高程序的执行速度,就需要充分利用CPU的多核资源,这就需要“并发编程”。
多进程编程让大量进程可以在多个CPU核心上运行,程序代码已经能够把CPU的多核资源利用起来了,已经可以解决“并发编程”的问题了。
我们为什么又要学习多线程编程呢?
线程又是什么呢?和进程又有什么关系?
带着这些疑问,我慢慢进行介绍。
二、线程和进程的关系
(1)进程包含线程。一个进程可以包含一个线程,也可以包含多个线程。进程中至少有一个线程,不能没有。
(2)一个线程是通过一个PCB来描述的,PCB对应的是线程,一个线程对应一个PCB,一个进程对应1个或多个PCB。
(3)进程是操作系统资源分配的基本单位,线程是操作系统调度执行的基本单位。同一进程里的多个线程之间共享进程资源。
PCB中的核心属性:pid、内存指针、文件描述符表,这些是进程中的线程共用的。处于同一个进程中的线程,pid相同,内存指针和文件描述符表也是一样的。
PCB中与调度相关的属性:状态、优先级、上下文、记账信息,是每个线程自己有自己的,各自记录各自的。
对于第三点,我再啰嗦几句,希望能加深大家的理解:
同一个进程里的多个线程之间,共用了进程的同一份资源。这里共用的资源主要指的是内存指针和文件描述符表。比如:线程1里new的对象,在线程2,3,4里都可以直接使用(共用内存指针),线程1打开的文件,在线程2,3,4里都可以直接使用(共用文件描述符表)。
操作系统,实际调度的时候,是以线程为单位进行调度的。并不关系进程,只关心线程。谈到调度,就和进程无关了!!上节提到的进程调度,指的是这里的进程只包含一个线程的情况。如果进程中有多个线程,那么每个线程是独立在CPU上调度的。比如,线程1可能在核心A上执行,线程2可能在核心B上执行。线程是操作系统调度执行的基本单位。每个线程都有自己的执行逻辑,我们称为执行流。
三、为什么要使用多线程编程?
多进程和多线程都可以解决并发编程问题,为什么更倾向于使用多线程编程呢?
因为,
进程,太“重”了。创建/销毁/调度一个进程,都需要很大的资源消耗,速度慢。
线程,又叫“轻量级进程”。创建/销毁/调度一个线程,消耗资源少,速度快。
使用多线程编程,会提高效率。
为什么线程比较“轻”?
因为只有在创建第一个线程时,操作系统会进行资源分配(主要指内存指针,文件描述符表),之后创建的第2,3,4...个线程,复用之前的分配的资源,基本不需要操作系统再分配资源。销毁一个线程,也基本不需要释放资源。除非这个进程中只有这一个线程了,才需要释放资源。
而进程就不一样了,创建一个进程,操作系统会进行资源分配,销毁一个进程,要释放资源。创建第二个进程,还需要申请资源,销毁第二个进进程,同样需要释放资源。消耗资源多,速度慢。
因此,由于进程和线程各自的特点,我们一般使用多线程编程,减少资源消耗,提高速度。
四、线程越多越好?多线程会有安全问题吗?
增加线程数量并不是可以一直提高速度。 CPU的核心数量是有限的。线程太多,核心数目有限,不少的开销反而浪费在线程调度上。所以,并不是线程越多越好。
多线程容易出现线程安全问题。
(1)多线程中,共享同一份资源,可能会出现多个线程同时都需要同一个资源的现象(如同一个变量),出现争抢
(2)如果一个线程抛异常,处理不好的话,可能会把整个进程都带走,其它线程也就挂了。
什么时候会出现安全问题?多个执行流访问同一个共享资源的时候。
线程模型,天然就是资源共享的,多线程争抢同一个资源(如同一个变量)非常容易触发。
进程模型,天然就是资源隔离的,不容易触发。只有进行进程间通信时,多个进程访问同一个资源,这时才可能会出问题。
也就是说,多线程会提高效率,但是不如多进程安全。当然,代码写的靠谱,线程安全问题也不怕
五、在Java中如何进行多线程编程?
操作系统提供了操作线程的一系列API。
而Java是一个跨平台的语言,很多操作系统提供的功能,都被JVM给封装好了。我们不需要学习操作系统提供的原生API,只需要学习Java提供的API就行啦。
Java操作多线程,最核心的类是Thread(在java.lang下,不用import)
创建线程,是希望线程成为一个独立的执行流,能执行一段代码。我们可以这样理解,
创建线程,就相当于雇了个人来干活,我们得告诉他要干啥活,他才能去执行。
如何告诉他要干啥活呢?
我们有如下方法,java中创建线程的写法有很多种,如下所示:
- 继承Thread,重写run方法
- 实现Runnable接口
- 使用匿名内部类,继承Thread
- 使用匿名内部类,实现Runnable接口
- 使用Lambda表达式
下面分别进行介绍:
1、继承Thread,重写run方法
class MyThread extends Thread{
@Override
public void run() {
System.out.println("hello thread");
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
t.start();
start方法是线程中的一个特殊方法,作用是创建一个线程。
在上面代码中,start这个方法创建了一个新的线程,新的线程负责执行run方法,并不是start方法里面调用了run方法,是新的线程调用的run方法。当run方法执行完时,新的线程自然销毁。
那么start是如何创建一个新线程的?
调用操作系统提供的API(系统调用),通过操作系统内核创建新线程的PCB,并且把要执行的指令交给这个PCB,当PCB被调度到CPU上执行的时候,也就执行到了线程run方法中的代码了。
在main方法中直接打印hello world,和在main方法中调用start方法的上述做法有啥区别?
如果只是在main方法中直接打印hello world,这个java进程就只有一个线程(调用main方法的线程),也就是主线程。
在main方法中调用t.start(),是主线程调用start方法创建出一个新的线程,新的线程调用run方法执行其中的代码。这个java进程中有两个线程。
如果把 t.start(); 改成 t.run(); 有什么区别吗?
有很大区别。
是 t.run(); 的话,这个java进程中还是只有一个主线程。所有的活都是主线程一个人干的。因为new Thread对象的操作并不创建线程,只有调用了start方法才是真正创建了PCB,才真正有个货真价实的线程。
2、实现Runnable接口
解耦合,目的是让线程和线程要干的活之间分离开。
未来如果要改代码,不用多线程了,使用多进程,或者线程池,协程......此时代码改动比较小。
//Runnable 作用:描述一个”要执行的任务“, run方法就是执行任务的细节
class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("hello world");
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
//这只是描述了个任务,就是线程要干的活
Runnable runnable = new MyRunnable();
//把任务交给线程来执行
Thread t = new Thread(runnable);
t.start();
}
}
3、使用匿名内部类,继承Thread
public class ThreadDemo3 {
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run() {
System.out.println("hello world");
}
};
t.start();
}
}
红框框里是一个匿名内部类对象,这里做了两件事:
1、创建了一个Thread的子类,继承Thread类,(子类没有名字,所以才叫“匿名”)。
2、对子类进行实例化(new),让 t 引用指向该实例。
4、使用匿名内部类,实现Runnable接口
public class ThreadDemo4 {
public static void main(String[] args) {
Thread t = new Thread(
new Runnable() {
@Override
public void run() {
System.out.println("hello world");
}
}
);
t.start();
}
}
5、使用Lambda表达式
直接把 lambda 传给 Thread 的构造方法
lambda 就是个匿名方法
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t = new Thread(
() -> {
System.out.println("hello world");
}
);
t.start();
}
}