并发基础(四) - JAVA线程基础

209 阅读1小时+

并发编程——线程基础

0.1. 内容概要

1. 线程概述

2. 线程的启动和终止

3. 线程的属性及常用API

4. 线程的状态

5. 线程同步

6. 总结

0.2. 学习目标

  • 理解线程和进程、并发和并行等基本概念
  • 能够通过三种方式创建并启动线程
  • 能够正确终止线程
  • 能够描述出线程的状态
  • 理解监视器、锁、条件等概念
  • 能够应用监视器机制实现线程的互斥和协作
  • 能够使用线程本地(局部)变量

1. 线程概述

1.1. 什么是线程?

1.1.1. 线程的概念

线程(Thread),字面意思:线比喻像线一样的,按顺序依次执行的,程即过程。所谓线程是将一系列活动按照事先设定好的顺序依次执行的过程,是一系列指令的集合。线程是操作系统能够进行运算调度的最小单位,系统中会有很多的线程,每个线程执行不同的任务。你可以简单的理解为,一个线程就是系统中的一条执行路径一个线程就是用来执行一个任务的。 01.线程-任务.png

1.1.2. 进程的概念

线程不能单独存在,它必须被包含在**进程(Process)**之中,一个进程可以有多个线程。进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。进程的概念里有三个点要了解:第一,进程是一个独立功能的程序,意思是说,每一个进程都是一个应用程序;第二,进程是一次运行活动,指的是,进程是处于运行状态的应用程序,即所谓“活动的实体”,如果某个应用程序没有运行,它不是一个进程;第三,进程是一个实体,它有自己的独立的地址空间,用于存储代码、变量和指令等等,称之为“数据集合”。总之,进程是正在运行的程序的实例(an instance of a computer program that is being executed),是系统进行资源分配和调度的基本单位

03.Windows系统进程.png

1.1.3. 线程和进程的关系

操作系统安照进程分配和调度系统资源,每个进程拥有一个完整的虚拟地址空间,不同的进程的虚拟地址空间互不干扰,而同一进程的不同线程共享该进程的地址空间。线程与资源分配无关,它一定属于某个进程,并与该进程内的其它线程共享该进程的资源,从这一点上来看,进程是系统分配资源的基本单位,而线程是独立运行和独立调度的基本单位

抢占式(preemptive):抢占式调度系统给每一个可运行线程一个时间片(每个线程被分配运行的时间段,称为它的时间片,即该线程被允许运行的时间)来执行任务。当时间片用完, 操作系统剥夺该线程的运行权, 交给另一个线程运行机会。当选择下一个线程时, 操作系统考虑线程的优先级。

协作式(cooperative):协作式调度器在将CPU执行权交给其他线程前,会等待线程运行直到结束或阻塞,然后才会交给另外一个线程。一些早期的虚拟机或者专用系统,会使用这种方式。

由于线程比进程更小,几乎不拥有系统资源,调度它们的开销就小,所以,启动多个线程而不是进程能更高效的提高系统资源的利用率和吞吐量。线程可以认为是轻量级的进程,现代的操作系统都引入了线程,以便提高系统的并发性。

1.2. 并发编程的意义

1.2.1. 操作系统的并发性

02.计算机中的应用程序.png

操作系统的并发性,就是指系统在某个时间段内能够同时运行的最大任务数,这个指标直接反应了系统的处理能力。起初,CPU的核心只有一个,也就意味着,它只能同时并行一个任务,后来出现了多核心CPU和超线程技术——一个物理芯片可以模拟出两个逻辑内核,比如一个4核心8线程的CPU,最多就可以同时并行8个任务。事实上,由于一个CPU核心在同一个时间点上只能运行一道程序(一个线程),所以系统中大量的任务只能在不同的时间点交替执行,CPU核心在多个任务之间进行高速切换。CPU的效率极高,所以我们完全感受不到它在应用程序之间切换的间隔。

  • 并行:同一时间点上执行的任务数

    • 八达岭长城的城墙上,最宽处可五马并行
    • 职工食堂有4个窗口,那么该食堂打餐的并行量为4,即同一个时间点最多可以有4位职工打餐
    • 某火车站有8个站台,则该火车站可以同时并行8辆列车上下旅客(只考虑单面站台)
  • 并发:同一时间段内执行的任务数

    • 早高峰时段(7点-9点)通过某个路口的并发车辆数为325辆

    • 职工食堂有4个窗口,每个窗口每分钟可以给3个人打餐,那么在1小时的用餐时间内,该食堂窗口可支持的最大并发打餐职工数为:4 * 3 * 60 = 720人次

    • 某火车站有8个站台,每个站台每分钟上下旅客15人次,则该火车站每小时的最大旅客吞吐量为8 * 15 * 60 = 7200人次

并行关注的是某个时间点系统/设备处理任务的能力,并发关注的是单位时间段内系统/设备处理任务的量。

1.2.2. 并发编程的意义

多核心处理器无疑会使程序运行更快。在一个进程中启动多个线程,提高CPU的利用率,就可以大大提高应用程序的性能。并发编程的意义,就在于充分利用CPU的每个核心,达到最佳的处理性能,从而缩短应用程序的响应时间,达到提高系统或应用程序性能的目的。通俗的说,并发编程也是多线程编程(不专业的说法)。

1.2.3. 并发编程的业务应用场景

  • 并行化,面对复杂业务模型,并行程序(进行业务拆分)会比串行程序更适应业务需求
    • 迅雷的多线程下载,从数据文件的不同位置启动线程分阶段下载数据
    • 使用外卖平台下单时,多个没有直接依赖关系的子业务功能(查询用户、菜品、折扣等信息)可以并行
  • 异步化,部分业务优先级低,或不需要立即获取其执行结果,可以让该业务异步执行
    • 远程过程调用(RPC)或HTTP调用异步化
    • 转账后短信提醒延迟
    • 耗时的IO操作(后台传输)

1.2.4. 并发编程可能带来的风险

  • 安全性问题

    ​ 多线程并发时,操作共享数据可能会产生意料之外的结果

  • 活跃性问题(liveness)

    ​ 并发应用程序及时执行的能力称为其活跃性(A concurrent application’s ability to execute in a timely manner is known as its liveness)。当某个操作无法继续进行时,就会发生活跃性问题。

    • 死锁:进入等待或阻塞的线程长时间收不到唤醒信号或者获取不到资源,而造成永久阻塞
    • 饥饿:一个线程要访问并执行的共享资源被其它线程长期占用,而造成频繁阻塞
    • 活锁:两个或多个线程互相响应对方的活动,而无法恢复自己的工作
  • 性能问题

    • 线程过多时,会消耗大量内存,CPU花费在调度(在线程之间切换,即上下文切换)上的时间大大增加
    • 多线程并发常常不可避免的要使用同步机制,继而产生线程等待时间
    • 多线程并发时, 使用寄存器代替内存访问,缓冲到寄存器中的数据而未写回,则导致编译器的优化失效

2. 线程的启动和终止

2.1. 线程的启动

一个Thread就是一个线程。在Java程序中启动一个线程的方法有三种,分别是:

  • 继承java.lang.Thread
  • 实现java.lang.Runnable接口
  • 实现java.util.concurrent.Callable接口

三种方式各有不同,但归根结底,“一个Thread就是一个线程”的基本概念是不变的。接下来为你演示三种用法。

2.1.1. 第一种方式:继承Thread类

通过扩展自java.lang.Thread类来创建线程的方式非常简单,大体分为三步:

  1. 定义java.lang.Thread的子类SubThread,并重写run()方法,该方法就是线程需要单独执行的任务:

    public class SubThread extends Thread {
    	@Override
    	public void run() {
    		System.out.println("I am a sub-thread inherited from java.lang.thread");
    	}
    }
    
  2. 创建SubThread类的实例

    SubThread subThread = new SubThread();
    
  3. 通过start()方法启动线程

    subThread.start();
    

    线程运行过程输出结果如下:

    I am a sub-thread inherited from java.lang.thread
    

这里要注意的是,启动线程的方法是start,而不是run,直接调用run并不会启动新的线程。就像汽车启动时也是通过start按钮,而run代表的是该线程真正要执行的任务。start()方法最终会调用到run()方法去完成该线程的任务。

那么,在程序运行的时候,如果只考虑我们自己编写的代码,有几个线程?

答案是:两个。

一个是main方法所属的线程——新线程的启动者,另一个是新启动的线程subThread

2.1.2. 第二种方式:实现Runnable接口

由于继承的强耦合性,实现一个接口要比继承一个父类更容易支持扩展。实现Runnable接口这种方式与第一种类似,但是,实现了Runnable接口的类,并不是一个线程,要把它交给Thread的构造器,才能包装成一个线程,这就印证了前面提到的概念:一个Thread是一个线程。具体操作如下:

  1. 定义java.lang.Runnable接口的实现类RunnableThreadImpl,并重写run()方法

    public class RunnableThreadImpl implements Runnable {
    	@Override
    	public void run() {
    		System.out.println("I am a sub-thread that implements the interface from java.lang.Runnable");
    	}
    }
    
  2. 创建RunnableThreadImpl类的实例,并把它交给Thread的构造器,这一步是关键

    RunnableThreadImpl runnableThreadImpl = new RunnableThreadImpl();
    Thread runnableThread = new Thread(runnableThreadImpl); // 交给Thread的构造器
    
  3. 通过start()方法启动线程

    runnableThread.start();
    

    线程运行过程输出结果如下:

    I am a sub-thread that implements the interface from java.lang.Runnable
    

继承Thread类和实现Runnable接口来启动线程的差别不大,甚至,换句话说,无非是Java语言的extends和implements的区别:Java只支持类的单继承,却可以支持接口的多实现。在使用线程的时候,根据上述两种方式的区别进行使用即可,建议使用实现Runnable接口的方式,更方便扩展。

2.1.3. 第三种方式:实现Callable接口

启动一个线程,就是为了完成一个任务。很多时候,我们可能还需要关注任务的执行结果,就像方法有返回值一样,任务完成后,要把相应的处理结果返回给线程的启动者或者传递给下一个任务。所以,必须有一种办法来获取线程的结果,以便更好的进行下一步动作。

