JUC并发编程第一章(进程与线程)


JUC并发编程 进程与线程

1. 进程和线程

1.1 进程

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

1.2 线程

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

1.3 二者对比

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

2. 并行与并发

单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是 同时运行的 。总结为一句话就是: 微观串行,宏观并行 。一般会将这种 线程轮流使用 CPU 的做法称为并发concurrent

引用 Rob Pike 的一段描述:

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

3. Java线程

3.1 创建和运行线程

3.1.1 直接使用 Thread

// 创建线程对象
Thread t = new Thread() {
    public void run() {
        // 要执行的任务
    }
};
// 启动线程
t.start();

例如:

// 构造方法的参数是给线程指定名字,推荐
Thread t1 = new Thread("t1") {
    @Override
    // run 方法内实现了要执行的任务
    public void run() {
        log.debug("hello");
    }
};
t1.start();

输出:

10:39:27.289 [t1] DEBUG com.example.Main - hello

3.1.2 使用 Runnable 配合 Thread

把【线程】和【任务】(要执行的代码)分开

  • Thread 代表线程
  • Runnable 可运行的任务(线程要执行的代码)
package com.example;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Main {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            public void run(){
                // 要执行的任务
            }
        };
        // 创建线程对象
        Thread t = new Thread( runnable );
        // 启动线程
        t.start();
    }
}

例如:

package com.example;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Main {
    public static void main(String[] args) {
        // 创建任务对象
        Runnable task2 = new Runnable() {
            @Override
            public void run() {
                log.debug("hello");
            }
        };
        // 参数1 是任务对象; 参数2 是线程名字,推荐
        Thread t2 = new Thread(task2, "t2");
        t2.start();
    }
}

输出:

10:41:55.173 [t2] DEBUG com.example.Main - hello

Java 8 以后可以使用 lambda 精简代码

package com.example;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Main {
    public static void main(String[] args) {
       Runnable task2 = ()->log.debug("hello lamb");
        Thread lamb = new Thread(task2, "lamb");
        lamb.start();
    }
}

输出:

10:44:28.381 [lamb] DEBUG com.example.Main - hello lamb

3.1.3 原理之 Thread 与 Runnable 的关系

分析 Thread 的源码,理清它与 Runnable 的关系

小结

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

3.1.4 FutureTask 配合 Thread

在Java中,FutureTask是一个有趣且有用的类,它是java.util.concurrent包中的一部分。FutureTask可以用来封装一个Callable或Runnable任务,并允许在将来的某个时刻获取任务的结果。

作用:
FutureTask的主要作用是允许在一个线程中提交任务在另一个线程中获取任务的结果。这对于处理异步任务非常有用,可以让主线程继续执行其他任务,而不必等待异步任务完成。当需要获取任务的结果时,可以使用FutureTaskget()方法来获取结果,如果结果还没有准备好,get()方法会阻塞调用线程直到结果准备完毕

FutureTask与其他线程类的区别:

  1. 实现接口不同: FutureTask实现了RunnableFuture接口,该接口继承自RunnableFuture接口。因此,FutureTask既可以用作Runnable提交给Executor执行,也可以作为Future获取任务结果。

    部分源码截图

  2. 获取结果: FutureTask可以在任务执行完成后获取结果,而RunnableThread类无法直接获取任务的返回结果。

    获取结果是用的Future接口的get方法

  3. 任务状态: FutureTask有几种状态:等待运行、正在运行、已完成、已取消等。可以通过isDone()方法来检查任务是否已完成,而ThreadRunnable没有这种状态跟踪。

    Future接口的方法

  4. 异常处理: 在执行任务过程中,FutureTask能够捕获任务抛出的异常,并在获取结果时重新抛出这些异常。而对于普通的RunnableThread,异常会直接在任务执行的线程中抛出,需要通过其他机制来捕获。

  5. 取消任务: FutureTask提供了cancel()方法用于取消任务的执行,而ThreadRunnable没有直接支持取消任务的方法。

    Future接口的方法

示例:
下面是一个简单的示例,演示了如何使用FutureTask来异步执行一个任务并获取其结果。

import java.util.concurrent.*;

public class FutureTaskExample {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        Callable<Integer> callableTask = () -> {
            Thread.sleep(2000);
            return 42;
        };

        FutureTask<Integer> futureTask = new FutureTask<>(callableTask);

        // 提交任务给Executor执行
        ExecutorService executor = Executors.newFixedThreadPool(1);
        executor.submit(futureTask);

        // 可以继续执行其他任务...

