开发高性能并发应用不是一件容易的事情。这类应用的例子包括高性能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语言中的synchronizedvolatilefinal等关键词对主存中变量读写操作的意义。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()方法来重新设置这个标记。

参考资料

java 线程​基本概念 可见性 同步的更多相关文章

  1. Java线程:概念与原理

    Java线程:概念与原理 一.操作系统中线程和进程的概念 现在的操作系统是多任务操作系统.多线程是实现多任务的一种方式. 进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程 ...

  2. Java线程:概念与使用

    Java线程大总结 原文章地址:一篇很老的专栏,但是现在看起来也感觉深受启发,知识点很多,很多线程特点我没有看,尴尬.但是还是整理了一下排版,转载一下. 操作系统中线程和进程的概念 在现代操作系统中, ...

  3. Java线程:概念与原理(转)

    一.操作系统中线程和进程的概念 现在的操作系统是多任务操作系统.多线程是实现多任务的一种方式. 进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程中可以启动多个线程.比如在 ...

  4. Java - 线程基本概念

    [java并发编程实战]-----线程基本概念 线程状态图 说明:线程共包括以下5种状态.1. 新建状态(New)         : 线程对象被创建后,就进入了新建状态.例如,Thread thre ...

  5. Java线程的概念

    1.      计算机系统 使用高速缓存来作为内存与处理器之间的缓冲,将运算需要用到的数据复制到缓存中,让计算能快速进行:当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了. 缓 ...

  6. 1 Java线程的内存可见性

    Java内存的可见性 可见性: 一个线程对共享变量的修改,能够及时被其它线程看到 共享变量: 如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量 Java内存模型(JM ...

  7. 简单测试Java线程安全中阻塞同步与非阻塞同步性能

    摘抄自周志明老师的<深入理解Java虚拟机:JVM高级特性与最佳实践>13.2.2 线程安全的实现方法 1.名词解释 同步是指锁哥线程并发访问共享数据时,保证共享数据同一时刻只被一个线程访 ...

  8. JAVA线程基础概念及使用

    一.线程和进程的区别 在操作系统中所有运行的任务通常对应一个进程,进程是系统进行资源分配和调度的一个独立单位.线程是进程的组成部分,一个进程最少包含一个线程.并发和并行的区别是,并发指的在同一时刻内, ...

  9. java 线程之对象的同步和异步

    一.多线程环境下的同步与异步 同步:A线程要请求某个资源,但是此资源正在被B线程使用中,因为同步机制存在,A线程请求不到,怎么办,A线程只能等待下去. package com.jalja.org.th ...

随机推荐

  1. hibernate批量删除和更新数据

    转载自:http://blog.csdn.net/yuhua3272004/article/details/2909538 Hibernate3.0 採用新的基于ANTLR的HQL/SQL查询翻译器, ...

  2. etrace跟踪Nginx代码+ FASTCGI

    http://blog.csdn.net/jianqiangchen/article/details/29175285 http://blog.csdn.net/jianqiangchen/artic ...

  3. Maven Integration for Eclipse 正确地址

    m2eclipse has moved from sonatype to eclipse. The correct update site is http://download.eclipse.org ...

  4. 10.4 noip模拟试题

    题目名称 PA 青春 三部曲 名称 huakai taritari truetears 输入 huakai.in taritari.in truetears.in 输出 huakai.out tari ...

  5. excel中VBA对多个文件的操作

    添加引用 "Scripting.FileSystemObject" (Microsoft Scripting Runtime) '用于操作文件.目录 Sub 数据整理部分() ' ...

  6. eclipse-自动注释

    在eclipse中自动添加'注释'的快捷键是'Alt+Shift+J',可以在 MyEclipse中的 Java->Code Style->Code Template->Commen ...

  7. 获取IP所在地

    $source=file_get_contents('http://www.ip138.com/ips138.asp?ip='.$ip.'&action=2'); preg_match_all ...

  8. Fluent NHibernate之旅

    Fluent NHibernate 之旅 导航篇: [原创]Fluent NHibernate之旅开篇: [原创]Fluent NHibernate之旅二--Entity Mapping: [原创]F ...

  9. method=“post/get”

    Form表单中method="post/get'的区别   Form提供了两种数据传输的方式——get和post.虽然它们都是数据的提交方式,但是在实际传输时确有很大的不同,并且可能会对数据 ...

  10. Linux下安装SVN服务(CentOS7下)

    1. 安装 centos(我这里使用的是CentOS7)下yum命令即可方便的完成安装 测试安装是否成功: 2. 建立版本库 创建svn数据目录(subversion默认是把/var/svn作为数据根 ...