JUC学习笔记——进程与线程

JUC学习笔记——进程与线程

在本系列内容中我们会对JUC做一个系统的学习,本片将会介绍JUC的进程与线程部分

我们会分为以下几部分进行介绍:

  • 进程与线程
  • 并发与并行
  • 同步与异步
  • 线程详解

进程与线程

在这一小节我们将简单介绍进程与线程

进程

首先我们来简单了解一下程序:

  • 程序由指令和数据组成,我们必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。

接下来我们才能讲解进程的定义:

  • 进程就是用来加载指令、管理内存、管理 IO 的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程,也有的程序只能启动一个实例进程。

线程

我们来简单介绍一下线程:

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

两者区别

我们来介绍一下进程与线程之间的区别:

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集

  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享

  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

此外两者的通信方式也不相同:

  • 进程通信:同一台计算机的进程通信称为 IPC;不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
  • 线程通信:线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量

并发与并行

在这一小节我们将简单介绍并发与并行

并发

首先我们需要了解一下任务调度器:

  • 单核 cpu 下,线程实际还是 串行执行 的。
  • 操作系统中有一个组件叫做任务调度器,将 cpu 的时间片分给不同的程序使用。

那么我们的并发实际上就是根据任务调度器来工作的:

  • 并发是借助任务调度器,在一段时间段内将CPU分给多个线程使用,但由于切换快时间短,所以被看作是同时进行
  • 一般会将这种 线程轮流使用 CPU 的做法称为并发
  • 实际上并发的情况是:微观串行,宏观并行

通俗来讲:

  • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力

例如下图:

CPU 时间片 1 时间片 2 时间片 3 时间片 4
core 线程 1 线程 2 线程 3 线程 4

并行

并行的概念就相对而言比较简单了:

  • 并行就是借助多核CPU,确确实实地在同一时间执行多个进程

通俗来讲:

  • 并行(parallel)是同一时间动手做(doing)多件事情的能力

例如下图:

CPU 时间片 1 时间片 2 时间片 3 时间片 4
core 1 线程 1 线程 1 线程 3 线程 3
core 2 线程 2 线程 4 线程 2 线程 4

同步与异步

在这一小节我们将简单介绍并发与并行

同步与异步概念

首先我们来简单介绍一下同步与异步:

  • 需要等待结果返回才能继续执行的操作就是同步操作
  • 不需要等待结果返回就可以继续执行的操作就是异步操作

另外同步操作还有另一个概念:

  • 在多线程中,表示多线程的步调一致

同步与异步选择方法

我们的同步与异步的选择通常会决定程序的运行速度,因而选择同步或异步是非常重要的

我们先来介绍同步与异步的实现方式:

  • 同步就是在一个线程内完全执行所有命令
  • 异步可以在多线程中实现,当一个线程执行复杂操作比较耗时时,另一个线程可以执行其他简单操作

我们再来介绍同步与异步的选择方法:

  • 针对比较繁琐的操作,我们通常会单独创建一个新线程来进行处理,避免阻塞主线程
  • Tomcat里面的异步Servlet也是异步操作,让用户线程处理耗时较长的操作,避免阻塞Tomcat的工作线程
  • UI程序中,开线程进行其他操作,避免UI线程

我们分别给出同步异步的代码展示:

// 同步代码

package cn.itcast.n2;

import cn.itcast.Constants;
import cn.itcast.n2.util.FileReader;
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Sync")
public class Sync {

    public static void main(String[] args) {
        FileReader.read(Constants.MP4_FULL_PATH);
        log.debug("do other things ...");
    }

}

// 异步代码

package cn.itcast.n2;

import cn.itcast.Constants;
import cn.itcast.n2.util.FileReader;
import lombok.extern.slf4j.Slf4j;


@Slf4j(topic = "c.Async")
public class Async {

    public static void main(String[] args) {
        new Thread(() -> FileReader.read(Constants.MP4_FULL_PATH)).start();
        log.debug("do other things ...");
    }

}

同步与异步实际使用

我们通常采用异步操作来实现应用的速度提升:

// 例如我们有下面三个操作

计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms

如果我们采用主线程的同步操作来实现:

// 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms

但是如果我们采用三个CPU的异步操作来实现:

// 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3
// 那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms

下面我们给出同步异步的实际使用规则:

  • 多核多线程速度快于单核多线程(异步进行速度较快)
  • 单核多线程速度慢于单核单线程(线程切换也需要耗费时间)

但是单核多线程也并非是一无是处:

  • 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换
  • 不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活

此外多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的 :

  • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分
  • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义

最后就是我们的IO操作部分:

  • IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】
  • 这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程
  • 所以才有后面的【非阻塞 IO】和【异步 IO】优化

线程详解

这一小节我们将详细介绍线程的具体内容

创建和运行线程

我们下面将介绍三种创建和运行线程的方法

直接使用 Thread

我们可以直接使用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();

我们给出实际输出结果:

// 我们会注意到:前面标记了[t1]线程~
19:19:00 [t1] c.ThreadStarter - hello

使用 Runnable 配合 Thread

这里我们将Thread里面的方法采用Runnable类型的方法来代替:

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

我们给出一个实际例子:

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

其结果为:

// 结果正常
9:19:00 [t2] c.ThreadStarter - hello

除此之外,我们在JDK8之后,我们可以采用函数式接口Lambda来简化Runnable的书写:

// 创建任务对象
Runnable task2 = () -> log.debug("hello");
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();

甚至我们都不用定义task,来直接采用Lambda方法书写Thread中的task:

// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(() -> log.debug("hello"), "t2");
t2.start();

底层简单解释:

  • 至于Thread为什么能够直接调用Runnable
  • Thread在接收Runnable类型后,会将其赋值在this.target
  • 而Thread的run方法会先来判断是否存在target,如果存在就直接采用target方法

最后我们介绍一下使用Runnable的好处:

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

使用FutureTask 配合 Thread(了解即可)

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

// 创建任务对象(Integer是返回对象)
FutureTask<Integer> task3 = new FutureTask<>(() -> {
	log.debug("hello");
	return 100;
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
new Thread(task3, "t3").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task3.get();
log.debug("结果是:{}", result);

我们给出结果:

19:22:27 [t3] c.ThreadStarter - hello
19:22:27 [main] c.ThreadStarter - 结果是:100

我们给出简单解释:

  • FutureTask内置了一个Callable对象,初始化方法将指定的Callable赋给这个对象。
  • FutureTask实现了Runnable接口,并重写了Run方法,在Run方法中调用了Callable中的call方法,并将返回值赋值给outcome变量
  • get方法就是取出outcome的值。

多线程运行状况

我们给出单核CPU运行多线程时不断切换进程的状况展示:

// 下述的操作不受我们控制,谁先调用,调用多久都不是我们管控的

@Slf4j(topic = "c.TestMultiThread")
public class TestMultiThread {

    public static void main(String[] args) {
        new Thread(() -> {
            while(true) {
                log.debug("running");
            }
        },"t1").start();
        new Thread(() -> {
            while(true) {
                log.debug("running");
            }
        },"t2").start();
    }
}

我们直接给出结果展示:

23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t2] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running
23:45:26.254 c.TestMultiThread [t1] - running

查看进程线程方法

由于不同系统的查看方法不同,我们主要介绍三种类型查看方法

Window

  • 任务管理器可以查看进程和线程数,也可以用来杀死进程
  • tasklist 查看进程 :tasklist| findstr (查找关键字)
  • taskkill 杀死进程:taskkill /F(彻底杀死)/PID(进程PID)

Linux

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

Java

  • jps 命令查看所有 Java 进程
  • jstack 查看某个 Java 进程(PID)的所有线程状态
  • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)

线程运行底层解释

我们将会介绍两个与线程底层运行相关的原理

栈与栈帧

下面我们来介绍一下与进程息息相关的底层原理:

  • 栈:存放栈帧的个体,每个线程具有一个单独的栈来存放多个栈帧
  • 栈帧:方法的全部内存占用,每个方法使用时的全部内存都用一个栈帧来表示

同时栈和栈帧也有一定限制:

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

我们给出一个简单的代码展示:

// 这里展现的是单线程,目前只有一个栈!