新启动的线程究竟什么时候完成任务往往是未知的,甚至无法确定准确时间——这种情况似乎是惯例。因此,我们把新线程要做的事情称为“未来的任务”或“将要发生的任务”,即“Future Task”。Java的设计者定义了java.util.concurrent.FutureTask类来描述这样的任务,它需要把任务的内容包装起来,然后再把包装后的对象传递给Thread的构造器。具体的操作如下:

  1. 定义java.util.concurrent.Callable接口的实现类CallableThreadImpl,并重写call()方法

    public class CallableThreadImpl implements Callable<String> {
    	@Override
    	public String call() throws Exception {
    		System.out.println("I am a sub-thread that implements the interface from java.util.concurrent.Callable");
            return "Called Result";
    	}
    }
    

    java.util.concurrent.Callable接口是一个泛型接口,其中的泛型,指代该线程返回的结果类型。如果任务执行不顺,可能会由于异常而终止,所以,call()方法的设计支持抛出异常,这也是与前两种启动线程方式的另一个不同之处。该接口源码如下:

    @FunctionalInterface
    public interface Callable<V> {
        /**
         * Computes a result, or throws an exception if unable to do so.
         *
         * @return computed result
         * @throws Exception if unable to compute a result
         */
        V call() throws Exception;
    }
    
  2. call()方法与前面介绍过的run()方法作用一致,都是要执行的任务的具体内容。接下来创建CallableThreadImpl类的实例,并用FutureTask包装起来,这样才是“将要发生的任务”,任务执行的结果会被封装到FutureTask实例中:

    CallableThreadImpl callableThreadImpl = new CallableThreadImpl();
    // 用FutureTask的构造器把“将要发生的任务”包装起来
    FutureTask<String> callableFutureTask = new FutureTask<>(callableThreadImpl);
    

    注意,FutureTask也是泛型类,它的泛型与Callable接口的泛型意义一致:线程返回结果的类型。FutureTask实现了java.lang.Runnable接口,并在重写的run()方法中调用了call()方法,所以,你完全有理由把这两种启动线程的方式看成是一样的。

04.Runable和Callable的区别.png

  1. 最后,像Runnable的实现方式一样,把FutureTask的实例交给Thread,并通过start()方法启动线程,因为前面介绍过:只有Thread才是一个线程:

    Thread subThreadCallable = new Thread(callableFutureTask); // 交给Thread的构造器
    subThreadCallable.start(); // 启动线程
    

    线程运行过程输出结果如下:

    I am a sub-thread that implements the interface from java.util.concurrent.Callable
    
  2. 获取任务的执行结果,要通过FutureTask的实例,而不是Thread,因为Thread只负责线程相关的操作,具体业务的执行数据它不关心。通过FutureTask实例的get()方法来获取线程的执行结果:

    // 通过FutureTask实例的get()方法来获取任务的执行结果
    String result = callableFutureTask.get();
    System.out.println(result);
    

    线程运行结果输出如下:

    Called Result
    

一般情况下,启动一个线程,有前两种方式就够了。如果你有必要获取某个任务的执行结果,可以使用第三种方式。这里要注意的是,FutureTask实例的get()方法在获取到数据之前,是阻塞的。由于新线程执行完毕的时间是不可预期的,如果你在启动了线程之后立即调用get()方法获取结果,在新线程结束之前,get()方法将一直等待,这样做的后果,恐怕你的程序依然是串行的,新起线程并不能提高效率。

每当你要新启动一个线程的时候,首先要想到的是:只有一个Thread才是一个线程。所以,无论你使用上述三种方式的哪一种,最终都是以Thread类的实例调用start()方法来启动的。在实际编码中启动一个线程非常简单,根据三种方式的特点推荐使用的优先级如下:

  1. 实现Runnable接口;
  2. 实现Callable接口;
  3. 继承Thread类。

2.1.4. 面试题:start和run方法的区别

前面提到过,在通过调用 start() 方法启动线程之后,程序最终会调用 run() 方法执行具体的任务代码,启动线程的方式是 start() 而不是 run()。为什么这么设计,调用这两个方法的区别又是什么呢?

先说结论:JVM在真正启动线程之前需要做一些准备工作,比如创建系统线程、设置线程类型和状态、分配线程栈等等,然后才能执行任务。

产生这种情况的根本原因在于,Java中的Thread并不是真正的“线程”,启动线程依赖于JVM的宿主系统。对于操作系统来说,JVM只不过是一个应用程序(进程),所以JVM要想启动新的线程,其实是向操作系统申请分配并创建线程,然后将其跟Java的Thread实例关联起来,这一切的过程都在 start() 之后,run() 之前完成,这就是两个方法的本质区别。

参考 start() 方法的源码:

	/**
     * Causes this thread to begin execution; the Java Virtual Machine
     * calls the {@code run} method of this thread.
     * <p>
     * The result is that two threads are running concurrently: the
     * current thread (which returns from the call to the
     * {@code start} method) and the other thread (which executes its
     * {@code run} method).
     * <p>
     * It is never legal to start a thread more than once.
     * In particular, a thread may not be restarted once it has completed
     * execution.
     *
     * @throws     IllegalThreadStateException  if the thread was already started.
     * @see        #run()
     * @see        #stop()
     */
public synchronized void start() {
    /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */
    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
            /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
        }
    }
}

private native void start0();

start() 的注释清楚地表明会在线程启动后调用 run() 方法,它代码的核心就是调用本地方法 start0(),而这个本地方法是用 C语言编写的,经过一系列调用,最终会回到 run() 方法,下图显示了调用过程:

05.start调用run方法的底层原理.png

(图示的源码在我为你提供的资料中可以找到)

如果Thread类中的 run() 被子类重写,则直接执行子类的内容;如果没有被重写,则检查是否在实例化Thread对象的时候传递了Runnable的实例给属性target,若有,则执行其 run() 方法,否则什么也不做:

/**
  * If this thread was constructed using a separate
  * {@code Runnable} run object, then that
  * {@code Runnable} object's {@code run} method is called;
  * otherwise, this method does nothing and returns.
  * <p>
  * Subclasses of {@code Thread} should override this method.
  *
  * @see     #start()
  * @see     #stop()
  * @see     #Thread(ThreadGroup, Runnable, String)
  */
@Override
public void run() {
    if (target != null) { // target是Runnable接口的实例,经由Thread构造器传入
        target.run();
    }
}

这就是为什么启动线程是 start() 而不是 run()

2.1.5. 方法摘要

  1. void start() 启动一个线程。线程启动后会调用其run()方法。结果是并发两个线程:调用start()方法的代码所属的线程和新启动执行run()方法的线程。一个线程不能被重复启动——即使它已经终止,否则会抛出非法线程状态异常:java.lang.IllegalThreadStateException
  2. void run() 线程运行过程中真正要执行的工作。线程经由start()方法启动后,会调用此方法。
    • 继承Thread类:Thread的所有子类都应该重写此方法,否则该线程启动后将立即返回。
    • 实现Runnable接口:Runnable的实例通过Thread的构造器赋值给成员target,并在此方法中调用该实例的run()方法。调用顺序:start() -> Runnable.run()。
    • 实现Callable接口:Callable的实例封装到FutureTask(Runnable的实现类),然后将FutureTask的实例通过Thread类的构造器赋值给成员target,最终调用target的run()方法。调用顺序:start() -> FutureTask.run() -> Callable.call()。
  3. <V> get() 通过此方法获取线程运行的结果。在定义Callable子类的时候指定线程的返回类型。此方法在获取到数据之前,一直阻塞。

2.2. 线程的终止

当线程正常运行完毕(run方法经由return语句返回),就会自动终止,并释放相关的资源,这种情况我们称之为自然终止。如果某个线程在运行过程中发生了未经捕获的异常,也会被JVM终止,这种情况叫异常终止。一般情况下异常终止并不是我们想看到的结果,未经处理的异常可能会给用户带来糟糕的体验,我们无法对此视而不见。如何正确的终止线程,让每个正在运行的线程实例都能够优雅的功成身退,并以最恰当的方式释放系统资源,是非常重要的事情。

2.2.1. 被弃用的stop方法

除了前面提到的两种导致线程终止的情况外,早期的Java版本还提供了Thread.stop()方法强制终止线程,但是这种方法是不安全的,该方法已从JDK1.2起被标记为过时(参见下面源码)。执行stop方法会通过抛出一个未经检查的ThreadDeath异常终止该线程所有正在运行的方法(包括run),当线程被终止,会立即释放它持有的所有监视器(内部对象锁,后面介绍),数据得不到同步的处理,对象被破坏了。如果先前受这些监视器保护的任何对象处于不一致状态,则受损对象对其他线程可见,可能导致任意行为。令人沮丧的是,当一个线程要终止另一个线程时,无法确切的知道什么时候调用stop 方法是安全的,什么时候导致对象被破坏,因此,该方法被弃用了。

@Deprecated(since="1.2")
public final void stop() {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        checkAccess();
        if (this != Thread.currentThread()) {
            security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
        }
    }
    // A zero status value corresponds to "NEW", it can't change to
    // not-NEW because we hold the lock.
    if (threadStatus != 0) {
        resume(); // Wake up thread if it was suspended; no-op otherwise
    }

    // The VM can handle all thread states
    stop0(new ThreadDeath());
}

所以,没有可以强制线程终止的方法。

2.2.2. 请求中断线程:interrupt方法

然而,却可以请求中断线程。

stop方法的不安全、系统资源无法正确释放,导致浪费和性能的下降。stop()方法过于暴力,Java的设计者希望以一种更温柔的方式中断线程,以确保在中断的同时,有足够的时间进行清理工作,资源能够被正确的释放,从而达到预期的效果。

线程对象的interrupt()方法可以用来请求中断线程,但是调用该方法并不一定会立即中断该线程,究竟什么时候、以什么方式中断,由线程自己决定。因为没有任何语言方面的需求要求一个线程应该立即终止,所谓“中断请求”不过是引起它的注意,该线程可以自己决定如何响应中断。某些线程如此重要以至于不会理会中断请求而继续执行,比如下面的代码,将永远不会响应中断:

Runnable runnableThreadImpl = () -> {
    while (true) {
        System.out.println("Runnable-thread");
    }
};
Thread runnableThread = new Thread(runnableThreadImpl);
runnableThread.start();
runnableThread.interrupt(); // 请求中断线程,但该线程不会响应中断请求

输出结果(永久打印):

Runnable-thread
Runnable-thread
Runnable-thread
...

事实上,interrupt()方法只是把该线程的中断状态设置为true,它并不能真的中断线程。如果某个线程需要响应中断,那么该线程在设计之初就应该加上配合interrupt()方法的代码。设计这样的代码的思路就是,时不时的检查线程的中断状态,以决定何时以及如何响应中断请求。isInterrupted()方法可以判断该线程当前的中断状态,通过该状态决定如何进行下一步操作来响应中断。对上面的代码稍作改进:

Runnable runnableThreadImpl = () -> {
    Thread currentThread = Thread.currentThread(); // 当前线程的实例
    while (!currentThread.isInterrupted()) { // 线程的中断状态默认为false
        System.out.println("Runnable-thread");
    }
    System.out.println("线程的中断状态:" + currentThread.isInterrupted());
};
Thread runnableThread = new Thread(runnableThreadImpl);
runnableThread.start();
// 使用sleep方法使主线程休眠20ms
Thread.sleep(20);
runnableThread.interrupt(); // 请求中断线程

