Java多线程梳理之一_多线程开发入门

本章是Java多线程开发入门,重点介绍Java的线程状态及其相互转换。

一、基础知识点

多线程开发中,会涉及到很多基础知识,这里先行梳理其中两个重要的知识点。

1.1 并发与并行

在多线程开发中,会经常提到并发与并行两个概念,我们需要先明确这两个概念的含义到底是什么。

并发,concurrency,是一段时间内处理多件事情的需求,它是问题域problem domain的概念。

并行,parallelism,是在同一时刻同时处理多件事情的方式,它是方法域solution domain的概念。

并发以黑盒的角度,将目标系统看做一个实体,要求该实体在一段时间内能够处理多件事情;并行以白盒的角度,将目标系统分解为多个实体,这些实体可以同时分别处理多件事情,有多少个实体,就有多少并行,比如一个8核的CPU,可以并行地运行8个任务。

并发是问题,是需求;并行是解决并发问题的方法之一。并发只提出要求,实际实现时,可以是单个实体串行处理任务(一个任务处理完再处理下一个),可以是多个实体并行处理任务(每个实体处理一个任务),可以是单个实体不断在多个任务之间来回切换着处理(如单核CPU),甚至是多个实体不断在多个任务之间来回切换着任务做并行处理(如多核CPU)。如下图所示:

Java多线程梳理之一_多线程开发入门_第1张图片

1.2 进程与线程

理解进程与线程的不同是多线程编程基础中的基础。

操作系统并非一开始就划分了进程和线程,甚至直到现在,Linux这样的操作系统上,进程与线程的分界依然不是那么清晰。但我们可以简单地认为:操作系统将内存分配给不同的进程,将CPU分配给不同的线程;同一个进程内有多个线程可以共享该进程分配到的内存,而CPU以线程为单位在不同线程间不断切换执行。

勉为其难来个定义:

  • 进程就是操作系统中一个具有独立功能的程序,操作系统管理所有进程的执行并且以进程为单位分配存储空间。
  • 线程是一个进程中的可以并发的执行流程,是可以获得CPU调度和分派的基本执行单元。一个进程可以有多个线程。

进一步展开,进程是从操作系统申请并拥有内存的基本单位。每个进程是由私有的虚拟地址空间、代码、数据和其它各种系统资源组成。可以认为,进程是被操作系统加载到内存中的任务单元。

而线程则是CPU调度和分配的基本单位。线程是进程内部的一个执行单元。 每一个进程至少有一个主线程,它无需由用户去主动创建,是由系统自动创建的。 用户根据需要在应用程序中创建其它线程,多个线程并发地运行于同一个进程中。

从开发语言的角度看,线程可以视为进程中的运行时控制流。开发语言中静态的控制流由一条条自上而下的语句组成,可能会有分支和循环;这种静态的控制流在运行时的映射就是线程。一个进程至少会包含一个线程,因为其中至少会有一个控制流持续运行。因而,一个进程的第一个线程会随着这个进程的启动而创建,这个线程称为该进程的主线程。当然,一个进程往往包含多个线程,这些线程都是由当前进程中已存在的线程创建出来的,线程不可能独立于进程存在。它的生命周期不可能逾越其所属进程的生命周期。

从计算机资源的使用角度看,进程是计算机资源的拥有者,创建、切换和销毁都有较大的时空开销,而一个进程内的所有线程共享这个进程的资源,更轻量级,相关操作的开销也相对更小。需要注意的是,对于单核CPU系统而言,并行其实是不存在的,任何时刻CPU其实只能被一个线程所获取,线程之间共享了CPU的执行时间。由于切换的速度很快,对外表现为并发执行的样子。

拥有多个线程的进程可以并发地执行多个任务,并且即使某个或某些任务被阻塞,也不会影响其他任务正常执行。在资源足够的情况下,这可以大大改善程序的响应时间和吞吐量。这就是多线程编程的基本思路。当然实际实现的时候,还有很多问题要解决,这个在后续章节会有梳理。

二、创建线程基本方式

让我们从创建线程开始,看看Java中如何进行多线程编程。

Java创建新的线程有三种基本的方式:继承Thread类,实现Runnable接口,实现Callable接口。

