Java 深度历险(三)——Java 线程​:基本概念、可见性与同步

阅读数:21279 2011 年 1 月 17 日 18:37

开发高性能并发应用不是一件容易的事情。这类应用的例子包括高性能 Web 服务器、游戏服务器和搜索引擎爬虫等。这样的应用可能需要同时处理成千上万个请求。对于这样的应用,一般采用多线程或事件驱动的架构。对于Java 来说,在语言内部提供了线程的支持。但是Java 的多线程应用开发会遇到很多问题。首先是很难编写正确,其次是很难测试是否正确,最后是出现问题时很难调试。一个多线程应用可能运行了好几天都没问题,然后突然就出现了问题,之后却又无法再次重现出来。如果在正确性之外,还需要考虑应用的吞吐量和性能优化的话,就会更加复杂。本文主要介绍Java 中的线程的基本概念、可见性和线程同步相关的内容。

Java 线程基本概念

在操作系统中两个比较容易混淆的概念是进程(process)和线程(thread)。操作系统中的进程是资源的组织单位。进程有一个包含了程序内容和数据的地址空间,以及其它的资源,包括打开的文件、子进程和信号处理器等。不同进程的地址空间是互相隔离的。而线程表示的是程序的执行流程,是CPU 调度的基本单位。线程有自己的程序计数器、寄存器、栈和帧等。引入线程的动机在于操作系统中阻塞式I/O 的存在。当一个线程所执行的I/O 被阻塞的时候,同一进程中的其它线程可以使用CPU 来进行计算。这样的话,就提高了应用的执行效率。线程的概念在主流的操作系统和编程语言中都得到了支持。

一部分的 Java 程序是单线程的。程序的机器指令按照程序中给定的顺序依次执行。Java 语言提供了 java.lang.Thread 类来为线程提供抽象。有两种方式创建一个新的线程:一种是继承 java.lang.Thread 类并覆写其中的 run() 方法,另外一种则是在创建 java.lang.Thread 类的对象的时候,在构造函数中提供一个实现了 java.lang.Runnable 接口的类的对象。在得到了 java.lang.Thread 类的对象之后,通过调用其 start() 方法就可以启动这个线程的执行。

一个线程被创建成功并启动之后,可以处在不同的状态中。这个线程可能正在占用 CPU 时间运行;也可能处在就绪状态,等待被调度执行;还可能阻塞在某个资源或是事件上。多个就绪状态的线程会竞争 CPU 时间以获得被执行的机会,而 CPU 则采用某种算法来调度线程的执行。不同线程的运行顺序是不确定的,多线程程序中的逻辑不能依赖于 CPU 的调度算法。

可见性

可见性(visibility)的问题是 Java 多线程应用中的错误的根源。在一个单线程程序中,如果首先改变一个变量的值,再读取该变量的值的时候,所读取到的值就是上次写操作写入的值。也就是说前面操作的结果对后面的操作是肯定可见的。但是在多线程程序中,如果不使用一定的同步机制,就不能保证一个线程所写入的值对另外一个线程是可见的。造成这种情况的原因可能有下面几个:

  • CPU 内部的缓存:现在的 CPU 一般都拥有层次结构的几级缓存。CPU 直接操作的是缓存中的数据,并在需要的时候把缓存中的数据与主存进行同步。因此在某些时刻,缓存中的数据与主存内的数据可能是不一致的。某个线程所执行的写入操作的新值可能当前还保存在 CPU 的缓存中,还没有被写回到主存中。这个时候,另外一个线程的读取操作读取的就还是主存中的旧值。
  • CPU 的指令执行顺序:在某些时候,CPU 可能改变指令的执行顺序。这有可能导致一个线程过早的看到另外一个线程的写入操作完成之后的新值。
  • 编译器代码重排:出于性能优化的目的,编译器可能在编译的时候对生成的目标代码进行重新排列。

