03_concurrency_02_complexity

第三十六章 Java多线程的复杂性(Complexity of Multi-Threading)

1 概述

多线程程序的运行过程是十分复杂的。这不仅仅是因为程序自身的处理逻辑复杂。通常情况下,因为多线程运行环境的复杂性,导致了多线程程序的复杂化。在本章节中,我们将从四个方面讨论多线程运行环境的复杂性。这些“外部因素”极大的增加了多线程编程的难度。另一方面,正是因为这些复杂性的存在鼓励和激发了开发人员不断推出和完善新技术,帮助简化多线程编程设计过程。

2. 多线程的复杂性(Complexity of Multi-Threading)

2.1 调度的不确定性(Unpredictability of Scheduling)

线程作为一个独立的可执行实体(Executable Entity),它们之间相互竞争CPU资源。当一个线程被操作系统的调度器(Scheduler)选中,调度到CPU上运行时,该线程的指令会在该CPU上依次执行。当这个线程被调出时,该线程的执行状态会被保存下来,以便于下次被调用时,从暂停处开始继续执行。

但是,一个线程何时会被调度器选中是一个复杂的问题,它受到多种因素的影响。这些因素包括,但不限于,服务器上可用的CPU资源,线程的优先级,其他可运行程序的数量和优先级等。这些因素是随时间和运行环境变化的。

接下来,我们使用一个例子来解释调度的不确定性对多线程程序运行的影响。

例如:这是一个运行了两个线程的程序。在运行时,服务器有两个CPU核可用,即:两个线程可以同时调度到这两个核上同时运行。假设线程A和线程B同时运行下面的run()方法。run()方法的逻辑很简单,如果成员变量i小于或者等于100时,自增1。那么,从这段代码的逻辑上看,成员变量i的值不可能大于101。然而,当线程A和线程B同时运行时,i最后的值有可能为102。

public class ThreadExample implements Runnable {
    private int i = 100;
    public void run() {
        if (i <= 100)
            i++;
    }
    public void print() {
        System.out.println("the value of i is " + i);
    }
    public static void main(String[] args) {
        // 创建两个线程,并开始运行这两个线程
        ThreadExample runnable = new ThreadExample();
        Thread threadA = new Thread(runnable);
        Thread threadB = new Thread(runnable);
        threadA.start();
        threadB.start();
        
        try {
            // 等待这两个线程结束
            threadA.join();
            threadB.join();
        
            // 打印成员变量i
            runnable.print();
        } catch (Exception ex) {}
    }
}

如下表所示,如果当线程A和线程B同时测试i的值时,因为此时i的值是100,所以,线程A和线程B的测试都可以通过。但是,由于某种原因,此时线程A被替换出,而线程B继续运行,将i增加至101。稍后,当线程A再次获得运行机会时,线程A继续运行,将变量i增加至102。所以,在这种情况下,i的值可以增加至102。

时刻线程 A线程 B
时刻 t1线程 A 测试i是否小于等于100,条件满足。线程 B 测试i是否小于等于100,条件满足。
时刻 t2线程 A 被调度出CPU,暂停执行。线程 B 增加成员变量i至101。
时刻 t3-线程 B 结束。
时刻 t4线程 A 被调度至CPU,继续执行。-
时刻 t5线程 A 增加成员变量i的值至102。-
时刻 t6线程 A 结束。-

从上述的例子可以看出,如果线程A与线程B顺序执行,那么,最后成员变量i的值是101。但是,如果按照上表所述的步骤运行,最后成员变量i的值是102。注意:在多线程环境下,由于调度的不确定性,上表所述的执行顺序不一定能够容易重现,需要执行多次,并且多种条件同时触发才可能重现。这也这是调度不确定性的一种体现。

这种因为多个线程同时访问共享资源(例如:变量,文件等),而导致运行结果不确定的情形,我们称之为竞态条件(Race Condition)

2.2 原子性(Atomicity)

原子操作是那些不能再次分割的操作。对于多线程程序而言,原子操作的意义在于一旦原子操作开始执行后,在该原子操作完成之前,该线程不会被调度出CPU。因为Java语言是解释执行的,一条简单的Java语句可能会由多个操作完成。例如,i++自增语句是由(a)读取i;(b)生成i的一份拷贝;(c)增加i的值;(d)写回i;(e)返回i的拷贝;五个操作完成的。所以,自增操作不是原子操作。