输出结果:

...
Runnable-thread
Runnable-thread
Runnable-thread
线程的中断状态:true

线程被成功“中断”,并按照合理的代码执行顺序退出。每个线程都应该不时的检查它的中断状态标识,以判断是否被中断继而进行响应。

如果一个线程检测到自己的中断标识被设置为true之后,由于种种因素并不打算立即响应而是继续执行任务,那么清除标志位的动作就是必要的——以便下次响应中断。Thread类的静态方法interrupted()也可以用来判断线程的中断状态,而且,该方法会清除中断标识为false,用以满足复杂的中断需求:

Runnable runnableThreadImpl = () -> {
    Thread currentThread = Thread.currentThread(); // 当前线程的实例
    while (!Thread.interrupted() && /* 其它条件 */) { // 使用静态方法判断中断状态
        System.out.println("Runnable-thread");
        // TODO 清理工作...
    }
    System.out.println("线程的中断状态:" + currentThread.isInterrupted());
};
Thread runnableThread = new Thread(runnableThreadImpl);
runnableThread.start();
// 使用sleep方法使主线程休眠20ms
Thread.sleep(20);
runnableThread.interrupt(); // 请求中断线程

为确保线程仍然能够正确的响应中断请求,while的判断条件可能会增加很多,实际编码中根据业务需求进行设计即可。

值得注意的是,处于阻塞状态的线程,无法检测中断状态。那它们该如何响应interrupt()方法的中断请求呢?非常庆幸,导致线程阻塞的方法——wait()、sleep()、join()及它们的重载方法都会抛出InterruptedException异常并清除线程的中断状态。如果该线程有必要对中断请求做出响应,可以捕获该异常,并在处理的过程中重新请求中断:

Runnable runnableThreadImpl = () -> {
    Thread currentThread = Thread.currentThread(); // 当前线程的实例
    while (!currentThread.isInterrupted()) {
        try {
            System.out.println("Runnable-thread");
            Thread.sleep(200); // 线程休眠,此时该线程处于阻塞状态
        } catch (InterruptedException e) {
            System.out.println("InterruptedException is thrown.");
            currentThread.interrupt(); // 捕获异常,并再次调用请求中断
        }           
    }
    // 在catch代码块中捕获InterruptedException之后再次请求中断,则最终线程中断状态为true
    System.out.println("线程的中断状态:" + currentThread.isInterrupted()); // true
};
Thread runnableThread = new Thread(runnableThreadImpl);
runnableThread.start();
// 使用sleep方法使主线程休眠20ms
Thread.sleep(20);
// 请求中断处于阻塞状态的线程,该线程内部将抛出InterruptedException异常
runnableThread.interrupt();

这里再强调一下,处于阻塞状态的线程,被前面提到的导致线程阻塞的方法抛出的InterruptedException异常中断其阻塞调用,并清除该线程的中断状态为false,所以有必要重新调用interrupt()方法请求中断,这是处理阻塞调用进行中断线程正确并推荐的做法。

2.2.3. 方法摘要

  1. void interrupt() 向线程发送中断请求,线程的中断状态设置为true。如果该线程目前正在被阻塞调用,则抛出InterruptedException异常,并中断其阻塞调用。
  2. boolean isInterrupted() 判断线程是否中断。不会改变线程的中断状态。
  3. static boolean interrupted() 判断线程是否中断。将线程的中断状态重置为false。
  4. static Thread currentThread() 获取当前线程的实例
  5. static native void sleep(long mills) 使当前线程休眠指定的毫秒数。线程睡眠期将处于阻塞状态,被打断将抛出InterruptedException异常。

3. 线程的属性及常用API

3.1. 线程的属性

属性方法签名描述
long getId()获取线程的唯一标识,在构造线程对象时对该属性赋值
String getName() / void setName(String)获取/设置线程的名称
int getPriority() / void setPriority(int)
static int MIN_PRIORITY=1;
static int MAX_PRIORITY=10;
static int NORM_PRIORITY=5;
获取/设置线程的优先级(不可靠)。
线程的最小优先级;
线程的最大优先级;
线程的默认优先级;
void setDaemon(boolean)设置线程对象为守护线程或者用户线程

3.1.1. 线程的优先级

Java中的运算符之间有优先级,同样的道理,线程之间也有优先级。线程的优先级是从1-10的数字,默认值为5。可以使用setPriority()方法调整线程的优先级。调整线程的优先级即决定线程的执行顺序,这样可以使多线程程序更加合理的使用CPU资源,减少冲突。理论上讲,当线程调度器有机会选择调度线程时,优先级较高的线程会被优先执行,当执行完毕,才会轮到优先级较低的线程执行,如果优先级相同,那么就采用轮流执行的方式。但是,线程的优先级是高度依赖于操作系统的。虚拟机依赖于宿主平台的线程实现机制,Java线程的优先级被映射到宿主平台的优先级上,优先级个数可能变多,也可能变少,这时,过度使用线程优先级将无法达到预期的目的。比如Windows系统有7个优先级,一些Java线程的优先级将映射到系统相同的优先级,多余的被舍弃;而在Oracle为Linux提供的Java虚拟机中,线程的优先级被忽略——所有线程拥有完全相同的优先级。

每当调度器决定运行一个线程时,首先会在具有高优先级的线程中进行选择,如果几个高优先级的线程没有进入非活动状态(阻塞或等待),低优先级的线程永远也不能执行,尽管这样可能会使低优先级的线程完全饿死。所以,在组织功能代码时,不要将程序功能的正确性依赖于优先级

3.1.2. 守护线程

线程有用户线程和守护线程之分,守护线程用于为其它用户线程提供服务,它依存于被服务线程。相对于用户线程,守护线程并没有特殊之处,但是当被服务线程终止,守护线程也会随之退出——无论该守护线程是否执行完毕。如果虚拟机中只剩下守护线程,虚拟机将退出,因为它认为没有继续运行下去的必要了。

The Java Virtual Machine exits when the only threads running are all daemon threads.

通过设置线程的属性,可以将某个线程设置为守护线程:

public static void main(String[] args) {	
	Runnable runnable  = () -> {
        for (int i = 0; i < 1000; i++) {
            System.out.println(i);
        }
    };
    Thread thread = new Thread(runnable);
    thread.setDaemon(true); // 设置为主线程的守护线程
    thread.start();
    System.out.println("主线程准备退出。。。");
}

输出结果:

主线程准备退出。。。
...
24
25
26

主线程main退出的同时,守护线程runnable随即退出,并不像之前的做法一样打印所有的数值。注意一个细节,设置为守护线程的语句必须放在启动语句(start)之前。

由于守护线程的生命周期(关于线程的生命周期稍后讨论)与它的用户线程紧密关联,守护线程中的finally代码块可能无法正常执行,这样会导致部分资源无法正确释放的情况。比如下面的代码:

Runnable runnable  = () -> {
    try {
        while (!isInterrupted()) {
            System.out.println(Thread.currentThread().getName()
                               + " I am extends Thread.");
        }
        System.out.println(Thread.currentThread().getName()
                           + " interrupt flag is " + isInterrupted());
    } finally {
        System.out.println("...........finally");
    }
};
Thread thread = new Thread(runnable);
thread.setDaemon(true); // 设置为主线程的守护线程
thread.start();
System.out.println("主线程准备退出。。。");

如果像往常一样把关闭资源的代码放到finally代码块中,这些代码可能无法执行,相应的资源也无法释放。守护线程应该永远不去访问固有资源, 如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断

3.2. 构造方法

构造方法签名描述
Thread()分配一个新的Thread对象。线程名称默认为“Thread-n”,n是序号
Thread(String)分配一个新的Thread对象。参数为线程名称
Thread(Runnable)分配一个新的Thread对象。
参数为该线程要运行的目标对象(运行线程时run()方法所属的对象)
Thread(Runnable, String)分配一个新的Thread对象。第一个参数同上。第二个参数为线程名称
Thread(ThreadGroup, Runnable)分配一个新的Thread对象。第二个参数同上。
第一个参数代表该新建线程所属的线程组。
Thread(ThreadGroup, String)分配一个新的Thread对象。第一个参数同上。第二个参数为线程名称

Thread类的常用构造器非常简单,提供了大量的重载方法,方便开发者使用。这里介绍一下关于ThreadGroup的概念。

3.2.1 线程组

ThreadGroup,即线程组,它的作用就像它的名字一样简单,将线程分组,以便于管理。我们通过线程组对线程进行批量操作,比如设置优先级、设置守护线程、中断以及处理异常等等。

所有的线程都会属于某一个线程组,当我们运行main方法的时候,它的线程组是谁呢?可以通过当前线程对象的getThreadGroup() 来查看线程所属的组:

public static void main(String[] args) {
    Thread thread = Thread.currentThread();
    ThreadGroup mainThreadGroup = thread.getThreadGroup();
    System.out.println(mainThreadGroup.getName());
}

输出结果:main。主线程即属于main线程组,默认情况下,当我们创建一个新的线程,如果没有给它指定所属线程组,那么它就会属于当前的线程组,如下创建一个属于main线程组的线程:

Thread subThread = new Thread("sub-thread");
System.out.println(subThread.getThreadGroup().getName());

输出结果依然是:main。

我们当然可以给新建的线程一个其它的线程组,调用上面的构造方法:

ThreadGroup subGroup = new ThreadGroup("sub-group");
Thread subThread = new Thread(subGroup,"sub-thread");
System.out.println(subThread.getThreadGroup().getName());

输出新建线程所属的线程组为:sub-group。值得注意的是,必须先有线程组,再有线程,JVM不允许创建线程之后再把它归属于某个线程组,而且,不能更改一个线程所属的组

现在我们有了两个线程组:main和sub-group。它们有什么关系吗?sub-group线程组在主线程中定义,我们猜测它们有一定的从属关系。通过查看 java.lang.ThreadGroup 类的API,发现有个方法可以获取父级线程组:getParent() ,打印sub-group线程组的父级:

System.out.println(subGroup.getParent().getName());

输出结果果然是:main,说明我们的猜想是正确的。就像线程一样,线程组也必须属于其它线程组,需要在构造器里指定它所属的父线程组,如果没有指定,则默认属于当前正在运行的线程所属的组。这么说来,线程组之间是有一个层级关系的。没错,所有线程和线程组的最高父级就是系统线程组,可以通过主线程组对象来获取它:

ThreadGroup sysThreadGroup = mainThreadGroup.getParent();
System.out.println("系统线程组:" + sysThreadGroup.getName()); // 输出:system