现实的情况是:不同的 CPU 可能采用不同的架构,而这样的问题在多核处理器和多处理器系统中变得尤其复杂。而 Java 的目标是要实现“编写一次,到处运行”,因此就有必要对 Java 程序访问和操作主存的方式做出规范,以保证同样的程序在不同的 CPU 架构上的运行结果是一致的。Java 内存模型( Java Memory Model )就是为了这个目的而引入的。 JSR 133 则进一步修正了之前的内存模型中存在的问题。总得来说,Java 内存模型描述了程序中共享变量的关系以及在主存中写入和读取这些变量值的底层细节。Java 内存模型定义了 Java 语言中的 synchronized volatile final 等关键词对主存中变量读写操作的意义。Java 开发人员使用这些关键词来描述程序所期望的行为,而编译器和 JVM 负责保证生成的代码在运行时刻的行为符合内存模型的描述。比如对声明为 volatile 的变量来说,在读取之前,JVM 会确保 CPU 中缓存的值首先会失效,重新从主存中进行读取;而写入之后,新的值会被马上写入到主存中。而 synchronized 和 volatile 关键词也会对编译器优化时候的代码重排带来额外的限制。比如编译器不能把 synchronized 块中的代码移出来。对 volatile 变量的读写操作是不能与其它读写操作一块重新排列的。

Java 内存模型中一个重要的概念是定义了“在之前发生(happens-before)”的顺序。如果一个动作按照“在之前发生”的顺序发生在另外一个动作之前,那么前一个动作的结果在多线程的情况下对于后一个动作就是肯定可见的。最常见的“在之前发生”的顺序包括:对一个对象上的监视器的解锁操作肯定发生在下一个对同一个监视器的加锁操作之前;对声明为 volatile 的变量的写操作肯定发生在后续的读操作之前。有了“在之前发生”顺序,多线程程序在运行时刻的行为在关键部分上就是可预测的了。编译器和 JVM 会确保“在之前发生”顺序可以得到保证。比如下面的一个简单的方法:

public void increase() {
    this.count++;
}

这是一个常见的计数器递增方法,this.count++ 实际是 this.count = this.count + 1,由一个对变量 this.count 的读取操作和写入操作组成。如果在多线程情况下,两个线程执行这两个操作的顺序是不可预期的。如果 this.count 的初始值是 1,两个线程可能都读到了为 1 的值,然后先后把 this.count 的值设为 2,从而产生错误。错误的原因在于其中一个线程对 this.count 的写入操作对另外一个线程是不可见的,另外一个线程不知道 this.count 的值已经发生了变化。如果在 increase() 方法声明中加上 synchronized 关键词,那就在两个线程的操作之间强制定义了一个“在之前发生”顺序。一个线程需要首先获得当前对象上的锁才能执行,在它拥有锁的这段时间完成对 this.count 的写入操作。而另一个线程只有在当前线程释放了锁之后才能执行。这样的话,就保证了两个线程对 increase() 方法的调用只能依次完成,保证了线程之间操作上的可见性。

如果一个变量的值可能被多个线程读取,又能被最少一个线程锁写入,同时这些读写操作之间并没有定义好的“在之前发生”的顺序的话,那么在这个变量上就存在数据竞争(data race)。数据竞争的存在是 Java 多线程应用中要解决的首要问题。解决的办法就是通过 synchronized 和 volatile 关键词来定义好“在之前发生”顺序。

Java 中的锁

当数据竞争存在的时候,最简单的解决办法就是加锁。锁机制限制在同一时间只允许一个线程访问产生竞争的数据的临界区。Java 语言中的 synchronized 关键字可以为一个代码块或是方法进行加锁。任何 Java 对象都有一个自己的监视器,可以进行加锁和解锁操作。当受到 synchronized 关键字保护的代码块或方法被执行的时候,就说明当前线程已经成功的获取了对象的监视器上的锁。当代码块或是方法正常执行完成或是发生异常退出的时候,当前线程所获取的锁会被自动释放。一个线程可以在一个 Java 对象上加多次锁。同时 JVM 保证了在获取锁之前和释放锁之后,变量的值是与主存中的内容同步的。

Java 线程的同步