        // 获取任务结果(会阻塞当前线程直到结果准备好)
        int result = futureTask.get();
        System.out.println("Result: " + result);

        // 关闭Executor
        executor.shutdown();
    }
}

在上面的例子中,我们创建了一个Callable任务,表示一个耗时的操作。我们将该任务传递给FutureTask,然后将FutureTask提交给ExecutorService执行。在主线程中,我们可以继续做其他事情,然后通过futureTask.get()获取任务的结果。当结果准备好时,get()方法会返回结果,否则它会一直阻塞当前线程。

因此,要想使用FutureTask来创建一个任务对象,就必须使用Callable,或者Runnable来创建任务对象。

构造方法

其中,FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况

FutureTask 部分源码

Callable源码

可以看到调用Callable接口的call方法可以获取一个返回值

举例:

public static void main(String[] args) throws ExecutionException, InterruptedException {
    //创建任务对象
    FutureTask<Integer> task3 = new FutureTask<Integer>(()->{
        log.debug("callable");
        return 100;
    });
    new Thread(task3,"t3").start();
    //获取t3线程执行结果,如果t3还没有执行完,那么当前主线程(Main线程)会阻塞,同步等待t3的结果
    Integer res = task3.get();
    log.debug("结果是:"+res);
}

输出

11:13:06.358 [t3] DEBUG com.example.Main - callable
11:13:06.361 [main] DEBUG com.example.Main - 结果是:100

3.2 栈与栈帧

Java Virtual Machine Stacks (Java 虚拟机栈)

我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
  1. 栈(Stack): 在Java中,栈是用于存储方法调用和局部变量的内存区域。每个线程在执行Java程序时,都会有一个独立的栈,称为线程栈(Thread Stack)。线程栈遵循后进先出(LIFO)的原则。

在Java中,栈的主要作用是跟踪方法调用。每当调用一个方法时,一个新的栈帧会被创建并被压入该线程的栈中。当方法执行完毕后,对应的栈帧将被弹出栈。这种方式允许方法的嵌套调用,包括递归调用。

  1. 栈帧(Stack Frame): 在Java中,栈帧是与每个方法调用相关联的数据结构,用于存储方法的参数、局部变量和方法调用过程中的一些信息。每个线程栈中的栈帧数量取决于当前活动的方法调用链

栈帧的组成如下:

  • 方法的参数:存储方法调用时传递的参数值。
  • 局部变量:存储方法中声明的局部变量和临时变量。
  • 返回地址:指示方法执行完毕后应该返回的位置。
  • 操作数栈:用于存储方法执行过程中的中间结果和临时数据。
  • 动态链接:指向调用该方法的方法的运行时常量池中的方法符号引用。
  • 旧的栈帧指针(Frame Pointer):指向上一个栈帧,帮助在方法返回时恢复之前的栈帧。

总体来说,在Java中的栈帧用于支持方法的调用和返回过程,并且为方法的参数和局部变量提供了存储空间。栈的大小和栈帧的大小通常是固定的,在Java虚拟机中可以通过调整JVM参数来改变这些大小。

需要注意的是,在Java中,堆(Heap)是另一个重要的内存区域,用于存储对象实例和数组等动态分配的数据。与栈不同,堆的内存分配和回收是由Java虚拟机自动管理的。而栈的分配和释放是由Java虚拟机直接管理的每个线程都有自己的栈,并且栈帧的创建和销毁是由方法的调用和返回决定的。

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

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

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用sleepyieldwaitjoinparksynchronizedlock 等方法

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

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

3.4 常见方法

方法名 static 功能说明 注意
start() 启动一个新线程,在新的线程运行 run 方法中的代码 start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现IllegalThreadStateException
run() 新线程启动后会调用的方法 如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为
join() 等待线程运行结束 谁来调用这个join,就是等待谁的线程运行结束
join(long n) 等待线程运行结束,最多等待 n毫秒
getId() 获取线程长整型的 id id 唯一
getName() 获取线程名
setName(String) 修改线程名
getPriority() 获取线程优先级
setPriority(int) 修改线程优先级 java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率
getState() 获取线程状态 Java 中线程状态是用 6 个 enum 表示,分别为:NEW, RUNNABLE, BLOCKED, WAITING,TIMED_WAITING, TERMINATED
isInterrupted() 判断是否被打断 不会清除 打断标记
isAlive() 线程是否存活(还没有运行完毕)
interrupt() 打断线程 如果被打断线程正在 sleep,wait,join 会导致被打断的线程抛出 InterruptedException,并清除 打断标记 ;如果打断的正在运行的线程,则会设置 打断标记 ;park 的线程被打断,也会设置 打断标记
interrupted() static 判断当前线程是否被打断 会清除 打断标记
currentThread() static 获取当前正在执行的线程
sleep(long n) static 让当前执行的线程休眠n毫秒,休眠时让出 cpu的时间片给其它线程
yield() static 提示线程调度器让出当前线程对CPU的使用 主要是为了测试和调试