下图就是JVM中线程组与线程之间的从属关系:

06.线程组的从属关系.png

提出线程组的目的,是为了更好的管理线程,线程可以修改同一个线程组(含子线程组)内的其它线程,而不能操作别的线程组中的线程,这样更容易确保安全。如果你不知道当前线程对象能否操作线程组,可以调用checkAccess() 方法查看访问权限,以避免抛出 java.lang.SecurityException 异常。

可以通过调用 setDaemon(boolean daemon) 方法将此线程组设置为守护线程组,但这个动作并不会影响该线程组(含子线程组)中线程的 daemon 状态,调用这个方法仅仅意味着当该线程组中的所有线程(组)销毁后,是否要将此线程组销毁。

3.3. 常用方法

常用方法签名描述
void start()启动一个线程
void run()线程需要执行的任务指令。必须重写,否则不执行任何操作
static Thread currentThread()返回当前(正在执行的代码所属的)线程对象的引用
Thread.State getState()返回此线程的状态。返回内部类Thread.State的枚举实例
ThreadGroup getThreadGroup()返回此线程所属的线程组
void interrupt()中断此线程
boolean isInterrupted()判断当前线程是否已被中断,不会清除中断状态
static boolean interrupted()判断当前线程是否已被中断,清除中断状态为false
static void sleep(long)使当前线程休眠。多个重载方法支持休眠不同的时间单位。
boolean alive()判断当前线程是否存活
static void yield()让出当前线程的执行权。但下个时间片仍然可能被再次选中执行
void join()等待此线程死亡。被插队线程将等待此线程执行完毕之后才能继续执行
String toString()返回此线程的字符串表示形式,包括线程的名称,优先级和线程组

3.3.1. yield()方法的使用

yield() 方法的作用是提示调度程序,当前线程放弃CPU执行权,将它让给其他的任务。这哥们看起来是个大义凛然的好青年,但是调度程序不一定买账,因为即使当前线程放弃了执行权,它还是有执行资格的,下个时间片依然可能选中该线程继续执行。yield() 方法的存在是一种尝试:用于改善线程之间的相对进展,防止单个线程过度利用CPU资源。

上代码:

Runnable runnable = () -> {
    for (int i = 1; i <= 1000; i++) {
        System.out.println("子线程:" + i);
        if (i % 50 == 0) { // 每到50的倍数,让出执行权
            System.err.println("子线程让出执行权"); // 突出颜色
            Thread.yield();
        }
    }
};
Thread subThread = new Thread(runnable);
subThread.start();

for (int i = 1; i <= 1000; i++) {
    System.out.println("main线程" + i);
}

控制台输出:

....
子线程:50
子线程让出执行权
main线程77
main线程78
子线程:51
main线程79
子线程:52
...
子线程:100
子线程让出执行权
main线程131
main线程132
main线程133
main线程134
main线程135
main线程136
main线程137
子线程:101
main线程138
子线程:102

起初主线程和子线程不规则交替执行,每当子线程变量为50的倍数( i % 50 == 0 )时,子线程调用 yield() 方法让出CPU执行权,但是很快,子线程又一次抢到了执行权(可能跟线程的优先级有关),导致两个线程依然不规则交替执行。

频繁调用 yield() 方法确实会给别的线程提供更多的执行机会,问题是,我们为什么要这样做?

yield() 方法可以实现非常短暂的“等待”效果,可以让线程更快的返回运行,这是它非常重要的特点。这句话的意思是说,允许另一个线程运行,但允许自己很快恢复工作,因为此线程可能需要快速的改变某些东西。而如果使用 sleep()join() 或者 wait/notify 机制(篇末介绍)暂停线程将会导致极大的性能损失。

如果你想更可控的实现线程等待,可以考虑 sleep()join() 或者 wait/notify 机制,而如果为了防止某些线程长时间占用CPU,那么可以考虑使用 yield()

在某些专用系统上,JVM的实现并不是基于时间片的,线程调度程序采用协作式调度,高优先级的线程会独占CPU,而低优先级的线程可能永远也得不到运行机会,这种情况下 yield() 方法显得很有用。

3.3.2. join()方法的使用

  • join() 运行此线程,直至此线程死亡
  • join(long millis) 运行此线程,等待此线程死亡,最多等待millis毫秒
  • join(long millis, int nanos) 运行此线程,等待此线程死亡,最多等待millis毫秒 + nanos纳秒

join()方法的作用是等待指定的线程终止,这里“指定的线程”即调用此方法的线程。join() 方法用于实现“插队”的效果,当执行此方法的线程插队到别的线程,被插队线程将等待此线程执行完毕或指定的时间(重载方法)后才能够继续执行。调用 join() 方法与调用 join(0) 方法的效果完全相同。若在此方法执行期间被其它线程中断,则抛出java.lang.InterruptedException异常并清除线程的中断状态(为false)。

接下来模拟一个实际开发场景为你介绍join()方法的使用。

业务场景介绍:本应用对第三方开放接口,当第三方访问此接口时要求同步,实时返回。 场景分析:

​ 1.主线程(main)模拟主业务场景的执行

​ 2.当第三方应用通过接口访问本应用时,启动子线程执行相关任务

​ 3.由于该接口要求实时返回,需要将子线程“插队”到主业务线程

示例代码:

/*
    第三方访问接口时需要执行的任务
 */
Runnable runnable = () -> {
    System.out.println("接口任务开始......");
    for (int i = 1; i <= 1000; i++) {
        System.out.println("子线程:接口业务:" + i);
    }
    System.err.println("接口任务完成......");
};
Thread subThread = new Thread(runnable);

/*
    主业务执行
 */
for (int i = 1; i <= 1000; i++) {
    System.out.println("主线程:业务指令执行:" + i);
    if (i == 50) { // 接口被第三方访问
        subThread.start(); // 启动子线程
        subThread.join(); // 并加入主线程执行队列
        System.err.println("主线程继续....");
    }
}

控制台输出:

主线程:业务指令执行:1
主线程:业务指令执行:2
...
主线程:业务指令执行:50
接口任务开始......
子线程:接口业务:1
子线程:接口业务:2
...
子线程:接口业务:999
子线程:接口业务:1000
接口任务完成......
主线程继续....
主线程:业务指令执行:51
主线程:业务指令执行:52
....
主线程:业务指令执行:1000

主线程执行业务的过程中,当第三方应用访问本系统的开放接口(主线程业务指令 i == 50)时,该接口要求实时返回数据,所以启动子线程并将其“加入(subThread.join())”到主线程的执行队列中,此时,主线程被迫等待,直到子线程执行完毕,主线程继续执行。

问题是,子线程完成任务的时间是未知的,join()方法是如何让被插队的主线程一直等待的?查看源码:

public final void join() throws InterruptedException {
	join(0);
}
public final synchronized void join(long millis)
    throws InterruptedException {
	long base = System.currentTimeMillis();
	long now = 0;

	if (millis < 0) {
		throw new IllegalArgumentException("timeout value is negative");
	}

	if (millis == 0) {
		while (isAlive()) {
			wait(0);
		}
	} else {
		while (isAlive()) {
			long delay = millis - now;
			if (delay <= 0) {
				break;
			}
			wait(delay);
			now = System.currentTimeMillis() - base;
		}
	}
}

join() 方法的源码非常简单,直接调用了它的重载方法 join(0) ,这与 Object.wait() 方法的调用方式是一样的。通过阅读 join(0) 的源码,当超时为0毫秒时,执行如下的while循环,迫使主线程处于始终等待的状态:

while (isAlive()) { // this.isAlive(),判断subThread线程是否存活
	wait(0); // this.wait(0),使当前线程——此行代码所属的线程(main)进入等待
}

这里的代码可能让人觉得混淆,调用isAlive() 的对象和调用 wait(0) 的对象都是 this ——即subThread对象,但是它们关注的线程却不同:

isAlive() 判断this对象指代的线程是否存活——启动之后,死亡之前

wait() 使当前线程——此行代码所属的线程进入等待,直到超时或被唤醒。此方法必须在同步块/方法中使用,使获取该同步块/方法的锁对象(稍后介绍)的线程进入等待,直到超时或被唤醒。

在迫使主线程进入无限等待之后,包含while循环的 if 语句之后便没有更多的代码了。当isAlive() 返回结果为false,也就是说子线程执行结束之后,它就拍拍屁股走人了,留下主线程一个人风中凌乱。所以,交友要谨慎,不能交这种只顾自己开心,不顾他人死活的朋友,典型的渣男行径。

单从 join(0) 的源码看不出子线程终止之后的事情,无法知道主线程什么时候、以什么方式被唤醒继续执行自己的任务,而继续追踪 wait(0) 方法也进入了 native 修饰的本地方法的死胡同。

子线程退出之后到底做了什么事情,它又是如何唤醒主线程的呢?无奈翻开JVM的源码(在“资料”中),打开thread.cpp文件,找到Java线程退出函数:

// Java线程退出函数.
void JavaThread::exit(bool destroy_vm, ExitType exit_type) {
  assert(this == JavaThread::current(),  "thread consistency check");
  // ...省略一大段代码
    
  // Notify waiters on thread object. This has to be done after exit() is called
  // on the thread. 通知线程对象上的等待程序。必须在对线程调用exit()之后执行此操作。
  ensure_join(this); 

  // ...又省略一大段代码
  // Remove from list of active threads list, and notify VM thread if we are the last non-daemon thread. 从活动线程列表中删除此线程对象,如果是最后一个非守护线程,则通知虚拟机(退出)
  Threads::remove(this);
}
// 仅在调用exit()之后执行
static void ensure_join(JavaThread* thread) {
  // ...
  // Thread is exiting. So set thread_status field in  java.lang.Thread class to TERMINATED.线程正在退出,设置线程状态为TERMINATED(被终止)
  java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);
  // Clear the native thread instance - this makes isAlive return false and allows the join() to complete once we've done the notify_all below. 清理本地线程对象,这将导致 isAlive()方法返回false,并在我们一旦完成下面的notify_all之后允许join()方法结束。
  java_lang_Thread::set_thread(threadObj(), NULL);
  lock.notify_all(thread); // 通过notify_all方法唤醒正在(此监视器对象上)等待的线程
    
  // Ignore pending exception (ThreadDeath), since we are exiting anyway
  thread->clear_pending_exception();
}

通过C++源码可以看出,在子线程退出时,调用 ensure_join(JavaThread* thread) 方法进行一些清理工作,设置线程状态为TERMINATED(被终止),并通知(唤醒)正在等待的线程(即主线程)——哥们,别晾着了,你可以继续工作了。总之,一系列的调用之后,使得 isAlive() 方法返回结果为false,并通过 notify_all (相当于Object.notifyAll())唤醒正在等待的主线程,然后,主线程继续执行(参考下图)。

