Java筑基——多线程(状态、安全以及通信)

1. 引入

在学习多线程之前,我们需要先学习一些基本概念:

多核 CPU 和多 CPU

多核 CPU 是在一枚处理器(CPU)中集成两个或多个完整的计算引擎(核心),不同的核通过 L2 cache 进行通信,存储和外设通过总线与CPU 通信。

多CPU 是多个物理 CPU,CPU 通过总线进行通信,效率比较低。

无论多个计算核是在多个 CPU 芯片上还是在单个 CPU 芯片上,我们称之为多核或多处理器系统。

CPU 核心数和线程数的关系

核心数和线程数是 1:1 的关系,也就是说 4 核的 CPU 可以同时运行 4 个线程。需要注意的是,这里说的同时是指单位时间内可以处理 4 个线程。

英特尔的多线程技术是在CPU内部仅复制必要的资源、让两个线程可同时运行;在一单位时间内处理两个线程的工作,模拟实体双核心、双线程运作。

所以,4 核的 CPU 采用如果采用了超线程技术,那么它可以同时运行 8 个线程。

CPU 时间片轮转调度算法

CPU 时间片轮转调度算法,又叫 RR 调度(Round-Robin,RR),它是专门为分时系统设计的。

在这个算法中,将一个较小的时间单元定义为时间量或时间片。时间片的大小通常是 10~100 ms。就绪队列作为循环队列。CPU 调度程序循环整个就绪队列,为每个进程分配不超过一个时间片的 CPU。

为了实现 RR 调度,我们再次将就绪队列视为进程的 FIFO 队列。新进程添加到就绪队列的尾部。CPU 调度程序从就绪队列中选择第一个进程,将定时器设置在一个时间片后中断,最后分派这个进程。

接下来,有两种情况可能发生。进程可能只需少于时间片的 CPU 执行。对于这种情况,进程本身会自动释放 CPU。调度程序接着处理就绪队列的下一个进程。否则,如果当前运行进程的 CPU 执行大于一个时间片,那么定时器会中断,进而中断操作系统。然后,进行上下文切换(从一个任务切换到另一个任务),再将进程加到就绪队列的尾部(从这点看,RR 调度是抢占式的),接着 CPU 调度程序会选择就绪队列内的下一个进程。

RR 算法的性能很大程度取决于时间片的大小。时间片设置的太短,会导致大量的上下文切换,降低 CPU 效率;时间片设置太长,可能会引起对短的交互请求的响应变差。

关于时间片轮转调度算法可以查看资料: 时间片轮转(RR)调度算法(详解版)。这里介绍地比较详细。

进程和线程

进程是操作系统进行资源分配的最小单位。一个程序至少有一个进程。线程是程序执行的最小单位,进程中的一个负责程序执行的控制单元(执行路径)。一个线程就是在进程中的一个单一的顺序控制流。一个进程至少有一个线程。

进程有自己独立的地址空间,在进程启动时,系统就给它分配了地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程小很多,而且创建一个线程的开销也比进程小很多(这是因为线程基本上不拥有系统资源,只拥有在运行中必不可少的资源,如程序计数器,一组寄存器和栈)。

线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要使用 IPC 接口,包括管道、消息排队、共用内存以及套接字等。

并行和并发

并行(Parallel)是“并排行走”或“同时实行”,在计算机操作系统中指,一组程序按照独立异步的速度执行,无论是微观还是宏观,程序都是一起执行的。
并发(Concurrent),是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

讨论并发的时候一定要加个单位时间,也就是说单位时间内的并发量是多少,离开了单位时间谈论并发是没有意义的。

2. 为什么使用并发编程?

使用并发编程可以发挥多处理器的强大能力

我们可以让操作系统同时运行多个任务,比如一边在浏览器里浏览文章,一边在 Word 里做笔记,还可以一边听着 MP3,这里就有 3 个任务在运行。

使用并发编程可以构建响应更灵敏的用户界面

比如我们在网页上看电影,一边又在下载电影,这里就需要用到两个线程:一个线程用于播放,一个线程用于下载。试想只有一个线程实现的话,就要么先在网页上看电影,等看完后再去下载;要么先下载好,再去网页上看电影。

可能会有同学说,我在一个线程里同时执行播放电影和下载电影不行吗?这样播放电影的界面会非常卡。其实,我们还可以用 Android 手机的应用来说明,我们知道应用里有一个主线程,也叫UI线程,这个线程负责处理用户的操作响应及界面的绘制,现在用户在屏幕上点击按钮下载一个 MP3 文件,这时如果仍在主线程单独去处理这个下载任务,那么用户在屏幕上做点击,滑动等操作会很卡,这非常影响用户体验。所以,这种情况下,Android 会弹出 ANR (应用无响应)的提示框。当然,最佳的做法是开启一个工作线程,单独去处理下载 MP3 文件的任务,等到下载任务完成后,通知主线程,这样用户就可以得知下载已完成。这样的好处,就是保持用户界面灵敏,及时响应。

异步化,模块化代码

比如,我们应用里有登录,数据上报,下载,这些其实都是一个一个的任务,把它们放在单独的线程里来执行,不仅可以使用户界面灵敏,而且实现了模块化。模块化怎么理解呢?比如,登录任务,在公司的几个应用里都用到了登录功能,那么我们把登录做一下封装,提供一些调用接口及回调接口供其他同学使用,这就是模块化。模块化有什么好处?避免了其他同学再去开发一遍,也可以方便地对这个模块进行测试以及定位问题。

3. Java 如何实现并发编程?

实现并发编程的方式有:多进程模式;多线程模式;多进程+多线程模式。

Java语言内置了对多线程的支持:运行一个 Java 程序实际上是在运行一个 JVM 进程,JVM 进程在主线程里执行 main() 方法;在 main() 方法内部,又可以启动多个线程。JVM 还会开启其他工作线程,如垃圾回收线程等。

所以,Java 是采用多线程模式实现并发编程的。

不过,需要特别说明的是,多线程模式下,会出现线程安全问题。

下面的部分包括从开启线程的方式,线程的状态,引出线程的安全问题,解决线程安全问题的一系列方案,线程间通信一一介绍。

4. 开启线程的方式

声明继承 Thread 的类

步骤如下:

  1. 定义一个继承 Thread 的子类;
  2. 在子类中覆盖 Thread 中的 run 方法;
  3. 创建子类对象得到线程对象;
  4. 调用线程对象的 start 方法启动线程。

下面是演示代码:

// 1, 定义一个继承 Thread 的子类
class MyThread extends Thread {
    // 2, 在子类中覆盖 Thread 中的 run 方法
    @Override
    public void run() {
        System.out.println("I am executing a heavy task.");
    }
}
public class Create1 {
    public static void main(String[] args) {
        // 3, 创建子类对象得到线程对象
        Thread myThread = new MyThread();
        // 4, 调用线程对象的 start 方法启动线程
        myThread.start();
    }
}
/**
 打印结果:
 I am executing a heavy task.
 */

声明实现 Runnable 接口的类

步骤如下:

  1. 定义实现 Runnable 接口的类;
  2. 在实现类中覆盖 Runnable 接口的 run 方法;
  3. 通过 Thread 类创建线程对象,把 Runnable 接口的实现类通过 Thread 的构造方法进行传递;
  4. 调用线程对象的 start 方法启动线程。

下面是演示代码:

// 1. 定义实现 Runnable 接口的类;
class MyRunnable implements Runnable {
    // 2. 在实现类中覆盖 Runnable 接口的 run 方法;
    @Override
    public void run() {
        System.out.println("I am executing a heavy task.");
    }
}
public class Create2 {
    public static void main(String[] args) {
        // 3. 通过 Thread 类创建线程对象,把 Runnable 接口的实现类
        // 通过 Thread 的构造方法进行传递;
        Thread thread = new Thread(new MyRunnable());
        // 4. 调用线程对象的 start 方法启动线程。
        thread.start();
    }
}
/*
打印结果:
I am executing a heavy task.
 */

需要注意的地方

一个线程实例只能调用 start 方法一次

调用多次,会抛出如下异常:

Exception in thread "main" java.lang.IllegalThreadStateException

这一点的原因,从 Thread 类的 start 方法源码可以得出来。

不能混淆了 start 方法和 run 方法

  • 它们的所属不同:start 方法是属于 Thread 类的方法,run 方法是属于 Runnable 接口的方法;
  • 它们的定位不同:在 Thread 对象上调用 start 方法才能创建并开启线程,正因为调用了 start 方法,线程才从无到有,从有到启动,它内部调用了 start0() 这个 native 方法,而run 方法仅仅是封装了需要执行的代码,这是一个普通方法本有的作用。
  • 它们的调用不同:在开启线程时,start 方法是需要手动调用来创建并开启线程的,而 线程的run 方法是由虚拟机调用的。

