11.1 多线程

在操作系统中,两个比较容易混淆的概念是进程(process)和线程(thread)。操作系统中的进程是一个计算机程序的运行实例。计算机程序中包含了需要执行的指令,而进程则表示正在执行的指令。对同一个计算机程序可以创建多个进程。这些进程的运行状态各不相同。进程一般作为资源的组织单位。进程有自己独立的地址空间,包含程序内容和数据。不同进程的地址空间是互相隔离的。进程拥有各种资源和状态信息,包括打开的文件、子进程和信号处理器等。线程表示的是程序的执行流程,是CPU调度执行的基本单位。线程有自己的程序计数器、寄存器、堆栈和帧等。同一进程中的线程共用相同的地址空间,同时共享进程所拥有的内存和其他资源。

引入线程的主要动机在于提高程序的运行性能。在一个程序中主要存在使用CPU和I/O操作的两类计算。I/O操作相对CPU运算来说比较耗时,而且很多都是阻塞式的。当一个线程所执行的I/O操作被阻塞时,同一进程中的其他线程可以使用CPU来进行计算。在资源允许时,多个线程可以同时进行I/O操作。这种方式提高了操作系统中资源的使用效率,进而提高了程序的运行性能。线程的概念在主流操作系统和编程语言中都得到了支持。不同操作系统和编程语言中的线程的使用方式有很大的区别。这对于开发跨平台的多线程程序来说是一个不小的挑战。Java平台通过Java虚拟机解决了跨平台的问题,使由相同API开发的多线程程序在不同平台上都能够正确运行。

Java标准库提供了与进程和线程相关的API。第6章具体介绍了表示进程的java.lang.Process类和创建进程的java.lang.ProcessBuilder类的使用方式。表示线程的是java.lang.Thread类。在虚拟机启动之后,通常只有一个普通线程来运行程序的代码。这个线程用来启动主Java类的main方法的运行。程序在运行时可以根据需要创建新的线程并启动线程的运行。除了普通线程之外,还有一类线程是守护线程(daemon thread)。守护线程一般在后台运行,提供程序运行时所需的服务。当虚拟机中运行的所有线程都是守护线程时,虚拟机终止运行。

线程表示的是一段程序的执行过程。线程中最重要的部分是所要执行的代码逻辑。有两种方式可以创建线程。第一种方式是继承Thread类并覆写run方法,在run方法中包含线程的执行逻辑。第二种方式是实现java.lang.Runnable接口,并在Thread类的构造方法中传入Runnable接口的实现对象。使用这种方式创建的Thread类的对象的run方法中,调用的实际是Runnable接口中的run方法。在创建出Thread类的对象之后,通过调用start方法启动线程的运行。当线程的代码逻辑执行完毕之后,线程会自动结束。

Java线程API的具体实现由底层的Java虚拟机来负责提供。为了更好地理解线程API的使用及多线程开发,需要对虚拟机内部的相关机制有一定的了解。下面的内容围绕Java语言规范中定义的Java内存模型展开,涉及可见性问题的基本概念以及相关的volatile和final关键词。

11.1.1 可见性

在一个多线程程序中,多个线程通过共同协作来完成指定的任务。在协作的过程中,线程之间需要进行数据交换以协调各自的状态。同一个进程中的各个线程通过共享内存的方式来进行通信。由于这些线程共享所在进程的地址空间,因此都可以自由访问所需的内存位置。互相协作的线程之间对共享的内存位置达成一致。一个线程在适当的时候修改该内存位置的值,另外一个线程在后续的操作中通过读取相同内存位置来得到修改后的值。Java中的代码无法直接操作内存,而是通过不同类型的变量来间接访问。比如线程A和线程B共同协作完成某项任务,线程B需要等待线程A完成其任务之后才能继续运行,两个线程可以使用一个boolean类型的变量来协调状态。当线程A完成任务之后,修改该变量的值为true,以通知线程B。线程B在运行时,如果读取到该变量的值为true,就开始执行自身的操作。使用共享内存方式在多线程程序中可能造成可见性相关的问题,即一个线程所做的修改对于其他的线程并不可见,导致其他的线程仍然使用错误的值。比如线程B看不到线程A对该boolean类型变量所做的修改,造成线程B一直等待下去。

每个线程在运行过程中所做的事情是按照代码中编写的逻辑来执行对应的虚拟机字节代码指令。Thread类或Runnable接口实现类中的run方法所包含的代码就是线程要执行的指令序列的来源。对于一个单线程程序来说,可见性的含义是很容易理解和验证的。在代码执行过程中,如果先改变一个变量的值,再读取该变量的值,那么所读取的值是上次写入操作所写入的值。也就是说前面的写入操作的结果对后面的读取操作是肯定可见的。这个在单线程程序中显而易见的特征,在多线程程序中则不一定成立。如果不使用某些互斥或同步机制,则不能保证一个线程所写入的值对另外一个线程是可见的。如果可见性条件不能成立,那么程序在运行中就会出现问题。