2.1 继承Thread类

实现代码如下:

package com.czhao.test.mutithread;

/**
 * @author zhaochun
 */
public class ThreadTest {
    public static void main(String[] args) {
        // 直接 new 一个Thread对象
        Thread subThread = new Printer();
        // 调用Thread对象的 start 方法,注意不能调用 run 方法
        subThread.start();
        System.out.println("Print in mainThread.");
    }

    // 定义一个继承了Thread的类
    static class Printer extends Thread {
        // 重写run方法,实现自己的业务逻辑
        @Override
        public void run() {
            System.out.println("Print in subThread.");
        }
    }
}

注意,线程创建后,使用start()方法才是启动一个新的线程,不能直接调用Thread子类中重写的方法run()

2.2 实现Runnable接口

仔细看一下继承的父类Thread,会发现它实现了接口Runnable,我们也可以直接实现Runnable接口:

package com.czhao.test.mutithread;

/**
 * @author zhaochun
 */
public class RunnableTest {
    public static void main(String[] args) {
        // 将一个 Runnable 对象作为Thread的构造参数
        Thread subThread = new Thread(new Printer());
        // 调用Thread对象的 start 方法,注意不能调用 run 方法
        subThread.start();
        System.out.println("Print in mainThread.");
    }

    // 定义一个实现了 Runnable 接口的类
    static class Printer implements Runnable {
        // 实现 run 方法
        @Override
        public void run() {
            System.out.println("Print in Runnable.");
        }
    }
}

2.3 实现Callable接口

Runnable接口的run方法是没有返回值的。当我们需要子线程运行结束后提供一个返回值时,就需要用到Callable接口。Callable的返回值支持泛型,但因为它的返回值是异步返回的,因此无法直接在主线程中获取返回值,而是配合Future接口或FutureTask类来获取返回值。

2.3.1 使用Future获取Callable返回值

package com.czhao.test.mutithread;

import java.time.LocalDateTime;
import java.util.concurrent.*;

/**
 * @author zhaochun
 */
public class CallableTest {
    public static void main(String[] args) {
        CallableTest me = new CallableTest();
        me.testFuture();
    }