3.5 start 与 run

调用run:

public static void main(String[] args) {
    Thread task = new Thread(() -> log.debug("hello"));
    task.run();
    log.debug("over");
}

输出:

13:59:22.358 [main] DEBUG com.example.Main - hello
13:59:22.360 [main] DEBUG com.example.Main - over

可以看出,你直接调用run方法,其实还是使用的main线程,而没有开启其他线程来执行。

调用start:

public static void main(String[] args) {
    Thread task = new Thread(() -> log.debug("hello"));
    task.start();
    log.debug("over");
}

输出:

14:03:51.317 [main] DEBUG com.example.Main - over
14:03:51.317 [Thread-0] DEBUG com.example.Main - hello

可以看见使用start()的时候,使用的就不在是main线程了,而是使用的其他线程。

小结

  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程
  • 使用 start启动新的线程,通过新的线程间接执行 run 中的代码

3.6 sleep 与 yield

sleep

  1. 调用 sleep 会让当前线程Running 进入 Timed Waiting 状态(阻塞);即卸载哪个线程中就让哪个线程休眠

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

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

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

    public static void main(String[] args) throws InterruptedException {
        //表示睡眠1秒
        TimeUnit.SECONDS.sleep(1);
    }
    

yield

  1. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
  2. 具体的实现依赖于操作系统的任务调度器,也就是你想让出时间片,但是不一定能够让出去。

二者区别

  1. 作用
    • sleep 方法主要用于暂停当前线程的执行一段指定的时间,以毫秒为单位。它可以用于模拟等待时间、定时任务等情况。
    • yield 方法用于提示线程调度器,当前线程愿意放弃一部分 CPU 执行时间,以便其他相同优先级,或优先级更高的线程有机会执行。但并不能确保一定会让出 CPU 时间,具体是否让出由线程调度器决定。
  2. 阻塞状态与就绪状态
    • 调用 sleep 方法会使当前线程进入阻塞状态,即线程不会占用 CPU 时间,直到指定的时间过去,然后进入就绪状态等待被调度执行。
    • 调用 yield 方法并不会使线程进入阻塞状态,它只是提供一个提示,告诉线程调度器可以选择其他线程来执行,当前线程进入就绪状态等待重新调度。
  3. 精确性
    • sleep 方法的时间是相对精确的,可以在指定的时间过后唤醒线程,但也受到操作系统和虚拟机的影响,可能会有一定的误差。
    • yield 方法的精确性相对较低,因为它仅仅是一个提示,具体的线程调度依赖于调度器的实现和其他线程的状态。
  4. 用途
    • sleep 方法通常用于控制线程的等待时间,例如在定时任务中等待一段时间后再执行。
    • yield 方法通常用于在多线程中平衡线程的执行机会,尤其是在相同优先级的线程中,避免某个线程长时间独占CPU。

总之,sleepyield 方法都用于线程的调度和控制,但它们的作用、效果和用途有所不同。sleep 是让线程暂停执行一段时间,进入阻塞状态;yield 是提示线程调度器让出一部分 CPU 执行时间,以平衡线程的执行。

3.7 线程优先级

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用

3.8 join 方法详解

join 是一个用于线程同步的方法。它允许一个线程等待另一个线程完成后再继续执行。当一个线程调用另一个线程的 join 方法时,它会被阻塞(在哪个线程中写join就会阻塞哪个线程),直到被等待的线程完成为止。

具体来说,join 方法的作用是等待调用它的线程等待被指定的线程执行完毕。这在多线程编程中非常有用,特别是当一个线程依赖于另一个线程的结果时,或者需要确保某个线程完成后再执行后续操作。

join 方法有几种不同的重载形式:

  1. join():等待被调用线程执行完毕。
  2. join(long millis):等待被调用线程执行完毕,但最多等待指定的毫秒数。
  3. join(long millis, int nanos):等待被调用线程执行完毕,最多等待指定的毫秒数和纳秒数。

为什么需要join?

回答这个问题之前,我们先来看一下如下的代码:

@Slf4j
public class Main {
    static int r = 0;