package cn.itcast.n3;

public class TestFrames {
    
    // 首先main方法被调用,所以main方法先进入栈中,在main方法执行结束后被抛出栈
    public static void main(String[] args) {
        method1(10);
    }

    // 由于main方法调用了method1,所以栈中在存有main栈帧的同时也将method1栈帧调入,在method1方法执行完毕后抛出
    private static void method1(int x) {
        int y = x + 1;
        Object m = method2();
        System.out.println(m);
    }

    // 由于method1方法调用了method2,所以栈中在存有main,method1栈帧的同时也将method2栈帧调入,method2方法执行完毕后抛出
    private static Object method2() {
        Object n = new Object();
        return n;
    }
}

// 这里展现的是多线程,主线程和线程t1独自各占有一个栈,互不影响!

package cn.itcast.n3;

public class TestFrames {
    // 这里会产生两个栈,两个栈互不影响,两个栈都会顺序调用main,method1,method2栈帧,顺序不定
    public static void main(String[] args) {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                method1(20);
            }
        };
        t1.setName("t1");
        t1.start();
        method1(10);
    }

    private static void method1(int x) {
        int y = x + 1;
        Object m = method2();
        System.out.println(m);
    }

    private static Object method2() {
        Object n = new Object();
        return n;
    }
}

线程上下文切换

我们再来介绍一下上下文切换:

  • 当出现一些状况时,系统会自动将CPU的使用权进行切换,交付给不同的线程进行使用

上下文切换措施:

  • 要由操作系统保存当前线程的状态,并恢复另一个线程的状态,
  • Java 中对应的就是程序计数器,它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等

上下文切换时机:

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

但是我们需要注意:

  • Context Switch 频繁发生会影响性能

线程方法详解

这一小节我们将介绍线程的各种方法

线程方法总述

我们首先给出线程的全部方法一览:

方法 功能 说明
public void start() 启动一个新线程;Java虚拟机调用此线程的run方法 start 方法只是让线程进入就绪,里面代码不一定立刻 运行(CPU 的时间片还没分给它)。每个线程对象的 start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException
public void run() 线程启动后调用该方法 如果在构造 Thread 对象时传递了 Runnable 参数,则 线程启动后会调用 Runnable 中的 run 方法,否则默 认不执行任何操作。但可以创建 Thread 的子类对象, 来覆盖默认行为
public void setName(String name) 给当前线程取名字
public void getName() 获取当前线程的名字。线程存在默认名称:子线程是Thread-索引,主线程是main
public static Thread currentThread() 获取当前线程对象,代码在哪个线程中执行
public static void sleep(long time) 让当前线程休眠多少毫秒再继续执行。Thread.sleep(0) : 让操作系统立刻重新进行一次cpu竞争
public static native void yield() 提示线程调度器让出当前线程对CPU的使用 主要是为了测试和调试
public final int getPriority() 返回此线程的优先级
public final void setPriority(int priority) 更改此线程的优先级,常用1 5 10 java中规定线程优先级是1~10 的整数,较大的优先级 能提高该线程被 CPU 调度的机率
public void interrupt() 中断这个线程,异常处理机制
public static boolean interrupted() 判断当前线程是否被打断,清除打断标记
public boolean isInterrupted() 判断当前线程是否被打断,不清除打断标记
public final void join() 等待这个线程结束
public final void join(long millis) 等待这个线程死亡millis毫秒,0意味着永远等待
public final native boolean isAlive() 线程是否存活(还没有运行完毕)
public final void setDaemon(boolean on) 将此线程标记为守护线程或用户线程
public long getId() 获取线程长整型 的 id id 唯一
public state getState() 获取线程状态 Java 中线程状态是用 6 个 enum 表示,分别为: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
public boolean isInterrupted() 判断是否被打 断 不会清除 打断标记

start与run

我们首先来介绍线程的两个相似的启动方法:

// 首先我们采用start:start方法是启动该线程,线程开始运行

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.start();
    log.debug("do other things ...");
}

// 然后我们直接使用run方法:run方法是调用该线程的run方法,实际是main线程在运行t1线程的run方法

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 ...");
}

