线程安全性
线程安全性
1.概述
当对一个复杂对象进行某种操作时,从操作开始到操作结束,被操作的对象往往会经历若干非法的中间状态。
这跟外科医生做手术有点像,尽管手术的目的是改善患者的健康,但医生把手术过程分成了几个步骤,每个步骤如果不是完全结束的话,都会严重损害患者的健康。想想看,如果一个医生切开患者的胸腔后要休三周假会怎么样?与此类似,调用一个函数(假设该函数是正确的)操作某对象常常会使该对象暂时陷入不可用的状态(通常称为不稳定状态),等到操作完全结束,该对象才会重新回到完全可用的状态。如果其他线程企图访问一个处于不可用状态的对象,该对象将不能正确响应从而产生无法预料的结果,如何避免这种情况发生是线程安全性的核心问题。单线程的程序中是不存在这种问题的,因为在一个线程更新某对象的时候不会有其他线程也去操作同一个对象。(除非其中有异常,异常是可能导致上述问题的。当一个正在更新某对象的线程因异常而中断更新过程后,再去访问没有完全更新的对象,会出现同样的问题)
给线程安全下定义是比较困难的。很多正式的定义都比较复杂。如,有这样的定义:“一个类在可以被多个线程安全调用时就是线程安全的”。但是它不能帮助我们区分一个线程安全的类与一个线程不安全的类。
实际上,所有线程安全的定义都有某种程序的循环,因为它必须符合类的规格说明——这是对类的功能、其副作用、哪些状态是有效和无效的、不可变量、前置条件、后置条件等等的一种非正式的松散描述(由规格说明给出的对象状态约束只应用于外部可见的状态,即那些可以通过调用其公共方法和访问其公共字段看到的状态,而不应用于其私有字段中表示的内部状态)[1]。
类要成为线程安全的,首先必须在单线程环境中有正确的行为.如果一个类实现正确(这是说它符合规格说明的另一种方式),那么没有一种对这个类的对象的操作序列(读或者写公共字段以及调用公共方法)可以让对象处于无效状态,观察到对象处于无效状态、或者违反类的任何不可变量、前置条件或者后置条件的情况。
此外,一个类要成为线程安全的,在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排或者交错,它必须仍然有如上所述的正确行为,并且在调用的代码中没有任何额外的同步。其效果就是,在所有线程看来,对于线程安全对象的操作是以固定的、全局一致的顺序发生的。
正确性与线程安全性之间的关系非常类似于在描述ACID(原子性、一致性、独立性和持久性)事务时使用的一致性与独立性之间的关系:从特定线程的角度看,由不同线程所执行的对象操作是先后(虽然顺序不定)而不是并行执行的。
考虑下面的代码片段,它迭代一个Vector中的元素。尽管Vector 的所有方法都是同步的,但是在多线程的环境中不做额外的同步就使用这段代码仍然是不安全的,因为如果另一个线程恰好在错误的时间里删除了一个元素,则get() 会抛出一个ArrayIndexOutOfBoundsException 。
Vector v = new Vector(); // contains race conditions -- may require external synchronization for (int i=0; i<v.size(); i++) { doSomething(v.get(i)); }
这里发生的事情是:get(index) 的规格说明里有一条前置条件要求 index 必须是非负的并且小于 size() 。但是,在多线程环境中,没有办法可以知道上一次查到的size() 值是否仍然有效,因而不能确定 i<size() ,除非在上一次调用了 size() 后独占地锁定Vector 。
更明确地说,这一问题是由 get() 的前置条件是以 size() 的结果来定义的这一事实所带来的。只要看到这种必须使用一种方法的结果作为另一种讲法的输入条件的样式,它就是一个状态依赖,就必须保证至少在调用这两种方法期间元素的状态没有改变。一般来说,做到这一点的唯一方法在调用第一个方法之前是独占性地锁定对象,一直到调用了后一种方法以后。在上面的迭代 Vector 元素的例子中,您需要在迭代过程中同步Vector 对象。
如上面的例子所示,线程安全性不是一个非真即假的命题。Vector 的方法都是同步的,并且Vector明确地设计为在多线程环境中工作。但是它的线程安全性是有限制的,即在某些方法之间有状态依赖(类似地,如果在迭代过程中Vector被其他线程修改,那么由Vector.iterator() 返回的 iterator 会抛出 ConcurrentModificationException )。
对于 Java 类中常见的线程安全性级别,没有一种分类系统可被广泛接受,不过重要的是在编写类时尽量记录下它们的线程安全行为。
Bloch 给出了描述五类线程安全性的分类方法:不可变、线程安全、有条件线程安全、线程兼容和线程对立。只要明确地记录下线程安全特性,那么您是否使用这种系统都没关系。这种系统有其局限性——各类之间的界线不是百分之百地明确,而且有些情况它没照顾到,但是这套系统是一个很好的起点。这种分类系统的核心是调用者是否可以或者必须用外部同步包围操作(或者一系列操作)。下面分别描述了线程安全性的这五种类别。
1) 不可变
不可变的对象一定是线程安全的,并且永远也不需要额外的同步。因为一个不可变的对象只要构建正确,其外部可见状态永远也不会改变,永远也不会看到它处于不一致的状态。Java 类库中大多数基本数值类如Integer、String和BigInteger都是不可变的。
2) 线程安全
由类的规格说明所规定的约束在对象被多个线程访问时仍然有效,不管运行时环境如何排列,线程都不需要任何额外的同步。这种线程安全性保证是很严格的——许多类,如Hashtable 或者 Vector 都不能满足这种严格的定义。
3) 有条件的线程安全
有条件的线程安全类对于单独的操作可以是线程安全的,但是某些操作序列可能需要外部同步。条件线程安全的最常见的例子是遍历由 Hashtable 或者 Vector 或者返回的迭代器——由这些类返回的 fail-fast 迭代器假定在迭代器进行遍历的时候底层集合不会有变化。为了保证其他线程不会在遍历的时候改变集合,进行迭代的线程应该确保它是独占性地访问集合以实现遍历的完整性。通常,独占性的访问是由对锁的同步保证的——并且类的文档应该说明是哪个锁(通常是对象的内部监视器(intrinsic monitor))。
如果对一个有条件线程安全类进行记录,那么您应该不仅要记录它是有条件线程安全的,而且还要记录必须防止哪些操作序列的并发访问。用户可以合理地假设其他操作序列不需要任何额外的同步。
4) 线程兼容
线程兼容类不是线程安全的,但是可以通过正确使用同步而在并发环境中安全地使用。这可能意味着用一个synchronized 块包围每一个方法调用,或者创建一个包装器对象,其中每一个方法都是同步的(就像 Collections.synchronizedList() 一样)。也可能意味着用synchronized 块包围某些操作序列。为了最大程度地利用线程兼容类,如果所有调用都使用同一个块,那么就不应该要求调用者对该块同步。这样做会使线程兼容的对象作为变量实例包含在其他线程安全的对象中,从而可以利用其所有者对象的同步。
许多常见的类是线程兼容的,如集合类 ArrayList 和 HashMap 、java.text.SimpleDateFormat 、或者 JDBC 类 Connection 和 ResultSet 。
5) 线程对立
线程对立类是那些不管是否调用了外部同步都不能在并发使用时安全地呈现的类。线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的其他类的行为,这时通常会出现线程对立。线程对立类的一个例子是调用 System.setOut() 的类。
线程安全类(以及线程安全性程度更低的的类) 可以允许或者不允许调用者锁定对象以进行独占性访问。Hashtable 类对所有的同步使用对象的内部监视器,但是ConcurrentHashMap 类不是这样,事实上没有办法锁定一个 ConcurrentHashMap 对象以进行独占性访问。除了记录线程安全程序,还应该记录是否某些锁——如对象的内部锁——对类的行为有特殊的意义。
通过将类记录为线程安全的(假设它确实是线程安全的),您就提供了两种有价值的服务:您告知类的维护者不要进行会影响其线程安全性的修改或者扩展,您还告知类的用户使用它时可以不使用外部同步。通过将类记录为线程兼容或者有条件线程安全的,您就告知了用户这个类可以通过正确使用同步而安全地在多线程中使用。通过将类记录为线程对立的,您就告知用户即使使用了外部同步,他们也不能在多线程中安全地使用这个类。不管是哪种情况,您都在潜在的严重问题出现之前防止了它们,而要查找和修复这些问题是很昂贵的。
一个类的线程安全行为是其规格说明中的固有部分,应该成为其文档的一部分。因为还没有描述类的线程安全行为的声明式方式,所以必须用文字描述。虽然 Bloch 的描述类的线程安全程度的五层系统没有涵盖所有可能的情况,但是它是一个很好的起点。如果每一个类都将这种线程行为的程度加入到其 Javadoc 中,那么可以肯定的是我们大家都会受益。
2.Servlet的线程安全性
1. 无状态Servlet
下面是一个无状态的Servlet,它从Request中解包数据,然后将这两个数据进行相乘,最后把结果封装在Response中。
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 ConcurrentServlet extends HttpServlet { private static final long serialVersionUID = 1L; public ConcurrentServlet() { super(); } protected void 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 = response.getWriter(); out.print(result); out.close(); } }
这个Servlet是无状态的,它不包含域,也没有引用其它类的域,一次特定计算的瞬时状态,会唯一的存储在本地变量中,这些本地变量存在线程的栈中,只有执行线程才能访问,一个执行该Servlet的线程不会影响访问同一个Servlet的其它线程的计算结果,因为两个线程不共享状态,他们如同在访问不同的实例。
因为线程访问无状态对象的行为,不会影响其它线程访问对象时的正确性,所以无状态对象是线程安全的。
2 有状态Servlet
对上面的Servlet进行修改,把result变量提升为类的实例变量。那么这个Servlet就有状态了。有状态的Servlet在多线程访问时,有可能发生线程不安全性。请看下面的代码。
/** * */ package snippet; 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; 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 StatefulServlet 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.getParameter("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把它的值进行输出。当只有一个用户访问该Servlet时,程序会正常的运行,但当多个用户并发访问时,就可能会出现其它用户的信
息显示在另外一些用户的浏览器上的问题。这是一个严重的问题。
为了突出并发问题,便于测试、观察,我们在回显用户信息时执行了一个延时的操作。
打开两个浏览器窗口,分别输入:
http://localhost:8080/test/StatefulServlet?num1=5&num2=80
http://localhost:8080/test/StatefulServlet?num1=5&num2=70。
相隔5000毫秒之内执行这两个请求,产生的结果如下图:
从运行结果可以看出,两个请求显示了相同的计算结果,也就是说,因为两个线程访问了共同的有状态的Servlet,其中一个线程的计算结果覆盖了另外一个线程的计算结果。从程序分析可以看出第一个线程在输出result时,暂停了一段时间,那么它的值就被第二个线程的计算结果所覆盖,两个请求输出了相同的结果。这就是潜在的线程不安全性。
要解决线程不安全性,其中一个主要的方法就是取消Servlet的实例变量,变成无状态的Servlet。另外一种方法是对共享数据进行同步操作。使用synchronized 关键字能保证一次只有一个线程可以访问被保护的区段,同步后的Servlet如下:
/** * */ package snippet; 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 StatefulServlet 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.getParameter("num2"); synchronized (this) { 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的线程安全问题只有在大量的并发访问时才会显现出来,并且很难发现,因此在编写Servlet程序时要特别注意。线程安全问题主要是由实例变量造成的,因此在Servlet中应避免使用实例变量。如果应用程序设计无法避免使用实例变量,那么使用同步来保护要使用的实例变量,但为保证系统的最佳性能,应该同步可用性最小的代码路径。
3.同步与互斥
线程通信主要通过共享访问字段或者字段引用的对象完成的,但是有可能出现两种错误:线程干扰(thread interference)和内存一致性错误(memory consistency)。用来防止这些错误的工具是同步(synchronization)。
2.1 线程干扰
为了便于说明线程干扰的问题,定义一个银行帐户类BankAccount,有两个方法:取款的方法withdraw和存款的方法deposit。并定义不同的线程进行存款和取款。存款线程每次存100元,取款线程每次取100元。各运行10万次。
package sync; public class BankAccount { private int number; private int balance; public BankAccount(int number, int balance) { this.number = number; this.balance = balance; } public int getBalance() { return balance; } public void deposit(int amount) { balance = balance + amount; } public void withdraw(int amount) { balance = balance - amount; } public static void main(String[] args) throws InterruptedException { BankAccount a = new BankAccount(1, 1000); Thread t1 = new Thread(new Depositor(a, 100), "depositor"); Thread t2 = new Thread(new Withdrawer(a, 100), "withdraw"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(a.getBalance()); } static class Depositor implements Runnable { BankAccount account; int amount; public Depositor(BankAccount account, int amount) { this.account = account; this.amount = amount; } @Override public void run() { for (int i = 0; i < 100000; i++) account.deposit(amount); } } static class Withdrawer implements Runnable { BankAccount account; int amount; public Withdrawer(BankAccount account, int amount) { this.account = account; this.amount = amount; } @Override public void run() { for (int i = 0; i < 100000; i++) account.withdraw(amount); } } }
帐户的初始余额为1000元。取款线程和存款线程各运行10万次后,程序的运行结果如下:
再运行一次,程序的运行结果如下:
分析运行结果,第一个结果 “1000” 符合我们的预期,第二个结果“10001000”不符合我们的预期,但是它确实是一个程序的最终运行结果。这就是因为线程之间的干扰导致的预期之外的结果。
当运行在不同线程中的两个操作对相同数据进行操作时,就会出现干扰,就是说,两个操作有多个步骤组成,并且操作步骤的序列重叠了。BankAccount中的操作似乎不可能重叠,他们都是单一的简单语句,但是即使单一的语句也可能被虚拟机转换为多个步骤。“balance = balance - amount;”一般可能会分解成3个步骤: 1)取出balance的值,
2)执行减法,
3)计算结果赋值给balance。
假设线程t1执行deposit操作时,线程t2几乎同时执行withdraw操作,帐户的初始值为1000,那么当存款的初始化值为1000时,取款的初始值也为1000,存款操作的结果可能覆盖取款操作的结果,balance变为1100。10万次操作后,就会形成比较严重的误差。
2.2 同步
当两个线程需要使用同一个对象时,存在交叉操作而破坏数据的可能性。这种潜在的干扰动作在术语上被称作临界区(critical section)。通过同步(Synchronize)对临界区的访问可以避免这种线程干扰。
某些动作操作对象之前,必须先获得这个对象的锁。获取待操作对象上的锁可以阻止其他对象获取这个锁,直至这个锁的持有者释放它为止。这样,多线程就不会同时执行那些会互相干扰的动作。
同步是围绕被称为内在锁(intrinsic lock)或者监视器锁(monitor lock)的内部实体构建的,强制对对象状态的独占访问,以及建立可见性所需的发生前关系。每个对象都具有与其关联的内在锁,按照约定,需要对对象的字段进行独占和一致性访问的线程,在进行访问之前,必须获得这个对象的内在锁,访问操作完成之后必须释放内在锁。在从获得锁到释放锁的时间段内,线程被称为拥有内在锁。只要有线程拥有内在锁,其他线程就不能获得同一个锁,试图获得锁的其他线程将被阻塞。
Java提供了synchronized关键字来支持内在锁。Synchronized关键字可以放在方法的前面、对象的前面、类的前面。
1. 同步方法中的锁
当线程调用同步方法时,它自动获得这个方法所在对象的内在锁,并且方法返回时释放锁,如果发生未捕获的异常,也会释放锁。当调用静态同步方法时,因为静态方法和类相关联,线程获得和这个类关联的Class对象的内在锁。使用内在锁后,把deposit方法和withdraw方法修改为同步方法,就可以避免线程干扰。
public synchronized void deposit(int amount) { balance = balance + amount; } public synchronized void withdraw(int amount) { balance = balance - amount; }
2. 同步语句
同步语句必须指定提供内在锁的对象,其基本用法如下:
synchronized(提供锁的对象){
临界代码
}
用同步语句修改BankAccount类中的方法如下:
public void deposit(int amount) { synchronized (this) { balance = balance + amount; } }
public void withdraw(int amount) { synchronized (this) { balance = balance - amount; } }
3. 同步类
把synchronized关键字放在类的前面,这个类中的所有方法都是同步方法。
4. 可重入同步
线程可以获得他已经拥有的锁,运行线程多次获得同一个锁,就是可以重入(reentrant)同步。这种情况通常是同步代码直接或者间接的调用也包含了同步代码的方法,并且两个代码集都使用同一个锁。如果没有可重入同步,那么,同步代码就必须采取很多额外的预防措施避免线程阻塞自己。
3.同步与volatile
任何被不同线程所共享的可变值应该总是被同步的访问以防止发生干扰,然而同步是需要代价的。Java可以保证对任何变量的读写都是原子性的,原子(atomic)操作是必须同时完成的操作,这样变量就只会持有某个线程写入的值,而绝不会持有两个不同线程写入的部分交叉混合的值。这意味着原子变量只能有一个线程来写,多个线程来读,因此不需要对他的访问进行同步以防止数据被破坏,因为这些访问之间不存在互相干扰的可能性。但这对“获取-修改-设置”(如++操作)没有任何帮助,这种操作需要同步。
需要注意的是,原子访问并不能保证线程总是会读取变量最近的写入值,如果没有同步,一个线程的写入值对另一个线程可能永远都不会是可见的。有很多因为会影响一个线程写入的变量何时会对另一个线程变为可见的。当缺乏同步机制时,不同线程发现被更新变量的顺序也可以完全不同。在确定内存访问如何排序以及合适,可以确保他们可见时所使用的规则被称为Java编程语言的内存模型。
线程所读取的所有变量的值都是由内存模型决来决定的,因为内存模型定义了变量被读取时允许返回的值集合。从程序员的角度看,这个值集合应该只包含单一的值,即由某个线程最近写入的值。然而在缺乏同步时,实际获得的值集合可能包含许多不同的值。
假设BankAccount中的字段balancek可以被一个线程不断的显示,并且可以由其他线程使用非同步的方法对其进行修改。
//更新 public void updateBalance() { balance = (int) (Math.random() * 100); } //显示 public void showValue() throws InterruptedException { balance = 10; for (;;) { showBalance(balance); Thread.sleep(1000); } }
当第一次进行循环时,balance唯一可能的值是10,由于没有使用线程同步,所以每当由线程调用updateBalance时,都会有新值被添加到所要读取的可能值集合中。当在循环中读取balance时,可能值也许已经包含了10,20,25,35和78,其中任何一个值都可以通过读取操作返回,因为根据内存模型的规则,任何被某个线程写入的值都可以通过读取操作返回。实际上,如果showValue无法改变balance的值,那么编译器就会假设他可以认为balance在循环体内未发生改变,从而在每次调用showValue时直接使用常量10来表示balance。这种策略和内存模型是一致的。内存模型没有控制要返回哪一个值。为了让程序能像我们所描述的那样运行,我们必须使得在写入balance时,写入值可以成为内存模型唯一允许读取的值。要做到这一点,必须对写入和读取的操作进行同步。
Java提供了一种同步机制,它不提供对锁的独占访问,但同样可以确保对变量的每一个读取操作都返回最近写入的值,这种机制就是只用volatile变量。字段变量可以用修饰符volatile来声明,volatile变量的写入操作将与随后所有这个变量的读取操作进行同步。如果balance被声明为volatile,那么我们所给出的示例代码就会被正确的同步,并且总是会显示最新的值。volatile变量并没有提供可以跨多个动作的原子性,经常被用作简单的标记以表示发生了某个事件,或者被用来编写无锁算法(lock-free)。
将变量设置为volatile所产生的另一个效果就是可以确保读写操作都是原子性的。
4.活性
并发应用程序按照及时方式执行的能力称为活性(liveness)[2]。一般包括三种类型的问题死锁、饿死和活锁。
1. 死锁
线程死锁是并发程序设计中可能遇到的主要问题之一。他是指程序运行中,多个线程竞争共享资源时可能出现的一种系统状态,每个线程都被阻塞,都不会结束,进入一种永久等待状态。
2. 饿死
饿死(starvation)描述这样的情况:一个线程不能获得对共享资源的常规访问,并且不能继续工作,当共享资源被贪婪线程长期占有而不可用时,就会发生这样的情况。
3. 活锁
一个线程经常对另一个线程的操作作出响应,如果另一个线程的操作也对这个线程的操作作出响应,那么就可能导致活锁(livelock)。和死锁类似,发生活锁的线程不能进行进一步操作。但是,线程没有被锁定,它只是忙于相互响应,以致不能恢复工作
4.静态代码块死锁
/** * * * @author mjorcen * @email mjorcen@gmail.com * @dateTime Sep 11, 2014 12:39:14 PM * @version 1 */ private static void staticBlocklock2() { synchronized (lock1) { synchronized (lock2) { } } } /** * * * @author mjorcen * @email mjorcen@gmail.com * @dateTime Sep 11, 2014 12:39:01 PM * @version 1 */ private static void staticBlockLock1() { synchronized (lock2) { synchronized (lock1) { } } }
5.动态代码块死锁
/** * * * @author mjorcen * @email mjorcen@gmail.com * @dateTime Sep 11, 2014 12:42:52 PM * @version 1 * @param lock22 * @param lock12 */ private static void dynamicBlockLock1(Object lock12, Object lock22) { synchronized (lock12) { synchronized (lock22) { } } } /** * * * @author mjorcen * @email mjorcen@gmail.com * @dateTime Sep 11, 2014 12:42:52 PM * @version 1 * @param lock22 * @param lock12 */ private static void dynamicBlockLock2(Object lock12, Object lock22) { synchronized (lock22) { synchronized (lock12) { } } }