两种开启线程方式的区别

源码上的区别:

  • 继承Thread : 由于子类重写了Thread类的run(), 当调用start()时, 直接找子类的run()方法;
  • 实现Runnable :构造函数中传入了Runnable的引用, 赋值给成员变量Runnable targetstart()调用run()方法时内部判断成员变量Runnable的引用是否为空,不为空编译时看的是Runnablerun(),运行时执行的是子类的 run() 方法。
    // 这是 Thread 类的代码。
    private Runnable target;
    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
    

使用上的区别:

  • 继承Thread类:可以直接使用Thread类中的方法,代码简单;但是如果已经有了父类,就不能用这种方法,这是因为 Java 中的类不支持多继承;
  • 实现 Runnable 接口:将线程的任务从线程的子类中分离出来,进行了单独的封装。按照面向对象的思想将任务封装成了对象。这个是思想的变化。即使自己定义的线程类有了父类也没关系,因为有了父类也可以实现接口,而且接口是可以多实现的,避免了 Java 中单继承的缺点。但是,不能直接使用Thread中的方法需要先获取到线程对象后,才能得到Thread的方法,代码复杂。

5. 线程的状态

在这里插入图片描述
线程的状态究竟有几种,网上有多个版本,说的都有道理;但其实 Java 已经帮我们定义好了线程的状态。
我们看一下 Thread 类中的枚举类 State ,它包含 6 种状态:

public enum State {
	NEW,
	RUNNABLE,
	BLOCKED,
	WAITING,
	TIMED_WAITING,
	TERMINATED;
}
  • NEW(新建状态)

    在创建了 Thread 对象后还没有调用 start() 方法时所处的状态,这时只是一个堆内存中的对象而已。

  • RUNNABLE(可运行状态)

    线程对象调用 start() 方法后,它会被线程调度器执行,也就是交给操作系统来执行,这整个状态叫 RUNNABLE。处在这个状态的线程正在 JVM 里面执行,但也可能正在等待操作系统(例如处理器)分配资源。

    所以 RUNNABLE 内部有两个状态:Ready 就绪状态和 Running 运行状态。

    • Ready 状态

      Ready 状态是说线程目前处于 CPU 的等待队列里,在等待 CPU 执行。这时线程对象具备 CPU 执行资格(具备 CPU 执行资格是指线程对象可以被 CPU 处理,正在处理队列中排队),但是不具备 CPU 执行权(具备 CPU 执行权指的是获取了 CPU 的时间片,正在执行 run() 方法中的代码)。

      这里举个例子来说明 CPU 执行资格和 CPU 执行权:去园区餐厅吃饭需要有园区的餐卡才可以,这时就可以说有卡的员工能够在园区排队打饭,也就是说具备打饭的资格;有卡正在打饭的员工是具备打饭的资格并且具备打饭的执行权;那么,没有卡的外来人员,不能在园区排队打饭,也就是说没有打饭的资格,当然也不可能去打饭,也就是说没有打饭的执行权。
      大家一定要理解 CPU 执行资格和 CPU 执行权,因为下面会用它们来区分线程的一些状态。

    • Running 状态

      Running 状态是说线程正在被 CPU 执行。这时线程具备 CPU 的执行资格并且具备 CPU 的执行权,具体来说,线程正处于 CPU 分配的时间片内,执行着 run() 方法里的代码。

  • BLOCKED 阻塞状态

    线程正在等待获取监视器锁对象,就处于阻塞状态。处于受阻塞状态的某一线程正在等待监视器锁,以便进入一个同步的块/方法,或者在调用 Object.wait() 之后再次进入同步的块/方法。

  • WAITING 等待状态

  • TIMED_WAITING 定时等待状态

  • TERMINATED消亡状态

    run() 方法结束的时候,也就是线程任务完成的时候,就自然进入了消亡状态;
    当调用了线程对象的 stop()(这个方法已经标记为 @Deprecated,不建议使用的方法) 方法后,好比是因为不可抗力进入消亡状态;
    至于 thread.setDaemon(true) 是指的后台线程,调用这句代码需要在线程的 start() 方法之前调用,把线程设置为后台线程。后台线程,是在程序运行时在后台提供一种通用服务的线程,它并不属于程序中不可缺少的部分。因此,当所有的非后台线程结束时,程序就终止了,并且会杀死进程中所有的后台线程。

另外,需要说明一下上面图中提到的方法:

join 方法

 public final synchronized void join(long millis)
    throws InterruptedException

这是 Thread 类的一个成员方法,会抛出 InterruptedException。比如现在有线程 A 和线程 B,A,B 线程已经开启,现在在 A 线程的 run 方法里,调用 B线程的 join() 方法,这时 A 线程就会被挂起,直到 B 线程结束了才恢复。直观地理解就是,B 线程插到 A 线程之前执行。
下面是演示代码:

class Task implements Runnable {
    private Thread joiner;
    public Task(Thread joiner) {
        this.joiner = joiner;
    }
    @Override
    public void run() {
        try {
            joiner.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " is doing task.");
    }
}
public class JoinDemo {
    public static void main(String[] args) {
        Thread joiner = Thread.currentThread();
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(new Task(joiner), "Thread " + i);
            thread.start();
            joiner = thread;
        }
        System.out.println(Thread.currentThread().getName() + " is doing task.");
    }
}

打印结果:

main is doing task.
Thread 0 is doing task.
Thread 1 is doing task.
Thread 2 is doing task.
Thread 3 is doing task.
Thread 4 is doing task.

可以看到,小号线程都是在大号线程前面执行,这不是一种偶然,每次运行都是这样的结果。这是因为调用了 joiner.join() 方法。可以尝试一下,把 joiner.join() 注释掉,打印出来的结果一定不能保证每次都一样。

sleep 方法:

public static native void sleep(long millis) 
	throws InterruptedException;

这是 Thread 类中的一个静态方法,会抛出 InterruptedException。表示使任务中止执行给定的时间。

yield 方法:

public static native void yield();

这是 Thread 类的一个静态方法,没有异常抛出。执行这个方法,表明当前线程已经执行完最重要的任务,现在给线程调度器建议:切换给其他线程执行。

interrupt 方法:

public void interrupt()
public static boolean interrupted()

Thread 类中有一个静态的 interrupted() 方法和一个interrupt() 成员方法。关于它们的不同,后面会做说明。

wait 方法:
Object 类中:

public final void wait() throws InterruptedException
public final native void wait(long timeout) throws InterruptedException;
public final void wait(long timeout, int nanos) throws InterruptedException

表示等待某个条件发生变化。需要强调一下的是,这个方法是 Object 类中的方法,而不是 Thread 类中的方法。
notify/notifyAll 方法:
Object 类中:

public final native void notify();
public final native void notifyAll();

表示通知条件已发生变化。同样地,需要强调一下的是,这个方法是 Object类中的方法,而不是 Thread 类中的方法。

6. 线程安全问题

6.1 线程安全问题的原因

我们从一个多窗口卖票的例子来做一下说明线程安全问题:
火车站售票大厅有 4 个售票窗口可以售票,现有 10 张票待售。现在考虑一下,使用程序实现这个卖票的过程。

思考一下:10 张票待出售,这是任务,任务在程序里是 Runnable 的实现类;4 个售票窗口是用来执行售票任务,在程序中就是线程。

程序实现如下:

class Ticket implements Runnable {
    private int num = 10;
    @Override
    public void run() {
        while (true) {
            if (num > 0) {
            	// 线程逗留点1
                try {
                	// 售票需要时间,这里给 100 ms。
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 线程逗留点2
                System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
            }
        }
    }
}

public class TicketDemo {
    public static void main(String[] args) {
        Ticket t = new Ticket();
        Thread window1 = new Thread(t, "Window1");
        Thread window2 = new Thread(t, "Window2");
        Thread window3 = new Thread(t, "Window3");
        Thread window4 = new Thread(t, "Window4");
        window1.start();
        window2.start();
        window3.start();
        window4.start();
    }
}

运行一下上面的程序,可以发现打印结果并非是每次一样的。
这里以一次的运行结果为例,来说明问题:

Window4.....sell.....Ticket#10
Window1.....sell.....Ticket#8
Window3.....sell.....Ticket#9
Window2.....sell.....Ticket#10
Window1.....sell.....Ticket#7
Window2.....sell.....Ticket#6
Window3.....sell.....Ticket#5
Window4.....sell.....Ticket#4
Window1.....sell.....Ticket#3
Window4.....sell.....Ticket#2
Window3.....sell.....Ticket#1
Window2.....sell.....Ticket#0
Window1.....sell.....Ticket#-1
Window4.....sell.....Ticket#-2

观察上面的打印信息,不难发现一些不对劲儿的地方:
Ticket#10 这张票,被 Window4 和 Window2 各售出一次;更严重的是,竟然卖出了 Ticket#0,Ticker#-1,Ticket#-2 这种根本就不存在的票。

这是什么原因呢?我们就打印日志的最后四行来分析:

Window3.....sell.....Ticket#1
Window2.....sell.....Ticket#0
Window1.....sell.....Ticket#-1
Window4.....sell.....Ticket#-2

run 方法里的 if 语句单独拿出来:

if (num > 0) {
  	 // 线程执行站点1
      try {
      	// 售票需要时间,这里给 100 ms。
          Thread.sleep(100);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
      // 线程执行站点2
      System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
}

为方便说明,在上面的代码中加入了线程站点 1,线程站点 2。它们的含义是线程在这两处可能被给到 CPU 时间片,或者被剥夺 CPU 时间片。
Window3 首先拿到了 CPU 时间片,它执行到了打印语句的地方;与此同时,Window2,Window1,Window4 都到达了线程执行站点1,它们被剥夺了 CPU 时间片。在 Window3 执行完打印语句后,这时 num 的值已经是 0 了;
这时 Window2 获取了 CPU 时间片,继续执行它的代码,打印出 0 号票,这时num 的值已经是-1
之后,Window1 获取了 CPU 时间片,继续执行它的代码,打印出 -1 号票,这时 num 的值已经是 -2 了;
之后,Window4 获取到了 CPU 时间片,继续执行它的代码,打印出 -2 号票。

上面分析了导致结果异常的原因,就是多个线程同时执行了相同的任务,对票数这一数据操作导致的。
试想一下,如果只有一个窗口在卖票,还会出现输出结果异常吗?
我们可以把 Window2,Window3,Window4 这几个窗口关掉,仅留下 Window1,打印一下,结果如下:

Window1.....sell.....Ticket#10
Window1.....sell.....Ticket#9
Window1.....sell.....Ticket#8
Window1.....sell.....Ticket#7
Window1.....sell.....Ticket#6
Window1.....sell.....Ticket#5
Window1.....sell.....Ticket#4
Window1.....sell.....Ticket#3
Window1.....sell.....Ticket#2
Window1.....sell.....Ticket#1

这可不是偶然的结果,因为一个线程执行任务,数据只有它自己在操作,不会出现异常情况。
那么,多线程执行同一个任务就一定会出现问题吗?并不是的,如果多线程所执行的任务只是一行打印语句,当然不会有问题。问题在于任务里面包含了多行代码。

所以,这里总结一下,线程安全问题产生的原因
第一点,多个线程在操作共享的数据;
第二点,操作共享数据的线程代码有多行。

这两点是且的关系。

在本例中,共享的数据就是 Ticket 对象。

6.2 线程安全问题解决办法

如果有一种机制,能使一个线程在操作多行代码时,其他线程不能再去操作这些多行代码;只有当这个线程操作完这些多行代码后,其他线程才能够去操作这些多行代码。这样就能好比把这些多行代码打包成一个整体,就好像“一行代码”一样。这样就不会出现线程安全问题了。
在 Java 中,已经存在这样的机制,这种机制是通过关键字 synchronized 来完成的。

6.2.1 同步代码块

同步代码块的写法如下:

sychronized(对象) {
	需要被同步的代码;
}

其中,sychronized 是一个关键字,后面跟着一个括号里,是对象,起锁的作用,大括号里就是需要同步的代码。这里面关键的是用什么对象来作锁,识别哪些是需要被同步的代码。

Java 通过提供synchronized 关键字的形式,对于防止资源冲突提供了内置的支持。当任务要执行被 synchronized 关键字保护的代码片段的时候,会先检查锁是否可用,可用的话就获取锁,执行代码片段,再释放锁;不可用的话就不能获取锁,也就不能执行代码片段,此时任务处于阻塞状态。

回到卖票的例子里,我们使用一个 new Object(); 对象来作锁,需要同步的代码是:

if (num > 0) {
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
}

完整的代码如下:

class Ticket implements Runnable {
    private int num = 10;
    private Object obj = new Object();
    @Override
    public void run() {
        while (true) {
            synchronized (obj) {
                if (num > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
                }
            }
        }
    }
}

再次多次运行程序,可以看到输出结果都是正常的。

6.2.2 同步函数

我们把需要同步的多行代码,封装在一个函数 sellTicket 里面,代码就是这样的:

class Ticket implements Runnable {
    private int num = 10;
    private Object obj = new Object();

    @Override
    public void run() {
        while (true) {
            sellTicket();
        }
    }

    private void sellTicket() {
        if (num > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
        }
    }
}

注意,目前我们并没有做同步处理,运行时必然是存在线程安全问题的。现在我们对 sellTicket() 里面的代码作同步处理,如下:

private void sellTicket() {
    synchronized (obj) {
        if (num > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
        }
    }
}

这样同样解决了线程安全问题。但是,我们注意到,sellTicket 是对需要同步的多行代码进行了封装,而同步代码块同样是对多行代码进行了封装,实现了同步。既然它们都是封装,难道不能合并吗?
可以的,这就是同步函数的写法:

private synchronized void sellTicket() {
    if (num > 0) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
    }
}

就是把 sychronized 关键字写在函数声明里面,这和上面同步代码块的写法作用是一样的,同步函数可以说是同步代码写法的简写形式。

6.2.3 静态同步函数

在同步函数的声明上添加 static 关键字,这就是静态同步函数:

private static synchronized void sellTicket() {
    if (num > 0) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ".....sell.....Ticket#" + num--);
    }
}

在本例中,还需要把 num 变量声明为 static 类型。
静态同步函数同样可以达到目的。

6.2.4 同步代码块,同步函数,静态同步函数的区别

既然三者都可以实现同步的目的,那么它们之间有什么区别呢?
它们的区别在于它们持有的锁不一样。
我们知道,同步代码块的锁可以是任意的对象
同步函数使用的锁是 this
静态同步函数使用的锁是该函数所在类的字节码文件对象,即类名.class。

6.2.5 同步的优点和缺点

同步的好处:解决了线程的安全问题;
同步的弊端:相对降低了效率,因为同步外的线程都会判断同步锁;
同步的前提:同步中必须有多个线程并使用同一个锁。多个线程需要在同一个锁当中。

6.2.6 死锁的例子

如果此时有一个线程 A,需要按照先获得锁 1 再获得锁 2 的的顺序获得锁,而在此同时又有另外一个线程B,按照先获得锁 2 再锁 1 的顺序获得锁,这种情况就会造成死锁,谁也无法拿到对方的锁。

class TaskA implements Runnable {

    @Override
    public void run() {
        while (true) {
            synchronized (DeadLockDemo.lock1) {
                System.out.println(Thread.currentThread().getName() + " do something.");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (DeadLockDemo.lock2) {
                    System.out.println(Thread.currentThread().getName() + " do other thing.");
                }
            }
        }
    }
}

class TaskB implements Runnable {

    @Override
    public void run() {
        while (true) {
            synchronized (DeadLockDemo.lock2) {
                System.out.println(Thread.currentThread().getName() + " do something.");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (DeadLockDemo.lock1) {
                    System.out.println(Thread.currentThread().getName() + " do other thing.");
                }
            }
        }

    }
}
public class DeadLockDemo {
    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(new TaskA(), "ThreadA");
        Thread threadB = new Thread(new TaskB(), "ThreadB");

        threadA.start();
        threadB.start();

    }
}

打印结果1:

ThreadB do something.
ThreadA do something.

打印结果2:

ThreadA do something.
ThreadB do something.

这里分析一下为什么会造成死锁?就第一种打印结果来说明。
main() 方法中开启了 ThreadAThreadB 之后,ThreadB 首先获得 CPU 的执行权,开始执行 TaskBrun() 方法:获取 DeadLockDemo.lock2 这把锁,打印出 ThreadB do something. ,接着就调用 Thread.sleep(100); 开始休眠 100 ms。

到这里,需要特别说一下 sleep() 方法的作用:当调用 sleep() 方法之后,当前线程会释放 CPU 的执行权,但是不会释放锁。

回到我们的例子,调用 Thread.sleep(100); 之后,当前线程 ThreadB 会释放 CPU 的执行权,但是不会释放它持有的锁DeadLockDemo.lock2

接着,线程 ThreadA 获取了 CPU 的执行权,开始执行 TaskArun() 方法:获取 DeadLockDemo.lock1 锁,打印出 ThreadA do something.,接着调用 Thread.sleep(100); 开始休眠 100 ms,也就是说,线程 ThreadA 会释放 CPU 的执行权,但是不释放它持有的锁 DeadLockDemo.lock1