    public static void main(String[] args){
        test1();
    }

    private static void test1(){
        log.debug("开始");
        Thread t1 = new Thread(() -> {
            log.debug("开始");
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug("结束");
            r = 10;
        });
        t1.start();
        log.debug("结果为:{}", r);
        log.debug("结束");
    }
}

分析如上代码:打印出来的结果是什么?

14:52:40.589 [main] DEBUG com.example.Main - 开始
14:52:40.631 [Thread-0] DEBUG com.example.Main - 开始
14:52:40.631 [main] DEBUG com.example.Main - 结果为:0
14:52:40.632 [main] DEBUG com.example.Main - 结束
14:52:40.633 [Thread-0] DEBUG com.example.Main - 结束

可以看出,打印出来的结果为0,并不是我们预想中的10,这是为什么呢?

分析:

因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出 r=10;而主线程一开始就要打印 r 的结果,所以只能打印出 r=0;

对应到程序上就是:当主线程执行test1()方法的时候,会先走到t1.start()来让t1线程执行,但是由于是2个不同的线程,主线程会继续执行t1.start()后面的代码,而此时线程t1的休眠时间还没有过,所以打印出来是0

所以,针对如上情况,我们这里用join就是水到渠成的事情了——谁来调用这个join,就是等待谁的线程运行结束。

@Slf4j
public class Main {
    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("开始");
            try {
                sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug("结束");
            r = 10;
        });
        t1.start();
        //由于是在主线程中写join,而且是t1调用,表明是主线程阻塞,等待t1执行完毕
        t1.join();
        log.debug("结果为:{}", r);
        log.debug("结束");
    }
}

结果:

15:00:37.986 [main] DEBUG com.example.Main - 开始
15:00:38.025 [Thread-0] DEBUG com.example.Main - 开始
15:00:38.027 [Thread-0] DEBUG com.example.Main - 结束
15:00:38.027 [main] DEBUG com.example.Main - 结果为:10
15:00:38.028 [main] DEBUG com.example.Main - 结束

可见现在就正常了。

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
graph TD
A(main)-->B(t1.start)
A-->D
B--1s 后-->C(r=10)
C--t1 终止-->D(t1.join)

示例:等待多个结果

@Slf4j
public class Main {
    static int r1 = 0;
    static int r2 = 0;
    public static void main(String[] args) throws InterruptedException {
        test2();
    }
    private static void test2() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            r1 = 10;
        });
        Thread t2 = new Thread(() -> {
            try {
                sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            r2 = 20;
        });
        long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long end = System.currentTimeMillis();
        log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
    }
}

输出:

15:10:50.197 [main] DEBUG com.example.Main - r1: 10 r2: 20 cost: 2011

可以发现结果大约为2S

分析如下

  • 第一个 join:等待 t1 时, t2 并没有停止, 而在运行
  • 第二个 join:1s 后, 执行到此, t2 也运行了 1s, 因此也只需再等待 1s

如果颠倒两个 join 呢?最终都是输出2S

graph TD
subgraph one
H(main)-->I(t1.start)
H-->J(t2.join)
H-->K(t2.start)
K--2s 后-->L(r=20)
L--t2 终止-->J
I--1s 后-->M(r=10)
M--t1 终止-->N(t1.join - 无需等待)
J-->N
end

subgraph two
A(main)-->B(t1.start)
A-->C(t1.join)
A-->D(t2.start)
B--1s 后-->E(r=10)
E--t1 终止-->C
C-->F(t2.join - 仅需等1s)
D--2s 后-->G(r=20)
G--t2 终止-->F
end

注意:

如果join(long n)中的时间小于线程执行的时间,那么会导致线程提前结束

如果join(long n)中的时间大于线程执行的时间,那么线程结束的时候,join也会结束。

3.9 interrupt方法详解

interrupt() 方法是用于中断线程的方法。它可以用于通知一个正在运行的线程,告诉它应该中断自己的执行。但需要注意的是,interrupt() 方法并不会直接停止线程的执行,而是发送一个中断信号给线程,线程可以在适当的时候检查这个信号并决定是否终止自己的执行。