07.join()方法的执行过程.png

一句话总结:join() 方法通过 Object.wait()Object.notifyAll() 配合Thread.isAlive() 实现被插队线程(main)的等待和唤醒。

4. 线程的状态

线程的生命周期指的是一个线程从新建到死亡(被终止)的过程。在线程的生命周期中,共有6种状态:

  • NEW(新创建)

    尚未启动的线程处于此状态。

  • RUNNABLE(可运行)

    在Java虚拟机中执行的线程处于此状态。

  • BLOCKED(被阻塞)

    等待监视器锁的线程处于此状态。

  • WAITING(等待)

    等待另一个线程执行特定操作的线程处于此状态。

  • TIMED_WAITING(计时等待)

    正在等待另一个线程执行最多指定等待时间的操作的线程处于此状态。

  • TERMINATED(被终止)

    已退出的线程处于此状态。

线程的状态被定义在枚举类java.lang.Thread.State中,它是Thread类的内部类。线程在给定时间点只能处于一种状态,这些状态是虚拟机状态,不反映任何操作系统线程状态。如果要确定当前线程的状态,可以通过调用getState()方法来查看。接下来为你介绍这六种状态。

08.01.线程的状态.png

08.线程的状态.png

08.02.线程的状态.png

4.1. 新创建

使用 new 关键字创建一个线程对象后,比如new Thread(),该线程还没有开始运行,它的状态就是NEW——新创建。当一个线程处于新创建状态时,程序还没有开始运行线程中的代码,它将保持这个状态直到程序 start() 这个线程。启动线程之前,还需要一些准备工作,这些工作都是由虚拟机来做的。

4.2. 可运行

当线程对象调用了 start() 方法之后,该线程进入可运行(RUNNABLE)状态。一个可运行的线程,可能正在运行,也可能没有运行,这取决于操作系统给线程提供运行的时间。事实上,运行中的线程随时可能被调度程序剥夺执行权,目的为了让其它线程获得运行机会。调度程序给每一个可运行线程一个时间片来执行任务,当时间片用完, 就会剥夺该线程的运行权, 并给另一个线程运行机会,但是被剥夺执行权的线程也是有执行资格的。所以很多人把线程处于具有执行资格但是没有执行权的情况称为“就绪”状态,但Java的规范里并没有将它作为一个单独的状态。切记,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行——这就是为什么将这个状态称为可运行而不是运行。

没有【就绪】状态:有人把执行了 start() 方法之后,未执行 run() 方法之前,和线程被CPU剥夺执行权回到具有执行资格的状态称为“就绪(Ready)”状态,但Java的规范中没有这种线程状态。

没有【运行】状态:有人把线程正在运行,或正在执行 run() 方法中的代码的情况称为“运行(Running)”状态,Java的规范中也没有这种状态。

4.3. 被阻塞和等待

发生阻塞和等待的情况很多,也是常常容易混淆的地方。当线程处于被阻塞或等待状态时, 它暂时不活动。它不运行任何代码且消耗最少的资源,直到线程调度器重新激活它。根据线程达到非活动状态的原因分为以下情况:

  • 被阻塞:当一个线程试图获取一个内部的对象锁(不是java.util.concurrent 库中的锁,可以理解为一个指定的对象),而该锁被其他线程持有, 则该线程进入阻塞状态。处于阻塞状态的线程正在等待监视器锁定以进入同步块/方法,或在调用Object.wait() 方法后重新输入同步块/方法。当所有其它线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。

  • 等待:当线程需要另一个线程通知调度器一个条件或一个信号时, 它自己进入等待状态。这种情况常常发生在调用Object.wait()Thread.join()LockSupport.park()方法之后, 或者是等待java.util.concurrent 库中的Lock 或Condition 时。处于等待状态的线程正在等待另一个线程执行特定操作。例如,调用wait() 方法的线程正在等待另一个线程在同一个对象上调用 notify()/notifyAll()方法。已调用Thread.join() 的线程正在等待指定的线程终止。

    很多人习惯性的把被阻塞状态和等待状态混为一谈。实际上,二者是有明显的区别的:被阻塞状态的发生是由于请求内部对象锁却并没有获取到;而等待状态是由于某些业务需求或某种条件而进行等待。

  • 计时等待:与等待相似,但是需要超时参数,比如

    • Thread.sleep(time)

    • Thread.join(time)

    • Object.wait(time)

    • LockSupport.parkNanos(blocker, nanos)

    • LockSupport.parkUntil(blocker, deadline)

    • Lock.tryLock(time)

    • Condition.await(time, unit)

    调用它们导致线程进入计时等待( timed waiting ) 状态。这一状态将一直保持到超时期满或者接收到适当的通知(调用方法Object.notify()/notifyAll()Condition.signal()/signalAll())之后。

4.4. 被终止

线程会因为如下两个原因之一而被终止:

  • 因为run() 方法正常退出而自然死亡
  • 因为一个没有捕获的异常终止了run() 方法而意外死亡

调用线程的stop() 方法也会杀死一个线程,但是该方法抛出ThreadDeath 错误对象,该方法已过时, 不要在自己的代码中调用这个方法。

##5. 线程同步

多线程并发运行时,线程之间产生协作的情况几乎是必然的,线程间的协作也叫线程同步。这里的“同”指的就是协同、协作的意思,多个线程相互配合、相互协作才能更好地完成一个活动。

5.1. 竞争条件

当两个或两个以上的线程对同一个对象——即共享对象进行存取时,如果两个线程先后修改了共享对象,会发生什么?可以想像,根据各线程访问数据的次序,数据可能发生错误,最终结果依赖于最后操作共享对象的线程。多线程操作共享资源的时候,由于操作次序而产生对共享资源的竞争条件(race condition)。为避免由于竞争而产生的错误,你要学会如何使用同步存取,这就是为什么要向你介绍线程同步的原因。

下面的程序使用四个线程分别模拟火车站的四个售票窗口,它们的职责都相同:售卖某趟列车剩余的车票,每卖出去一张,将总车票数减1。先为你演示不使用同步程序会出现什么样的结果,稍后再演示如何同步存取共享数据:

private static int tickets = 20; // 某趟列车剩余车票数
public static void main(String[] args) {
    Runnable seller = () -> {
        while(true) {
            if(tickets <= 0) // 查看剩余车票数
                break;
            try {
                Thread.sleep(10); // 短暂的休眠,增加不同窗口的售票机会
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 售票动作,打印车票
            System.out.println(Thread.currentThread().getName() 
                               + "...这是第" + tickets-- + "号票");
        }
    };
    new Thread(seller, "窗口1").start(); // 依次启动四个线程模拟售票窗口
    new Thread(seller, "窗口2").start();
    new Thread(seller, "窗口3").start();
    new Thread(seller, "窗口4").start();
}

输出结果:

...
窗口4...这是第4号票
窗口2...这是第3号票
窗口1...这是第3号票
窗口3...这是第3号票
窗口4...这是第2号票
窗口2...这是第1号票
窗口3...这是第1号票
窗口1...这是第0号票
窗口4...这是第-1号票

发生了售卖同号票和非法号码车票的情况。这就好比你费尽千辛万苦抢到一张车票,当你拿着车票上车,发现有人坐在你的位置上,你却不能赶他走,想想就知道有多么糟糕(心里一千只羊驼飞过...)。分析卖同号票的原因并不复杂:假设此时只剩下3张车票,当第一个线程通过 if 语句的判断之后,被CPU剥夺了执行权给其它的线程,后续的线程也如此执行,这将导致多个线程同时通过 if 判断并且它们拿到的剩余车票数量相同,所以售卖的票号也相同。售卖负数票的道理是一样的。

真正的问题就在于,一个线程在执行 while 循环的过程中随时可能被中断,如果操作共享资源(tickets)的相关代码能够确保在线程失去控制之前完成,那么别的线程就无法干扰此线程的执行过程,数据也不会出错。

原子操作:多线程操作共享数据带来了线程安全问题,实际生产环境中是绝对不允许的。产生上述现象的根本原因在于,操作共享资源(tickets)的相关代码不具有原子性。所谓具有原子性的操作(atomic operation)是指将整个操作视作一个整体,不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会切换到另一个线程。原子操作执行的步骤不会被打乱,也不会被切割。

5.2. 监视器

使用同步机制可以防止代码受并发访问的干扰。那么,Java中如何实现线程同步?

Java中实现线程同步的机制是监视器。监视器是什么?其实,你完全可以把监视器看作一个普通对象——没错,就是我们日用而不知的类的实例。监视器实现线程同步的方式有两种:互斥和协作。互斥是指允许多个线程在同一个共享数据上独立而互不干扰的工作协作是指允许多个线程为了同一目标而共同工作

5.2.1. 互斥线程

显然,互斥线程能够满足上面火车站售票窗口示例的需求。每个监视器都有一个锁,它是在堆内存中对象头部的一部分数据(关于对象的内存结构在并发编程的第二部分介绍),线程可以使用它来协调对对象的访问,这就是人们常说的对象锁Java中通过对象锁来实现互斥线程,对象锁工作原理的核心就是加锁和释放锁操作

那么,具体怎么操作的呢?我们希望操作共享数据的代码片段能够在不受干扰的情况下执行完成,那么我们可以在这个代码片段开始的位置加锁,然后在结束的位置释放锁,被锁定的区域受到监视器保护,监视器会保证该代码片段在同一时间点上只会执行一个线程,从而实现在多线程环境下,操作该代码片段的线程是互斥的。被监视器保护的代码片段被称作监视区域,也叫临界区。

5.2.1.1. synchronized关键字

Java中提供了一种非常简单的方式来实现互斥:synchronized关键字。步骤很简单:

  1. 创建任意类型的对象作为操作共享资源的代码片段的监视器;

  2. 使用synchronized代码块(或方法)将监视区域包起来;

通常,我们将synchronized修饰的代码块称为同步代码块,修饰的方法称为同步方法,如果该方法是静态(static)的,那么我们称之为静态同步方法

使用同步代码块来实现互斥线程的示例代码:

private static int tickets = 1000;
public static void main(String[] args) {
    // 1.创建任意类型的对象作为操作共享资源的代码片段的监视器
    Object obj = new Object(); 
    Runnable seller = () -> {
        while(true) {
            // 2.使用synchronized代码块将操作共享资源的代码包起来
            synchronized (obj) { 
                if(tickets <= 0)
                    break;
                try {
                    Thread.sleep(10); // 短暂的休眠,增加不同窗口的售票机会
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 售票动作,打印车票
                System.out.println(Thread.currentThread().getName()
                        + "...这是第" + tickets-- + "号票");
            }
        }
    };
    new Thread(seller, "窗口1").start();
    new Thread(seller, "窗口2").start();
    new Thread(seller, "窗口3").start();
    new Thread(seller, "窗口4").start();
}

输出结果:

...
窗口4...这是第12号票
窗口4...这是第11号票
窗口3...这是第10号票
窗口3...这是第9号票
窗口3...这是第8号票
窗口3...这是第7号票
窗口3...这是第6号票
窗口2...这是第5号票
窗口1...这是第4号票
窗口1...这是第3号票
窗口1...这是第2号票
窗口1...这是第1号票

无论运行多少次,都不会出现非法车票的情况,多个线程总是不规则的交替操作共享资源,相互独立,而且互不干扰,这样就实现了多线程并发的互斥。

分析代码运行的过程:

  1. 当第一个线程(可能是任意售票窗口的线程,取决于CPU是如何调度的)运行到synchronized代码块时,它进入监视器并尝试获取监视器的锁(在锁内存区域设置一些标识),由于此时没有其它线程抢先执行到这个代码块,那么第一个线程将持有锁并进入同步代码块开始运行;
  2. 无论第一个线程执行到哪一条指令,第二个线程都可能开始运行,并尝试获取synchronized代码块的锁,如果此时第一个线程的代码还在同步代码块中,那么第二个线程获取锁的动作失败,并立即进入阻塞状态;
  3. 第三、第四个线程也会遭遇同样的结果:只要有任何一个线程预先进入同步代码块,其它线程都被迫等待;
  4. 直到第一个线程在退出同步代码块的同时释放锁(改变标识)并退出监视器,所有的线程进入可运行状态,并争抢CPU执行权。如果任何一个线程抢到CPU的执行权,它会像之前的第一个线程那样获取对象锁,之后以同样的方式进入同步代码块,那么其它线程被迫等待,重复上面的过程直到程序退出。

每个同步代码块都需要一个监视器,监视器通过锁来保护代码片段, 任何时刻只能有一个线程执行被保护的代码,这就是如何使用对象锁来实现互斥线程的方式。如果你想要把方法中所有的代码都放到同步代码块中,除了刚才的做法,还可以使用同步方法

public synchronized void method () {
    // ...method body
}

或者静态同步方法

public static synchronized void method () {
    // ...method body
}

看起来同步方法和静态同步方法都没有监视器对象,其实不然,synchronized 关键字内置了对象锁:普通同步方法的监视器是 this 对象,静态同步方法的监视器是类的 java.lang.Class 对象。

人们常常将同步代码块/方法的监视器称为对象锁,而将静态同步方法的监视器称为类锁,其实类锁也是对象锁的一种,因为类锁也是一个对象。

5.2.1.2. ReentrantLock类

对于初级开发者来说,使用synchronized关键字实现同步很简单,但是它并不完美。为了更加细粒度的对锁机制进行管理,Java的设计者们开发了java.util.concurrent框架。java.util.concurrent.locks.Lock接口始于Java SE 5.0,它提供了比使用同步块/方法更广泛的锁定操作。它允许以Java类的形式实现锁机制,而不是作为语言的特性。这就为 Lock 的多种实现留下了广阔的空间,允许更灵活的结构,可能具有完全不同的属性,并且可以支持多个关联的条件,从而各种实现可能有不同的调度算法、性能特性或者锁定语义。

灵活性的增加,也带来更大的开发难度。比如使用synchronized同步块/方法时,获取锁和释放锁的操作由JVM自动完成,而使用 Lock会增加一些代码。设计者们建议使用以下的格式来使用 Lock,这样有助于避免许多涉及锁的常见编程错误:

 Lock lock = ...; // 锁对象
 lock.lock(); // 加锁
 try {
   // 进入被此锁保护的资源
 } finally {
   lock.unlock(); // 释放锁
 }

这一结构确保任何时刻只有一个线程进人临界区。一旦一个线程封锁了锁对象, 其他任何线程都无法通过 lock 语句。当其他线程调用 lock 时,它们被阻塞, 直到第一个线程释放锁对象。 把解锁操作括在 finally 子句之内是至关重要的,如果在临界区的代码抛出异常,锁必须被释放,否则,其他线程将永远阻塞。

在进入synchronized代码块之前获取锁,在离开该代码块时释放锁,这是Java语言中使用锁机制实现同步的基本流程。使用Lock接口的子类ReentrantLock(可重入锁) 来实现同步的做法也符合这个流程:

private static int tickets = 100;
public static void main(String[] args) {
    Lock lock = new ReentrantLock(); // 创建锁对象
    Runnable seller = () -> {
        while(true) {
            lock.lock(); // 加锁
            try {
                if(tickets <= 0)
                    break;
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()
                        + "...这是第" + tickets-- + "号票");
            } finally {
                lock.unlock(); // 释放锁
            }

        }
    };
    new Thread(seller, "窗口1").start();
    new Thread(seller, "窗口2").start();
    new Thread(seller, "窗口3").start();
    new Thread(seller, "窗口4").start();
}

这段代码所能达到的运行效果与使用synchronized完全相同。注意**,释放锁的代码务必放到 finally 代码块中,以确保该线程不会因为异常而无法正常释放锁**。还要注意临界区的代码不要因为异常的抛出而跳出临界区,如果在临界区代码结束之前抛出了异常,finally子句将释放锁,临界区中的对象可能处于一种受损状态,下个线程访问临界区时可能会发生意想不到的问题。synchronizedReentrantLock都可以实现线程的互斥,当两个线程试图访问同一个锁对象, 那么锁将能够确保两个线程以串行方式运行,此时,多线程并发并不是为了提高效率,而是确保数据的安全。在使用线程同步的时候,一定要确保多个线程使用的锁是同一个对象,否则,每个线程各自持有自己的锁(不同的对象),线程之间不会相互影响(如下图)。

09.同步与非同步线程的运行过程.png

5.2.1.3. 可重入锁与公平锁
  • 可重入锁

可重入锁.png

还有一个重要的细节,在加锁的代码片段中可以调用另一个被同样的锁保护的方法,这样,一个线程就会多次对同一个对象上锁,这种现象说明锁是可重入的(Reentrant)。事实上,Java允许线程重复地获得已经持有的锁,对每一个对象,JVM维护一个计数器,记录该对象被加锁了多少次。计数器默认值是0,每当一个线程获得锁的时候,计数器就加1,每当该线程释放一次,计数器就减1,直到计数器的值为0,锁才会被完全释放。注意一点,这里仅限于拥有了这个锁的线程才能再次加锁,在它完全释放锁之前,其它线程是不能对这个对象加锁的。参考关于可重入性的核心代码:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState(); // 锁的同步状态,默认为0
    if (c == 0) { // 第一次获取锁
        if (compareAndSetState(0, acquires)) { // 将同步状态设置为新值
            setExclusiveOwnerThread(current); // 记录该锁的持有者线程为当前线程
            return true;
        }
    }
    // 锁的同步状态不为0,说明不是第一次获取锁,检查该锁的持有者是否为当前线程
    else if (current == getExclusiveOwnerThread()) { 
        int nextc = c + acquires; // 同步状态加1
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc); // 设置同步状态为新值
        return true;
    }
    return false;
}

这段代码的逻辑非常简单:当线程尝试获取锁的时候,检查当前锁的同步状态是否第一次被锁定,若是,则说明该锁未被任何线程持有,将锁状态加1,并设置该锁的所有者为当前线程;若该锁不是第一次被锁定,则查看该锁的持有者是否为当前线程,若是,则继续增加锁的同步状态,否则说明该锁的持有者不是当前线程,不做任何操作。释放锁的过程是获取锁的反向操作,请参考注释,不再赘述:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases; // 同步状态减1
    if (Thread.currentThread() != getExclusiveOwnerThread()) // 锁的持有者非当前线程
        throw new IllegalMonitorStateException(); // 抛出非法监视器状态异常
    boolean free = false;
    if (c == 0) {
        free = true; // 同步状态为0时,锁被完全释放,返回true
        setExclusiveOwnerThread(null); // 设置锁的持有者为null,默认值
    }
    setState(c);
    return free;
}
  • 公平锁

公平锁.png

在构造ReentrantLock对象的时候,还有一个重载构造方法,它接收一个boolean类型的参数,代表公平策略,默认值为false。如果传递给构造器的值为true,那么将构造一个带有公平策略的可重入锁。参考源码如下:

public ReentrantLock() { // 使用默认的公平策略(false)构造一个可重入锁实例
    // 非公平锁,使用内部类NonfairSync
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) { // 使用给定的公平策略构造一个可重入锁实例
    // 如果参数为true,构造一个公平锁实例,使用内部类FairSync
    sync = fair ? new FairSync() : new NonfairSync();
}

这里所谓的公平性,是指针对等待获取锁的线程而言,公平锁对象偏爱等待时间最长的线程(遵循先入先出原则——First Input First Output,FIFO)。公平锁的特点在于保证请求获取锁的线程的次序——谁先请求锁,谁就先获取锁,它总是选择等待队列的第一个线程(等待队列的第一个线程是排在最前面的线程,也是等待最久的线程)来执行。看起来非常的“公平合理”,但是这一公平的保证将大大降低性能。使用公平锁比使用常规锁要慢的多,所以,默认情况下,锁没有被强制为公平的。参考公平锁获取锁对象的代码:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() && // 与非公平锁唯一的区别
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

hasQueuedPredecessors() 方法的作用是计算是否还有等待时间更长的线程,返回true或者false。事实上,即使使用公平锁,也无法确保线程调度器是公平的。如果线程调度器选择忽略一个线程(由于中断和超时导致的取消可能随时发生),而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。所以,只有当你确定了解自己要做什么,并且对于你要解决的问题有一个特定的理由必须使用公平锁的时候,才可以使用公平锁,其它时候,都不建议使用。

5.2.2. 协作线程

协作线程是监视器实现同步的另外一种方式。与互斥线程的排他性访问不同,协作线程不关注资源的竞争,而是更多强调线程之间通过协同、配合达到预期的功能。

5.2.2.1. 条件对象

还是以上面火车站售票窗口为例,铁路总公司为了均衡系统的并发访问量,采用了阶段性放票的策略,这样不仅有助于降低系统的负载,也有助于乘客更好的选择出行时间。假设当某个窗口刷新余票页面的时候发现本批次车票已经售完,那么窗口的工作人员只能告诉买票的乘客进行等待,直到新的放票批次才能出票。那么,如何使用多线程技术来实现这一情况呢?