Java原子操作的数量比我们想象的少得多。大致的讲,单个32位数据的读操作或者写操作是原子操作。如果未特别说明,其他的操作均为非原子操作。Java语言的原子操作有:

  1. 基本数据类型的读写(除long和double以外)。因为long和double是64位的数据类型。
  2. 引用的读写。
  3. 锁操作。
  4. volatile的long或者double数据类型的读写。
  5. ...

从这里可以看出,绝大多数的Java操作都不是原子操作,因此,绝大多数的Java语句也不是原子操作。

2.3 数据可见性(Data Visibility)

为了追求更快的运行效果,硬件平台也在逐步的自我优化。现代CPU体系架构包含了多级的高速缓存(Multi-Level Cache)。在多核CPU架构下,除了各个核心(Core)共享的高速缓存以外,每个核心都有独立的高速缓存。各自独立的高速缓存只有各自的核心能够访问。这种缓存常被称为一级缓存(L1 Cache)。共享的高速缓存常被称为二级缓存(L2 Cache)。下图展示的是一个有着二级高速缓存的CPU体系结构图。目前,高端CPU一般有三级缓存,情况比二级缓存的CPU架构更复杂。

图一 高速缓存等级结构

图一 有着二级缓存的多核CPU体系结构

在CPU中设置多级Cache的目的是,当核心需要读写数据时,数据可以保存在高速缓存(Cache)中快速访问,而无序访问内存(Main Memory)。这样能提高程序访问数据的效率。然而,随着程序的运行,高速缓存中可能存有一份数据的不同拷贝;这些拷贝的数值也可能不同。所以,这也造成了数据不同步的问题。为了解决这个数据同步问题,各级缓存之间存在一种数据替代算法;该算法会最终将数据同步至内存中。Cache的出现的确提高了程序运行的速度,但是,也为开发人员带来了数据同步问题。因为,当一个线程写入一个数据时,该数据可能仅在该核的L1 Cache中可见,而在其他核上同时运行的程序可能并不知道数据的变更。在该数据被同步到L2 Cache或者内存上之后,其他线程才能从内存中读取。这种数据同步的滞后问题又被称为数据的可见性问题。

2.4 程序运行的顺序

与硬件优化同时进行的是软件优化。Java语言允许Java编译器优化并调整代码的执行顺序。如下面的代码所示,i和j的初始值都是0;在run()方法中,i先自增,然后j再自增;经过一些处理后,将i和j的和赋值给新的变量k。当Java编译器编译这段代码时,可以根据上下文和优化条件,在不改变代码语义的情况下,调整代码执行的顺序。

public class ThreadExample implements Runnable {
    private int i = 0;
    private int j = 0;
    public void run() {
        i++;
        j++;
        ...
        int k = i + j;
    }
}

例如,如下的代码和上述代码是等效的(如果在run()方法的其他位置未使用i和j)。如果Java编译器认为下面代码的运行速度更快的话,Java编译器可以将上述代码转化为下面的代码。

public class ThreadExample implements Runnable {
    private int i = 0;
    private int j = 0;
    public void run() {
        j++;
        ... // 如果在这段代码中未使用i
        i++;
        int k = i + j;
    }
}

因为,在有些情况下,改变代码的顺序会改变代码执行的结果。因此,Java语言引入了一个新的概念:Happens-Before关系,来约束编译器。Happens-Before是两个动作(Actions)之间的关系(例如:读操作和写操作)。如果操作A与操作B有着Happens-Before关系,那么,在B操作执行之前,A操作的执行结果必须对B操作可见(visible)。实际上,Happens-Before关系明确的说明了两个问题:执行顺序问题(A操作必须在B操作之前)和数据可见问题(A操作的结果必须立即对B操作可见)。

所以,在Happens-Before关系的帮助下,Java语言进一步限制了Java编译器的调整代码顺序的自由度。例如,使用了finalvolatilesynchronized关键字的地方,Java语言隐含的加入了Happens-Before关系,以确保程序能正确执行。

3. 结语

本文从四个方面介绍了Java多线程程序的复杂度。面对这四个问题,Java语言为开发人员准备了各种方法和工具,以帮助开发出正确高效的多线程程序。在语言方面,Java语言引入了关键字finalvolatilesynchronized帮助开发人员同步线程和数据。在标准库方面,Java语言提供了wait/notify同步机制和标准并发库。我们将在接下来的章节中一一介绍Java线程同步的机制和工具。

 

上一章
下一章

注册用户登陆后可留言

Copyright  2019 Little Waterdrop, LLC. All Rights Reserved.