interrupt() 方法具有以下几个方面的作用和行为:

  1. 中断信号的发送: 调用一个线程的 interrupt() 方法会向目标线程发送一个中断信号,告诉目标线程应该中断自己的执行。这个中断信号通过 isInterrupted() 方法来检查。
  2. 中断标志位Thread 类内部有一个 boolean 类型的中断标志位,当一个线程被中断时,这个标志位会被设置为 true。线程可以通过 isInterrupted() 方法来检查自己是否被中断。
  3. InterruptedException 异常: 如果一个线程正在阻塞状态(如等待 sleepjoinwait 等操作),那么调用它的 interrupt() 方法会将线程的中断状态清除,并且抛出 InterruptedException 异常。这可以用来提前结束阻塞状态,例如在等待某个资源时可以通过中断来退出等待。
  4. 自行处理中断: 线程可以在适当的时候调用 isInterrupted() 方法来检查自己的中断状态,从而决定是否终止自己的执行。线程可以在处理完自身的任务后,通过捕获 InterruptedException 或检查 isInterrupted() 来正确地退出。

示例:打断sleep,wait,join 的线程

public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
    Thread t1 = new Thread(()->{
        try {
            //使用sleep会导致阻塞,所以打断这个线程会抛出InterruptedException异常
            sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }, "t1");
    t1.start();
    sleep(500);
    t1.interrupt();
    //由于使用了sleep,即使打断了某个线程,打断标记也会被清空
    log.debug(" 打断状态: {}", t1.isInterrupted());
}

输出:

Exception in thread "t1" java.lang.RuntimeException: java.lang.InterruptedException: sleep interrupted
    at com.example.Main.lambda$test1$0(Main.java:18)
    at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at com.example.Main.lambda$test1$0(Main.java:16)
    ... 1 more
15:33:57.320 [main] DEBUG com.example.Main -  打断状态: false

示例:打断正常运行的程序

public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
    Thread t1 = new Thread(()->{
       while (true){
           //获取当前线程的打断通知
           boolean interrupted = Thread.currentThread().isInterrupted();
           if (interrupted) {
               break;
           }
       }
    }, "t1");
    t1.start();
    sleep(500);
    t1.interrupt();
    //对于正常运行的程序,打断不会抛出异常,并且不会清空打断标志
    log.debug(" 打断状态: {}", t1.isInterrupted());
}

输出:

15:38:16.713 [main] DEBUG com.example.Main -  打断状态: true

示例:打断 park 线程

park 是 Java 并发包中 LockSupport 类提供的一个方法,用于暂停(阻塞)当前线程的执行。它是线程阻塞的一种手段,类似于 Thread.sleep()Object.wait() 等方法,但 park 提供了更灵活的控制方式。

park 方法具有以下几个关键特点:

  1. 无需持有锁
    Object.wait() 方法不同,park 方法不需要在进入同步块或同步方法的情况下调用。它可以在任何地方调用,无需事先获得锁。

  2. 不会抛出异常
    park 方法不会抛出任何异常,这与 Object.wait() 方法不同。它避免了处理异常的复杂性。

  3. 支持中断
    park 方法支持线程中断。即使线程在阻塞状态下被调用了 interrupt() 方法,它也会在阻塞状态结束时响应中断。

  4. unpark 配合使用
    LockSupport 还提供了 unpark 方法,用于解除 park 对线程的阻塞。可以在某个线程中调用 unpark 方法,然后在另一个线程中调用 park 方法,实现线程间的通信和同步。

示例代码:

public static void main(String[] args) throws InterruptedException {
   test3();
}

private static void test3() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        log.debug("park...");
        //阻塞
        LockSupport.park();
        log.debug("unpark...");
        log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
    }, "t1");
    t1.start();
    Thread.sleep(500);
}

输出:

需要注意,park 方法是以许可(permit)为基础的。每个线程最多只能有一个许可。如果调用 unpark 时线程没有被阻塞,那么它将保留这个许可。如果在后续调用 park 时有可用的许可,那么线程将不会被阻塞,否则将会阻塞,就好比下面的例子。

public static void main(String[] args) throws InterruptedException {
   test3();
}

private static void test3() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        log.debug("park...");
        //阻塞
        LockSupport.park();
        log.debug("unpark...");
        log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
        //此时,由于打断标记(许可)已经是true了,他已经没有阻塞了,会保留许可
        LockSupport.park();
        log.debug("unpark...");
    }, "t1");
    t1.start();
    Thread.sleep(500);
    t1.interrupt();
}

输出:

17:09:08.126 [t1] DEBUG com.example.Main - park...
17:09:08.631 [t1] DEBUG com.example.Main - unpark...
17:09:08.631 [t1] DEBUG com.example.Main - 打断状态:true
17:09:08.632 [t1] DEBUG com.example.Main - unpark...

