1、Java并发编程第二章第2章 构建线程安全应用程序第二章 构建线程安全应用程序. 1 2.1. 什么是线程安全性.22.2. Servlet的线程安全性 .52.3. 同步与互斥.92.3.1 线程干扰.92.3.2 同步.11 2.4. 同步与volatile.132.5. 活性 .142.6. ThreadLocal变量 .152.7. 高级并发对象.19参考文献 .202.1. 什么是线程安全性当对一个复杂对象进行某种操作时,从操作开始到操作结束,被操作的对象往往会经历若干非法的中间状态。这跟外科医生做手术有点像,尽管手术的目的是改善患者的健康,但医生把手术过程分成了几个步骤,每个步骤如
2、果不是完全结束的话,都会严重损害患者的健康。想想看,如果一个医生切开患者的胸腔后要休三周假会怎么样?与此类似,调用一个函数(假设该函数是正确的)操作某对象常常会使该对象暂时陷入不可用的状态(通常称为不稳定状态),等到操作完全结束,该对象才会重新回到完全可用的状态。如果其他线程企图访问一个处于不可用状态的对象,该对象将不能正确响应从而产生无法预料的结果,如何避免这种情况发生是线程安全性的核心问题。单线程的程序中是不存在这种问题的,因为在一个线程更新某对象的时候不会有其他线程也去操作同一个对象。(除非其中有异常,异常是可能导致上述问题的。当一个正在更新某对象的线程因异常而中断更新过程后,再去访问没
3、有完全更新的对象,会出现同样的问题)给线程安全下定义是比较困难的。很多正式的定义都比较复杂。如,有这样的定义:“一个类在可以被多个线程安全调用时就是线程安全的”。但是它不能帮助我们区分一个线程安全的类与一个线程不安全的类。实际上,所有线程安全的定义都有某种程序的循环,因为它必须符合类的规格说明 这是对类的功能、其副作用、哪些状态是有效和无效的、不可变量、前置条件、后置条件等等的一种非正式的松散描述(由规格说明给出的对象状态约束只应用于外部可见的状态,即那些可以通过调用其公共方法和访问其公共字段看到的状态,而不应用于其私有字段中表示的内部状态)1。类要成为线程安全的,首先必须在单线程环境中有正确
4、的行为。如果一个类实现正确(这是说它符合规格说明的另一种方式),那么没有一种对这个类的对象的操作序列(读或者写公共字段以及调用公共方法)可以让对象处于无效状态,观察到对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。此外,一个类要成为线程安全的,在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排或者交错,它必须仍然有如上所述的正确行为,并且在调用的代码中没有任何额外的同步。其效果就是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。正确性与线程安全性之间的关系非常类似于在描述 ACID(原子性、一致性、独立性和持久性)事务时使用的一致
5、性与独立性之间的关系:从特定线程的角度看,由不同线程所执行的对象操作是先后(虽然顺序不定)而不是并行执行的。考虑下面的代码片段,它迭代一个 Vector 中的元素。尽管 Vector 的所有方法都是同步的,但是在多线程的环境中不做额外的同步就使用这段代码仍然是不安全的,因为如果另一个线程 恰 好 在 错 误 的 时 间 里 删 除 了 一 个 元 素 , 则 get() 会 抛 出 一 个ArrayIndexOutOfBoundsException 。Vector v = new Vector(); / contains race conditions - may require extern
6、al synchronization for (int i=0; iv.size(); i+) doSomething(v.get(i); 这里发生的事情是:get(index) 的规格说明里有一条前置条件要求 index 必须是非负的并且小于 size() 。但是,在多线程环境中,没有办法可以知道上一次查到的 size() 值是否仍然有效,因而不能确定 isize() ,除非在上一次调用了 size() 后独占地锁定 Vector 。更明确地说,这一问题是由 get() 的前置条件是以 size() 的结果来定义的这一事实所带来的。只要看到这种必须使用一种方法的结果作为另一种讲法的输入条件的
7、样式,它就是一个状态依赖,就必须保证至少在调用这两种方法期间元素的状态没有改变。一般来说,做到这一点的唯一方法在调用第一个方法之前是独占性地锁定对象,一直到调用了后一种方法以后。在上面的迭代 Vector 元素的例子中,您需要在迭代过程中同步 Vector 对象。如上面的例子所示,线程安全性不是一个非真即假的命题。Vector 的方法都是同步的,并且 Vector 明确地设计为在多线程环境中工作。但是它的线程安全性是有限制的,即在某些方 法 之 间 有 状 态 依 赖 ( 类 似 地 , 如 果 在 迭 代 过 程 中 Vector 被 其 他 线 程 修 改 , 那 么 由Vector.it
8、erator() 返回的 iterator 会抛出 ConcurrentModificationException )。对于 Java 类中常见的线程安全性级别,没有一种分类系统可被广泛接受,不过重要的是在编写类时尽量记录下它们的线程安全行为。Bloch 给出了描述五类线程安全性的分类方法:不可变、线程安全、有条件线程安全、线程兼容和线程对立。只要明确地记录下线程安全特性,那么您是否使用这种系统都没关系。这种系统有其局限性各类之间的界线不是百分之百地明确,而且有些情况它没照顾到,但是这套系统是一个很好的起点。这种分类系统的核心是调用者是否可以或者必须用外部同步包围操作(或者一系列操作)。下面分
9、别描述了线程安全性的这五种类别。1) 不可变不可变的对象一定是线程安全的,并且永远也不需要额外的同步。因为一个不可变的对象只要构建正确,其外部可见状态永远也不会改变,永远也不会看到它处于不一致的状态。Java 类库中大多数基本数值类如 Integer、String 和 BigInteger 都是不可变的。2) 线程安全由类的规格说明所规定的约束在对象被多个线程访问时仍然有效,不管运行时环境如何排列,线程都不需要任何额外的同步。这种线程安全性保证是很严格的许多类,如Hashtable 或者 Vector 都不能满足这种严格的定义。3) 有条件的线程安全有条件的线程安全类对于单独的操作可以是线程安
10、全的,但是某些操作序列可能需要外部同步。条件线程安全的最常见的例子是遍历由 Hashtable 或者 Vector 或者返回的迭代器由这些类返回的 fail-fast 迭代器假定在迭代器进行遍历的时候底层集合不会有变化。为了保证其他线程不会在遍历的时候改变集合,进行迭代的线程应该确保它是独占性地访问集合以实现遍历的完整性。通常,独占性的访问是由对锁的同步保证的并且类的文档应该说明是哪个锁(通常是对象的内部监视器(intrinsic monitor)。如果对一个有条件线程安全类进行记录,那么您应该不仅要记录它是有条件线程安全的,而且还要记录必须防止哪些操作序列的并发访问。用户可以合理地假设其他操
11、作序列不需要任何额外的同步。4) 线程兼容线程兼容类不是线程安全的,但是可以通过正确使用同步而在并发环境中安全地使用。这可能意味着用一个 synchronized 块包围每一个方法调用,或者创建一个包装器对象,其中每 一 个 方 法 都 是 同 步 的 ( 就 像 Collections.synchronizedList() 一 样 ) 。 也 可 能 意 味 着 用synchronized 块包围某些操作序列。为了最大程度地利用线程兼容类,如果所有调用都使用同一个块,那么就不应该要求调用者对该块同步。这样做会使线程兼容的对象作为变量实例包含在其他线程安全的对象中,从而可以利用其所有者对象的同
12、步。许 多 常 见 的 类 是 线 程 兼 容 的 , 如 集 合 类 ArrayList 和 HashMap 、java.text.SimpleDateFormat 、或者 JDBC 类 Connection 和 ResultSet 。5) 线程对立线程对立类是那些不管是否调用了外部同步都不能在并发使用时安全地呈现的类。线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的其他类的行为,这时通常会出现线程对立。线程对立类的一个例子是调用 System.setOut() 的类。线程安全类(以及线程安全性程度更低的的类) 可以允许或者不允许调用者锁定对象以进 行 独 占 性 访 问
13、 。 Hashtable 类 对 所 有 的 同 步 使 用 对 象 的 内 部 监 视 器 , 但 是ConcurrentHashMap 类不是这样,事实上没有办法锁定一个 ConcurrentHashMap 对象以进行独占性访问。除了记录线程安全程序,还应该记录是否某些锁如对象的内部锁对类的行为有特殊的意义。通过将类记录为线程安全的(假设它确实是线程安全的),您就提供了两种有价值的服务:您告知类的维护者不要进行会影响其线程安全性的修改或者扩展,您还告知类的用户使用它时可以不使用外部同步。通过将类记录为线程兼容或者有条件线程安全的,您就告知了用户这个类可以通过正确使用同步而安全地在多线程中使
14、用。通过将类记录为线程对立的,您就告知用户即使使用了外部同步,他们也不能在多线程中安全地使用这个类。不管是哪种情况,您都在潜在的严重问题出现之前防止了它们,而要查找和修复这些问题是很昂贵的。一个类的线程安全行为是其规格说明中的固有部分,应该成为其文档的一部分。因为还没有描述类的线程安全行为的声明式方式,所以必须用文字描述。虽然 Bloch 的描述类的线程安全程度的五层系统没有涵盖所有可能的情况,但是它是一个很好的起点。如果每一个类都将这种线程行为的程度加入到其 Javadoc 中,那么可以肯定的是我们大家都会受益。2.2.Servlet 的线程安全性Servlet/JSP 默认是以多线程模式执
15、行的,所以,在编写代码时需要非常细致地考虑多线程的安全性问题。然而,很多人编写 Servlet/JSP 程序时并没有注意到多线程安全性的问题,这往往造成编写的程序在少量用户访问时没有任何问题,而在并发用户上升到一定值时,就会经常出现一些莫明其妙的问题。Servlet 体系结构是建立在 Java 多线程机制之上的,它的生命周期是由 Web 容器负责的。当客户端第一次请求某个 Servlet 时,Servlet 容器将会根据 web.xml 配置文件实例化这个Servlet 类。当有新的客户端请求该 Servlet 时,一般不会再实例化该 Servlet 类,也就是有多个线程在使用这个实例。Ser
16、vlet 容器会自动使用线程池等技术来支持系统的运行。这样,当两个或多个线程同时访问同一个 Servlet 时,可能会发生多个线程同时访问同一资源的情况,数据可能会变得不一致。所以在用 Servlet 构建的 Web 应用时如果不注意线程安全的问题,会使所写的 Servlet 程序有难以发现的错误。1. 无状态 Servlet下面是一个无状态的 Servlet,它从 Request 中解包数据,然后将这两个数据进行相乘,最后把结果封装在 Response 中。import java.io.IOException;import java.io.PrintWriter;import javax.s
17、ervlet.ServletException;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;public class ConcurrentServlet extends HttpServlet private static final long serialVersionUID = 1L;public ConcurrentServlet() super();protected voi
18、d doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException,IOException String s1 = request.getParameter(num1);String s2 = request.getParameter(num2);int result = 0;if (s1 != null & s1 != null) result = Integer.parseInt(s1) * Integer.parseInt(s2); PrintWriter out = respo
19、nse.getWriter();out.print(result);out.close();这个 Servlet 是无状态的,它不包含域,也没有引用其它类的域,一次特定计算的瞬时状态,会唯一的存储在本地变量中,这些本地变量存在线程的栈中,只有执行线程才能访问,一个执行该 Servlet 的线程不会影响访问同一个 Servlet 的其它线程的计算结果,因为两个线程不共享状态,他们如同在访问不同的实例。因为线程访问无状态对象的行为,不会影响其它线程访问对象时的正确性,所以无状态对象是线程安全的。2 有状态 Servlet对上面的 Servlet 进行修改,把 result 变量提升为类的实例变量。
20、那么这个 Servlet 就有状态了。有状态的 Servlet 在多线程访问时,有可能发生线程不安全性。请看下面的代码。import java.io.IOException;import java.io.PrintWriter;import javax.servlet.ServletException;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;public class Statef
21、ulServlet extends HttpServlet private static final long serialVersionUID = 1L;int result = 0;public StatefulServlet() super();protected void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException,IOException String s1 = request.getParameter(num1);String s2 = request.g
22、etParameter(num2);if (s1 != null & s1 != null) result = Integer.parseInt(s1) * Integer.parseInt(s2); try Thread.sleep(5000); catch (InterruptedException e) e.printStackTrace(); PrintWriter out = response.getWriter();out.print(result);out.close();在 Servlet 中定义了一个实例变量 result,Servlet 把它的值进行输出。当只有一个用户访问
23、该 Servlet 时,程序会正常的运行,但当多个用户并发访问时,就可能会出现其它用户的信息显示在另外一些用户的浏览器上的问题 。这是一个严重的问题。为了突出并发问题,便于测试、观察,我们在回显用户信息时执行了一个延时的操作。打开两个浏览器窗口,分别输入:http:/localhost:8080/test/StatefulServlet?num1=5&num2=80 http:/localhost:8080/test/StatefulServlet?num1=5&num2=70。相隔 5000 毫秒之内执行这两个请求,产生的结果如下图:从运行结果可以看出,两个请求显示了相同的计算结果,也就是说
24、,因为两个线程访问了共同的有状态的 Servlet,其中一个线程的计算结果覆盖了另外一个线程的计算结果。从程序分析可以看出第一个线程在输出 result 时,暂停了一段时间,那么它的值就被第二个线程的计算结果所覆盖,两个请求输出了相同的结果。这就是潜在的线程不安全性。要解决线程不安全性,其中一个主要的方法就是取消 Servlet 的实例变量,变成无状态的Servlet。另外一种方法是对共享数据进行同步操作。使用 synchronized 关键字能保证一次只有一个线程可以访问被保护的区段,同步后的 Servlet 如下:import java.io.IOException;import java
25、.io.PrintWriter;import javax.servlet.ServletException;import javax.servlet.http.HttpServlet;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;public class StatefulServlet extends HttpServlet private static final long serialVersionUID = 1L;int result = 0;publi
26、c StatefulServlet() super();protected void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException,IOException String s1 = request.getParameter(num1);String s2 = request.getParameter(num2);synchronized (this) if (s1 != null & s1 != null) result = Integer.parseInt(s1) *
27、 Integer.parseInt(s2); try Thread.sleep(5000); catch (InterruptedException e) e.printStackTrace(); PrintWriter out = response.getWriter();out.print(result);out.close(); Servlet 的线程安全问题只有在大量的并发访问时才会显现出来,并且很难发现,因此在编写 Servlet 程序时要特别注意。线程安全问题主要是由实例变量造成的,因此在 Servlet 中应避免使用实例变量。如果应用程序设计无法避免使用实例变量,那么使用同步来保护要使用的实例变量,但为保证系统的最佳性能,应该同步可用性最小的代码路径。2.3. 同步与互斥线程通
copyright@ 2008-2022 冰豆网网站版权所有
经营许可证编号:鄂ICP备2022015515号-1