Java多线程编程(2)--多线程编程的术语与概念

2022-10-17,,,,

一.串行、并发和并行

  为了更清楚地解释这三个概念,我们来举一个例子。假设我们有a、b、c三项工作要做,那么我们有以下三种方式来完成这些工作:

  第一种方式,先开始做工作a,完成之后再开始做工作b,以此类推,知道完成工作c。在这种情况下实际上只需要投入一个人。
  第二种方式,先开始做工作a,做了一会之后再开始做工作b;工作b做了一会再开始做工作c,工作c做了一会又重新开始做工作a,以此类推,直到所有工作都完成。这样看上去像是在同时进行三个工作一样,但是这种方式可以只投入一个人。
  第三种方式需要投入三个人,每个人负责一项工作,这三个人在同一时刻齐头并进地完成这些事情。这种方式比其他两种方式都要快。
  在软件开发领域,这三种方式分别被称为串行、并发和并行。串行就是一次一个任务、每个任务完成之后再进行下一个任务的行为,这种方式耗费的时间往往是最长的。并发就是在一段时间内以交替的方式去完成多个任务,它可能会加速任务的执行,也可能和串行消耗相同的时间。例如,如果一个任务在等待某些资源或者执行某些耗时但不占用cpu的操作(例如io操作),这段时间让cpu去处理其他任务,就不会白白浪费时间,从而缩短整个程序的执行时间;而如果每个任务都是cpu密集型任务,那么使用并发并不会比串行快多少。并行就是以齐头并进的方式同时处理多个任务,它一定会缩短程序的执行时间。
  从硬件的角度来说,在一个处理器一次只能够运行一个线程的情况下,由于处理器可以使用时间片分配的技术来实现在同一段时间内运行多个线程,因此一个处理器就可以实现并发。而并行则需要靠多个处理器在同一时刻各自运行一个线程来实现。
  多线程编程的实质就是将任务的处理方式由串行改为并发或并行,以提高程序对cpu资源的利用率,最大限度地使用系统资源。至于多线程实现的到底是并发还是并行,则要视具体情况而定。在cpu比较繁忙,资源不足的时候,操作系统只为一个含有多线程的进程分配仅有的cpu资源,这些线程就会为自己尽量多抢时间片,这就是通过多线程实现并发,线程之间会竞争cpu资源争取执行机会。而在cpu资源比较充足的时候,一个进程内的多线程,可以被分配到不同的cpu资源,这就是通过多线程实现并行。这个分配过程是操作系统的行为,不可人为控制。所有,如果有人问我我所写的多线程是并发还是并行的?我会说,都有可能。

二.竞态

  多线程编程中经常遇到的一个问题就是对于同样的输入,程序的输出有时候是正确的而有时候却是错误的。这种一个计算结果的正确性与时间顺序有关的现象就被称为竞态(race condition)。
  下面的例子中,在主线程中创建了4个线程,每个线程都执行相同的任务,即调用20次计数器并输出计数器的值。

// code 2-1