需要注意的是,虽然 interrupt 方法可以用于取消 park 阻塞,但并不是所有的阻塞方法都会受到 interrupt 方法的影响。例如,一些阻塞方法(如 Object.wait()Thread.sleep() 等)会抛出 InterruptedException 异常,而 park 方法并不会抛出异常,只是返回后中断状态被设置。因此,在使用 interrupt 方法来取消阻塞时,需要注意线程的中断状态并进行适当的处理。

parkunpark 方法可以灵活地用于各种线程同步的场景,但需要注意合理使用,以避免死锁或其他并发问题。

3.9.1 两阶段终止模式

针对与interrupt方法的使用,这里有一个经典的设计模式: 两阶段终止模式(Two Phase Termination)

问题:在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

错误思路:

  • 使用线程对象的 stop() 方法停止线程
    stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁
  • 使用 System.exit(int) 方法停止线程
    目的仅是停止一个线程,但这种做法会让整个程序都停止
graph TD
A("while(true)")-->B(有没有被打断)
B--是-->C(料理后事)
B--否-->D(睡眠2s)
C-->E((结束循环))
D--无异常-->F(执行监控记录)
F-->A
D--有异常-->G(设置打断标记)
G-->A

上图解读:

T1线程中,执行一个死循环,然后在死循环中,判断当前现在有没有被打断,如果有,则料理后事并且结束循环,如果没有,那么就在循环中休眠2S(防止死循环导致CPU100%),然后在判断是否被打断,这里会有两种情况,因为你sleep(2000)。

  • 如果刚好在休眠的时候被打断,会抛出异常,然后打断标记会被重置,所以你要重新设置打断标记,否则无法结束循环。
  • 如果没有在休眠的时候被打断,而在循环的其他部位被打断,那么就正常退出循环即可。
@Slf4j
public class Main {
    public static void main(String[] args) throws InterruptedException {
        TwoPhaseTermination tpt = new TwoPhaseTermination();
        //开启监控
        tpt.start();
        Thread.sleep(3500);
        log.debug("stop");
        //关闭监控
        tpt.stop();
    }
}
@Slf4j
class TwoPhaseTermination{
    /**
     * 监控线程
     */
    private Thread monitor;
    /**
     * 开启监控
     */
    public void start(){
        monitor = new Thread(()->{
            while (true){
                Thread current = currentThread();
                boolean interrupted = current.isInterrupted();
                if (interrupted){
                    log.debug("料理后事");
                    break;
                }
                try {
                    //如果在此处被打断会抛出异常,需要重新设置打断标记
                    Thread.sleep(1000);
                    //如果在此处被打断,无需设置打断标记,属于正常运行
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
                    //注意,这里就不要在抛出异常了,否则会导致无法料理后事
                    e.printStackTrace();
                    //重新设置打断标记
                    current.interrupt();
                }
            }
        },"监控线程");
        monitor.start();
    }

    /**
     * 停止监控线程
     */
    public void stop(){
        monitor.interrupt();
    }
}

输出:

16:33:26.118 [监控线程] DEBUG com.example.TwoPhaseTermination - 将结果保存
16:33:27.135 [监控线程] DEBUG com.example.TwoPhaseTermination - 将结果保存
16:33:28.148 [监控线程] DEBUG com.example.TwoPhaseTermination - 将结果保存
16:33:28.620 [main] DEBUG com.example.Main - stop
16:33:28.621 [监控线程] DEBUG com.example.TwoPhaseTermination - 料理后事
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at com.example.TwoPhaseTermination.lambda$start$0(Main.java:42)
    at java.lang.Thread.run(Thread.java:748)

Process finished with exit code 0

3.10 不推荐的方法

还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁

方法名 static 功能说明
stop() 停止线程运行
suspend() 挂起(暂停)线程运行
resume() 恢复线程运行

3.11 主线程与守护线程

在Java中,守护线程(Daemon Thread)是一种特殊类型的线程,它的存在并不会阻止Java虚拟机(JVM)的终止。默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。守护线程通常用于在后台执行某些任务,例如垃圾回收、周期性的资源清理等。

守护线程的特点包括:

  1. 当只剩下守护线程在运行时,JVM 会自动退出,无需显式地终止守护线程。
  2. 守护线程不能持有程序中重要资源(如文件、数据库连接等),因为它们可能会在任何时候被终止,从而导致资源泄漏或不稳定的情况。
  3. 守护线程的优先级较低,通常不应该依赖于特定的执行顺序。

在Java中,可以使用setDaemon(true)方法将一个线程设置为守护线程。例如:

Thread daemonThread = new Thread(() -> {
    while (true) {
        // 执行后台任务
    }
});
daemonThread.setDaemon(true); // 设置为守护线程
daemonThread.start();

需要注意的是,一旦线程启动后,就不能再修改其是否为守护线程,所以要在线程启动之前设置。此外,如果守护线程中产生了新的线程,这些新线程默认不会继承父线程的守护状态,需要手动设置。

守护线程适合用于执行一些不需要被显式控制或等待的后台任务,但要小心确保它们不会影响程序的稳定性和资源管理。

示例:

public static void main(String[] args) throws InterruptedException {
    log.debug("开始运行...");
    Thread t1 = new Thread(() -> {
        while (true){
            if (Thread.currentThread().isInterrupted()){
                break;
            }
        }
    }, "t1");

    t1.start();
    Thread.sleep(1000);
    log.debug("运行结束...");
}

输出:

可以看见主线程运行结束了,但是t1线程还在运行,所以导致整个程序没有停止

现在我们将t1线程设置为守护线程:

public static void main(String[] args) throws InterruptedException {
    log.debug("开始运行...");
    Thread t1 = new Thread(() -> {
        while (true){
            if (Thread.currentThread().isInterrupted()){
                break;
            }
        }
    }, "t1");
    //设置为守护线程,一定要在启动这个线程之前设置
    t1.setDaemon(true);
    t1.start();
    Thread.sleep(1000);
    log.debug("运行结束...");
}

输出:

可以看见设置为守护线程之后,主线程运行结束了,即使有其他非守护线程还没结束,也会被强制结束。

3.12 线程五种状态

这是从 操作系统 层面来描述的

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

3.13 在Java中线程的六种状态

这是从 Java API 层面来描述的

根据 Thread.State 枚举,分为六种状态

Java中的六种状态

  1. NEW(新建状态):线程对象被创建,但尚未调用 start() 方法。在这个状态下,线程尚未启动执行。可以通过调用线程对象的 start() 方法来将线程置于可运行状态。
  2. RUNNABLE(可运行状态):线程正在 Java 虚拟机中执行,或者在等待获取 CPU 时间片以执行。一旦线程进入可运行状态,它可能会在任何时候被调度并执行其 run() 方法。
  3. BLOCKED(阻塞状态):线程被阻塞,正在等待获取一个监视器锁以进入同步块或同步方法。当一个线程在同步块内,而另一个线程尝试获取相同监视器锁时,后者会进入阻塞状态。一旦持有锁的线程退出同步块,等待锁的线程中的一个会被唤醒并进入可运行状态。
  4. WAITING(等待状态):线程无限期地等待另一个线程执行特定操作。线程可以通过以下方法进入等待状态:
    • 调用 Object.wait() 方法,线程等待另一个线程调用相同对象的 notify()notifyAll() 方法。
    • 调用 Thread.join() 方法,当前线程等待被调用的线程执行完毕。
    • 调用 LockSupport.park() 方法。
  5. TIMED_WAITING(定时等待状态):线程等待另一个线程执行特定操作,但在一定时间内会超时。线程可以通过以下方法进入定时等待状态:
    • 调用 Thread.sleep(long n) 方法,在指定的时间内休眠。
    • 调用 Object.wait(long timeout) 方法,线程等待一段指定的时间,直到其他线程调用了相同对象的 notify()notifyAll() 方法。
    • 调用 Thread.join(long millis) 方法,当前线程等待被调用的线程执行完毕,但最多等待指定的时间。
    • 调用 LockSupport.parkNanos()LockSupport.parkUntil() 方法。
  6. TERMINATED(终止状态):线程已经完成执行或因为异常而终止。一旦线程的 run() 方法执行完毕,线程进入终止状态。也可以通过异常或其他终止条件使线程进入终止状态。

3.14 习题(烧水泡茶)

阅读华罗庚《统筹方法》,给出烧水泡茶的多线程解决方案,提示

  • 参考图二,用两个线程(两个人协作)模拟烧水泡茶过程
    • 文中办法乙、丙都相当于任务串行
    • 而图一相当于启动了 4 个线程,有点浪费
  • 用 sleep(n) 模拟洗茶壶、洗水壶等耗费的时间

附:华罗庚《统筹方法》

统筹方法,是一种安排工作进程的数学方法。它的实用范围极广泛,在企业管理和基本建设中,以及关系复杂的科研项目的组织与管理中,都可以应用。

怎样应用呢?主要是把工序安排好。