同时我们可以通过查看线程状态来判断start和run的区别:

// 这里我们仅对start判断

public static void main(String[] args) {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug("running...");
        }
    };
    System.out.println(t1.getState());
    t1.start();
    System.out.println(t1.getState());
}

// 我们可以注意到main状态从就绪状态切换为Runnable
NEW
RUNNABLE
03:45:12.255 c.Test5 [t1] - running...

所以我们给出小结:

  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程

  • 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码

sleep 与 yield

我们首先给出sleep的相关解释:

  • 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
  • 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  • 睡眠结束后的线程未必会立刻得到执行
  • 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性 。其底层还是sleep方法
  • 在循环访问锁的过程中,可以加入sleep让线程阻塞时间,防止大量占用cpu资源

我们再给出yield的相关解释:

  • 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程;具体的实现依赖于操作系统的任务调度器
  • 加入当前线程中只有这一个方法,那么停止该方法执行后仍旧执行该方法

我们采用代码来进行展示:

/*sleep状态转换:我们运行下面代码后可以看到其t1状态从就绪状态到Runnable到Timed Waiting*/

public static void main(String[] args) {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug("running...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                log.debug("wake up...");
                e.printStackTrace();
            }
        }
    };
    System.out.println(t1.getState());
    t1.start();
    System.out.println(t1.getState());
    Thread.sleep(2000);
    System.out.println(t1.getState());
}

/*sleep打断睡眠线程,抛出异常:注意当睡眠时抛出异常后该程序的打断状态为false*/ 

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread("t1") {
        @Override
        public void run() {
            log.debug("enter sleep...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                log.debug("wake up...");
                e.printStackTrace();
            }
        }
    };
    t1.start();

    Thread.sleep(1000);
    log.debug("interrupt...");
    t1.interrupt();
}

// 输出结果为:
03:47:18.141 c.Test7 [t1] - enter sleep...
03:47:19.132 c.Test7 [main] - interrupt...
03:47:19.132 c.Test7 [t1] - wake up...
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at cn.itcast.test.Test7$1.run(Test7.java:14)
    
/*sleep采用TimeUnit的方法更具有代码可读性*/
    
@Slf4j(topic = "c.Test8")
public class Test8 {

    public static void main(String[] args) throws InterruptedException {
        log.debug("enter");
        TimeUnit.SECONDS.sleep(1);
        log.debug("end");
//        Thread.sleep(1000);
    }
}

/*yield简单测试:我们需要在Linux虚拟机中使用单核cpu来处理该代码,下面我们同时使用一个cpu处理两个进程*/

@Slf4j(topic = "c.TestYield")
public class TestYield {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            int count = 0;
            for (;;) {
                System.out.println("---->1 " + count++);
            }
        };
        Runnable task2 = () -> {
            int count = 0;
            for (;;) {
                Thread.yield();
                System.out.println("---->2 " + count++);
            }
        };
        Thread t1 = new Thread(task1, "t1");
        Thread t2 = new Thread(task2, "t2");
        t1.start();
        t2.start();
    }
}

// 我们会发现task1的count数更多,因为轮到task2时,它会调用yield可能会导致当前线程停止从而去运行另一个线程
---->1 119199
---->2 101074

最后我们讲解一个项目中常用的案例:

// 在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权 给其他程序