代码清单11-1给出了一个简单的Java类IdGenerator,用来生成唯一的标识符。在单线程程序中,每次对getNext方法的调用都可以保证得到一个不重复的值。对于一个IdGenerator类的对象来说,域value的初始值为0。每调用一次getNext方法,value的值会加1。每次调用后的value的新值,对后续的调用都是可见的。

代码清单11-1 生成唯一标识符的Java类


public class IdGenerator{

private int value=0;

public int getNext(){

return value++;

}

}


如果在多线程程序中使用IdGenerator类的对象,则不能保证每次调用getNext方法所返回的值是不重复的。在多线程程序中,可以运行的各个线程之间相互竞争CPU时间来获取运行的机会。一般CPU采用时间片轮转等不同算法来对线程进行调度。当调度发生时,当前正在运行的线程被暂停运行。CPU进行上下文切换,开始运行其他线程的代码。在上下文切换时,之前运行线程的当前状态会被记录下来,以便在下次被调度时从上次被中断的地方继续运行。CPU进行调度的时机是不可预知的,可能发生在当前运行线程的任何两条指令的执行间隙。代码清单11-1中的getNext方法虽然只有一行代码,但是对应于7条字节代码指令,具体的指令序列如代码清单11-2所示。

代码清单11-2 IdGenerator类的getNext方法的指令序列


aload_0

dup

getfield#12

dup_x1

iconst_1

iadd

putfield#12


这里对代码清单11-2中的指令序列进行简单说明:aload_0指令的含义是把第一个局部变量加载到操作数堆栈中,这里的第一个局部变量是this;dup指令的含义是把操作数堆栈中的栈顶元素进行复制;getfield指令的含义是获取操作数堆栈中栈顶元素表示的对象中的指定域的值,并将其压入堆栈中;dup_x1指令的含义是复制操作数堆栈中的栈顶元素,并将其插入到距离栈顶两个元素的位置上;iconst_1指令的含义是把常量1压入栈中;iadd指令的含义是从操作数堆栈中弹出两个元素,进行整数加法操作,再把结果压入栈中;putfield指令的含义是从操作数堆栈中依次弹出要设置的域的值和所在的对象的引用,进行域的赋值操作。这个指令序列的作用是先读取域value的值,再把该值加上1,最后把所得的新值赋值给域value。

当多个线程共享同一个IdGenerator类的对象时,可能某个线程在执行getNext方法对应的7条指令的过程中CPU进行了线程切换,该线程的运行被暂停,而另外一个共享了相同IdGenerator类的对象的线程获得了运行的机会。多个线程执行getNext方法的实际指令序列是交织在一起的。考虑下面的线程运行情况:该IdGenerator类的对象的域value的值当前为1。线程A执行到了getfield指令,把获取的域value的值1压入操作数堆栈中。接着线程A暂停运行,线程B获得了运行的机会。当线程B执行getfield指令时,所看到的域value的值也是1。线程B继续执行,把域value的值更新为2之后,返回域value之前的值1作为结果。然后线程A再次获得运行的机会,继续从上次暂停的指令开始运行。由于当前操作数堆栈中的值为1,线程A仍然使用这个值来继续运行,把域value的值更新为2,而返回的结果同样为1。在这个情况下,线程A和线程B使用的是值相同的标识符,出现了错误。这是因为线程B对IdGenerator类的对象所做的修改并没有被线程A所看到。线程A仍然使用的是它上次读取操作时获取的旧值。

除了多个线程的实际执行顺序之外,还有其他一些原因会造成与可见性相关的问题。目前CPU一般采用层次结构的多级缓存的架构。有的CPU提供了L1、L2和L3三级缓存。现在硬件平台多采用多核CPU或多个CPU。当CPU需要读取主存中某个位置的数据时,会依次检查各级缓存中是否存在对应的数据。如果有,直接从缓存中读取。这比从主存中读取速度快很多。在进行写入时,数据先被写入缓存中,之后在某个特定的时间被写回主存中。不同的CPU可能采用不同的写入策略,如写穿透或写返回等。由于缓存的存在,在某些时间点上,缓存中的数据与主存中的数据可能是不一致的。某个线程所执行的写入操作的新值当前可能在CPU的缓存中,还没有被写回到主存中,这时另外一个线程的读取操作所读取的可能还是主存中的旧值。这样的问题主要出现在包含多核CPU或多个CPU的操作系统中。另外一个造成与可见性相关的问题的原因是代码重排。出于性能的考虑,编译器在编译时可能会对生成的字节代码中的指令顺序进行重新排列,以优化指令的执行顺序。CPU也可能改变指令的执行顺序。指令顺序的重新排列在单线程程序中通常不会产生问题,但是在多线程程序中可能产生与可见性相关的问题。

这些可见性相关的问题与底层硬件平台和操作系统密切相关。Java平台的做法是提供一个抽象的模型来描述多线程程序中变量的访问语义,提供相关的API来允许开发人员定义线程之间的交互方式,最后由Java虚拟机和编译器来保证该抽象模型的语义在不同平台上都有一致的行为。开发人员只需要正确使用Java平台提供的API,就可以开发出线程安全的多线程程序。