比如,想泡壶茶喝。当时的情况是:开水没有;水壶要洗,茶壶、茶杯要洗;火已生了,茶叶也有了。怎么- 办?

  • 办法甲:洗好水壶,灌上凉水,放在火上;在等待水开的时间里,洗茶壶、洗茶杯、拿茶叶;等水开了,泡茶喝。
  • 办法乙:先做好一些准备工作,洗水壶,洗茶壶茶杯,拿茶叶;一切就绪,灌水烧水;坐待水开了,泡茶喝。
  • 办法丙:洗净水壶,灌上凉水,放在火上,坐待水开;水开了之后,急急忙忙找茶叶,洗茶壶茶杯,泡茶喝。

哪一种办法省时间?我们能一眼看出,第一种办法好,后两种办法都窝了工。

这是小事,但这是引子,可以引出生产管理等方面有用的方法来。

水壶不洗,不能烧开水,因而洗水壶是烧开水的前提。没开水、没茶叶、不洗茶壶茶杯,就不能泡茶,因而这些又是泡茶的前提。它们的相互关系,可以用下边的箭头图来表示:

graph LR
A(洗水壶 1分钟) -->B(洗水壶 15分钟)
B --> D
C(洗茶壶 1分钟) --> D(泡茶)
E(洗茶杯 2分钟) --> D
F(拿茶叶 1分钟) -->D

从这个图上可以一眼看出,办法甲总共要16分钟(而办法乙、丙需要20分钟)。如果要缩短工时、提高工作效率,应当主要抓烧开水这个环节,而不是抓拿茶叶等环节。同时,洗茶壶茶杯、拿茶叶总共不过4分钟,大可利用“等水开”的时间来做。

是的,这好像是废话,卑之无甚高论。有如走路要用两条腿走,吃饭要一口一口吃,这些道理谁都懂得。但稍有变化,临事而迷的情况,常常是存在的。在近代工业的错综复杂的工艺过程中,往往就不是像泡茶喝这么简单了。任务多了,几百几千,甚至有好几万个任务。关系多了,错综复杂,千头万绪,往往出现“万事俱备,只欠东风”的情况。由于一两个零件没完成,耽误了一台复杂机器的出厂时间。或往往因为抓的不是关键,连夜三班,急急忙忙,完成这一环节之后,还得等待旁的环节才能装配。

洗茶壶,洗茶杯,拿茶叶,或先或后,关系不大,而且同是一个人的活儿,因而可以合并成为:

graph LR
A(洗水壶 1分钟)-->B(烧开水 15分钟)
B-->C(泡茶)
D(洗茶壶,洗茶杯,拿茶叶 4分钟)-->C

看来这是“小题大做”,但在工作环节太多的时候,这样做就非常必要了。

这里讲的主要是时间方面的事,但在具体生产实践中,还有其他方面的许多事。这种方法虽然不一定能直接解决所有问题,但是,我们利用这种方法来考虑问题,也是不无裨益的。

3.14.1 使用join实现

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("洗水壶 要花1S");
        try {
            Thread.sleep(15000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("烧开水 要花15S");
    },"老王");

    Thread t2 = new Thread(() -> {
        log.debug("洗茶壶");
        log.debug("洗茶杯");
        log.debug("拿茶叶");
        try {
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("洗茶壶,洗茶杯,拿茶叶 要花4S");
        //这里如果是小王来泡茶的话,小王就要等待老王的水烧开
        try {
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("小王开始泡茶");
    },"小王");

    t1.start();
    t2.start();
}

输出:

21:29:58.095 [小王] DEBUG com.nxz.Main - 洗茶壶
21:29:58.097 [小王] DEBUG com.nxz.Main - 洗茶杯
21:29:58.098 [小王] DEBUG com.nxz.Main - 拿茶叶
21:29:59.101 [老王] DEBUG com.nxz.Main - 洗水壶 要花1S
21:30:02.101 [小王] DEBUG com.nxz.Main - 洗茶壶,洗茶杯,拿茶叶 要花4S
21:30:14.109 [老王] DEBUG com.nxz.Main - 烧开水 要花15S
21:30:14.109 [小王] DEBUG com.nxz.Main - 小王开始泡茶

这种方法实现是由缺陷的:

  • 上面模拟的是小王等老王的水烧开了,小王泡茶,如果反过来要实现老王等小王的茶叶拿来了,老王泡茶呢?
  • 上面的两个线程其实是各执行各的,如果要模拟老王把水壶交给小王泡茶,或模拟小王把茶叶交给老王泡茶呢?

文章作者: 念心卓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 念心卓 !
  目录