while(true) {
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

// - 可以用 wait 或 条件变量达到类似的效果 
// - 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景 
// - sleep 适用于无需锁同步的场景

// wait实现

synchronized(锁对象) {
    while(条件不满足) { 
        try {
            锁对象.wait();
        } catch(InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do sth...
}

// 条件变量实现

lock.lock();
try {
    while(条件不满足) {
        try {
            条件变量.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do sth...
} finally {
    lock.unlock();
}

Priority

下面我们来介绍线程的优先级设置:

  • 首先优先级的大小从1~10,默认为5
  • 优先级只是起到一定影响作用,不会对线程的执行顺序起到绝对作用,只是优先级越高其获得CPU可能性越大

我们给出简单代码示例:

// 我们同样需要在单核CPU下进行

@Slf4j(topic = "c.TestYield")
public class TestYield {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            int count = 0;
            for (;;) {
                System.out.println("---->1 " + count++);
            }
        };
        Runnable task2 = () -> {
            int count = 0;
            for (;;) {
                System.out.println("              ---->2 " + count++);
            }
        };
        Thread t1 = new Thread(task1, "t1");
        Thread t2 = new Thread(task2, "t2");
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);
        t1.start();
        t2.start();
    }
}

// 运行结果:我们会发现t2的执行次数明显高于t1
---->1 283500
---->2 374389

join

首先我们需要了解join的作用:

  • 用于等待直至所指线程结束为止

我们采用一个简单的例子进行解释:

// 下述例子我们希望打印线程t1的r的值,但是我们都知道main和t1线程都是同时运行的,并且t1等待了1s,所以这时的r是没有赋值的,为0

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();
    log.debug("结果为:{}", r);
    log.debug("结束");
}

// 但是我们可以选择使用join方法来使主线程等待,知道t1线程结束后再去运行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("开始");
        sleep(1);
        log.debug("结束");
        r = 10;
    });
    t1.start();
    log.debug("结果为:{}", r);
    t1.join();
    log.debug("结果为:{}", r);
    log.debug("结束");
}

// 注意我们如果采用sleep或许也可以实现相同的效果,但是很难准确确定其实际线程的结束时刻,所以正常情况下无法使用sleep

此外我们还需要讲解join的其他几个性质:

  • 我们可以借助join来实现线程之间的同步操作
  • 当多个线程都采用join时,我们需要等所有线程都结束后继续运行
  • join是可以设置时效性的,当超过时则不再等待而是直接运行,若不超过就按照线程结束时间计算

我们通过几个案例解释上述性质:

// 借助join完成线程之间的同步操作(其实前面第一个例子就是同步操作,我们需要先完成t1线程才能执行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("开始");
        sleep(1);
        log.debug("结束");
        r = 10;
    });
    t1.start();
    t1.join();
    log.debug("结果为:{}", r);
    log.debug("结束");
}

// 多个join需要等待所有线程完成
// 如下述,一个需要1s,一个需要2s,但由于同时进行,所以我们只需要2s就可以全部执行,然后再执行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(() -> {
        sleep(1);
        r1 = 10;
    });
    Thread t2 = new Thread(() -> {
        sleep(2);
        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);
}  

// join是可以设置时效性的,当超过时则不再等待而是直接运行,若不超过就按照线程结束时间计算

static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
    test3();
}
public static void test3() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        // 这里设置时间,若低于1.5s按sleep时间执行;若高于1.5s则在1.5s时执行
        sleep(1);
        r1 = 10;
    });
    long start = System.currentTimeMillis();
    t1.start();
    // 线程执行结束会导致 join 结束
    t1.join(1500);
    long end = System.currentTimeMillis();
    log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}

interrupt

我们首先来简单介绍一下interrupt方法:

  • 翻译过来就是打断,本质是将线程的打断标记设为true,并调用线程的三个parker对象(C++实现级别)unpark该线程。

我们来进行更详细的介绍:

  • 打断操作实际上只是通知,不是打断
  • 打断线程不等于中断线程,有以下两种情况:
  • 打断正在运行中的线程并不会影响线程的运行,但如果线程监测到了打断标记为true,可以自行决定后续处理。
  • 打断阻塞中的线程会让此线程产生一个InterruptedException异常,结束线程的运行。
  • 但如果该异常被线程捕获住,该线程依然可以自行决定后续处理(终止运行,继续运行,做一些善后工作等等)

总而言之我们的打断其实主要分为两种类型,我们采用代码解释:

/* 正常运行情况下打断,并不会影响线程正常运行,但是会将线程的打断标记设置为true */

package cn.itcast.test;

import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Test12")
public class Test12 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // 这里就是一个正常运行的线程,我们在启动后1s对他进行打断
            while(true) {
                // 打断不会直接对线程造成影响,但是会将打断状态interrupted变为true(由isInterrupted方法得到)
                boolean interrupted = Thread.currentThread().isInterrupted();
                // 然后我们可以自行根据打断状态来做对应处理!
                if(interrupted) {
                    log.debug("被打断了, 退出循环");
                    break;
                }
            }
        }, "t1");
        t1.start();

        // 1s后实现打断操作
        Thread.sleep(1000);
        log.debug("interrupt");
        t1.interrupt();
    }
}