100 ms 之后,ThreadAThreadB 休眠时间到了,就会继续往下执行代码,这时它们中的一个会获取 CPU 的执行权,比如说是 ThreadA 获取了 CPU 的执行权,它去获取锁 DeadLockDemo.lock2,但是这把锁还被 ThreadB 持有,所以 ThreadA 无法获得这把锁,ThreadA 就不得不阻塞在这里。

ThreadA 被阻塞后,ThreadB 就被 CPU 选中了,它从休眠的代码后继续执行,去获取 DeadLockDemo.lock1 这把锁,但是这把锁还被 ThreadA 持有,所以 ThreadB 无法获得这把锁,ThreadB 就不得不阻塞在这里。

7. 线程间通信

7.1 单开发单测试的例子

7.1.1 发布 apk /测试 apk 的例子

在软件开发过程中,对 apk 来说有两个过程:一个是开发工程师发布 apk,一个是测试工程师测试 apk。测试 apk 任务在发布 apk 任务完成之前,是不能执行工作的;而发布 apk 任务在发另一个 apk 之前,必须等待测试任务完成。

从面向对象思想的角度,apk 在程序里就是一个对象,所以我们声明 Apk.java,它目前有两个属性,apkName 表示应用名称,versionName 表示版本名称:

class Apk {
   String apkName;
   String versionName;
}

两个过程:开发工程师发布 apk,测试工程师测试 apk,在程序中就是两个不同的任务,即两个不同的 Runnable 实现类。分别命名为 ReleaseApkRunnableTestApkRunnable,它们都是 Runnable 接口的实现类。
虽然任务是不同的,但是它们都要处理同一个 apk,即测试工程师测试的 apk 就是开发工程师发布的 apk。所以,这两个任务拥有共同的资源,即 Apk 对象,声明如下:

Apk apk = new Apk();

在程序中,如何让两个任务共享这个 Apk 对象呢?这里,采用通过任务声明的构造函数把 Apk 对象分别注入到两个任务中。
开发工程师执行发布 apk 的任务,我们需要把这个任务放在 ReleaseApkRunnbalerun 方法里面,代码如下:

class ReleaseApkRunnable implements Runnable {
   private Apk apk;

   public ReleaseApkRunnable(Apk apk) {
       this.apk = apk;
   }

   @Override
   public void run() {
       int x = 0;
       while (true) {
           try {
               // 这 200 ms 当作开发时间
               TimeUnit.MILLISECONDS.sleep(200);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
           if (x % 2 == 0) {
               apk.apkName = "QQ";
               apk.versionName = "Overseas";
           } else {
               apk.apkName = "微信";
               apk.versionName = "国内版";
           }
           System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
           x++;
       }
   }
}

可以看到上面的代码,通过构造函数传参的方式,把 Apk 对象这个共同的资源传递进来;在 run 方法里,就是开发工程师发布 apk 的任务代码:首先,使用 200 ms 的休眠代表开发时间,然后就开始打包,这里有两种包:QQ 的 Overseas 版,微信的国内版,最后发布。
我们使用了一个 int x 来保证开发工程师发布的包是按照 QQ 的 Overseas 版,微信的国内版这样的顺序一个一个发布的。这一点是比较好理解的。

需要注意的是,上述的发布过程包含在一个 while 无限循环里。

测试工程师执行测试 apk 的任务,同样地需要把测试 apk 的执行代码放到 TestApkRunnablerun 方法中:

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                // 这 60 ms 作为测试时间
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

同样地,在上面的代码中,通过构造函数传参的方式,把 Apk 对象传递给 TestApkRunnable 类,在 run 方法内部,就是执行测试任务的代码:首先,休眠 60ms 的时间作为测试时间,之后,就发出测试结果,这里都作测试通过处理。
同样地,测试 apk 的过程包含在一个 while 无限循环里面。

要运行ReleaseApkRunnableTestApkRunnable 这两个任务,需要创建两个线程,releaseThreadtestThread,并调用它们的 start 方法。测试代码如下:

public class ApkDemo {
    public static void main(String[] args) {
        Apk apk = new Apk();
        Runnable releaseApkRunnable = new ReleaseApkRunnable(apk);
        Runnable testApkRunnable = new TestApkRunnable(apk);
        Thread releaseThread = new Thread(releaseApkRunnable);
        Thread testThread = new Thread(testApkRunnable);
        releaseThread.start();
        testThread.start();
    }
}

运行程序后,其中一次的打印结果如下(这里只是截取一段日志):

Test pass: null,null
Test pass: null,null
Test pass: null,null
Release apk: QQ,Overseas
Test pass: QQ,Overseas
Test pass: QQ,Overseas
Test pass: QQ,Overseas
Release apk: 微信,国内版
Test pass: 微信,国内版
Test pass: 微信,国内版
Test pass: 微信,国内版

从打印结果里,我们看到:
在没有任何 Apk 信息的情况下,测试工程师首先就开始了 3 次测试,也就是说,开发工程师还没有帆布 Apk 包,测试工程师就进行了 3 次测试,这肯定是不对的。
开发工程师发布了一个 QQ,Overseas 的 Apk 包,测试工程师居然进行了 3 次测试 QQ, Overseas 包的过程,这也是不对的。因为,我们的设定是一次测试通过,不存在这种多次测试一个包的情况。

7.1.2 解决数据错乱问题

还有一个问题,上面的例子没有跑出来,就是由于线程不安全造成数据错乱的问题。

回顾一下,线程安全问题产生的条件:
第一,多条线程操作共享数据;
第二,共享数据里包含多条执行代码。

看一下我们的代码,ReleaseApkRunnableTestApkRunnable 都在操作共享数据 Apk 对象,满足第一条;对共享数据的处理,包括给 apkNameversionName 赋值,以及打印语句,这里面包含了多行执行代码,满足第二条。所以,这个例子也是有线程安全问题。

这里通过把 ReleaseApkRunnableTestApkRunnable 稍作修改,来验证存在线程安全问题:

class ReleaseApkRunnable implements Runnable {
    private Apk apk;

    public ReleaseApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        int x = 0;
        while (true) {
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "国内版";
            }
            x++;
        }
    }
}

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

截取一段打印结果如下:

Test pass: QQ,国内版
Test pass: QQ,Overseas
Test pass: QQ,Overseas
Test pass: 微信,国内版
Test pass: 微信,国内版
Test pass: 微信,Overseas

这就证明了我们的例子确实存在线程安全问题。

解决线程安全问题,这里采用同步代码块。

ReleaseApkRunnable 中的 run 方法调整如下:

public void run() {
    int x = 0;
    while (true) {
        synchronized (apk) {
            try {
                // 这 200 ms 当作开发时间
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "国内版";
            }
            System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
        }
        x++;
    }
}

TestApkRunnable 中的 run 方法调整如下:

public void run() {
    while (true) {
        synchronized (apk) {
            try {
                // 这 60 ms 作为测试时间
                TimeUnit.MILLISECONDS.sleep(75);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

需要特别注意的是两者使用的是同一个锁,即 Apk 对象。这样才能保证同步。可以测试,如果两者使用的是不同的锁,一定不能保证同步,也就是说一定还存在线程安全问题。

解决了线程安全问题之后,回到打印结果不正常的问题,这样的结果是如何造成的呢?

可以回顾上面的代码,开发工程师发布 apk 的任务就是不停地按照一个 QQ,Overseas版,一个微信,国内版,这样的次序不停地在发布 apk;而测试工程师测试 apk 的任务就是不停地测试通过。这两个任务彼此互不关心:开发工程师不管测试工程师是不是测试 apk 完毕,只管自己发布 apk;测试工程师也不去看看开发工程师有没有待测的 apk,只管傻乎乎地去执行测试。

它们之间没有任何沟通,没有协作造成了输出结果不正常。

如何解决上面提到的问题呢?

7.1.3 尝试解决线程不协作的问题

可以想到实际的工作中,是不会出现这些问题的。因为开发工程师总是在确认测试工程师测试 apk 完毕后才会发布另一个 apk;测试工程师也会在知道有待测的 apk 的情况下,才会去执行测试。

这里我们给 Apk 对象添加一个字段:boolean isForTest;

class Apk {
    String apkName;
    String versionName;
    // 新增加的字段,默认是 false
    boolean isForTest = false; 
}

当开发工程师确认 isForTestfalse时,表示测试工程师没有测试 apk,这时就会发布 apk,并且把 isForTest 设置为 true,表示交付测试了;如果 isForTesttrue 时,表示测试工程师有测试 apk,开发工程师就不再执行发布 apk 的代码。

当测试工程师确认 isForTesttrue 时,表示现在有 apk 需要测试,就会执行测试 apk 代码;如果 isForTestfalse,表示现在没有 apk 需要测试,就不执行测试 apk 的代码。

上面就是我们解决问题的思路,下面看代码实现:

修改 ReleaseApkRunnable 中的 run 方法如下:

public void run() {
    int x = 0;
    while (true) {
        synchronized (apk) {
            if (apk.isForTest) {
            	// 测试工程师有apk在测试,不执行发布apk的代码
                continue;
            }
            try {
                // 这 200 ms 当作开发时间
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "国内版";
            }
            System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
            // 发布apk后,设置 isForTest 为 true,表示交付测试。
            apk.isForTest = true;
        }
        x++;
    }
}

修改 TestApkRunnablerun 方法如下:

public void run() {
    while (true) {
        synchronized (apk) {
            if (!apk.isForTest) {
            	// 没有 apk 要测试,不执行下面测试 apk 的代码。
                continue;
            }
            try {
                // 这 60 ms 作为测试时间
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 测试完毕,把 isForTest 改为 false,表明手头没有要测试的 apk。
            apk.isForTest = false;
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
        }
    }
}

好了,多次执行测试代码,都可以得到正确的结果,如下面的截取日志:

Release apk: QQ,Overseas
Test pass: QQ,Overseas
Release apk: 微信,国内版
Test pass: 微信,国内版

虽然打印输出是正确的,但是程序本身还是存在很大的问题。

回顾一下代码,开发工程师在判断有测试 apk 的标记 isForTesttrue 时,是采用 continue 的方式跳过发布 apk 的代码,继续下一轮循环;而测试工程师在判断有要测试 apk 的标记 isForTestfalse 时,同样采用 continue 的方式跳过测试 apk 的代码,继续下一轮循环。
如果开发工程师一直不发布 apk,那么测试工程师就要一直去循环:检查 isForTest 的标记何时为 true,以便去执行测试的代码;如果测试工程师一直有 apk 待测,那么开发工程师就要一直去循环:检查 isForTest 的标记何时为 false,以便去执行发布 apk 的代码。

这显然是不正常的,开发工程师不可能不停地查看测试工程师是否测试完毕,测试工程师也不会一直询问开发工程师发布 apk 了没有。这样的话,等于在浪费时间。对于程序来说这会消耗宝贵的资源:它们都处于运行状态,它们都需要 CPU 分配时间片。

我们知道,实际的工作中的情况是这样的:

开发工程师在注意到测试工程师还有 apk 测试时,就知道这时不必去发布 apk,这时等着就行了,如果测试工程师没有 apk 在测试,那么就发布 一个 apk,并通知测试工程师:新的 apk 已发布,请测试。这时,测试工程师收到通知,就开始执行测试 apk 的过程。

测试工程师在注意到开发工程师还没有 apk 发布时,就知道这时不必去测试 apk,这时等着就行了;如果开发工程师发布了 apk,就去测试 apk,并通知开发工程师:apk 已测试完毕,测试通过。这时,开发工程师收到通知,就开始执行发布 apk 的过程。

那么,用程序如何实现呢?这就需要等待/唤醒机制。

7.2 等待/唤醒机制

7.2.1 初步代码实现

在 Java 中,有对应的实现:

Object 类中的 wait() 方法:调用对象的 wait() 方法时,当前线程被挂起,而锁会被释放。在其他线程调用此对象的 notify() 方法或notifyAll() 方法前,当前线程就会处于等待状态。这时线程释放了 CPU 执行权,并释放了 CPU 执行资格。

Object 类中的 notify() 方法:唤醒因调用对象的 wait() 方法的而被挂起的任务。如果有多个任务在此对象上等待,则会选择唤醒一个任务。被唤醒的任务就具备了 CPU 执行资格。

需要注意的是,如果当前线程不是对象监视器的所有者,那么调用对象监视器的 wait(),或notify() 方法会抛出 IllegalMonitorStateException。这个异常的含义是当一个线程本身不持有指定的对象监视器,却试图在这个对象监视器上等待,或者通知其他在这个对象监视器上等待的线程,这时就会抛出这个异常。换句话说,只有当前线程是对象监视器的所有者时,调用对象监视器的 wait()notify() 方法才不会抛出 IllegalMonitorStateException。所以,我们必须在同步中,调用对象的 wait()notify() 方法。

我们来看代码实现:

修改 ReleaseApkRunnablerun 方法如下:

public void run() {
    int x = 0;
    while (true) {
        synchronized (apk) {
            if (apk.isForTest) {
            	// 把 continue; 替换为 apk.wait();
                try {
                    apk.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 这 200 ms 当作开发时间
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (x % 2 == 0) {
                apk.apkName = "QQ";
                apk.versionName = "Overseas";
            } else {
                apk.apkName = "微信";
                apk.versionName = "国内版";
            }
            apk.isForTest = true;
            System.out.println("Release apk: "  + apk.apkName + "," + apk.versionName );
            // 添加了通知,唤醒方法的调用
            apk.notify();
        }
        x++;
    }
}

修改的地方有两处:一是把之前例子中的 continue; 替换为了 apk.wait(),二是在同步代码块的最后一行,添加 apk.notify()

解释一下改动的含义:

if 语句判断有 apk 在测试时,就会进入 if 分支调用 apk.wait() ,这时开发工程师线程会被挂起,而 apk 这个锁对象会被开发工程师线程释放。开发工程师线程会一直等待,直到测试工程师通知他需要再发包为止。

当开发工程师执行完发包任务后,就调用 apk.notify() 方法,这时就会通知在等待测试 apk 的测试工程师:新的 apk 已发布,请测试。这将通知在对 wait() 的调用中被挂起的测试工程师线程继续工作。在等待中的测试工程师就会收到通知继续工作前,必须重新获得之前因为调用 apk.wait() 时释放的锁。

修改 TestApkRunnablerun 方法如下:

public void run() {
    while (true) {
        synchronized (apk) {
            if (!apk.isForTest) {
            	// 把 continue; 替换为 apk.wait();
                try {
                    apk.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 这 60 ms 作为测试时间
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Test pass: "  + apk.apkName + "," + apk.versionName );
            apk.isForTest = false;
            // 添加了通知,唤醒方法的调用
            apk.notify();
        }
    }
}

修改的地方仍是两处:一是把之前例子中的 continue; 替换为了 apk.wait(),二是在同步代码块的最后一行,添加 apk.notify()

解释一下改动的含义:

if 语句判断没有 apk 需要测试时,就会进入 if 分支调用 apk.wait() ,这时测试工程师线程会被挂起,而 apk 这个锁对象会被测试工程师线程释放。测试工程师线程会一直等待,直到开发工程师通知他有新包要测试为止。

当测试工程师执行完测试任务后,就调用 apk.notify() 方法,这时就会通知在等待发布 apk 的开发工程师:apk 已测试完毕,测试通过。这将通知在对 wait() 的调用中被挂起的开发工程师线程继续工作。在等待中的开发工程师就会收到通知继续工作前,必须重新获得之前因为调用 apk.wait() 时释放的锁。

运行一下程序,结果是符合预期的。

总结一下,采用了等待/唤醒机制的例子与之前 7.1.3 中的实现相比,不再依靠循环来决定开发工程师何时发包,测试工程师何时测试,这会减少对 CPU 的无效占用。

7.2.2 优化后的代码实现

对 7.2.1 中的实现,优化为同步函数的实现,这也是实际开发中的写法。其实,就是进行了封装而已。这样的好处是,可以实现同步代码的复用。

class Apk {
    private String apkName;
    private String versionName;
    private boolean isForTest = false;

    public synchronized void releaseApk(String apkName, String versionName) {
        if (isForTest) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 这 200 ms 当作开发时间
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.apkName = apkName;
        this.versionName = versionName;
        isForTest = true;
        System.out.println("Release apk: " + this);
        notify();
    }

    public synchronized void testApk() {
        if (!isForTest) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 这 60 ms 作为测试时间
            TimeUnit.MILLISECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isForTest = false;
        System.out.println("Test Apk: " + this);
        notify();
    }

    @Override
    public String toString() {
        return apkName + "," + versionName;
    }
}

class ReleaseApkRunnable implements Runnable {
    private Apk apk;

    public ReleaseApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        int x = 0;
        while (true) {
            if (x % 2 == 0) {
                apk.releaseApk("QQ", "Overseas");
            } else {
                apk.releaseApk("微信", "国内版");
            }
            x++;
        }

    }
}

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            apk.testApk();
        }
    }
}

7.3 多开发多测试的例子

7.3.1 例子

由于公司业务的发展,一个开发加一个测试难以支撑,所以公司就新招一名开发以及一名测试。现在,有两名开发工程师,两名测试工程师。他们组成新的团队,共同完成任务。

首先是在 main() 方法里,增加了一条开发工程师线程,以及一条测试工程师线程,代码如下:

Thread releaseThread1 = new Thread(releaseApkRunnable, "releaseThread1");
Thread releaseThread2 = new Thread(releaseApkRunnable, "releaseThread2");
Thread testThread1 = new Thread(testApkRunnable, "testThread1");
Thread testThread2 = new Thread(testApkRunnable, "testThread2");
releaseThread1.start();
releaseThread2.start();
testThread1.start();
testThread2.start();

可以看到,这里通过 Thread 类的构造方法设置了线程的名字:releaseThread1releaseThread2testThread1testThread2。这样设置后,通过 Thread.currentThread().getName() 获取到的就是我们设置的名字,这样可读性更好。

其次,简化了 ReleaseApkRunnablerun 方法发布 QQ 应用:

while (true) {
    apk.releaseApk("QQ");
}

最后,在 Apk 类中,增加 code 字段,表示版本号,每次发布新包,版本号都会在原来的基础上加 1,代码如下:

public synchronized void releaseApk(String name) {
    if (isForTest) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    try {
        // 这 200 ms 当作开发时间
        TimeUnit.MILLISECONDS.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    this.apkName = name +"-V"+ code;
    System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.apkName);
    code++;
    isForTest = true;
    notify();
}

完整代码如下:

class Apk {
    private String apkName;
    private boolean isForTest = false;
    private int code = 1;
    public synchronized void releaseApk(String name) {
        if (isForTest) {
            try {
                wait(); // rt1
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 这 200 ms 当作开发时间
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.apkName = name +"-V"+ code;
        System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.apkName);
        code++;
        isForTest = true;
        notify();
    }

    public synchronized void testApk() {
        if (!isForTest) {
            try {
                wait(); // tt2, tt1
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 这 60 ms 作为测试时间
            TimeUnit.MILLISECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " Test Apk: " + this.apkName);
        isForTest = false;
        notify();
    }
}

class ReleaseApkRunnable implements Runnable {
    private Apk apk;

    public ReleaseApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            apk.releaseApk("QQ");
        }
    }
}

class TestApkRunnable implements Runnable {
    private Apk apk;

    public TestApkRunnable(Apk apk) {
        this.apk = apk;
    }

    @Override
    public void run() {
        while (true) {
            apk.testApk();
        }
    }
}

public class ApkDemo {
    public static void main(String[] args) {
        Apk apk = new Apk();
        Runnable releaseApkRunnable = new ReleaseApkRunnable(apk);
        Runnable testApkRunnable = new TestApkRunnable(apk);
        Thread releaseThread1 = new Thread(releaseApkRunnable, "releaseThread1");
        Thread releaseThread2 = new Thread(releaseApkRunnable, "releaseThread2");
        Thread testThread1 = new Thread(testApkRunnable, "testThread1");
        Thread testThread2 = new Thread(testApkRunnable, "testThread2");
        releaseThread1.start();
        releaseThread2.start();
        testThread1.start();
        testThread2.start();
    }
}

人员增加后,工作流程还是一样的:开发工程师先发布 apk,测试工程师才能测试 apk;测试工程师测试 apk 完毕后,开发工程师才能继续发布 apk。

7.3.2 分析打印输出不正确的问题

运行一下代码,查看现在是否还可以达到预期的效果。

这里取出刚开始的一段打印日志,大家看一下:

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
releaseThread2============>Release apk: QQ-V3
releaseThread1============>Release apk: QQ-V4
testThread1 Test Apk: QQ-V4

这段日志里就包含了两个问题:

一,发布了一个版本,测试了两轮

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2

二,漏测试版本

releaseThread2============>Release apk: QQ-V3
releaseThread1============>Release apk: QQ-V4
testThread1 Test Apk: QQ-V4

这两个问题都是十分严重的,严重违反了工作流程。

但是,我们的代码确实做了同步处理,也使用等待/唤醒机制。为什么在增加了一个开发,一个测试后,就出问题了呢?

有问题看日志,所以我们认真分析一下日志。

先看第一段问题日志:

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2
testThread1 Test Apk: QQ-V2
testThread2 Test Apk: QQ-V2

下面分析一下流程,步骤有些多,大家耐心一些啊:

应用刚启动,releaseThread1 就获取锁,这时 isForTestfalse,不会进入 if (isForTest) 里面,继续执行发布 apk 的代码,打印出 releaseThread1============>Release apk: QQ-V1 这行日志,修改 isForTest 的标记为 true,然后调用了 notify() 方法,不过这时等待队列中没有线程,之后,releaseThread1 就结束任务执行,自动释放了锁。

接着,testThread2 获取到了锁,开始执行同步方法里的代码:首先判断 if(!isForTest) 为 false(因为 releaseThread1 里将 isForTest 改为 true!isForTestfalse),不会进入 if 分支,继续执行测试 apk 的代码,打印出 testThread2 Test Apk: QQ-V1,修改 isForTest 标记为 false,最后调用 notify() 方法,这时等待队列中没有线程,之后,testThread2 就结束了测试任务,自动释放了锁。

接着,testThread1 获取到了锁,开始执行同步方法里的代码,因为此时 isForTestfalse,很快就调用锁的 wait() 方法,这样 testThread1 就被挂起,进入线程等待队列,并且释放了锁。这时,等待队列中是 testThread1

接着,testThread2 获取到了锁,开始执行同步方法里的代码,因为此时 isForTestfalse,很快就调用锁的 wait() 方法,这样 testThread2 就被挂起,进入线程等待队列,并且释放了锁。这时,等待队列中有 testThread1testThread2

接着,releaseThread2 获取到了锁,这时 isForTestfalse,继续执行发布 apk 的任务,打印出 releaseThread2============>Release apk: QQ-V2,将 isForTest 标记改为 true,调用 nofity() 方法,唤醒等待队列中的一个线程。现在,等待队列中有 testThread1testThread2。选中 testThread2 唤醒,testThread2 就具备了 CPU 执行资格。现在等待队列中是 testThread1

接着,testThread2 获取到锁,isForTesttrue,继续执行测试 apk 的代码,打印出 testThread2 Test Apk: QQ-V2,把 isForTest改为 false,调用 notify() 方法,唤醒等待队列中的一个线程。现在等待队列中是 testThread1testThread1 被唤醒,具备了 CPU 执行资格。这时,等待队列中没有线程。

接着,testThread2 获取到锁,这时 isForTestfalsetestThread2 调用锁的 wait() 方法,进入等待线程队列。这时等待队列中是 testThread2

接着,testThread1 获取到锁,从被唤醒的地方开始往下执行,打印 testThread1 Test Apk: QQ-V2,把 isForTest 改为 false,调用 notify()方法。这时等待队列中是 testThread2,它被唤醒,具有 CPU 执行资格。当前等待队列中没有线程。

接着,testThread1 获取到锁,这时 isForTestfalse,很快 testThread1 又进入了等待队列,并释放锁。这时等待队列中是 testThread1

接着,testThread2 获取到锁,从被唤醒的地方开始往下执行,打印 testThread2 Test Apk: QQ-V2,把 isForTest 改为 false,调用 notify()方法。

到这里,我们看到 testThread1testThread2 轮流执行了测试 apk 的代码,全然不去理会 isForTestfalse 这一判断条件。这是因为它们都是从被唤醒的地方开始往下执行的,不会再去判断 if(!isForTest)

同样地,可以去分析第二段问题日志:

releaseThread2============>Release apk: QQ-V3
releaseThread1============>Release apk: QQ-V4
testThread1 Test Apk: QQ-V4

是由于没有再去判断 if(isForTest) 导致的。

那么,怎样才能多次回去判断 !isForTestisForResult这两个条件呢?自然是while循环。

现在,releaseApkRunnable 中的 iftestApkRunnable 中的 if 都改为 while,如下:

while (!isForTest) {
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

7.3.3 分析死锁问题

再次运行程序,发现出现了死锁。
其中一次的日志如下:

releaseThread1============>Release apk: QQ-V1
testThread2 Test Apk: QQ-V1
releaseThread2============>Release apk: QQ-V2
testThread2 Test Apk: QQ-V2
releaseThread1============>Release apk: QQ-V3
|(这一行是光标在闪动)

我们来分析一下原因:
分析的思路还是根据日志,理流程。

程序启动后,releaseThread1 获取到锁, isForTestfalse,执行发布 apk 的代码,打印出 releaseThread1============>Release apk: QQ-V1,把 isForTest 修改为 true,调用 notify() 方法,没有需要唤醒的线程,任务结束,释放锁。
接着,releaseThread1 再次获取到锁,isForTesttrue,调用锁的 wait() 方法,releaseThread1 线程被挂起,进入等待队列,并释放锁。这时等待队列里是 releaseThread1

接着,releaseThread2 获取到锁,isForTesttrue,调用锁的 wait() 方法,releaseThread2 线程被挂起,进入等待队列,并释放锁。这时等待队列里是 releaseThread1releaseThread2

接着,testThread2 获取锁,isForTesttrue,开始执行测试 apk 的代码,打印 testThread2 Test Apk: QQ-V1,修改 isForTestfalse,调用 notify() 方法,唤醒等待队列中的一个线程。等待队列中现在是 releaseThread1releaseThread2。选中唤醒 releaseThread2,它具有 CPU 执行资格。等待队列中现在只有 releaseThread1

接着,testThread1 获取到锁,isForTestfalse,调用锁的 wait() 方法,testThread1 被挂起,进入等待队列,并释放锁。这时等待队列有: releaseThread1testThread1

接着,testThread2 获取到锁,isForTestfalse,调用锁的 wait() 方法,testThread2 被挂起,进入等待队列,并释放锁。这时等待队列有: releaseThread1testThread1testThread2

接着,releaseThread2 获取到锁, isForTestfalse,执行发布 apk 的代码,打印出 releaseThread2============>Release apk: QQ-V2,把 isForTest 修改为 true,调用 notify() 方法,唤醒 testThread2,任务结束,释放锁。目前,等待队列中:releaseThread1testThread1

接着,releaseThread2 获取到锁,isForTesttrue,调用锁的 wait() 方法,releaseThread2 线程被挂起,进入等待队列,并释放锁。这时等待队列里是 releaseThread1testThread1releaseThread2

接着,testThread2 获取锁,isForTesttrue,开始执行测试 apk 的代码,打印 testThread2 Test Apk: QQ-V2,修改 isForTestfalse,调用 notify() 方法,唤醒等待队列中的一个线程。等待队列中现在是 releaseThread1testThread1releaseThread2。选中唤醒 releaseThread1,它具有 CPU 执行资格。等待队列中现在只有 ,testThread1releaseThread2

接着,testThread2 获取到锁,isForTestfalse,调用锁的 wait() 方法,testThread2 被挂起,进入等待队列,并释放锁。这时等待队列有: testThread1releaseThread2testThread2

接着,releaseThread1 获取到锁, isForTestfalse,执行发布 apk 的代码,打印出 releaseThread1============>Release apk: QQ-V3,把 isForTest 修改为 true,调用 notify() 方法,唤醒 releaseThread2,任务结束,释放锁。目前,等待队列中:testThread1testThread2

接着,releaseThread2 获取到锁,isForTesttrue,调用锁的 wait() 方法,releaseThread2 线程被挂起,进入等待队列,并释放锁。这时等待队列里是testThread1testThread2releaseThread2

最后,releaseThread1 获取到锁,isForTesttrue,调用锁的 wait() 方法,releaseThread1 线程被挂起,进入等待队列,并释放锁。这时等待队列里是testThread1testThread2releaseThread2releaseThread1

到这里,四个线程都在等待队列中了。这就造成了死锁。
我们看关键的倒数第三步,当时等待队列中是 testThread1releaseThread2testThread2,调用锁的 notify() 方法后,本该唤醒 testThread1testThread2 中的一个,但是却唤醒了 releaseThread2。之后,就最终都进入了等待队列。

为什么没有去唤醒 testThread1testThread2 中的一个,而去唤醒了 releaseThread2 呢?

这是因为调用锁的 notify() 方法,当线程等待队列中有多个时,会选择其中一个唤醒,而选择是随机的,任意性的。

好吧。

问:能不能指定唤醒呢?再不济,全部唤醒也可以啊。
答:指定唤醒目前还没有,全部唤醒可以用 notifyAll()notifyAll() 可以唤醒所有的等待线程。

把代码中的 notify() 替换为 notifyAll() 方法重新测试,打印输出符合流程要求了。截取一段如下:

releaseThread2============>Release apk: QQ-V270
testThread2 Test Apk: QQ-V270
releaseThread1============>Release apk: QQ-V271
testThread2 Test Apk: QQ-V271
releaseThread2============>Release apk: QQ-V272
testThread1 Test Apk: QQ-V272
releaseThread2============>Release apk: QQ-V273
testThread2 Test Apk: QQ-V273
releaseThread1============>Release apk: QQ-V274
testThread2 Test Apk: QQ-V274

7.3.4 如何实现指定唤醒?

这需要借助 Java SE5 的 java.util.concurrent 类库,这里面包含定义在 java.util.concurrent.locks 中的显式的互斥机制。

我们知道,之前使用的synchronized 是一种隐式的互斥机制。

它们之间有什么区别呢?

Lock 对象必须被显式地创建、锁定和释放;而使用 synchronized 这样的内建锁,创建锁,获取锁,释放锁都不需要手动调用。从这一点来看,Lock 对象的形式,代码要比使用 synchronized 关键字时,需要写更多的代码,缺乏优雅性。

使用 synchronized 关键字的形式,包括同步代码块以及方法,它们仅仅是对代码进行封装,仅仅停留在代码块封装,方法封装这个概念上;而 Java SE5 中的 Lock 将同步和锁封装成了对象,包含了创建锁,获取锁,释放锁这些行为。从这里,也就将使用 synchronized 关键字形式的内置锁变为显式的,它可以显式地创建锁,获取锁以及释放锁。Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法(waitnotifynotifyAll)的使用。

Lock 对象不使用块结构,这样失去了使用 synchronized 的方法和代码块的自动释放锁的功能,所以使用 Lock 对象时,必须把把释放锁的操作(unlock())放在 try - finally 语句的 finally 子句中。如下:

Lock l = ...; 
l.lock();
try {
    // access the resource protected by this lock
} finally {
    l.unlock();
}

好了,它们之间的区别先对比到这里。更多的不同之处,我们会通过代码来演示。

注意到,java.util.concurrent.locks 下的 Condition 替代了 Object 类的监视器方法(waitnotifynotifyAll)的使用。那么,具体是怎么替代的呢?

Condition 接口中有 signal() 方法,唤醒一个等待的线程,这可以和 Object 类中的notify()相对应;await() 方法,挂起一个任务,这可以和 Object 类中的 wait() 方法相对应;signalAll() 方法,唤醒所有等待线程,这可以和 Object 类中的 notifyAll() 方法相对应。

注意,说相对应,并不是等同。一个 Lock 对象可以有多个 Condition 对象,也就相应地有多组 await()signal()signalAll() 方法,而使用 synchronized 关键字形式,只能对应一个锁对象,也仅仅有一组 wait()notify()notifyAll() 方法。通过调用 Condition 对象的 signal()signalAll() 方法来唤醒任务,唤醒的是被这个 Condition 对象自身所挂起的任务。

下面,我们准备把 7.3.3 中最后的例子使用 Lock 对象来实现:

class Apk {
    private String apkName;
    private boolean isForTest = false;
    private int code = 1;
    // 创建一个锁对象
    private Lock lock = new ReentrantLock();
    // 在 lock 对象上获取 Condition 实例
    private Condition condition = lock.newCondition();
    
	// 去掉了方法声明中的 synchronized 关键字
    public void releaseApk(String name) {
    	// 替换了原来的 synchronized 的同步方法自动获取锁
        lock.lock();
        try {
            while (isForTest) {
                try {
                	// 替换了原来的 apk.wait()
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 这 200 ms 当作开发时间
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.apkName = name +"-V"+ code;
            System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.apkName);
            code++;
            isForTest = true;
            // 替换了原来的 apk.notifyAll();
            condition.signalAll();
        } finally {
        	// 替换了原来的 synchronized 的同步方法自动释放锁
            lock.unlock();
        }

    }
	// 去掉了方法声明中的 synchronized 关键字
    public void testApk() {
        // 替换了原来的 synchronized 的同步方法自动获取锁
        lock.lock();
        try {
            while (!isForTest) {
                try {
                	// 替换了原来的 apk.wait();
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            try {
                // 这 60 ms 作为测试时间
                TimeUnit.MILLISECONDS.sleep(60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " Test Apk: " + this.apkName);
            isForTest = false;
            // 替换了原来的 apk.signalAll();
            condition.signalAll();
        } finally {
        	// 替换了原来的 synchronized 的同步方法自动释放锁
            lock.unlock();
        }
    }
}

上面的代码注释已经很详细了,把进行替换的地方一一进行了注释说明。

这时,执行代码,程序依然能够达到预期的输出。

同样地,可以测验:替换 7.3.2 的例子和 7.3.3 的死锁例子,也可以复现问题。大家可以自己实测一下。

到这里,大家可能会想:LockCondition 的作用也不过如此,之前使用 synchronized 同步方法不也一样实现吗?

目前看来,是这样的。后面会说到 LockConditionsynchronized 方式灵活,强大的地方。

到这里,我们证明了:Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。

在对比 Locksynchronized 的区别时,我们知道,一个 Lock 可以绑定多个 Condition 对象。每个 Condition 对象拥有一组监听器方法:await()signal()signalAll。并且,调用 Condition 对象的 signal()signalAll() 是唤醒被它自身挂起的任务。

也就是说,对于不同的 Condition 对象,谁挂起的任务,谁唤醒。

回到我们的例子中,有两组任务:发布 apk 的任务和测试 apk 的任务。这两组任务需要经历挂起,唤醒的操作。那么,我们自然需要两个 Condition 对象。

private Condition releaseCondition = lock.newCondition();
private Condition testCondition = lock.newCondition();

releaseCondition 负责发布 apk 这个任务的挂起和唤醒;
testCondition 负责测试 apk 这个任务的挂起和唤醒。

public void releaseApk(String name) {
    lock.lock();
    try {
        while (isForTest) {
            try {
            	// 挂起发布 apk 的任务
                releaseCondition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 这 200 ms 当作开发时间
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.apkName = name +"-V"+ code;
        System.out.println(Thread.currentThread().getName() + "============>Release apk: " + this.
        code++;
        isForTest = true;
        // 唤醒测试 apk 的任务
        testCondition.signal();
    } finally {
        lock.unlock();
    }
}
public void testApk() {
    lock.lock();
    try {
        while (!isForTest) {
            try {
            	// 挂起测试 apk 的任务
                testCondition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        try {
            // 这 60 ms 作为测试时间
            TimeUnit.MILLISECONDS.sleep(60);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " Test Apk: " + this.apkName);
        isForTest = false;
        // 唤醒发布 apk 的任务
        releaseCondition.signal();
    } finally {
        lock.unlock();
    }
}

这样就实现了指定唤醒的目标。

7.4 多开发多测试的实例

目前,多开发多测试的例子还是与实际不相符:例子中两个开发工程师,两个测试工程师,却是一个 apk 发布,一个 apk 测试这样的节奏在工作。这不是人浮于事吗?

实际中,会有一个待测 apk 的集合,比如这个集合的大小是 5,
开发工程师在判断集合不满时,说明集合中还可以存放新的 apk,就发布 apk;满时就不发布 apk。
测试工程师在判断集合不空时,说明有 apk 待测,就从集合中取出一个,开始测试;为空时,就不测试 apk.。

实现的代码如下:

class Apk {
    private String apkName;
    private static int counter = 1;
    private final int code = counter++;
    public Apk(String apkName) {
        this.apkName = apkName;
    }

    @Override
    public String toString() {
        return "Apk: " + apkName + "-V" + code;
    }
}

class ApkBuffer {
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    final Apk[] items = new Apk[5];
    private int putptr, takeptr, count;

    public void put(Apk x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            System.out.println("ApkBuffer, put=======>" + x);
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public Apk take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            Apk x = items[takeptr];
            System.out.println("ApkBuffer: take<===================" + x);
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }

}
class ReleaseApkRunnable implements Runnable {
    private ApkBuffer apkBuffer;
    public ReleaseApkRunnable(ApkBuffer apkBuffer) {
        this.apkBuffer = apkBuffer;
    }

    @Override
    public void run() {
        while (true) {
            try {
                apkBuffer.put(new Apk("QQ"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class TestApkRunnable implements Runnable {
    private ApkBuffer apkBuffer;

    public TestApkRunnable(ApkBuffer apkBuffer) {
        this.apkBuffer = apkBuffer;
    }

    @Override
    public void run() {
        while (true) {
            try {
                apkBuffer.take();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ApkDemo {
    public static void main(String[] args) {
        Apk apk = new Apk("QQ");
        ApkBuffer apkBuffer = new ApkBuffer();
        Runnable releaseApkRunnable = new ReleaseApkRunnable(apkBuffer);
        Runnable testApkRunnable = new TestApkRunnable(apkBuffer);
        Thread releaseThread1 = new Thread(releaseApkRunnable, "releaseThread1");
        Thread releaseThread2 = new Thread(releaseApkRunnable, "releaseThread2");
        Thread testThread1 = new Thread(testApkRunnable, "testThread1");
        Thread testThread2 = new Thread(testApkRunnable, "testThread2");
        releaseThread1.start();
        releaseThread2.start();
        testThread1.start();
        testThread2.start();
    }
}

这段代码不再详细说明了。因为核心代码和之前的例子是一样的。
需要说明的是:
Apk 类中:

 private static int counter = 1;
 private final int code = counter++;

这是为了保证 apk 的 code 值按次序递增。

8 线程的终止

Thread 类的 stop() 方法 :这个方法已过时,具有不安全性;
Thread 类的 suspend() 方法 :这个方法已过时,具有死锁倾向。
使得 run() 方法结束:run() 方法里有循环结构时,判断循环标记不满足时,就结束循环。这种方式在某些情况下不可靠。
我们通过一个小例子来说明:

class MyRunnable implements Runnable {
    private FlagBean flagBean;

    public MyRunnable(FlagBean flagBean) {
        this.flagBean = flagBean;
    }

    @Override
    public synchronized void run() {
        while (!flagBean.isFlag()) {
            try {
                wait();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "...." + e);
            }
        }
    }
}
class FlagBean {
    private boolean flag;

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}
public class UseFlag {
    public static void main(String[] args) {
        FlagBean flagBean = new FlagBean();
        MyRunnable target = new MyRunnable(flagBean);
        Thread thread1 = new Thread(target);
        Thread thread2 = new Thread(target);
        thread1.start();
        thread2.start();
        int i = 0;
        while (true) {
            if (i >= 30) {
                flagBean.setFlag(true);
                break;
            }
            System.out.println(Thread.currentThread().getName() + "。。。" + (i++));
        }
    }
}

flagBeanflag 标记为 true 时,就结束循环,完成 run() 方法的执行,线程也就该自然结束了。但是,在此之前,while 循环里,两个线程都执行到了 wait() 方法,它们都被挂起了,处于阻塞状态。即便是改变了标记,因为没有执行唤醒的操作,也没有机会再去判断标记,进而结束循环。

需要注意的是我们在 run 方法上加上了 synchronized 关键字,这是一个同步方法。这是因为我们在 run() 方法里使用了 wait() 方法,这需要当前线程持有锁对象。否则,会抛出 IllegalMonitorStateException

通过使用 Threadinterrupte() 方法来使等待中的任务结束。把线程从阻塞状态中断变为就绪状态。
例子如下:

class MyRunnable implements Runnable {
    private FlagBean flagBean;

    public MyRunnable(FlagBean flagBean) {
        this.flagBean = flagBean;
    }

    @Override
    public synchronized void run() {
        while (!flagBean.isFlag()) {
            try {
                wait();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + "...." + e);
                flagBean.setFlag(true);
            }
        }
    }
}

class FlagBean {
    private boolean flag;

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

public class UseInterrupt {
    public static void main(String[] args) {
        FlagBean flagBean = new FlagBean();
        MyRunnable target = new MyRunnable(flagBean);
        Thread thread1 = new Thread(target);
        Thread thread2 = new Thread(target);
        thread1.start();
        thread2.start();
        int i = 0;
        while (true) {
            if (i >= 30) {
                thread1.interrupt();
                thread2.interrupt();
                break;
            }
            System.out.println(Thread.currentThread().getName() + "。。。" + (i++));
        }
    }
}

9 需要区分的概念

sleep() 方法和 wait() 方法的区别

  • 所属不同:sleep() 方法在 Thread 类中,wait() 方法在 Object 类中;
  • 参数不同:sleep() 方法必须指定时间,wait() 方法可以指定时间也可以不指定时间;
  • 在同步中,对 CPU 的执行权和锁的处理不同:wait() 方法释放 CPU 执行权,并且释放锁;sleep() 方法释放 CPU 执行权,不释放锁。
  • Thread.yield() 被调用后,持有锁的线程不会释放锁。

Thread.interrupted()isInterrupted() 方法的区别

public static boolean interrupted() {
   return currentThread().isInterrupted(true);
}
public boolean isInterrupted() {
  	return isInterrupted(false);
}

Thread.interrupted() 方法会清除 ClearInterruptedtrueisInterrupted() 方法不会清除 ClearInterrupted

公平锁与非公平锁
synchronized 内置锁默认是非公平锁,不可以更改;ReentrantLock 默认是非公平锁,可以通过参数设置为公平锁。

可重入锁
synchronized 内置锁是可重入锁,也就是说,一个 synchronized 修饰的方法 g(),获取到锁之后,进入方法体内,方法体内又去调用 g(),仍能够获取到锁,而不会造成死锁。

ReentrantLock 是可重入锁。

排他锁
synchronized 内置锁是排他锁,ReentrantLock 是排他锁,ReadWriteLock 不是排他锁。
排他锁就是在同一时刻只能允许一个线程访问。

文章涉及的代码在https://github.com/jhwsx/Java_01_AdvancedFeatures/tree/master/src/com/java/advanced/features/concurrent

参考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章