在有些情况下,仅依靠线程之间对数据的互斥访问是不够的。有些线程之间存在协作关系,需要按照一定的协议来协同完成某项任务,比如典型的生产者 - 消费者模式。这种情况下就需要用到 Java 提供的线程之间的等待 - 通知机制。当线程所要求的条件不满足时,就进入等待状态;而另外的线程则负责在合适的时机发出通知来唤醒等待中的线程。Java 中的 java.lang.Object 类中的 wait / notify / notifyAll 方法组就是完成线程之间的同步的。

在某个 Java 对象上面调用 wait 方法的时候,首先要检查当前线程是否获取到了这个对象上的锁。如果没有的话,就会直接抛出 java.lang.IllegalMonitorStateException 异常。如果有锁的话,就把当前线程添加到对象的等待集合中,并释放其所拥有的锁。当前线程被阻塞,无法继续执行,直到被从对象的等待集合中移除。引起某个线程从对象的等待集合中移除的原因有很多:对象上的 notify 方法被调用时,该线程被选中;对象上的 notifyAll 方法被调用;线程被中断;对于有超时限制的 wait 操作,当超过时间限制时;JVM 内部实现在非正常情况下的操作。

从上面的说明中,可以得到几条结论:wait/notify/notifyAll 操作需要放在 synchronized 代码块或方法中,这样才能保证在执行 wait/notify/notifyAll 的时候,当前线程已经获得了所需要的锁。当对于某个对象的等待集合中的线程数目没有把握的时候,最好使用 notifyAll 而不是 notify。notifyAll 虽然会导致线程在没有必要的情况下被唤醒而产生性能影响,但是在使用上更加简单一些。由于线程可能在非正常情况下被意外唤醒,一般需要把 wait 操作放在一个循环中,并检查所要求的逻辑条件是否满足。典型的使用模式如下所示:

private Object lock = new Object();
synchronized (lock) { 
    while (/* 逻辑条件不满足的时候 */) { 
       try { 
           lock.wait();  
       } catch (InterruptedException e) {} 
    } 
    // 处理逻辑 
}

上述代码中使用了一个私有对象 lock 来作为加锁的对象,其好处是可以避免其它代码错误的使用这个对象。

中断线程

通过一个线程对象的 interrupt() 方法可以向该线程发出一个中断请求。中断请求是一种线程之间的协作方式。当线程 A 通过调用线程 B 的 interrupt() 方法来发出中断请求的时候,线程 A 是在请求线程 B 的注意。线程 B 应该在方便的时候来处理这个中断请求,当然这不是必须的。当中断发生的时候,线程对象中会有一个标记来记录当前的中断状态。通过 isInterrupted() 方法可以判断是否有中断请求发生。如果当中断请求发生的时候,线程正处于阻塞状态,那么这个中断请求会导致该线程退出阻塞状态。可能造成线程处于阻塞状态的情况有:当线程通过调用 wait() 方法进入一个对象的等待集合中,或是通过 sleep() 方法来暂时休眠,或是通过 join() 方法来等待另外一个线程完成的时候。在线程阻塞的情况下,当中断发生的时候,会抛出 java.lang.InterruptedException ,代码会进入相应的异常处理逻辑之中。实际上在调用 wait/sleep/join 方法的时候,是必须捕获这个异常的。中断一个正在某个对象的等待集合中的线程,会使得这个线程从等待集合中被移除,使得它可以在再次获得锁之后,继续执行 java.lang.InterruptedException 异常的处理逻辑。

通过中断线程可以实现可取消的任务。在任务的执行过程中可以定期检查当前线程的中断标记,如果线程收到了中断请求,那么就可以终止这个任务的执行。当遇到 java.lang.InterruptedException 的异常,不要捕获了之后不做任何处理。如果不想在这个层次上处理这个异常,就把异常重新抛出。当一个在阻塞状态的线程被中断并且抛出 java.lang.InterruptedException 异常的时候,其对象中的中断状态标记会被清空。如果捕获了 java.lang.InterruptedException 异常但是又不能重新抛出的话,需要通过再次调用 interrupt() 方法来重新设置这个标记。

参考资料


感谢张凯峰对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

收藏

评论

微博

用户头像
发表评论

注册/登录 InfoQ 发表评论