// 产生结果为:程序不停止,但打断状态为true
20:57:37.964 [t2] c.TestInterrupt - 打断状态: true

/* 不正常状态被打断,例如sleep,yield,join,会使线程进入阻塞状态,抛出异常,会清空打断状态,使其变为false */

private static void test1() throws InterruptedException {
    Thread t1 = new Thread(()->{
        sleep(1);
    }, "t1");
    t1.start();
    sleep(0.5);
    t1.interrupt();
    log.debug(" 打断状态: {}", t1.isInterrupted());
}

// 产生结果为:抛出异常,但程序不会停止
java.lang.InterruptedException: sleep interrupted
 at java.lang.Thread.sleep(Native Method)
 at java.lang.Thread.sleep(Thread.java:340)
 at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
 at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
 at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
 at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] c.TestInterrupt - 打断状态: false

最后我们介绍一个用于interrupt的思想模式之两阶段终止:

  • 我们需要在线程1调用线程2的interrupt方法,同时使线程2完成它的后续操作

我们首先给出其逻辑图:

我们通过代码解释:

/*首先利用isinterrupt方法来实现思想*/

// 我们需要书写一个检测类负责不断检测打断状态
class TPTInterrupt {
    private Thread thread;
    public void start(){
        thread = new Thread(() -> {
            // 首先不断检测
            while(true) {
                Thread current = Thread.currentThread();
                // 当我们发现被打断时
                if(current.isInterrupted()) {
                    // 自行处理后续内容
                    log.debug("料理后事");
                    break;
                }
                // 当我们发现没有被打断时,我们将程序停止一段时间,并进行简单处理
                try {
                    Thread.sleep(1000);
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
                    // 这里我们需要注意,如果存在sleep等操作就会导致抛出异常InterruptedException
                    // 但是抛出异常并不会导致程序结束,也不会导致打断标记为true,
                    // 所以我们需要手动设置打断标记为true,使其在下一次循环时,中断程序
                    current.interrupt();
                }
                // 执行监控操作 
            }
        },"监控线程");
        thread.start();
    }
    public void stop() {
        thread.interrupt();
    }
}

// 我们在主程序调用:
TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();

// 我们可以得到结果:
11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop 
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事

/*我们还可以手动设置一个参数来负责线程的关闭*/
    
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPTVolatile {
    private Thread thread;
    private volatile boolean stop = false;
    public void start(){
        thread = new Thread(() -> {
            while(true) {
                Thread current = Thread.currentThread();
                if(stop) {
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
                }
                // 执行监控操作
            }
        },"监控线程");
        thread.start();
    }
    public void stop() {
        stop = true;
        thread.interrupt();
    }
}

// 我们在主程序中调用:
TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();

// 我们得到下属结果:
11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop 
11:54:54.502 c.TPTVolatile [监控线程] - 料理后事
    
/*我们还可以通过打断 park 线程来实现思想*/
    
// 首先我们简单介绍一下park:当打断标记为false时,park起到线程暂停作用;当打断标记为true时,继续运行
    
// 首先我们给出打断标记为false的状态
    
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();
    sleep(0.5);
    t1.interrupt();
}

// 我们可以得到下述结果
21:11:52.795 [t1] c.TestInterrupt - park... 
21:11:53.295 [t1] c.TestInterrupt - unpark... 
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true 
    
// 如果打断标记已经是 true, 则 park 会失效
    
private static void test4() {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5; i++) {
            log.debug("park...");
            LockSupport.park();
            log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
        }
    });
    t1.start();
    sleep(1);
    t1.interrupt();
}

// 我们可以得到下述结果:
21:13:48.783 [Thread-0] c.TestInterrupt - park... 
21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.812 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
    
// 我们可以使用 Thread.interrupted() 清除打断状态
// Thread.interrupted()获得当前打断状态,并将打断状态设置为false
// Thread.currentThread().isInterrupted())只能获得当前打断状态,不会影响值
    