分析一下大体流程:当一个线程进入临界区,却发现车票已售完,此时,该线程不得不进入等待——等待铁路系统进行下个批次的放票动作,同样的,其它几个售票窗口对应的线程也会如此执行。当铁路系统对该列车次补充车票之后,这些线程应该被重新被激活,因为这些售票窗口不能(像美国政府那样)一直停摆。也就是说,进入临界区的线程必须满足一定的条件才能执行,在这一节里, 我们介绍Java并发库中的条件对象(condition object),条件对象用来管理那些已经获得了一个锁但是却不能做有用工作的线程,因为它们必须在某一条件满足之后才能执行。条件对象也经常被称为条件变量(conditional variable )。

使用条件对象实现上面的需求的步骤非常简单,但却很容易出错:

  1. 首先,要为你的锁创建一个条件对象,代表余票充足:

    // 1.使用锁对象创建一个条件对象,表示余票充足
    Condition sufficientTickets = lock.newCondition();
    

    条件对象是通过锁对象来创建的,一个锁可以有一个或多个条件对象,每个条件对象管理那些已经进入被保护的代码但还不能运行的线程(同样的,使用synchronized关键字的对象锁也有一个内置的条件,只不过这个条件不需要我们做任何操作)。注意条件对象声明的位置,由于多个线程都要使用同一个锁和一些条件对象,所以它们应该能够在不同的代码块中被访问。

  2. 在条件不满足的地方,让当前线程进行等待:

    if (tickets <= 0) {
        System.out.println(name + "无票,进入等待》》》");
        // 2.条件不满足,等待
        sufficientTickets.await();
        continue; // 被激活后重新回到循环的开始,并再次检测车票数量
    }
    

    Condition对象一般都要配合相应的判断条件来使用。通过调用 sufficientTickets.await() 方法使当前线程在不满足业务需求时等待。准确地说,await() 方法将该线程放到此条件的等待集(wait set)中,之后,该线程会释放它持有的锁,这一点非常关键,因为只有这样另一个线程才能够进行补充车票的操作。当一个线程调用 await() 之后,它没有办法重新激活自身,它寄希望于其他线程。 如果没有其他线程来重新激活它,它就永远不再运行了,这将导致令人不快的死锁(deadlock) 现象。

  3. 最后,在条件满足之后,重新激活等待的线程:

    我们新建一个线程用来执行放票的功能,该线程每当发现余票不足(小于等于0)时就重新放出100张票,并激活正在等待的线程:

    Runnable replenisher = () -> {
        int replenishCount = 0; // 放票次数
        while (true) {
            lock.lock();
            try {
                String name = Thread.currentThread().getName();
                if (tickets <= 0) {
                    tickets = 100; // 放出100张票
                    System.out.println(name + "第"+ ++replenishCount +"次成功放出100张票》》》");
                    // 3.余票充足,重新激活在此条件对象上等待的线程
                    sufficientTickets.signalAll();
                }
            } finally {
                lock.unlock(); // 释放锁
            }
        }
    };
    // 启动放票系统线程
    new Thread(replenisher, "放票系统").start();
    

    调用 signalAll() 方法重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,被激活的线程将试图重新进入锁对象,一旦锁成为可用的,这些线程中的某一个将从 await() 方法返回,获得该锁并从等待的地方继续执行。 简言之,条件对象通过await() 方法让一个不满足条件的线程等待,然后让另一个线程通过 signalAll() 方法重新激活它。

    要注意一点, signalAll() 方法仅仅是通知正在等待的线程:此时有可能已经满足条件,但无法确保该条件被满足,所以应该再次检测判断条件。即在第二步中通过 continue 语句返回到循环开始的地方重新检测判断条件,这是有必要的。还有一个方法 signal() ,用来激活在此条件对象上等待的某一个线程,如果随机选择的线程发现自己仍然不能运行, 那么它将再次被阻塞。如果没有其他线程再次调用 signal(),那么程序可能会被操作系统挂起。

    调用 await() 方法进入等待的线程和等待获得锁的线程的状态并不相同:一旦一个线程调用 await() 方法, 它进人该条件的等待集,当锁可用时,该线程并不会马上解除等待状态,它仍然是非活动的,除非另一个线程调用同一条件上的 signalAll() 方法时为止。 这样,调用 await() 方法进入等待的线程与调用同一条件上的 signalAll() 方法给前者发送信号的线程就实现了协作。

看起来步骤很少,容易出错的地方却一堆(^_^),每一个细节都至关重要。你可能需要反复的学习这部分内容,并通过资料包中的完整程序代码去练习,琢磨每个方法的细节,并谨慎调整代码的次序。希望你能够理解条件对象的概念、作用以及它的工作流程。

5.2.2.2. 等待/唤醒机制

同一个监视器,一个线程写入数据,另一个或多个线程读数据,只有“写线程” 完成了一些数据的写入,“读线程”才能做相应的读取动作,这样的监视器被称为等待并唤醒监视器(也被称为“发信号并继续”监视器)。在这种监视器中,一个已经持有监视器的线程,通过执行一个等待命令而暂停执行,随后,它就会释放监视器,并进入一个等待区(wait set),这个线程会一直在那里等待,直到一段时间后,这个监视器中的其它线程执行了唤醒命令。执行唤醒命令的线程继续持有监视器——除非它主动释放,比如执行了等待命令,或者执行完监视区域。当执行唤醒的线程释放了监视器之后,等待线程会苏醒,并重新获得监视器。这就是等待/唤醒机制(也叫等待/通知机制)的工作流程。

10.监视器-等待&唤醒机制.png

条件对象的 await()signalAll() 方法的使用已经实现了等待/唤醒的效果,实际上,正确地使用条件对象是富有挑战性的,很多人更喜欢使用 synchronized 关键字来实现线程同步。我们前面说过,既然监视器可以是任意对象,那么每个对象都可以用来实现等待/通知机制,因此,Java中的Object类提供了wait()notify()notifyAll() 这样的方法。相对于条件对象的 await()signal()signalAll() ,它们的区别仅仅是名字上的不同,底层的实现机理是一样的。接下来你可以把条件对象实现线程协作代码按照 synchronized关键字和Object类提供的功能进行改写,你会发现最终的运行效果是一样的。

实现等待/唤醒机制没有想像中的复杂,如果你还是觉得不知道如何下手,可以参考下面的范式,假设选择等待的线程叫“等待方”,选择唤醒的线程叫“唤醒方”:

  • 等待方
    • 持有锁
    • 判断条件是否满足,若不满足则执行等待命令(wait()await()等)
    • 若条件满足则继续执行业务逻辑代码,直到释放锁
  • 唤醒方
    • 持有锁
    • 根据业务需求尝试改变条件
    • 执行唤醒命令(notifyAll()signalAll()等),唤醒所有在此监视器上等待的线程
    • 运行直到释放锁

将你的代码对照上面的范式,如果全部满足,你就可以放心的调试自己的代码了。

5.2.2.3. 死锁

使用锁机制和条件对象并不能解决线程同步的所有问题,甚至,它们还有可能带来一些其它的问题——比如效率和安全性的矛盾。当你反复运行上面的程序,可能会发现它有些慢,这就是我们使用同步机制确保线程安全而牺牲效率所要付出的代价。当然,安全也不是绝对的。当你的线程执行了等待命令而始终没有接收到别的线程的唤醒命令而阻塞时,就会造成死锁现象。这是最简单的死锁,一般通过检查代码就能发现。

你可能已经发现,除了signalAll()notifyAll() 之外,还有两个方法:signal()notify(),这两个是不推荐使用的方法,它们的作用都是只能激活一个线程,如果被激活的线程发现条件不满足,那么它依然不能执行,这样将可能导致所有的线程都被阻塞,死锁再一次发生。

还要注意的是,使用线程同步技术要避免不同监视器管理的监视区域的嵌套(如果是同一个监视器控制的监视区域是可以重复访问的,请参考可重入锁的介绍),这样也很容易导致死锁的发生。比如下面的代码:

String chopstick1 = "筷子左";
String chopstick2 = "筷子右";

Runnable runnable1 = () -> {
    String name = Thread.currentThread().getName();
    while(true) {
        synchronized(chopstick1) {
            System.out.println(name + "...拿到" + chopstick1 + "等待" + chopstick2);
            synchronized(chopstick2) {
                System.out.println(name + "...拿到" + chopstick2 + "开吃");
            }
        }
    }
};
Runnable runnable2 = () -> {
    String name = Thread.currentThread().getName();
    while(true) {
        synchronized(chopstick2) {
            System.out.println(name + "...拿到" + chopstick2 + "等待" + chopstick1);
            synchronized(chopstick1) {
                System.out.println(name + "...拿到" + chopstick1 + "开吃");
            }
        }
    }
};
new Thread(runnable1).start();
new Thread(runnable2).start();

运行这段代码,你会发现很快进入死锁的状态,两根筷子无法协作,就再也吃不上饭了——没想到太平盛世还能发生这样的事。这两个线程由于同时需要对方的锁而进入阻塞。当两个或多个线程分别持有对方的锁,就会造成死锁的情况,这是在开发中应该极力避免的。

5.2.2.4. 锁与条件对象的核心
  • 锁用来保护代码片段, 任何时刻只能有一个线程执行被保护的代码;
  • 锁可以管理试图进入被保护代码段的线程;
  • 锁可以拥有一个或多个相关的条件对象;
  • 条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

Lock/Condition 为程序设计人员提供了更高难度和更精确的锁定控制。然而,大多数情况下,并不需要那样的控制,使用 Java 语言内嵌的机制——synchronized关键字即可。 当然,这两种方式都不使用,在某些场景下,也许除了直接使用同步之外还有更好的解决方案,比如阻塞队列、并行流、同步器等等,在后面章再为你详细介绍。

5.3. 线程本地(局部)变量

前面我们讨论了使用线程同步机制解决多线程并发访问共享资源而产生竞争的问题。之所以产生竞争,说白了,还是因为资源不够。资源总是稀缺的,如果给每个人足够多的财富,那么我们可以立即进入共产主义社会,人与人之间再也不需要争抢工作机会、医疗和教育资源等等,这样就达到了一个理想的社会。当然,这样做的成本是巨大的,但是具体要不要做,取决于我们衡量了成本和收益之间的关系之后才能决定。换句话说,与其产生竞争而去解决竞争带来的问题,倒不如直接避免竞争,这也不失为一个解决问题的思路。

5.3.1. 为什么需要线程本地变量

由于使用线程同步技术难以避免的降低了程序运行的效率,也带来了一定程度的线程安全上的风险,而且,开发难度也会随之增加。在开发中有时要尽可能避免使用共享资源,毕竟,使用同步的开销很大,操作少量的共享资源而使用同步有些浪费。我们使用并发编程技术主要是为了提高程序的效率,虽然安全问题是不可回避的,但毕竟它们只是由于并发而带来的附属问题,所以提高程序的效率才是我们主要追求的目标。回到多线程操作共享资源的问题上,如果给每个线程一份完全相同的“共享资源”,它们各自使用自己的副本,是不是避免了竞争的情况,而且也可以提高效率呢?