public class raceconditiondemo {
    public static void main(string[] args) {
        runnable task = () -> {
            for (int i = 0; i < 20; i++) {
                system.out.println(thread.currentthread().getname() + " : " + counter.getinstance().count());
            }
        };
        thread thread1 = new thread(task);
        thread thread2 = new thread(task);
        thread thread3 = new thread(task);
        thread thread4 = new thread(task);
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

class counter {
    private static final counter instance = new counter();
    private int countvalue = 0;

    private counter() {}

    int count() {
        if (countvalue >= 100) {
            countvalue = 1;
        } else {
            countvalue++;
        }
        return countvalue;
    }

    static counter getinstance() {
        return instance;
    }
}

  下图是某次运行后得到的结果(偶现,非必现):

  理论上来说,每次调用counter类的count方法,得到的计数值都是不一样的。但是上面的结果中,却出现了两个一模一样的计数值。上述程序的输出有时候是正确的而有时候是错误的,可见该程序在多线程环境下运行出现了竞态。
  下面我们来分析一下为什么会出现竞态。因为每个线程里只用到了计数器的count方法,因此,count()是导致竞态的直接因素。进一步来说,导致竞态的常见因素是多个线程在没有采取任何控制措施的情况下并发地更新、读取同一个共享变量。count()所访问的实例变量countvalue就是这样一个例子:多个线程通过调用count()并发地访问countvalue,显然这些线程没有采取任何控制措施。
  count()中的语句“countvalue++”看起来像是一个操作,但它实际上相当于如下伪代码所表示的3个指令:

load(countvalue, reg);  //指令1:将变量countvalue的值从内存读到寄存器reg
increment(reg);         //指令2:将寄存器reg的值加1
store(countvalue, reg); //指令3:将寄存器reg的内容写入countvalue对应的内存空间

  如果每个线程都等其他线程完成指令1、2、3之后再调用这个方法,那么就不会产生问题。但实际上,这些指令是有可能同时在不同线程里执行的。比如说,两个线程可能在同一时间读取到countvalue的同一个值,一个线程对countvalue所做的更新也可能“覆盖”其他线程对该变量所做的更新,这些都有可能导致各个线程拿到相同的计数值。
  根据上述分析我们可以更进一步来定义竞态:竞态(race condition)是指计算的正确性依赖于相对时间顺序或者线程的交错。根据这个定义可知,竞态不一定就导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能。

三.线程安全性

  如果我们以单线程的方式去调用count()方法,那么,我们可以发现该程序的输出总是正确的。一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么我们就称其是线程安全的。反之,如果一个类在单线程环境下运作正常而在多线程环境下则无法正常运作,那么这个类就是非线程安全的。因此, 一个类如果能够导致竞态,那么它就是非线程安全的;而一个类如果是线程安全的,那么它就不会导致竞态。下面是《java并发编程实战》一书中给出的对于线程安全的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

  使用一个类的时候我们必须先弄清楚这个类是否是线程安全的。因为这关系到我们如何正确使用这些类。java标准库中的一些类如arraylist、hashmap和simpledateformat,都是非线程安全的,在多线程环境下直接使用它们可能导致一些非预期的结果,甚至是一些灾难性的结果。一般来说,java标准库中的类在其api文档中会说明其是否是线程安全的(没有说明其是否是线程安全的,则可能是也可能不是线程安全的)。
  从线程安全的定义上我们不难看出,如果一个线程安全的类在多线程环境下能够正常运作,那么它在单线程环境下也能正常运作。既然如此,那为什么不干脆把所有的类都做成线程安全的呢?是否将一个类做成线程安全的,从某种程度上来说是一个设计上的权衡的结果或决定:一方面,一个类是否需要是线程安全的与这个类预期被使用的方式有关,比如,我们希望一个类总是只能被一个线程独自使用,那么就没有必要将这个类做成线程安全的。其次,把一个类做成线程安全的往往是有额外代价的。
  一个类如果不是线程安全的,我们就说它在多线程环境下直接使用存在线程安全问题。线程安全问题概括来说表现为3个方面:原子性、可见性和有序性。

四.原子性

  原子的字面意思是不可分割的。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性。所谓“不可分割”,其中一个含义是指访问某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果。
  在生活中我们可以找到的一个原子操作的例子就是人们从atm机提取现金:尽管从atm软件的角度来说,一笔取款交易涉及扣减户账户余额、吐出钞票、新增交易记录等一系列操作,但是从用户的角度来看atm取款就是一个操作。该操作要么成功了,即我们拿到现金(账户余额会被扣减)这个操作发生过了;要么失败了,即我们没有拿到现金,这个操作就像从来没有发生过一样(账户余额也不会被扣减)。除非atm软件有缺陷,否则我们不会遇到吐钞口吐出部分现金而我们的账户余额却被扣除这样的部分结果。
  总的来说,java中有两种方式来实现原子性。一种是使用锁(lock)。锁具有排他性,即它能够保障一个共享变量在任意时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竞态。另一种是利用处理器提供的专门cas(compare-and-swap)指令。cas指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,而cas是直接在硬件(处理器和内存)这一层次实现的,它可以被看作“硬件锁”。
  在java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作,即对基础类型(long、double除外)的变量和引用型变量的写操作都是原子的。这点是由java语言规范(java language specification)规定,由java虚拟机具体实现。一个long/double型变量的读/写操作在32位java虚拟机下可能会被分解为两个子步骤(比如先写低32位,再写高32位)来实现,这就导致一个线程对long/double型变量进行的写操作的中间结果可以被其他线程所观察到,即此时针对long/double类型的变量的访问操作不是原子操作。尽管如此,java语言规范特别地规定对于volatile关键字修饰的long/double类型变量的写操作具有原子性。因此,我们只需要用volatile关键字(下一篇文章会进一步介绍该关键字)修饰可能被多个线程访问的long/double类型的变量,就可以保障对该变量的写操作的原子性。

五.可见性

  在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性。
  下面看一个可见性的例子:

// code 2-2
public class visibilitydemo {
    public static void main(string[] args) {
        uselessthread uselessthread = new uselessthread();
        uselessthread.start();
        try {
            thread.sleep(500);
        } catch (interruptedexception e) {
            e.printstacktrace();
        }
        uselessthread.cancel();
    }
}

class uselessthread extends thread {
    private boolean cancelled = false;

    @override
    public void run() {
        system.out.println("task has been started.");
        while (!cancelled) {}
        system.out.println("task has been cancelled.");
    }

    public void cancel() {
        cancelled = true;
    }
}

  上面的程序中,主线程在uselessthread线程启动,此时该线程会输出“task has been started.”,一秒后,主线程会调用uselessthread的cancel方法,也就是将uselessthread的calcelled变量置为true。理论上来说,此时uselessthread的run方法中的while循环会结束,并在输出“task has been cancelled.”后结束线程。然而,运行该程序,我们会看到如下输出:

task has been started.

  我们发现,程序并没有输出“task has been cancelled.”,程序仍然一直在运行(如果没有出现这种现象可以在java命令后加上-server参数)。这种现象只有一种解释,那就是run方法中的while陷入了死循环。也就是说,子线程uselessthread读到的cancel变量值始终是false,尽管主线程已经将这个变量的值更新为true。可见,这里产生了可见性问题,即main线程对共享变量cancelled的更新对子线程uselessthread不可见。
  上述例子中的可见性问题是因为代码没有给jit编译器足够的提示而使得其认为状态变量cancelled只有一个线程对其进行访问,从而导致jit编译器为了避免重复读取状态变量cancelled以提高代码的运行效率,而将run方法中的while循环优化成与如下代码等效的机器码:

if (!cancelled) {
    while (true) {}
}

  不幸的是,此时这种优化导致了死循环,也就是我们所看到的程序一直运行而没有退出。
  另一方面,可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。另外,即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。这是因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存子系统进行的。如果高速缓存子系统中的内容没有及时更新,那么处理器读取到的值仍然有可能是一个旧值,这同样会导致可见性问题。

处理器并不是直接与主内存打交道而执行内存的读、写操作,而是通过定义寄存器、高速缓存、写缓冲器和无效化队列等部件执行内存的读、写操作的。从这个角度来看,这些部件相当于主内存的副本,因此本书为了叙述方便将这些部件统称为处理器对主内存的缓存,简称处理器缓存。

  虽然一个处理器的高速缓存中的内容不能被另外一个处理器直接读取,但是一个处理器可以通过缓存一致性协议(cache coherence protocol)来读取其他处理器的高速缓存中的数据,并将读到的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取数据并将其更新到该处理器的高速缓存的过程,我们称之为缓存同步,这些存储部件包括处理器的高速缓存、主内存。缓存同步使得一个处理器上运行的线程可以读取到另外一个处理器上运行的线程对共享变量所做的更新,即保障了可见性。因此,为了保障可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中(而不是始终停留在其写缓冲器中),这个过程被称为冲刷处理器缓存。并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量.那么该处理器必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为刷新处理器缓存。因此,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。
  那么,在java平台中我们如何保证可见性呢?实际上,使用volatile关键字就可以保证可见性。对于code 2-2所示的代码,我们只需要在实例变量cancelled的声明中添加一个volatile关键字即可:

private volatile boolean cancelled = false;

  这里,volatile关键字所起到的一个作用就是,提示jit编译器被修饰的变量可能被多个线程共享,以阻止jit编译器做出可能导致程序运行不正常的优化。另外一个作用就是读取一个volatile关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作,写一个volatile关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的动作,从而保障了可见性。
  对于同一个共享变量而言,一个线程更新了该变量的值之后,其他线程能够读取到这个更新后的值,那么这个值就被称为该变量的相对新值。如果读取这个共享变量的线程在读取并使用该变量的时候其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值。可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而不能保障该线程能够读取到相应变量的最新值。
  针对原子性,java语言规范中还定义了两条与线程的启动和停止有关的规范:

  1. 父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的;
  2. 一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。

六.有序性

  有序性指在某些情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的。所谓乱序,是指内存访问操作的顺序看起来像是发生了变化。在进一步介绍有序性这个概念之前,我们需要先介绍重排序的概念。

重排序的概念

  顺序结构是编程中的一种基本结构,它表示我们希望某个操作必须先于另外一个操作得以执行。另外,两个操作即便是可以用任意一种顺序执行,但是反映在代码上这两个操作也总是有先后关系。但是在多核处理器的环境下,这种操作执行顺序可能是没有保障的:编译器可能改变两个操作的先后顺序;处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫作重排序。
  重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能。但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题。与可见性问题类似,重排序也不是必然出现的。
  重排序的潜在来源有许多,包括编译器(在java平台中这基本上指jit编译器)、处理器和存储子系统(包括写缓冲器、高速缓存)。为了便于下面的讲解,我们先定义几个与内存操作顺序有关的术语

  • 源代码顺序:源代码中所指定的内存访问操作顺序。
  • 程序顺序:在给定处理器上运行的目标代码所指定的内存访问操作顺序。
  • 执行顺序:内存访问操作在给定处理器上的实际执行顺序。
  • 感知顺序:给定处理器所感知到的该处理器及其他处理器的内存访间操作发生的顺序。

  在此基础上,我们将重排序划分为指令重排序和存储子系统重排序两种,如下表所示:

指令重排序

  在源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序。指令重排序是一种动作,它确确实实地对指令的顺序做了调整,其重排序的对象是指令。

java平台包含两种编译器:静态编译器(javac)和动态编译器(jit编译器)。前者的作用是将java源代码(.java文本文件)编译为字节码(.class二进制文件),它是在代码编译阶段介入的。后者的作用是将字节码动态编译为java虚拟机宿主机的本地代码(机器码),它是在java程序运行过程中介入的。

  来看下面的程序:

// code 2-3
public class possiblereordering {
    private static int a;
    private static int b;
    private static int x;
    private static int y;

    public static void main(string[] args) {
        thread threada = new thread(() -> {
            a = 1;
            x = b;
        });
        thread threadb = new thread(() -> {
            b = 1;
            y = a;
        });
        threada.start();
        threadb.start();
        try {
            threada.join();
            threadb.join();
            system.out.printf("(%d,%d)", x, y);
        } catch (interruptedexception e) {
            e.printstacktrace();
        }
    }
}

  由于线程a可以在线程b开始之前就执行完成,线程b也有可能在线程a开始之前就完成,二者也有可能交替执行,因此,程序最终会输出什么是不确定的。但是,按照我们的认知,每个线程内的操作应该是按照代码的顺序来执行的。也就是说,a=1应该是在x=b之前执行的,b=1应该是在y=a之前执行的。我们可以对这几个操作进行简单的排列来分析最终的输出结果:

操作1 操作2 操作3 操作4 结果
a=1 x=b b=1 y=a (0,1)
a=1 b=1 x=b y=a (1,1)
a=1 b=1 y=a x=b (1,1)
b=1 y=a a=1 x=b (1,0)
b=1 a=1 y=a x=b (1,1)
b=1 a=1 x=b y=a (1,1)

  可以看到,在没有正确同步的情况下,程序输出(1,0)、(0,1)或(1,1)都是有可能的。但奇怪的是,程序还可以输出(0,0),这个结果不属于上面分析的任何一种情况。由于上面的每个线程中的各个操作之间不存在数据流依赖性,可能会发生指令重排序,因此这些操作有可能会乱序执行。下图给出了一种可能由重排序导致的交替执行方式,在这种情况中会输出(0,0)。

  由此可以看出,重排序可能导致线程安全问题。当然,这并不表示重排序本身是错误的,而是我们的程序本身有问题:我们的程序没有使用或者没有正确地使用线程同步机制。不过,重排序也不是必然出现的,上面的(0,0)是在程序大概运行了50000次左右才出现了一次。尽管如此,我们并不能忽视重排序带来的潜在的风险。
  在其他编译型语言(如c++)中,编译器是可能导致指令重排序的。在java平台中,静态编译器(javac)基本上不会执行指令重排序,而jit编译器则可能执行指令重排序。
  处理器也可能执行指令重排序,这使得执行顺序与程序顺序不一致。处理器对指令进行重排序也被称为处理器的乱序执行在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。处理器的指令重排序并不会对单线程程序的正确性产生影响,但是它可能导致多线程程序出现非预期的结果。

存储子系统重排序

  主内存(ram)相对于处理器是一个慢速设备。为了避免其拖后腿,处理器并不是直接访问主内存,而是通过高速缓存访问主内存的。在此基础上,现代处理器还引入了写缓冲器以提高写高速缓存操作的效率。有的处理器(如intel的x86处理器)对所有的写主内存的操作都是通过写缓冲器进行的。这里,我们将写缓冲器和高速缓存统称为存储子系统,它其实是处理器的子系统。
  即使在处理器严格依照程序顺序执行两个内存访问操作的情况下9,在存储子系统的作用下其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,即这两个操作的执行顺序看起来像是发生了变化。这种现象就是存储子系统重排序,也被称为内存重排序。
  指令重排序的重排序对象是指令,它实实在在地对指令的顺序进行涸整,而存储子系统重排序是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的执行顺序像是被调整过一样的现象,其重排序的对象是内存操作的结果。
  从处理器的角度来说,读内存操作的实质是从指定的ram地址加载数据(通过高速缓存加载)到寄存器,因此读内存操作通常被称为load, 写内存操作的实质是将数据存储到指定地址表示的ram存储单元中,因此写内存操作通常被称为store。所以,内存重排序实际上只有以下4种可能:

  内存重排序可能导致线程安全问题。假设处理器processor 0和处理器processor 1上的两个线程按照下图所示的交错顺序各自执行其代码,其中data、ready是这两个线程的共享变量,其初始值分别为0和false。processor 0上的线程所执行的处理逻辑是更新数据data并在此之后将相应的更新标志ready的值设为true。processor 1上的线程所执行的处理逻辑是当数据更新标志ready的值不为true时无限等待直到ready的值为true才将data
的值打印出来。

  假设processor 0依照程序顺序先后执行s1和s2,那么s1和s2的操作结果会被先后写入写缓冲器中。但是由于某些处理器的写缓冲器为了提高将其中的内容写入高速缓存的效率而不保证写操作结果先入先出的顺序,即较晚到达写缓冲器的写操作结果可能更早地被写入高速缓存,因此s2的操作结果可能先于s1的操作结果被写入高速缓存,即s1被重排序到s2之后(内存重排序)。这就导致了processor 1上的线程读取到ready的值为true时,由于s1的操作结果仍然停留在processor 0的写缓冲器之中,而一个处理器并不能读取到另外一个处理器的写缓冲器中的内容,因此processor 1上的线程读取到的data值仍然是0。可见,此时内存重排序导致了processor 1上的线程的处理逻辑无法达到其预期目标,即导致了线程安全问题。

保证内存访问的顺序性

  如何避免重排序导致的线程安全问题呢?需要了解的是,我们无法从物理上完全禁用重排序而使得处理器完全依照源代码顺序执行指令,因为那样性能太低。但是,我们可以从逻辑上有选择性地禁止重排序,即重排序要么不发生,要么即使发生了也不会影响多线程程序的正确性。
  从底层的角度来说,禁止重排序是通过调用处理器提供相应的指令(内存屏障)来实现的。当然,java作为一个跨平台的语言,它会替我们与这类指令打交道,而我们只需要使用语言本身提供的机制即可。前面我们提到的volatile关键字、synchronized关键字都能够实现有序性。有关volatile关键字、synchronized关键字以及重排序,我们会在后续的文章中进行更深入的了解。

《Java多线程编程(2)--多线程编程的术语与概念.doc》

下载本文的Word格式文档,以方便收藏与打印。