/*
此外还有一些不符合当前状态的打断方式,我们也简单介绍一下:
- 使用线程对象的 stop() 方法停止线程 
  - stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁 
- 使用 System.exit(int) 方法停止线程 
  - 目的仅是停止一个线程,但这种做法会让整个程序都停止
*/

过时方法介绍

最后我们给出三个线程的过时方法简单解释:

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

主线程和守护线程

首先我们简单介绍一下守护线程:

  • 守护线程是不重要的线程,当所有的主线程都完成时,无论守护线程是否结束都被迫结束

我们给出简单示例:

// 主函数代码:

log.debug("开始运行...");
Thread t1 = new Thread(() -> {
     log.debug("开始运行...");
     sleep(2);
     log.debug("运行结束...");
}, "daemon");
// 设置该线程为守护线程
t1.setDaemon(true);
t1.start();
sleep(1);
log.debug("运行结束...");

// 运行结果:
08:26:38.123 [main] c.TestDaemon - 开始运行... 
08:26:38.213 [daemon] c.TestDaemon - 开始运行... 
08:26:39.215 [main] c.TestDaemon - 运行结束... 

我们可以简单给出守护线程的一些实例:

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

线程状态

这一小节我们将介绍线程的两种状态形式

线程五种状态

从操作系统的角度来讲,线程具有五种状态:

我们来简单介绍一下:

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

线程六种状态

从Java虚拟机的角度来看,将其分为六种状态:

我们来简单介绍一下:

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的 【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为 是可运行)
  • BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节 详述
  • TERMINATED 当线程代码运行结束

我们给出相关代码进行解释:

package cn.itcast.n3;

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;

@Slf4j(topic = "c.TestState")
public class TestState {
    public static void main(String[] args) throws IOException {
        
        // 代码完善,但并未运行,属于NEW状态
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                log.debug("running...");
            }
        };

        // 代码完善且一直运行,属于runnable状态
        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                while(true) { // runnable

                }
            }
        };
        t2.start();

        // 执行一次结束,属于线程执行完毕,TERMINATED 状态
        Thread t3 = new Thread("t3") {
            @Override
            public void run() {
                log.debug("running...");
            }
        };
        t3.start();

        // 由sleep停止进程,属于timed_waiting状态
        Thread t4 = new Thread("t4") {
            @Override
            public void run() {
                synchronized (TestState.class) {
                    try {
                        Thread.sleep(1000000); // timed_waiting
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t4.start();

        // 由于等待其他进程结束而等待,属于waiting状态
        Thread t5 = new Thread("t5") {
            @Override
            public void run() {
                try {
                    t2.join(); // waiting
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        t5.start();

        // 由于等待锁而等待,属于blocked状态
        Thread t6 = new Thread("t6") {
            @Override
            public void run() {
                synchronized (TestState.class) { // blocked
                    try {
                        Thread.sleep(1000000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        t6.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 输出其状态
        log.debug("t1 state {}", t1.getState());
        log.debug("t2 state {}", t2.getState());
        log.debug("t3 state {}", t3.getState());
        log.debug("t4 state {}", t4.getState());
        log.debug("t5 state {}", t5.getState());
        log.debug("t6 state {}", t6.getState());
        System.in.read();
    }
}

本章小结

我们简单总结一下重点:

  • 线程创建
  • 线程重要 api,如 start,run,sleep,join,interrupt 等
  • 线程状态
  • 应用方面
    • 异步调用:主线程执行期间,其它线程异步执行耗时操作
    • 提高效率:并行计算,缩短运算时间
    • 同步等待:join
    • 统筹规划:合理使用线程,得到最优效果
  • 原理方面
    • 线程运行流程:栈、栈帧、上下文切换、程序计数器
    • Thread 两种创建方式 的源码
  • 模式方面
    • 终止模式之两阶段终止

结束语

到这里我们JUC的进程与线程内容就结束了,希望能为你带来帮助~

附录

该文章属于学习内容,具体参考B站黑马程序员满老师的JUC完整教程

这里附上视频链接:01.001-为什么学习并发_哔哩哔哩_bilibili