🎇个人主页:Ice_Sugar_7
🎇所属专栏:JavaEE
🎇欢迎点赞收藏加关注哦!
线程
- 🍉线程
- 🍌多线程
- 🍌线程与进程的联系&区别
- 🍌多线程编程
- 🍌创建线程
- 🍌Thread 其他重要属性与方法
- 🍉操作系统内核
🍉线程
上篇文章中我们介绍了进程,但实际上在Java中是不太鼓励“多进程编程”的,大多数时候我们使用的是线程
进程可以很好地解决并发编程这样的问题,但是在一些特定的情况下,它的表现不尽人意。比如有些场景需要频繁创建和销毁进程,此时使用多进程编程的话,系统开销就会很大
开销是哪来的呢?一个进程刚启动时,需要把依赖的代码和数据从磁盘加载到内存中。而从系统分配一块内存并非一件易事,一般申请内存的时候需要先指定一个大小,然后系统内部把各种大小的空闲内存通过一定的数据结构组织起来,这个过程需要一定的时间开销
而线程就是解决上述问题的方案
线程也可以称为轻量级进程
,它在进程的基础上做出改进
前面我们说一个进程是由 PCB 来描述的;其实 PCB 也可以用来描述一个线程
PCB 中有个属性,叫内存指针。多个线程 PCB 的内存指针指向的是同一个内存空间
这意味着在创建第一个线程的时候就需要从系统分配资源。后续的线程就不必再分配,直接共用前面那份资源就 ok 了
除了内存,文件描述符表也是多个线程共用一份的(共享经济属于是)
当然也不是随便两个线程都能共享资源,我们把能够共享资源的线程分成组,称为线程组
而一个进程可以有多个PCB,这就意味着这个进程包含了一个线程组(多个线程)
🍌多线程
多线程是指在同一个进程中
同时运行多个线程,每个线程可以执行独立的任务并且能够同时运行,这样同时执行多个任务可以提高程序的性能和响应速度
但是线程也不是越多越好,当线程数量太多的时候,线程之间就会相互竞争 CPU 的资源
(因为 CPU 调度执行线程的数量是有限的),导致不仅不会提高效率,还会增加调度的开销
而且多线程还有一个问题,就是线程之间可能会起冲突,这就会导致代码中出现一些逻辑上的错误(这是后面要讨论的线程安全问题);一个线程如果抛出异常,并且没有处理好,就可能导致整个进程终止
🍌线程与进程的联系&区别
- 进程是包含线程的
- 每个线程是一个独立的执行流,可以执行一些代码,并且单独参与到 CPU 调度中
- 每个进程有自己的资源,进程中的线程共享这一份资源(内存空间、文件描述符表等)
由2和3可以得出:进程是资源分配的基本单位;线程是调度执行的基本单位
- 进程与进程之间不会相互影响,但是线程会(线程安全问题)。如果同一个进程中的某个线程抛出异常,可能会影响到其他线程,甚至会导致整个进程中所有线程都异常终止
- 线程不是越多越好,差不多就得了,如果线程太多了,调度开销可能非常明显
🍌多线程编程
在Java中,写代码的时候推荐使用多线程并发编程,系统提供了多线程编程的 api,而Java标准库把这些 api 封装好了,在代码中就可以直接使用,比如Thread类
打开 idea,我们先写一个 MyThread 类继承 Thread,并写一个 run 方法:
这个 run 方法就类似于 main 方法,是一个 Java 线程的入口方法
。一个进程中至少有一个线程,这个进程的第一个线程,称为主线程
,所以 main 方法也就是主线程的入口方法(因为一个进程肯定要有一个 main 方法)
然后还有一点,就是 run 是不需要我们手动调用的,它会在合适的时机(线程创建好之后)被 jvm 自动调用执行
(这样的函数称为回调函数)
我们前面所学的优先级队列,往它插入个对象,需要先指定比较规则,这就要实现 Comparable 或者 Comparator 接口,分别重写 compareTo 和 compare 方法,这两个也属于回调函数
说回正题,现在要搞一个线程,就是要让这个线程执行一些代码。显然,标准库自带的 run 肯定是不知道我们的需求,这就需要我们进行拓展(Thread 类有很多属性、方法,大部分都可以复用,只用把需要拓展的进行拓展即可)
我们重写一下 run 方法,并创建一个线程:
public class MyThread extends Thread{
@Override
public void run() {
System.out.println("hello thread");
}
public static void main(String[] args) {
//根据刚才的类,创建出实例
Thread t = new MyThread();
//调用 Thread 类的 start 方法,才会真正调用系统的 api,在系统内核中创建线程(线程就会执行上面写好的 run 方法)
t.start();
}
}
那么现在上面的代码就有两个线程:t 线程和 main 线程
每个线程都是一个独立的执行流,它们都能独立去 CPU 上调度执行
以上面代码为例,现在稍微修改一下,两个线程都加个死循环:
public class MyThread extends Thread{
@Override
public void run() {
while(true) {
System.out.println("hello thread");
try {
Thread.sleep(1000); //控制隔一秒才打印,降低循环速度,避免循环跑起来的时候跑太快,导致 CPU 占用率比较高
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
while(true) {
System.out.println("hello main");
sleep(1000);
}
}
}
运行结果如下图
可以看到两个循环都在执行,因为这两个线程就是两个独立的执行流
具体的执行流程就是:在 main 方法中调用 start 创建线程之后“兵分两路”,一路沿着 main 方法继续执行,打印“hello main”,另一路进入到线程的 run 方法,打印“hello thread”
然后有个需要注意的点,当有多个线程的时候,这些线程执行的先后顺序是不确定的,这是因为操作系统内核中有一个“调度器”
模块,这个模块的实现了一种类似“随机调度”
的效果。所谓的随机调度,指的是:
①一个线程被调度到 CPU 上执行的时机是不确定的
②一个线程从 CPU 上下来,给其他线程让位的时机也是不确定的
这两点其实归因于线程执行采用抢占式执行
的机制:操作系统根据优先级等参数来决定何时中断当前线程,并切换到其他线程
这个机制使得多线程程序可以更好地利用 CPU 资源,增加并发性和吞吐量,但是也带来了线程安全问题
还是以上面的代码为例,别看是先进入 main 方法就以为是先执行 main 线程,其实它和 thread 谁先谁后是不确定的
🍌创建线程
上面介绍了一种创建线程的方式,不过那不是主流的方式。我们通常使用 lambda 表达式
创建一个线程
Thread t1 = new Thread(()-> {
System.out.println("hello thread");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
这个写法相当于实现 Runnable 接口并重写 run 方法,lambda 代替了 Runnable 的位置
🍌Thread 其他重要属性与方法
方法
Thread(Runnable target) //使用 Runnable 对象创建线程对象
Thread(String name) //创建线程对象并命名
Thread(Runnable target,String name) //使用 Runnable 对象创建线程对象并命名
我们自己创建的线程默认是按照 Thread-0 1 2……命名的,给不同线程起不同名字对于线程的执行没有影响,主要是方便调试。此外,线程之间的名字是可以重复的,但名字别乱起,最好要有一定的描述性
属性
属性 | 获取方法 |
---|---|
ID(jvm自动分配的身份标识,会保证唯一性) | getID() |
名称 | getName() |
状态(进程有就绪状态,阻塞状态等,线程也有状态) | getState() |
优先级 | getPriority() |
是否为后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
(为了让表格看上去不会冗杂,一些属性的说明放到这下面讲)
优先级:在 Java 中,由于系统是随机调度线程的,所以对线程设置优先级的效果不是很明显
后台线程:后台线程的运行不会阻止进程结束,与后台线程相对,还有前台线程,前台进程的运行,会阻止进程结束(注意这里的后台和我们平时手机的“杀后台”不是一回事)
我们来演示一下前台线程,只需把刚才代码中 main 线程的死循环去掉:
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()-> {
while(true) {
System.out.println("hello thread");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
}
进程执行后,会一直打印,只有当我们停止进程后,出现红色方框中这句话,才表示进程结束
这是因为我们创建的线程默认是前台线程,即使 main 已经执行完了,只要前台线程没执行完,进程就不会结束
然后我们把 t 改为后台线程:
t.setDaemon(true); //设为 true 就是改为后台,注意 setDaemon 一定要写在start前面
t.start();
可以看到什么都没打印,进程就结束了
isAlive:它表示内核中的线程(PCB)是否还存在。如果线程已经启动并且还没有终止,那就会返回 true;反之返回 false
Java 代码中定义的线程实例虽然表示一个线程,但是这个实例本身的生命周期和内核中 PCB 的生命周期是不完全一样的
Thread t = new Thread(()-> {
...
})
比如现在创建了 t 实例,由于线程还没有 start,所以此时 isAlive 的结果就是 false
🍉操作系统内核
我们在上文中多次提到“内核”
这个概念
内核
是操作系统中最核心部分的功能模块
,它负责管理硬件,给软件提供稳定的运行环境
操作系统的内存空间分为两块:内核空间
(内核态)和用户空间
(用户态)
为什么要划分出这两个空间呢?主要是为了稳定,防止应用程序把硬件设备或软件资源搞坏了。系统封装了一些 api,这些 api 都是一些合法的操作,应用程序只能调用这些 api,这样就不至于对系统以及硬件设备产生太大危害
我们平时运行的普通应用程序,比如 idea、谷歌、微信……都是在用户态运行的。这些程序有时候需要针对一些系统提供的软硬件资源进行操作,这些操作都不是应用程序直接操作的,需要调用系统提供的 api,然后在内核中完成这些操作