跳转到内容

并发编程JUC深度学习(一)进程与线程(文字描述部分)

草稿难度:高级#JUC

原文:CSDN(历史文章导入,当前状态为草稿)

新的专辑会深度总结并发编程的知识体系,力所能及地专精,同时也是我学习路上的一个总结,希望也能帮助有需要的人。
本章考虑到篇幅的问题,涉及到的方法都只是做了简略的文字描述,后面我会尽快更新源码部分,方法部分看不明白没事,后面结合着源码就懂了。

假设你开了一家餐厅:

  • 这家餐厅就是一个“进程”
  • 餐厅需要场地、厨房设备、餐桌椅等资源
  • 开一家新餐厅需要租场地、装修、采购设备,成本很高
  • 每家餐厅都是独立经营的,一家餐厅的问题通常不会影响其他餐厅

线程是什么?想象餐厅里的员工

Section titled “线程是什么?想象餐厅里的员工”

在这家餐厅里工作的员工就是“线程”:

  • 一个厨师是一个线程
  • 一个服务员是一个线程
  • 一个收银员是一个线程
  • 所有员工共享餐厅的资源(厨房、餐具等)
  • 招聘一个新员工比开一家新餐厅容易得多

当你打开微信时:

  1. 系统创建了一个“微信进程”
  2. 这个进程获得了一定的内存空间和其他资源
  3. 在这个进程内部,有多个线程在工作:
  • 一个线程负责显示界面
  • 一个线程负责接收消息
  • 一个线程负责播放语音
  • 一个线程负责上传照片
    如果微信崩溃了,只影响微信应用本身,不会导致手机上的其他应用(如支付宝)也崩溃。

想象一下,如果电脑上所有程序都在同一个大环境中运行:

  • 一个程序崩溃可能导致整个系统崩溃
  • 恶意程序可以轻易访问其他程序的数据
  • 资源分配和管理极其复杂

如果一个程序只能有一个执行流:

  • 当程序需要等待用户输入时,什么都做不了
  • 无法同时执行多个任务(如一边下载文件,一边播放音乐)
  • 无法充分利用多核处理器

1:程序由指令和数据构成,但是如果指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存
在指令运行过程中还需要用到磁盘,网络等设备。
2:进程就是用来加载指令,管理内存,管理IO的。
可以把进程视为程序的一个实例,大部分程序可以同时运行多个实例进程,有的只能启动一个实例进程。

从JVM的角度来说,每个进程都有自己独立的内存空间,这个先暂时了解一下,后面开JVM专辑的时候我们会好好聊聊这个问题。

1:一个进程之内可以分一到多个线程,这些线程都有各自的计数器,堆栈和局部变量等属性,并且能访问共享的内存变量。
2:一个线程就是一个指令流,将指令流的一条条指令以一定的顺序交给CPU执行。
3:Java中,线程作为操作系统的最小调度单位进程作为操作系统的资源分配的最小单位,也叫做轻量级进程(Light Weight Process),在windows里进程不活动,只是作为线程的容器。
我们可以举个例子看一下:

public class demo_1{
public static void main(String[] args) {
// 获取Java线程管理MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的monitor和synchronizer信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程ID和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.
getThreadName());
}
}
}

结果:

Signal Dispatcher  // 分发处理发送给JVM信号的线程
Finalizer     // 调用对象finalize方法的线程
Reference Handler // 清除Reference的线程
main       // main线程,用户程序入口
。。。其他等等

在这里插入图片描述
我们可以看到一个java程序的运行不仅仅是main方法的运行,而是main线程和多个线程同时运行

  1. 进程基本上相互独立,而线程存在于进程内,是进程的一个子集
  2. 进程拥有共享的资源,如内存空间等,仅供其内部的线程共享
  3. 进程间的通信较为复杂:同一台计算机通信为Ipc,不同计算机通信需要通过网络,并遵守协议
  4. 线程通信相对简单,因为它们共享进程内的内存
  5. 线程更轻量,线程上下文切换成本一般比进程上下文切换低.
    不同进程之间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间,所以线程做任务切换成本就很低了,现代操作系统都是基于更轻量的线程来调度,我们提到的“任务切换”都是指“线程切换”。

进程:就像不同的房子,各自有独立的空间和设施
线程:像同一个房子里的不同房间,共享水电和其他资源

进程间通信:像两个房子之间传递信息,需要特殊方式(如打电话、发信)
线程间通信:像房子内部房间之间交流,直接说话就行

进程:像建造和拆除一座房子,成本高
线程:像在房子里增加或减少一个人,成本低