    private void testFuture() {
        // Callable实现类不能直接作为Thread构造参数传入,这里使用线程池来提交一个Callable任务
        ExecutorService executor = Executors.newSingleThreadExecutor();
        // 通过submit方法向线程池提交Callable任务,submit方法返回的是Future对象
        Future future = executor.submit(new Printer());
        // 线程池不再接收新的任务
        executor.shutdown();
        System.out.println("Print in mainThread.");
        try {
            // future.get()获取子线程的运行结果,如果子线程此时尚未运行结束,则主线程在该步骤会等待直到子线程结束返回结果
            System.out.println(future.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

    // 定义一个实现了 Callable 接口的类,并指定返回值类型
    class Printer implements Callable {
        // 实现 call 方法,并返回指定类型的值
        @Override
        public LocalDateTime call() throws Exception {
            System.out.println("Print in Callable.");
            return LocalDateTime.now();
        }
    }
}

Future接口是用来获取目标线程执行结果的接口,通常和Callable一起通过线程池来使用。线程池技术在后续章节会有梳理,这里了解即可。

Future接口定义了几个简单的方法:

// 试图取消一个线程的执行,mayInterruptIfRunning表示是否尝试中断运行中线程,返回取消结果
// 线程未必能被取消,因为线程可能已经完成,已经取消过了,一直在运行中无法取消等等
boolean cancel(boolean mayInterruptIfRunning)

// 线程是否已被取消
boolean isCancelled()

// 任务是否已经完成
boolean isDone()

// 尝试获取目标线程运行的返回值,即Callable的返回值,目标线程没有结束的话当前线程会等待目标线程执行结束
V get()

// 限时的get
V get(long timeout, TimeUnit unit)

2.3.2 使用FutureTask获取Callable返回值

package com.czhao.test.mutithread;

import java.time.LocalDateTime;
import java.util.concurrent.*;

/**
 * @author zhaochun
 */
public class CallableTest {
    public static void main(String[] args) {
        CallableTest me = new CallableTest();
        me.testFutureTask();
    }

    private void testFutureTask() {
        // Callable实现类不能直接作为Thread构造参数传入,而是需要包装一层FutureTask将其转为Runnable接口
        FutureTask futureTask = new FutureTask<>(new Printer());
        Thread subThread = new Thread(futureTask);
        subThread.start();
        System.out.println("Print in mainThread.");
        try {
            // 在主线程中获取子线程执行结束后返回的结果,这里是LocalDateTime类型的时间戳。
            // 要注意的是,如果子线程此时尚未运行结束,则主线程执行futureTask.get()时会等待,一直到子线程结束返回结果。
            System.out.println(futureTask.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

    // 定义一个实现了 Callable 接口的类,并指定返回值类型
    class Printer implements Callable {
        // 实现 call 方法,并返回指定类型的值
        @Override
        public LocalDateTime call() throws Exception {
            System.out.println("Print in Callable.");
            return LocalDateTime.now();
        }
    }
}

FutureTask是一个实现类,它实现了RunnableFuture接口,而RunnableFuture接口继承了Runnable接口和Future接口。所以FutureTask能够作为Thread的构造参数,同时也可以用来获取目标线程执行结果。Future本身只是接口,要实现它的方法比较复杂,而有了FutureTask就降低了使用Future的难度。Future要结合线程池来使用,而FutureTask既可以与线程池配合使用,也可以直接作为Thread的构造参数使用,更加方便。

2.4 创建线程小结

本节讲的是线程的创建,有三种方式:

  • 继承Thread
  • 实现Runnable
  • 实现Callable

注意,以上三种方式是Java目前仅有的三种创建线程的方法,但仅仅只是创建线程的方法,并没有涉及到线程的完整生命周期的管理。特别要注意的是,线程创建后,并不是就会立即启动执行,那需要后续的thread.start()或者提交到线程池中去,后续章节会有介绍。

三、线程状态及相互转换

Java中的线程状态与操作系统的线程状态并不相同。操作系统的线程状态是以CPU为视角划分的,而Java的线程是以JVM为视角划分的。它们之间的关系,以及各个状态之间的转换关系,如下图所示:

Java多线程梳理之一_多线程开发入门_第2张图片

本节将对操作系统线程状态和JVM线程状态进行梳理。

3.1 操作系统线程状态及其转换

大部分操作系统都将线程状态大致划分为newreadyrunningwaitingterminated五种状态。

很多资料将这种状态划分称为进程状态,主要是因为早期操作系统只有进程没有线程,或者说是 单线程进程;而且后来线程出现后,像Linux这样的操作系统实际上并没有单独再实现线程,而是将线程实现为"轻量级进程",对Linux内核来说,进程和线程的数据结构是一样的,甚至可以说,对Linux来说进程和线程的界限是模糊的。

这五种状态中比较重要的是readyrunningwaiting这三种状态。下面以现代主流操作系统(如linux)为例,看一下这三种状态的转换。

  • 线程创建后会进入ready就绪状态。
  • 操作系统将CPU时间切分为一个一个连续的周期,一般在10~20ms,然后按照这个分片轮转地选择就绪状态的线程去执行,被选择的线程进入running状态。
  • 当一次时间分片结束,操作系统会发出一个中断信号(interrupt),通知CPU中断当前running状态的线程,将其退回就绪状态,并重新从就绪状态的线程中选择一个执行。即CPU切换线程。
  • 当某个running的线程执行到IO操作(比如读写磁盘)时,该线程会退出CPU,因为此时不再由CPU执行它,而是磁盘执行它;从CPU的角度而言,它进入了waiting等待/阻塞状态;习惯上我们称这种状态为IO阻塞,但其实对线程而言只是执行它的地点从CPU换到了磁盘或其他IO设备上了而已。此时CPU当然会选取其他就绪线程去执行以避免CPU资源浪费。
  • 当IO操作结束时,对应线程从waiting状态变为就绪状态,操作系统也会给CPU发出一个中断信号(interrupt),通知CPU再次切换线程。

当然这5种状态只是一个抽象或者说总结,实际上不同的操作系统对线程状态的划分和命名不尽相同。比如waiting,在Linux操作系统中,就对应着S(浅度睡眠)、D(深度睡眠)、T(暂停态)等状态,总之我们将其理解为还可能再次运行的,目前因为IO阻塞等原因不占用CPU的线程状态即可。

3.2 Java线程的6个状态

Thread.State这个枚举中可以看到Java定义的6种线程状态:

  • NEW
  • RUNNABLE
  • BLOCKED
  • WAITING
  • TIMED\_WAITING
  • TERMINATED

其中,NEWTERMINATED很简单,分别是还没有启动的线程状态,和已经结束的线程状态。它们与操作系统层面对应的线程状态是一致的。

但其他状态并不与操作系统线程状态一一对应,而是更为复杂的一种对应关系:

  • RUNNABLE对应着操作系统层面的readyrunning和IO阻塞时的waiting线程状态;
  • BLOCKEDWAITINGTIMED_WAITING这三个JVM线程状态则都对应着操作系统层面的waiting线程状态,它们在操作系统层面进入waiting状态不是由操作系统层面的IO阻塞或者某些事件引起的,而是JVM层面的某些语法主动引起的。

下面我们分别来看一下这几个状态以及它们之间的转换。

3.2.1 RUNNABLE状态

JavaDoc中对RUNNABLE状态的说明:

A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.

处于RUNNABLE状态的线程正在Java虚拟机中执行,但它可能正在等待来自操作系统的其他资源,如处理器。

对于JVM来说,不管是CPU,还是磁盘,网卡,都是资源,即使CPU不在执行这个线程,也有其他硬件正在执行这个线程。因此操作系统从CPU视角划分的readyrunning和IO阻塞引起的waiting状态对于JVM中的线程来说,都属于RUNNABLE状态;线程要么是就绪状态,要么就是正在被CPU/磁盘/网络接口等硬件执行着,所以都是RUNNABLE的。这种设计的根本原因在于,Java的主流JVM比如HotSpot,目前它的线程模型采用的是内核线程模型,每个线程实际上都和内核线程1:1对应,Java仅仅在内核线程外面封装了一层而已,对线程的创建/调度/销毁等操作实际都依赖于操作系统。JVM本身并不负责调度线程,所以很自然的,操作系统层面的readyrunningwaiting这三种线程状态对JVM来说就没有意义。

所以Java中有以下现象:

  1. 使用Thread.yield()方法不会导致Java线程状态变化。它只是让当前线程在操作系统层面从running退到ready,让CPU切换线程执行;并且CPU有可能又选择了这个刚退到running状态的线程来执行。这个过程中,Java线程状态将一直保持在RUNNABLE
  2. 当一个线程执行到IO语句时,对于操作系统来说,这个线程确实已经进入了IO阻塞waiting状态,但对于JVM来说,线程仍处于RUNNABLE状态;在IO结束时,操作系统将该线程变为ready就绪状态,但对于JVM来说,线程仍处于RUNNABLE状态;最后,如果CPU给面子选择了这个刚结束IO进入就绪状态的线程来执行,但对于JVM来说,线程仍处于RUNNABLE状态。

3.2.2 BLOCKED状态

Java线程状态BLOCKED阻塞状态,跟前面讲的IO阻塞不是一回事,它特指被Java的synchronized块或方法所阻塞的状态。实际上就是试图抢占synchronized锁失败的话,线程就会进入BLOCKED状态。

关于 synchronized锁,将在后面的章节中详细梳理,这里了解它是Java在语言层面实现的锁,用于同步代码块或方法即可。

JavaDoc中对BLOCKED状态的说明:

A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling {@link Object#wait() Object.wait}.

处于BLOCKED状态的线程正在等待获得监视器锁来进入一个synchronized代码块或方法;
或者这个线程之前获得过一个synchronized代码块或方法的监视器锁,但它后来通过wait方法释放了这个锁,并到期了或被其他线程的notify唤醒了,现在也处在BLOCKED状态,等待重新获得该锁以再次进入这个synchronized代码块或方法继续执行。

这段英文及翻译比较难懂,我们用两段代码来说明:

第一段代码:

private void testEnterBlocked() {
    class EchoPrinter {
        // synchronized修饰实例方法,则锁为 this,即EchoPrinter的一个实例
        public synchronized void echoPrint1() {
            Scanner scanner = new Scanner(System.in);
            System.out.println("我是胡汉三,我要准备跑路了,随便说点啥吧:");
            String content = scanner.nextLine();
            System.out.println(content);
        }

        public void echoPrint2() {
            System.out.println("我是潘冬子,准备抢锁,按导演的计划,我会失败...");
            // 使用 this 作为锁,即EchoPrinter的一个实例
            synchronized (this) {
                System.out.println("我是潘冬子,我抢到锁了...");
            }
        }
    }
    // 因为锁是EchoPrinter的一个实例,这里需要先生成实例
    EchoPrinter echoPrinter = new EchoPrinter();

    // 创建线程1并启动,线程1将运行echoPrint方法
    Thread t1 = new Thread(echoPrinter::echoPrint1);
//    t1.setDaemon(true);
    t1.start();

    // 主线程等待1秒钟,以确保线程1启动
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 创建线程2并启动,线程2将运行print方法
    Thread t2 = new Thread(echoPrinter::echoPrint2);
//    t2.setDaemon(true);
    t2.start();

    // 主线程等待1秒钟,以确保线程2启动
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 在主线程输出线程1的状态,由于echoPrint方法里有从System.in读取控制台输入的语句,会在这里陷入操作系统层面的IO阻塞,但JVM中线程状态仍然是RUNNABLE
    System.out.println(String.format("线程 %s 的状态 %s", "胡汉三", t1.getState().toString()));
    // 在主线程输出线程2的状态,由于print方法内部也使用this作为锁,而该锁目前仍然被线程1占用着,所以线程2获取锁失败而进入BLOCKED状态。
    // 注意线程2的BLOCKED状态是JVM线程状态,与线程1在操作系统层面的IO阻塞状态并不相同。
    System.out.println(String.format("线程 %s 的状态 %s", "潘冬子", t2.getState().toString()));

}

第一段代码解释了什么叫A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method,运行期间的线程状态变化如下图所示,注意潘冬子线程第一次尝试抢占锁失败后的状态:

Java多线程梳理之一_多线程开发入门_第3张图片

第二段代码:

private void testReenterBlocked() {
    class EchoPrinterAgain {
        // synchronized修饰实例方法,则锁为 this,即EchoPrinter的一个实例
        public synchronized void echoPrint1() {
            Scanner scanner = new Scanner(System.in);
            System.out.println("我是胡汉三,我要准备跑路了,随便说点啥吧:");
            String content = scanner.nextLine();
            System.out.println(content);
            try {
                // 让出锁,当前JAVA线程进入WAITING状态
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("我胡汉三又回来了。。。over。。。");
        }

        public void echoPrint2() {
            System.out.println("我是潘冬子,准备抢锁,按导演的计划,我会失败...");
            // 使用 this 作为锁,即EchoPrinter的一个实例
            synchronized (this) {
                System.out.println("我是潘冬子,我抢到锁了...");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("我是潘冬子,导演让我喊胡汉三回来...");
                this.notifyAll();
                System.out.println("我是潘冬子,我唤醒了胡汉三,等我休息三秒...");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("我是潘冬子,我即将退出舞台。。。");
            }
        }
    }

    // 因为锁是EchoPrinter的一个实例,这里需要先生成实例
    EchoPrinterAgain echoPrinterAgain = new EchoPrinterAgain();

    // 创建线程1并启动,线程1将运行echoPrint方法
    Thread t1 = new Thread(echoPrinterAgain::echoPrint1);
    t1.start();

    // 主线程等待1秒钟,以确保线程1启动
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // 创建线程2并启动,线程2将运行print方法
    Thread t2 = new Thread(echoPrinterAgain::echoPrint2);
    t2.start();

    // 主线程每隔一秒打印一次两个子线程的状态
    while (true) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(String.format("线程 %s 的状态 %s", "胡汉三", t1.getState().toString()));
        System.out.println(String.format("线程 %s 的状态 %s", "潘冬子", t2.getState().toString()));

        if (Thread.State.TERMINATED.equals(t1.getState())
                && Thread.State.TERMINATED.equals(t2.getState())) {
            break;
        }
    }
}

第二段代码解释了什么叫A thread in the blocked state is waiting for a monitor lock to reenter a synchronized block/method after calling {@link Object#wait() Object.wait},运行期间的线程状态变化如下图所示,注意胡汉三线程如何通过wait方法释放锁,又如何重新获得锁继续执行的:

Java多线程梳理之一_多线程开发入门_第4张图片

上述代码中还有 Thread.sleep(long)导致线程进入 TIMED_WAITING状态,以及 wait()导致线程进入 WAITING状态,这两种状态接下来继续梳理。

3.2.3 WAITING状态

根据JavaDoc的描述,当一个线程执行以下方法时,会进入WAITING状态:

  • 不带时限的Object.wait方法
  • 不带时限的Thread.join方法
  • LockSupport.park方法

处于该状态的线程只能无限期等待另一个线程执行一个特别的动作来唤醒该线程进入RUNNABLE状态。所谓特别动作包括:

  • 一个调用了锁对象的Object.wait方法的线程会等待另一个线程调用同一个锁对象的Object.notify()Object.notifyAll(),notify随机唤醒一个在该锁对象上WAITING的线程,notifyAll唤醒在该锁对象上WAITING的所有线程。
  • 一个调用了Thread.join方法的线程只能一直处于WAITING状态直到指定的线程执行结束,一般用于主线程获取子线程执行结果的场景。
  • 一个调用了LockSupport.park方法的线程只能一直处于WAITING状态直到有其他线程对该线程执行LockSupport.unpark(thread)thread.interrupt()

对于wait/notify的使用,参考【2) BLOCKED状态】中的第二段代码即可。我们对其进行进一步的分析,看看为什么Java要在BLOCKED之外,又引入了WAITING这个状态。

首先思考一个问题:

  • 在什么样的场景下,一个同步代码块执行了一半却需要释放锁呢?

答案很简单,某些业务可能需要满足某种条件才能继续执行。而这种条件的达成,无法在当前线程完成,只能由其他线程完成。于是本线程释放锁,等其他线程完成相应操作,达成了条件后,本线程再继续执行。这其实是观察者模式的一种应用场景,而wait/notify也就总是成对出现。我们用一个自动贩卖机的例子来说明这个过程,请参考下图:

Java多线程梳理之一_多线程开发入门_第5张图片

此时我们明白了为什么会有wait方法用于在同步代码块执行了一半的时候释放锁。现在我们思考第二个问题:

  • 为什么执行wait方法的线程进入了WAITING状态而不是直接进入BLOCKED状态?

对于这个答案,我们用下图说明原因:

Java多线程梳理之一_多线程开发入门_第6张图片

这样,我们就明白了为什么要有WAITING状态。

wait/notify机制的完整使用示意,则如下图所示:

Java多线程梳理之一_多线程开发入门_第7张图片

关于wait/notify的最后一个问题:

  • notify还是notifyAll

一般建议用notifyAll。因为在一个对象锁上进入WAITING的线程可能不止一个,notifyAll将唤醒全部,让它们都脱离WAITING状态,而notify只会随机唤醒其中一个。所以如果是多个线程等待相同条件才能继续,而只有一个线程能达成该条件且只做一次时,notify将导致有些WAITING线程一直处于WAITING状态。

对于Thread.join方法,它就是让当前线程等待目标线程执行结束后再执行,代码示例如下:

    private void testJoin() {
        class Poet implements Runnable {
            @Override
            public void run() {
                System.out.println("诗人:先喝点小酒。。。");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                String line = "诗人:大海啊,都是水~~~";
                System.out.println(line);
            }
        }

        Thread poet = new Thread(new Poet());
        poet.start();

        System.out.println("观众:翘首以待。。。");
        try {
            // 当前线程进入WAITING状态,一直到poet线程执行结束。
            poet.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("观众:这样的诗,我每天能写一箩筐。。。");
    }

对于LockSupport.park机制,实际是对wait/notify的一种改善。wait/notify有以下问题:

  1. wait必须在notify之前执行,否则wait的线程无法被唤醒;
  2. 执行wait与notify的线程必须占有锁,且是同一把锁。

LockSupport.park机制则可以先执行LockSupport.unpark,再执行LockSupport.park;也不需要同步代码块。代码示例如下:

private void testLockSupport() {
    class EchoPrinterAgain {
        public void echoPrint1() {
            Scanner scanner = new Scanner(System.in);
            System.out.println("我是胡汉三,我要跑路了。。。");
            // 暂停当前线程,进入WAITING状态
            LockSupport.park();
            if (Thread.currentThread().isInterrupted()) {
                System.out.println("我胡汉三回来了。。。有人 interrupt。。。");
            } else {
                System.out.println("我胡汉三回来了。。。有人 unpark。。。");
            }
        }
    }

    // 因为锁是EchoPrinter的一个实例,这里需要先生成实例
    EchoPrinterAgain echoPrinterAgain = new EchoPrinterAgain();

    // 创建线程1并启动,线程1将运行echoPrint方法
    Thread t1 = new Thread(echoPrinterAgain::echoPrint1);
    t1.start();

    // 尝试在子线程LockSupport.park之前先LockSupport.unpark
//        LockSupport.unpark(t1);
//        System.out.println("先执行了 LockSupport.unpark(t1)");

    // 主线程等待1秒钟,以确保线程1启动
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    System.out.println(String.format("线程 %s 的状态 %s", "胡汉三", t1.getState().toString()));
    // 唤醒t1
    LockSupport.unpark(t1);
//        t1.interrupt();
}

其实LockSupport.park也不是很好用,因为parkunpark/interrupt仍要保证在不同线程中成对出现。如果多了一个park,就会导致线程一直WAITING下去了。事实上,我们在实际开发中也几乎不会自己直接使用LockSupport。但很多直接供我们使用的多线程开发相关类库,都在底层使用了LockSupport,比如后面章节介绍的JUC锁ReentrantLock,它在试图获取锁时,底层使用的就是LockSupport

另外注意,synchronized以外的锁在获取锁失败时线程并不会进入BLOCKED状态,而是进入WAITING状态。

3.2.4 TIMED\_WAITING状态

TIMED_WAITING状态就是WAITING状态增加了一个闹钟,一旦时间到了还没有其他线程唤醒自己,就自己醒来回到RUNNABLE状态。

按JavaDoc的定义就是:

带指定等待时间来等待的线程所处的状态。由以下方式可以进入TIMED_WAITING状态:

  • Thread.sleep :前面的代码示例中已经多次使用。
  • 带时限(timeout)的Object.wait
  • 带时限(timeout)的Thread.join
  • LockSupport.parkNanosLockSupport.parkUntil

除了Thread.sleep,其他都是WAITING的对应方法的带时限版本。

注意, Thread.sleep的时间参数传入0就是真的不sleep;而其他wait之类的,timeout传入为0的话,就是无限等待的意思。

3.3 线程中断interrupt

Java中的线程是不能被外部强行中断或停止的,只能由线程自己自行停止。但Java依然提供了线程中断机制,在Thread类里提供了以下方法:

// 将该线程的中断标志位设为true
public void interrupt()

// 获取当前线程中断标志位,并将其恢复为`false`
public boolean isInterrupted()

这两个方法中,Thread.interrupt的作用是通知线程,你最好停下来,但是线程可以完全不予理会。。。

具体来说,如果我们调用一个线程的interrupt方法,那么:

  • 如果目标线程正好处于WAITINGTIMED_WAITING状态,即由sleepwaitjoin等等方法导致的等待状态中,那么目标线程会立即退出等待状态,并抛出一个InterruptedException异常。但作为Java程序员的你,应该不会因为这个异常就退出程序运行。通常你都会无视它,虽然你必须在代码里处理这个受检查异常,但往往只是打一个日志就完了,后面该干嘛干嘛。。。
  • 如果目标线程处于正常RUNNABLE状态,那么仅仅只是该线程的中断标志被设为true而已,如果正常业务逻辑中没有对线程中断标志做判断处理,那么就会无视interrupt。。。
  • 如果目标线程处于BLOCKED状态,也不会响应interruptBLOCKED状态只是获取synchronized锁失败时进入的阻塞状态,不是WAITINGTIMED_WAITING状态。所以作为Java程序员的你,从来不需要在使用synchronized锁时还要处理烦人的受检查异常InterruptedException

那么,正常处理逻辑中,怎么处理interrupt呢?

  • 在正常业务逻辑的某些合适的点,添加对Thread.interrupted()的判断,这个方法会获取当前线程中断标志位,并将其恢复为false
  • 在处理受检查异常时给个面子,该结束处理就不要恋恋不舍了,直接把异常往上抛或者直接return啥的。。。

给个例子:

Thread t1 = new Thread( new Runnable(){
    public void run(){
        // 如果没人中断你,就正常执行业务逻辑
        while(!Thread.currentThread.isInterrupted()){
            // 该干嘛干嘛
            doSomething();
        }

        // 结束业务处理。。。
        killMe();
    }
} ).start();

要注意的是,Java中的线程中断interrupt和操作系统CPU调度内核线程时的interrupt不是一个概念。操作系统的interrupt更接近它的字面意思,中断线程,让CPU切换线程;而Java里对JVM层面线程的interrupt就是个通知而已。

3.4 线程状态小结

Java的线程状态和操作系统的线程状态不可混为一谈,两者的视角是不一样的。但很多时候我们会将二者混淆着讲。比如讲线程因为磁盘IO而陷入阻塞,此时讲的是操作系统层面的线程状态而不是Java线程状态,这时候Java线程状态仍然是RUNNABLE;比如讲线程获取锁失败进入阻塞状态,此时特指Java层面的线程状态BLOACKED。大家要能够根据上下文分辨到底说的哪个层面的线程状态。

四、线程相关属性

Java线程除了线程状态,还有其他的一些属性,如线程名、是否守护线程、线程组、线程优先级等等。

线程名可以在创建线程的时候指定,但一般都不指定。

4.1 守护线程

守护线程的概念很简单,它不是线程状态,而是线程的一种属性。当一个线程被设置为守护线程时,只要主线程结束,该守护线程就会结束。

示例参考之前【3.2.2 BLOCKED状态】的第一段代码,将其中的t1.setDaemon(true);t2.setDaemon(true);从注释中释放出来,就会发现,主线程打印出t1和t2的状态后就会直接结束JVM,不会因为t1和t2尚在运行或阻塞中就也无法结束。

4.2 线程组和线程优先级

Java还为线程提供了线程组和优先级别。

ThreadGroup类来管理线程组,开发这可以使用线程组对线程进行批量控制。每个Thread必然存在于一个ThreadGroup中,如果没有显式指定,比如直接new Thread时没有传入ThreadGroup,那么默认将创建子线程的当前父线程的线程组设置为子线程的线程组。Java程序是从一个main方法开始的,线程组就是main

线程的优先级范围是1 ~ 10,它是一个参考值,最终线程的优先级还是由操作系统决定的,所以设置更高的优先级只能增加该线程被优先执行的几率,这很玄学。。。默认情况下,线程的优先级都是5。

通过以下代码确认:

public static void main(String[] args) {
    System.out.println("main主线程的ThreadGroup:");
    System.out.println(Thread.currentThread().getThreadGroup().getName());
    Thread t1 = new Thread();
    System.out.println("main主创建的子线程的ThreadGroup:");
    System.out.println(t1.getThreadGroup().getName());

    System.out.println("main主线程的优先级:");
    System.out.println(Thread.currentThread().getPriority());
    System.out.println("main主创建的子线程的默认优先级:");
    System.out.println(t1.getPriority());

    t1.setPriority(10);
    System.out.println("设置子线程的优先级:");
    System.out.println(t1.getPriority());

    Thread t2 = new Thread();
    t2.setDaemon(true);
    System.out.println("守护线程的默认优先级:");
    System.out.println(t2.getPriority());
}

Java提供线程组主要是为了统一控制线程的优先级以及检查线程权限等。作为开发基本不会用到。后续讲到线程池时,会再次遇到线程组和优先级。

五、多线程入门总结

Java多线程开发入门主要需要掌握以下知识点:

  1. 并发与并行的关系及区别。
  2. 什么是线程,以及,如何创建线程。
  3. JVM线程状态与操作系统的线程状态的对应关系。
  4. JVM线程状态相互间是如何转换的。

这些知识是后续学习多线程开发必须理解掌握的。

你可能感兴趣的:(java)