5.3.2. 如何使用线程本地变量

使用线程本地变量(java.lang.ThreadLocal,也叫线程局部变量)可以让每个并发的线程拥有一个共享变量的副本,线程间的副本各不相干,这样就避免了竞争,而且可以提高效率。下面就是使用ThreadLocal的示例代码:

  1. 首先,创建一个ThreadLocal对象:

    ThreadLocal<String> threadLocal = new ThreadLocal<>();
    

    ThreadLocal是一个泛型类,这里的泛型指的就是多个线程操作的本地变量的类型。这行代码的意思,相当于 String str = null,只不过把变量 str 用ThreadLocal包装了起来。ThreadLocal就是一个包装器,在每个使用它的线程中创建一个本地变量的副本,并分别维护它们。如果想给本地变量一个默认值,可以重写initialValue()方法并返回一个数据,就像这样:

    ThreadLocal<String> threadLocal = new ThreadLocal<>() {
        @Override
        protected String initialValue() {
            return "学编程";
        }
    };
    
  2. 接下来编写线程运行的代码,通过ThreadLocal分别访问每个线程中的本地变量,并尝试使用它们:

    String[] strs = {"来博学谷", "来黑马", "来传智"};
    Runnable runnable = () -> {
        String name = Thread.currentThread().getName();
        int index = Integer.parseInt(name.substring(7)); // 截取数字作为索引
        String str = threadLocal.get(); // 获取线程本地变量的值
        str += strs[index];
        threadLocal.set(str); // 为当前线程的本地变量赋值
        System.out.println(name + ":" + threadLocal.get());
    };
    

    使用 get() 方法获取当前线程本地变量的值,使用 set(T) 方法可以设置值,这种操作类似实体类的getter和setter方法,非常容易接受。

  3. 启动并运行三个线程,查看效果:

    for (int i = 0; i < 3; i++) {
        new Thread(runnable, "Thread-" + i).start();
    }
    

    输出:

    Thread-2:学编程来传智
    Thread-1:学编程来黑马
    Thread-0:学编程来博学谷
    

三个线程分别输出了不同的数据,你可以想像在三个线程中分别有一个 String str = "学编程" 这样的变量,在每个线程对仅属于自己的数据进行操作后,并没有影响到别的线程的值。

5.3.3. ThreadLocal的实现原理

明明只有一个ThreadLocal对象,它是如何在多个线程之间分别维护同一个变量的副本的?我们知道可以通过在多个线程之间创建变量副本来防止竞争,也知道ThreadLocal可以实现这样的功能,但是,我们不知道它具体是如何实现的。学知识要有寻根究底的钻研精神,也要有把事情做到极致的工匠精神,探索事物的本来面目,才能更好地把零散的知识整合成体系,才能游刃有余地使用它。

每一步与ThreadLocal相关的操作都有可能成为整个事情的关键。先来看创建并初始化对象的操作,跟踪ThreadLocal的构造方法,发现它只有一个默认的无参构造,看来问题的根源不在这儿:

public ThreadLocal() {
}

接下来就是从ThreadLocal中获取数据的 get() 方法,跟踪它的源码:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t); // 从当前线程对象中获取一个map
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

get() 方法中,首先获取当前线程对象,这样就可以操作线程了。getMap(Thread) 方法接收当前线程对象作为参数,并返回一个ThreadLocalMap的实例,从这个类的名字就大致可以猜测:ThreadLocal使用一个映射表(Map)这种数据结构来存储数据——后续的代码马上就证明了这一点。那么,ThreadLocalMap又是何许人也?继续跟踪进入getMap(Thread) 方法一探究竟:

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

这个方法居然只有一行代码,它直接返回了线程Thread对象的属性 threadLocals,那么打开Thread类的源码,查找这个属性:

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

Thread类中有一个成员变量 threadLocals,它的类型是ThreadLocal的内部类ThreadLocalMap类型。现在把ThreadLocalThreadLocalMap的代码找出来,这几个类的关系就基本上清晰了:

public class ThreadLocal<T> {
	static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Entry(ThreadLocal<?> k, Object v) {}
        }
    }
}

到此,我们就大概知道ThreadLocal是怎么工作的了:

  • Thread类中有个成员变量 threadLocals,它的作用是存储当前线程的本地变量;
  • 成员变量threadLocals 的数据类型是ThreadLocal的内部类ThreadLocalMap 类型,它是一个Map;
  • ThreadLocalMap 对象的key是ThreadLocal类型,value是Object类型,也就是说,在任意一个线程中,每个ThreadLocal对象对应着一个具体的本地变量的副本;
  • 在ThreadLocal中创建并维护ThreadLocalMap 对象,该类开放了操作本地变量的接口:get()set(T)remove() 等方法。

通过下图可以看到在多个线程中本地变量数据是怎么存储的:

11.ThreadLocal原理.png

每个线程都有一个ThreadLocalMap类型的成员变量,它们都是通过 new 关键字创建的,所以它们是同一个类型的多个不同的实例。它的结构相当于:ThreadLocalMap.Entry<ThreadLocal, Object>,map的key是线程本地变量ThreadLocal类型,也就是我们创建的ThreadLocal 的实例,这也就意味着,每个线程中map的key是相同的(都是threadLocal),但是它们的value,都是ThreadLocal 泛型类型的实例的不同副本(String类型的多个不同对象),即每个线程通过相同的key获取到的value是同类型的不同对象,并且每个线程都独占这个对象,这样就从根本上避免了竞争发生的可能性。

5.3.4. 使用ThreadLocal的注意事项

  1. 线程中多个ThreadLocal的问题:一个线程中可以有多个本地变量,即可以有多个ThreadLocal的实例,每个本地变量都要用一个ThreadLocal实例包起来。这样一来,map中就会有多个Entry对象,每个Entry的key分别来自我们自定义的ThreadLocal实例,value则是本地变量的副本;

  2. map的初始化问题:Thread类中的成员变量 threadLocals 到底是什么时候初始化的?很明显,不是在构造Thread对象的时候。通过查看源码我们发现,在第一次使用 ThreadLocal 实例的时候(比如使用 get()set() 方法),会去检测当前线程的 threadLocals 是否存在,如果不存在,则创建。参考 get() 方法最后一行调用的 setInitialValue() 方法和 set() 方法及相关源码:

    private T setInitialValue() { // get()的最后一行:当map不存在时,设置本地变量的初始化值
        T value = initialValue(); // 本地变量的初始化值,建议重写
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t); // 获取当前线程的本地变量映射表threadLocals
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value); // map不存在,则创建
        }
        if (this instanceof TerminatingThreadLocal) {
            TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
        }
        return value;
    }
    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t); // 获取当前线程的本地变量映射表threadLocals
        if (map != null) {
            map.set(this, value);
        } else {
            createMap(t, value); // map不存在,则创建
        }
    }
    void createMap(Thread t, T firstValue) {
        // 通过new关键字构造一个map,并赋值给线程对象的成员变量
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    

    只有用到某个对象的时候才去初始化它,而不是更早时候,这种做法常常被称为延迟初始化,这样做有助于减少不必要的内存占用。ThreadLocalMap 并没有实现 java.util.Map 接口,它仅仅使用哈希表(hash table)进行存储数据,也没有使用二叉树结构,这是因为在绝大多数情况下,线程本地变量的个数都不超过相互协作的线程的个数,所以相比使用 java.util.Map 的效率要高一些。

  3. map的销毁问题:ThreadLocal 提供仅供线程内部使用的本地(局部)变量,所以map对象会随着线程的终止而销毁。ThreadLocalMap 的内部类Entry 继承自弱引用类 java.lang.ref.WeakReference,创建并使用ThreadLocal 的实例的弱引用作为Entry 的键:

    static class Entry extends WeakReference<ThreadLocal<?>> {
        Entry(ThreadLocal<?> k, Object v) {
            /** The value associated with this ThreadLocal. */
            Object value;
    
            Entry(ThreadLocal<?> k, Object v) {
                super(k); // 调用父类构造器,创建k的弱引用
                value = v;
            }
        }
    }
    

    Entry的key 是一个弱引用(weak reference)对象,也就意味着,在垃圾回收器(garbage collector,GC)遇到该对象时会直接将其回收,ThreadLocalMap中就会出现key为null的Entry:Entry<null, object>,因此再也无法访问这些Entry的value,之所以这样设计的目的,是为了让ThreadLocalMap 知道映射表中已经存在不再引用的键,可以将这些过时的条目(stale entries)删去了,事实上也是这么做的——防止非法Entry过多造成内存泄漏。问题是,GC的动作可能无意回收了我们仍然需要的key,为了避免这样的情况发生,Java的设计者建议将ThreadLocal的实例修饰为 private static,然后在我们不需要这个本地变量的时调用 ThreadLocal.remove() 方法手动删除该Entry:

    public class StaticThreadLocal implements Runnable {
        private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
        @Override
        public void run() {
            // method body
            
            threadLocal.remove(); // 移除不需要的本地变量
        }
    }
    

    参考删除过时Entry的代码(Entry的key为null时即认为过时):

    private int expungeStaleEntry(int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
    
        // 删除过时的Entry
        tab[staleSlot].value = null;
        tab[staleSlot] = null;
        size--;
    
        // Rehash until we encounter null
        Entry e;
        int i;
        for (i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
            if (k == null) { // 当key为null,删除entry
                e.value = null; 
                tab[i] = null;
                size--;
            } else {
                int h = k.threadLocalHashCode & (len - 1);
                if (h != i) { // 此entry应在的位置非当前位置
                    tab[i] = null; // 当前位置置空
    
                    // Unlike Knuth 6.4 Algorithm R, we must scan until
                    // null because multiple entries could have been stale.
                    while (tab[h] != null)
                        h = nextIndex(h, len);
                    tab[h] = e; // 将此entry放置于正确位置
                }
            }
        }
        return i;
    }
    

    强引用(Strong Reference):强引用对象不会被GC回收;

    软引用(Soft Reference):软引用对象在内存紧张时被GC回收;

    弱引用(Weak Reference):弱引用对象无论内存是否紧张都会被GC回收;

    虚引用(Phantom Reference):虚引用对象在任何时候都可能被垃圾回收器回收。

5.3.5. 一句话总结ThreadLocal

相对于使用监视器机制实现线程同步的情况而言,ThreadLocal并不是为了解决线程间的竞争而存在的,恰恰相反,它的作用在于让每个协作的线程同时拥有一个共享变量的副本,从而避免竞争,以空间换时间,提高程序性能。