首先我们先了解什么是上下文
上下文是指某一时间点CPU寄存器和程序计数器的内容
而想到真正了解上下文切换需要操作系统的知识,这里我们简单了解它大致的作用。
简单来说:当前任务执行完CPU时间片切换到另一个任务之前回先保存自己的状态,以便下次再切换回这个任务,可以继续执行这个任务,任务从保存到再加载的过程就是一次上下文切换。
上下文频繁的切换会影响多线程的执行速度。

在这里插入图片描述

单核CPU下,线程实际还是串行执行的,操作系统中有一个组件叫做任务调度器,将cpu的时间片分给不同的线程使用
只是cpu在线程间的切换非常快,人感觉是同步运行的,但是本质是串行

并行:在这里插入图片描述多核CPU下,每个核都可以调度运行线程,这时候线程可以并行,同一时间做多件事。

方法的调度有两种:同步和异步
首先介绍同步:需要等待结果的返回才会继续向下运行
在这里插入图片描述
异步:不需要等待结果,就能继续运行
在这里插入图片描述
多线程可以让方法的执行变成异步,比如读取磁盘文件

我们可以列举几个应用场景:
1:在项目中,有需要视频文件转换格式等费时操作,这时候开一个新线程处理,避免阻塞主线程
2:tomcat的异步servlet也是类似目的
3:ui程序中,开线程进行其他操作,避免阻塞ui线程

对于单核的CPU:
多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用CPU,不至于一个线程总占用CPU
对于多核CPU:
1:可以并行跑多个程序,但能否提高程序运行效率还要分情况
2:有些任务经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。
但不是所有的计算任务都能拆分(参考阿姆达尔定律,在这不列举了,有兴趣百度吧)
3:不是所有的任务都需要拆分,任务的目的如果不同,谈拆分和效率没有意义
4:IO操作不占用cpu,只是我们一般拷贝文件使用的是【阻塞IO】的API,这时相当于线程虽然不用cpu,但需要一直等待IO结束,没能充分利用线程。
所以才有后面的【非阻塞IO】和【异步IO】优化

创建有三种方法:

优点:方便传参,可以在子类加成员变量,通过set方法设置参数或构造函数传参
缺点:Java不支持多继承

public static void main(String[] args){
Thread th =new MyThread();
th.start();
}
class MyThread extends Thread{
public void run(){
System.out.println("嘿嘿");
}
}

优点:用Runnable让任务脱离了Thread继承体系,更灵活
接口带有@FunctionalInterface,可以使用lambda表达式简化操作
有三种写法:
a:

public class Thread_1 {
public static void main(String[] args) {
//创建线程任务
Runnable r= new Runnable() {
@Override
public void run() {
System.out.println("在梅边");
}
};
Thread t =new Thread(r);
t.start();
}
}

b:

public class Thread_2 {
private static class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("在梅边落花似雪纷纷绵绵谁人怜");
}
}
public static void main(String[] args) {
MyRunnable r =new MyRunnable();
Thread th =new Thread(r);
th.start();
}
}

c:

public class Thread_3 {
public static void main(String[] args) {
//创建线程任务
Runnable r =()->{
System.out.println("哥们儿");
System.out.println("王力宏");
};
Thread t =new Thread(r);
t.start();
}
}

优点:可以拿到返回结果

public class Thread_4 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<String> futureTask = new FutureTask<>(new wang());
Thread thread =new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
class wang implements Callable<String>{
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
@Override
public String call() throws Exception {
return "花田错";
}
}

这里有三种情况分别是windows,linux,java。

任务管理器查看
在这里插入图片描述
两个指令 tasklist查看进程,taskkill杀死进程

ps -fe 查看所有进程
ps -fT -p <pid> 查看某个进程的所有线程
kill 杀死进程
top 按大写H切换是否显示线程
top -H -p <pid> 查看某个进程的所有线程

jps命令查看所有java进程
jstack 查看某个Java进程的所有线程状态

这里要说到栈和栈帧的概念

栈就是CPU寄存器里某个指针指向的一片内存区域,每个Java线程都有一个私有的Java虚拟机栈,创建线程的同时栈也被创建。
在这里插入图片描述

一个JVM栈由许多帧组成,称为“栈帧”
在这里插入图片描述
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
需要我们去注意线程状态
a:
首先是术语
当前方法——线程正在执行的方法
当前帧——当前方法对应的栈帧
当前类——当前方法对应的类
常量池——当前类对应的常量池
执行一个方法时,JVM会保存当前类和当前常量池的轨迹。
当JVM执行,需要操作栈帧中数据的指令时,JVM会在当前栈帧中处理

