JUC学习

引言

bilibili


1. 进程与线程

1.1 进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 、QQ等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

1.2 线程

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
  • Java 中,线程作为小调度单位,进程作为资源分配的小单位。 在 windows 中进程是不活动的,只是作 为线程的容器

1.3 二者对比

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集 进程拥有共享的资源,如内存空间等,供其内部的线程共享
    • 进程间通信较为复杂 同一台计算机的进程通信称为 IPC(Inter-process communication)
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

1.4 从 JVM 角度说进程和线程之间的关系

image-20220904162753787

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、虚拟机栈** 和 本地方法栈

总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。


2. 并行与并发

引用 Rob Pike 的一段描述:

  • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力(实际上还是串行)
  • 并行(parallel)是同一时间动手做(doing)多件事情的能力(多核)

最关键的点是:是否是 同时 执行。

例子

  • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
  • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
  • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行

3. 同步与异步

  • 同步 : 发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步 :调用在发出之后,不用等待返回结果,该调用直接返回。

例子:

你不可以在水烧开之前喝水–同步

你可以在烧水的同时刷牙–异步


3.1 设计

多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如
果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…

  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞
  • tomcat 的工作线程 ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

3.2 结论

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

4. Java线程

4.1 创建和运行线程

4.1.1 方法一:直接使用Thread或者继承Thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CreateThread {
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run(){
System.out.println("running");
}
};
//设置线程名称
t.setName("T1");
t.start();
System.out.println("running");
}
}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CreateThread {
public static void main(String[] args) {
Thread myThread = new MyThread();
// 启动线程
myThread.start();
}
}

class MyThread extends Thread{
@Override
public void run() {
System.out.println("my thread running...");
}
}

Java不支持多继承,如果继承了Thread类,那么就不能再继承其他类。另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码


4.1.2 方法二: 使用Runnable配合Thread或者实现实现Runnable接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test2 {
public static void main(String[] args) {
//创建线程任务
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Runnable running");
}
};
//将Runnable对象传给Thread
Thread t = new Thread(r, "T2");
//启动线程
t.start();
}
}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CreateThread {
private static class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("my runnable running...");
}
}

public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
}
}

通过实现Runnable接口,并且实现run()方法。在创建线程时作为参数传入该类的实例即可


4.1.3 原理之 Thread 与 Runnable 的关系

小结

  • 方法1 是把线程和任务合并在了一起
  • 方法2 是把线程和任务分开了
  • 用 Runnable 更容易与线程池等高级 API 配合 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活

4.1.4 方法三:FutureTask 配合 Thread或者实现Callable接口传参

FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况(Runnable的run方法没有返回值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Test3 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//需要传入一个Callable对象
FutureTask<Integer> task = new FutureTask<Integer>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("线程执行!");
Thread.sleep(1000);
return 100;
}
});

Thread r1 = new Thread(task, "t2");
r1.start();
//获取线程中方法执行后的返回结果
System.out.println(task.get());
}
}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class FutureTaskTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<String> futureTask = new FutureTask<>(new MyCall());
Thread thread = new Thread(futureTask);
thread.start();
// 获得线程运行后的返回值
System.out.println(futureTask.get());
}
}

class MyCall implements Callable<String> {
@Override
public String call() throws Exception {
return "hello world";
}
}

4.1.5 总结

使用继承方式的好处是方便传参,你可以在子类里面添加成员变量,通过set方法设置参数或者通过构造函数进行传递,而如果使用Runnable方式,则只能使用主线程里面被声明为final的变量。不好的地方是Java不支持多继承,如果继承了Thread类,那么子类不能再继承其他类,而Runable则没有这个限制。前两种方式都没办法拿到任务的返回结果,但是Futuretask方式可以


4.2 原理之线程运行

4.2.1 栈与栈帧

Java Virtual Machine Stacks (Java 虚拟机栈) 我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?

  • 其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
  • 线程之间的栈内存都是私有的,独立的,互补干涉

jvm学习-虚拟机栈 - GuTaicheng’s Blog

image-20220907101411880


4.2.2 线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  • 线程的 cpu 时间片用完
  • 垃圾回收 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

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

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch 频繁发生会影响性能

4.3 常用方法

4.3.1 start()与run()

直接调用线程中的run方法的话,可以执行run方法中的代码,但是实际上还是在主线程中运行,不是新建的线程中;

所以如果想要在所创建的线程中执行run方法,需要使用Thread对象的start方法。

调用 run:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug(Thread.currentThread().getName());
FileReader.read(Constants.MP4_FULL_PATH);
}
};
t1.run();
log.debug("do other things ...");
}
/**
19:39:14 [main] c.TestStart - main
19:39:14 [main] c.FileReader - read [1.mp4] start ...
19:39:18 [main] c.FileReader - read [1.mp4] end ... cost: 4227 ms
19:39:18 [main] c.TestStart - do other things ...
t1.start();
**/

调用start

1
2
3
4
5
6
7
8
9
//上代码的t1.run();替换即可
t1.start();

/**
19:41:30 [main] c.TestStart - do other things ...
19:41:30 [t1] c.TestStart - t1
19:41:30 [t1] c.FileReader - read [1.mp4] start ...
19:41:35 [t1] c.FileReader - read [1.mp4] end ... cost: 4542 ms
**/

4.3.2 sleep()与yield()

sleep

  1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞),可通过state()方法查看

  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException

  3. 睡眠结束后的线程未必会立刻得到执行

  4. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性 。如:

    1
    2
    3
    4
    //休眠一秒
    TimeUnit.SECONDS.sleep(1);
    //休眠一分钟
    TimeUnit.MINUTES.sleep(1);

yield(让出当前线程)

  1. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态(仍然有可能被执行),然后调度执行其它线程
  2. 具体的实现依赖于操作系统的任务调度

简单说:我不干了,你们比我牛逼你上,你们都不行我就继续

阻塞:CUP不会去考虑执行

就绪:CUP优先考虑


线程优先级

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用
  • 设置方法:
1
thread1.setPriority(Thread.MAX_PRIORITY); //设置为优先级最高

4.3.3 join方法

用于等待某个线程结束。哪个线程内调用join()方法,就等待哪个线程结束,然后再去执行其他线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
log.debug("开始");
Thread t1 = new Thread(() -> {
log.debug("开始");
sleep(1);
log.debug("结束");
r = 10;
});
t1.start();
//t1.join();
log.debug("结果为:{}", r);
log.debug("结束");
}

/**
输出结果为 r = 0
因为主线程要立即打印 r 的值 这时t1 线程还没有结束,所以打印的为 0

如果取消 t1.join()的注释,则打印结果为 r = 10
**/

可以应用于线程同步(需要结果)

多个线程同时运行并且join时,总等待时间是运行时间最长的线程的时间

join(long n)还可以携带参数,表示最多等待的时间,如果超过这个时间没有执行完就不等了,如果在这个时间内执行完了就直接跳过


JUC学习
https://blog.gutaicheng.top/2022/09/04/JUC学习/
作者
GuTaicheng
发布于
2022年9月4日
许可协议