b:当一个线程创建时
JVM会为这个线程创建一个新的Stack。
一个Java Stack 在一个个独立的栈帧中存储了线程的状态
JVM只会在Java Stack中做两个操作:Push 和Pop

c:当一个线程正在执行方法时
JVM将创建一个新的栈帧并且把它push到栈顶。
此时新的栈帧就变成了当前栈帧,方法执行时,
使用栈帧来存储 参数 ,局部变量, 中间指令以及其他数据

发生上下文切换的时机:
CPU不再执行当前的线程,转而执行另一个线程的代码的原因有下面几种:

  1. 线程的cpu时间片用完
  2. 垃圾回收
  3. 有更高优先级的线程需要运行
  4. 线程自己调用了sleep,yield,wait,join,synchronized,lock等方法。

当上下文切换发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java中对应的概念就是程序计数器(Program Counter Register)它的作用是记住下一条jvm指令的执行地址,是线程私有的

保存的状态包括:
程序计数器,虚拟机栈中每个栈帧的消息(如局部变量,操作数栈,返回地址等)
在这里插入图片描述

被创建的Thread对象直接调用重写run方法时,run方法是在主线程中被执行的,而不是在我们创建的线程中执行,如果想到在创建的线程中执行run方法,需要使用Thread对象的start方法。
简言之,如果我们不start直接run,这个run方法可以执行,但是还是在main方法中执行的,并不是在创建的线程中执行。
在这里插入图片描述

1:调用Sleep会让当前线程从Running进入Timed Waiting状态
2:其他线程可以使用interrput方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException异常
3:睡眠结束后的线程未必会立刻得到执行,要等时间片
4:建议用TimeUnitsleep代替Threadsleep来获得更好的可读性
适用的场景:

  1. 防止cpu占用100%
  2. 在没有cpu计算时,防止while(true)空转浪费cpu,这时可以使用yieldsleep来让出cpu的使用权给其他程序
  3. 可以用wait或条件变量达到类似的效果,但是需要加锁,并且需要唤醒操作,一般适用于要进行同步的场景
  4. sleep适用于无需锁同步的场景
  1. 调用yield会让当前线程从Running进入Runnable就绪状态(仍然有可能被执行),然后调度执行其他线程。
  2. 具体的实现依赖于操作系统的任务调度器
  1. 用于等待某个线程结束
  2. 哪个线程内调用join方法,就等待哪个线程结束,然后再去执行其他线程
    在这里插入图片描述
  1. 用于打断阻塞(sleep,wait,join....)的线程。
  2. 处于阻塞状态的线程,cpu不会给其分配时间片

打断阻塞:
如果是打断因sleep ,wait,join方法而被阻塞的线程,会将打断标记置为false
我们要注意,线程在运行时被打断,打断标记会置为true
如果正常运行的线程被打断后,不会停止,会继续执行。
如果要让线程在被打断后停下来,需要使用打断标记来判断。

这里顺便提一嘴两阶段终止模式(下一篇我会详细描述解析,这里留个印象就行):
在这里插入图片描述

默认情况下,Java进程需要等待所有线程都运行结束,才会结束。
有一种特殊的线程叫做守护线程,只要其他非守护线程运行结束了,即使守护线程的代码没有执行完,
也会强制结束。
注意:
1:垃圾回收器线程是一种守护线程
2:Tomcat中的AcceptorPoller线程都是守护线程
所以Tomcat接受到shutdown命令后,不会等待它们处理完当前请求。

分两个层面来说

在这里插入图片描述
1-初始状态:仅是在语言层面创建了线程对象,还未与操作系统线程关联
2-可运行状态:(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由CPU调度执行
3-运行状态:指获取了CPU时间片运行中的状态,如果CPU时间片用完,会从【运行状态】切换至【可运行状态】,会导致线程的上下文切换。
4-阻塞状态:
a:如果调用了阻塞API,如BIO读写文件,这时线程实际不会用到CPU,会导致线程上下文切换,进入【阻塞状态】。
b:等BIO操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
c:与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
5-终止状态:
表示线程已经执行完毕,生命周期已经结束,不会再转换为其他状态

在这里插入图片描述
在这里插入图片描述

关于进程和线程,这是在操作系统和JVM都有描述,这里等我们后面更到操作系统专辑,会详细深入探讨,这里我们目标专注于并发编程核心内容上,进程和线程知道这么多已经足够应对接下来的学习了。