之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于以下原因:
资源利用率,在一个程序等待时,可以运行另外一个程序,将提高资源利用率
公平性,通过粗粒度的时间分配使这些用户和程序能共享计算机资源,而不是由一个程序从头到尾,然后再启动下一个程序
便利性,计算多个任务,应该编写多个程序,每个程序执行一个任务在必要时互相通信,这只编写一个程序来计算要简单。
如果使用得到,线程可以有效降低程序的开发和维护等成本,同时提高复杂应用程序的信念。降低代码复杂度,使代码更容易维护,在GUI应用程序中,提高用户界面的响应度,而在服务器应用程序中,可以提升资源利用率以及系统吞吐率。
在多处理器中,如果只有一条线程,其余CPU资源则被浪费,而在单处理器中,使用多个线程可以提高吞吐率。如果程序是单线程,那么程序等待某个同步I/O时,处理器处于空闲,而多线程,可以使另外一个线程在这时候继续运行
如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步I/O以及资源等待等问题分类开来,可以将复杂的工作流进一步分解为一组简单并且同步的工作流。
例如Servlet和RMI。框架负责解决一些细节问题,例如请求关联、线程创建、负载平衡,并在正确的时刻将请求分发给正确的应用程序组件。Servlet的service方法来响应Web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,并缩短这种框架的学习时间
服务器应用程序在接受多个远端客户端的套接字请求时,如果为每个连接都分配各自的线程并且使用同步I/O,那么就会降低这类程度的开发难度。
如果某个应用程序对套接字执行读操作,而此时还没有数据到来,那么这个读操作将一直阻塞,在单线程应用程序中,意味着处理请求的过程将停顿,而且在这期间,所有请求的处理都将停顿。为了避免这个问题,单线程应用服务器必须使用非阻塞I/O,而这种I/o的复杂性要高于同步IO。
现在操作系统中,线程数量已经得到极大的提升,这使得在某些平台上,即时有更多客户端,为每个客户端分配一个线程也是可行的。
传统的GUI应用程序通常是单线程的,从而在代码的各个位置都需要调用poll方法来获得输入时间,或者通过一个主世界循环来间接地执行应用程序的所用代码。如果在主事件循环中调用的代码需要很长时间,那么用户界面就会冻结。
在现代GUI框架中,例如AWT和Swing等工具,都采用一个事件分发线程来替代主世界循环。当某个用户界面事件发生时,在事件线程中将调用应用程序的事件处理器。
线程安全性可能是非常复杂的,在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。由于多个线程要共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程正在使用的变量,这比其他线程间通信机制更容易实现数据共享,但是由于无法预料数据变化而发生错误。
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注另一个目标,即“某件正确的事情最终会发送”。当某个操作无法继续执行下去时,就会发生活跃性问题。比如线程死锁,饥饿,以及活锁,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程的事件发生时序,因此在开发或者测试中并不总是能够重现
活跃性意味着某件正确的时间最终会发生,但却不够好,因为我们通常希望正确的事情尽快发生。性能问题包括多个方面,例如服务时间过程,响应不灵敏,吞吐率过低,资源消耗过高,或者可伸缩性较低等。
在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总会带来一定程度运行时开销。在多线程程序中,当线程调度器临时挂起活跃线程转而运行其他线程,就会频繁的出现上下文操作,这种操作将带来极大的开销。当线程共享数据时,必须使用同步机制,而这些机制会抑制某些编译器优化,使内存缓存区汇中的数据无效,以及增加共享内存总线的同步流量。这些因素都将带来额外的性能消耗
即使在程序中没有显示创建线程,但是框架中仍可能会创建线程。虽然并发性认为是一种“可选的”或者“高级的”语言功能,但现实情况是,几乎所有Java应用程序都是多线程的,因此在使用这些框架时仍然需要对应用程序状态的访问进行协同。
当某个框架在应用程序中引入并发性时,通常不可能将并发性局限于框架代码,因为框架本身会回调应用程序代码,而这些代码将访问应用程序状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。所以,框架通过框架线程中调用应用程序代码将并发性引入到程序中,所以访问这些状态代码路径都必须是线程安全的。
下面给出模块,线程安全性需求可能源自这些模块,但是却不会止步于它们,而是会延伸到整个应用程序。
Timer。Timer类的作用是使任务在稍后时候运行,或者运行一次,或者周期性地运行。TimerTask是在Timer管理的线程中执行,而不是应用程序。如果TimerTask访问了应用程序的其他线程访问的数据,那么不仅TimerTask需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。最简单的方式是确保TimeTask访问的对象本身就是线程安全的。
Servlet和JavaServer Page(JSP)。Servlet框架用于部署网页应用程序以及分发来自HTTP客户端的请求。到达服务器的请求可能会通过一个过滤器被分发到正确的Servlet或JSP。Servlet同样需要被多个程序同时调用,换句话说Servlet需要线程安全,Servlet通常会访问与其他Servlet共享的信息,例如应用程序中的对象(这些对象保存在ServletContext中)或者会话中的对象(这些对象保存在客户端的HttpSession中)。当一个Servlet访问在多个Servlet或者请求中共享的对象是,必须正确协调对这些对象的访问。Servlet和JSP,以及在ServletContext和HttpSession等容器中保存的Servlet过滤器和对象等,都必须是线程安全的。
远程方法调用(Remote Method Invocation RMI)。RMI使代码能够调用在其他JVM中运行的对象。当通过RMI调用某个远程方法时,传递给方法的参数必须被打包(也称为列集)到一个字节流中,通过网络传输给远程JVM,然后由远程JVM拆包(或者称为散集)并传递给远程方法。远程对象必须注意两个线程安全性问题:正确地协同在多个对象中共享的状态,以及对远程对象本身状态的访问
SWing和AWT。GUI应用程序的一个固有属性是异步性。用户可以在任意时刻选择一个菜单项或者安心一个按钮,应用程序就会及时响应,即时应用程序当时正在执行其他任务。SWing和AWT很好的解决了这个问题,它们创建了一个单独的线程来处理用户触发的事件,并对程序给用户的图像界面进行更新。Swing的一些组件并不是线程安全的,例如JTable。Swing程序通过将所有对GUI组件的访问局限在事件线程中以实现程序安全性。
要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。
对象的状态是指存储在状态变量(例如实例或者静态域)中的数据。对象的状态可能还包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。
“共享”意味着变量可以由多个线程同时访问,“可变”意味着变量值在其生命周期内可以发送变化。一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采取同步机制来协同对对象可变状态的访问。
Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但是“同步”这个术语还包括volatile类型的变量,显示锁以及原子变量。
当多个线程访问同一个可变的状态变量时又没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
不在线程间共享该状态变量将状态变量修改为不可变的变量在访问状态变量时使用同步面向对象编程,可以使程序状态封装,而程序状态封装性越好,越容易实现程序的线程安全性。在编写并发应用程序时,首先使代码正确运行,然后在再提高代码的速度。完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。
当多个线程访问某个类时,这个类始终都表现出正确的行为,那么就称这个类是线程安全的。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都表现出正确的行为,那么就称这个类是线程安全的。
在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。下面就是一个无状态的,线程安全的类
public class StatelessFactorizer implements Servlet { public void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException { BigInteger i = extractFromRequest(req);//得到参数 BigInteger[] factors = factor(i);//因数分解 toResponse(resp, factors); } }在上面的代码中,增加一个计数器来统计所处理的请求数量count,然后每次有请求进来就进行自增操作,而由于自增操作是非原子性的,所以这个修改后的类就是非线程安全的。两个线程在没有同步的情况下同时对一个计数器执行递增操作时发生的情况是未知的,有可能出现错误的。在并发编程中,这种由于不恰当执行时序而出现的不正确结果,叫做竞态条件。
当某个计算正确性取决于多个线程交替执行的时序时,那么就会发生竞态条件。最常见的静态条件就是“先检查后执行”操作,即通过一个可能失效的观测来决定下一步动作(比如单例模式的懒加载)。
两个原子性的操作,单独是线程安全的,但是放在一起还是非线程安全的复合操作
“重入”意味着获取锁的操作粒度是“线程”,而不是“调用”,重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者的线程,当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。子类改写了父类的synchronized方法,然后调用父类的方法,此时没有可重入的锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中的doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁,如果内置锁如果不是可重入的,那么在调用super.doSomething时将无法获得Widget上的锁,从而导致死锁。
public class Widget { public synchronized void doSomething() { } } public class LoggingWidget extends Widget { public synchronized void doSomething() { System.out.println(); super.doSomething(); } }由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议一实现对共享状态的独占访问。
访问共享状态的复合操作,例如自增(读取-修改-写入)或者延时初始化(单例的懒汉式,先检查后执行),都必须是原子操作以避免产生静态条件。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式创建锁对象。每个共享的和可变的变量都应该只由一个锁来保护。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。然而这种模式并没有任何特殊之处,编译器或运行时都不会强制实施在访问某个对象的具体可变变量时的校验(如果某个变量在多个位置上的访问操作中都持有一个锁,但并非在所有位置上的访问操作都是如此,那么通过代码核查工具,例如FindBugs,就可以发现这种情况)
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
下面的Servlet虽然,能保持多线程并发时的安全,但是这些请求每一都只有一个线程可以执行,排队执行,我们称这种web应用程序称之为不良并发应用程序。
通常,在简单性与性能直接存在着互相制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性。当执行时间较长的计算或可能无法快速完成的操作时(例如,网络I/O或控制台IO)一定不要持有锁。
public class Factorizer implements Servlet { private BigInteger lastNumber; private BigInteger[] lastFactorys; public synchronized void service() { BigInteger i= extractFromRequest(req); if(i.equals(lastNumber)){ encodeIntoResponse(resp,lastFactorys); }else{ BigInteger [] factors=factory(i); lastNumber=i; lastFactors=factors; encodeIntoResponse(resp,factorys); } } }synchronized不仅是吸纳原子性或者确定临界区,它还实现了 内存可见性,我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
“发布”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如将一个指向该对象的引用保存到其他代码可以访问的地方。
当某个不应该发布的对象被发布时,这种情况就被称为“逸出”。
隐式的使this引用逸出(不要这么做)//不知道是个什么东西
数据不共享出来,仅在单线程内访问数据,就不需要同步。这种技术称为线程封闭,它是实现线程安全的最简单的方式之一。最常见应用就是JDBC的connection对象,由于大多数请求(例如servlet)都是由单个线程采用同步的方式来粗粒,并且在Connection对象返回之前,连接池不会再将它分配给其他线程,这种连接管理模式在处理请求时隐含地将Connection对象封闭在线程中。
Ad-hoc线程封闭时指,维护线程封闭性的职责完全由程序实现来承担。比如volatile变量,你只要保证单个线程对共享的volatile变量执行写入操作,那么就相当于将修改操作封闭在单个线程中以防止静态条件。由于Ad-hoc线程封闭技术的脆弱性,在可能的情况下,应该使用更强的线程封闭技术(栈封闭或者ThreadLocal类)
栈封闭(线程内部使用或者线程局部使用)
当某个频繁执行的操作需要一个临时变量,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。
在实现应用程序时大量使用了ThreadLocal。例如EJB调用期间,J2EE容器需要将一个事务上下文与某个执行中的线程关联起来。通过将事务上下文保存在ThreadLocal对象中,可以很容易实现:当框架代码需要判断当前运行的是哪一个事务时,只需要从ThreadLocal读取即可。这种机制,避免了在调用每个方法时都要传递执行上下文信息。ThreadLocal变量类似于全局变量,它能降低代码的可重用性,并在类直接引入隐含的耦合性,因此在使用时格外小心。
满足同步需求的另外一种方法是使用不可变对象,如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。
除非需要更高的可见性,否则应将所有的域都声明为私有域
除非需要某个域是可变的,否则应将其声明为final域
3.4.2
在设计线程安全类的过程中,需要包含以下三个基本要素:
找出构成对象状态的所有变量找出约束状态变量的不变性条件建立对象状态的并发访问管理策略对于含有n个基本类型域的对象,其状态就是这些域构成的n元组,如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。
同步策略(Synchronization Policy)规定了如何将不变性、线程封闭和加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写为正式稳定。
对象和变量所有可能的取值,叫做状态空间。状态空间越小,越容易判断出线程的状态。final类型域使用越多,就越能简化对象可能状态的分析过程。在操作中可能会包含一些后验条件来判断状态迁移是否有效,当下一个状态需要依赖当前状态操作时,这个操作是个复合操作,就不能保证原子性。
由于不变性条件以及后验条件在状态和状态转化上施加了各种约束,因此就需要额外的同步与封装。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换的各种约束条件,就需要借助于原子性与封装性。
类的不变性条件与后验条件约束了在对象上哪些状态和状态转化是有效的。在某些对象方法中还包含一些基于状态的先验条件。例如,不能从空队列中移除一个元素。如果某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但是在并发程序中先验条件可能会由于其他线程执行操作而变成真。
在Java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,想要正确地使用它们并不容易。想要实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如BlockingQueue或Semaphore)来实现状态依赖行为。
从对象可以达到的所有域中,需要满足哪些条件才不属于对象状态的一部分?
许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,对象对封装的状态拥有所有权。所有权意味着控制权,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非是这些方法被专门设计为转移传递进来 的对象所有权。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。例如。Servlet框架中的Servletcontext,ServletContext为Servlet框架提供了类似于Map形式的对象容器服务,在ServletContext中可以通过名称来住处或获取应用程序对象。其中由容器Servlet实现的ServletContext是线程安全的。当调用ServletContext的setAttribute和getAttribute时,Servlet不需要使用同步,但是当使用保存在ServletContext中的催下时,则可能需要使用同步。
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制,通常也称为封闭。将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封闭对象一定不能超过它们既定的作用域。对象可以封闭在类的一个实例(例如作为一个类的私有成员),或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在某个线程中将对象从一个方法传递到另外一个方法,而不是在多个线程之间共享该对象)。
前提:Person是线程安全的类。 下面的PersonSet说明如何通过封闭与加锁等机制使一个类称为线程安全的(即使这个类的状态变量并不是线程安全的)。PersonSet的状态由HashSet来管理,而HashSet并非线程安全的,但是由于mySet是私有的而且不会溢出,因此HashSet被封闭在PersonSet中。唯一能方位mySet的代码是方法addPerson与containsPerson,在执行他们时都需要获得PersonSet上的锁。PerSon的状态完全由它的内置锁保护,因而PersonSet是一个线程安全的类。
public class PersonSet { private final Set<Person> mySet= new HashSet<Person>(); public synchronized void addPerson(Person p){ mySet.add(p); } public synchronized boolean containsPerson(Person p){ return mySet.contains(p); } }Java类库中还有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。一些基本容器类并非线程安全的,例如ArrayList和HashMap,但类库提供了包装器工厂方法(例如Collections.synchronizedList及其类似方法),使得这些线程安全的类可以在多线程环境中安全地使用。这些工厂方法通过“装饰器”模式将容器类封装在一个同步包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装对象拥有对底层对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。
当发布其他对象时,例如迭代器或内部类实例,可能会间接地发布被封闭的对象,同样会是封闭对象逸出
我们应该优先选重用现有类而不是创新类:重用能降低开发工作量、开发风险以及维护成本。
例如,假设需要一个线程安全的链表,它需要提供一个原子的“若没有则添加(Put-If-Absent)”的操作,同步的List类已经实现大部分功能,我们可以根据它提供的contains方法和add方法来构造一个“若没有则添加”操作。
(1)修改原始的类
“若没有则添加”必须是一个原子操作,最安全的方法是修改原始的类,意味着实现同步策略的所有代码仍然处于一个源文件代码中,从而更容易维护与理解。
(2 )扩展这个类,前提是这个类的设计时考虑了可扩展性。扩展Vector很简单,但是并非所有类都像Vector那样将状态向子类公开,因此也就不适合采用这种方法
public class Bettervecotor <E> extends Vector<E>{ public synchronized boolean putIfAbsent(E x){ boolean absent=!contains(x); if(absent){ add(x); } return absent; } }“扩展”方法比直接将代码添加到勒种更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源文件代码文件中了。
(3) 客户端加锁机制
对于Collections.synchronizedList封装的ArrayList,这两种方法在原始类中添加一个方法或者对类进行扩展都行不通,因为客户端代码并不知道在同步封装器工厂方法中返回的List对象的类型。
所以只能采用第三种,扩展类的功能,但不是扩展类本身,在扩展功能的时候一定和原来的类使用同一个锁,下面代码就是错误示范
public class ListHelper<E> { public List<E> list = Collections.synchronizedList(new ArrayList<E>()); public synchronized boolean putIfAbsent(E x) { boolean absent = !list.contains(x); if (absent) { list.add(x); } return absent; } } 这里的ListHelper和List肯定使用的不是同一个锁, 那么putIfAbsent相对于List的其他操作就不是原子的。
要想使上面的方式正确执行,必须使List在客户端加锁或者外部加锁时使用同一个锁。在Java同步封装器类的文档中指出,它们通过使用内置锁来支持客户端加锁。所以按照下面方式则是可以的。
public class ListHelper<E> { public List<E> list = Collections.synchronizedList(new ArrayList<E>()); public boolean putIfAbsent(E x) { synchronized (list) { boolean absent = !list.contains(x); if (absent) { list.add(x); } return absent; } } }通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类,然而客户端加锁更加脆弱,因为它将类C的加锁代码放到与C完全无关的其他类中。
(4)组合
为现有类添加一个原子操作时,有一种更好的方法:组合。客户端不会直接使用该对象,而是通过其他对象访问它。
public class ImprovedList<T> implements List<T> { private final List<T> list; public ImprovedList(List<T> list){ this.list=list; } public synchronized boolean putIfAbsent(T x) { boolean absent = !list.contains(x); if (absent) { list.add(x); } return absent; } public synchronized void clear(){ list. clear(); } // ... 按照类似的方式委托List的其他方法 }ImprovedList通过自身的内置锁增加了一层额外的加锁,它不关心底层List是否线程安全的,但是额外的同步层会导致轻微的性能损失(性能损失很小,因为在底层的list上的同步不存在竞争,所以速度很快)
在文档中说明客户端代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。在设计同步策略时需要考虑多个方面,例如,将哪些变量声明为volatile类型,哪些变量用锁来包含,哪些锁保护哪些变量,哪些变量必须是不可变的或者被封闭在线程中的,哪些操作必须是原子操作。
同步容器类包括Vector和HashTable,还包括JDK1.2中添加的一些功能类似的类,由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类都是线程安全的,但是在某些状态下可能需要额外的客户端加锁来保护复合操作,比如在Vector中定义两个方法:getLast和deleteLast,他们都会“先检查在再运行”,先得到size,再根据size操作,而这属于复合操作,如果想让这种复合操作线程安全,就必须是原子操作。
public static Object getLast(Vector list)}{ synchronized(list){ int lastIndex=list.size()-1; return list.get(lastIndex); } }无论在直接迭代还是在Java5.0 引入的for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator。在设计同步容器类没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败(fail-fast)”,当它们方向容器在迭代过程中被修改时,就会抛出ConcurrentModificationException异常。它们采用的实现方式是,将计数器变化与容器关联起来:如果在迭代期间计数器被修改,那么hasNext或是next将抛出ConcurrentModificationException。
通过下面的方式,对容器加锁,可能会产生死锁,即时不存在饥饿或者死锁等风险,长时间对容器加锁也就会导致锁上竞争激烈,极大降低吞吐量和CPU利用率。
synchronized(vector){ for(int i=0;i<vector.size();i++){ doSometing(vector.get(i)); } }如果不希望在迭代器期间加锁,另外一种替代方法就是“克隆”容器,并在副本上进行迭代(在克隆过程中仍然需要对容器加锁)。在克隆容器时,存在显著的性能开销,这种方式取决于容器大小、每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率等等。
虽然加锁可以防止迭代器抛出ConcurrentModificationException,实际情况更加复杂,在某些情况下,迭代器会隐式的被调用。在下面的代码HiddenIterator中没有显示迭代操作,但是编译器将字符串的连接操作会转化为StringBuilder.append(Object),而这个方法又会调用容器的toString(),toString()将迭代容器,并且在每个元素上调用toString来生产容器内容格式化表示。
public class HiddenIterator { private final Set<Integer> set = new HashSet<Integer>(); public synchronized void add(Integer i) { set.add(i); } public synchronized void remove(Integer i) { set.remove(i); } public void addTenThings() { Random r = new Random(); for (int i = 0; i < 10; i++) { add(r.nextInt()); } System.out.println("added ten elements to" + set);//这里会转变成toString对容器进行迭代 } } addTenThings可能会抛出ConcurrentModificationExceptin,因为在生成输出语句的过程中,toString对容器进行迭代。真正的问题在于HiddenIteratcor不是线程安全的,使用printnl中的set之前补习首先获取HiddenIterator的锁,但是在调试代码和日志代码中通常会忽视这个要求。
容器的hashCode和equals等方法也会间接的执行迭代操作,当容器作为另外一个容器的元素或键值时,就会出现这种情况。同样,containsAll、removeAll和retainAll等方法,已经把容器作为参数的构造函数,都会对容器进行迭代。
ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList(用于在遍历操作为主要操作的情况下替代同步的List)。
Java 5.0 增加了两种新的容器类型:Queue 和BlockingQueue。Queue 用来临时保存一组等待处理的元素。它提供了集中实现,包括:ConcurrentLinkedQueue,这是传统的先进先出队列,以及PriorityQueue,这是一个(非并发)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为——事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的随机访问需求,从而实现高效并发。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将会一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。
正如ConcurrentHashMap用于代替基于散列的同步Map,Java6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet分别作为同步的SortedMap和SortedSet的并发替代品(例如synchronizedMap包装的TreeMap或TreeSet)
HashMap.get 或List.contains,可能包含大量的工作: 当遍历散列桶或链表来查找某个特定的对象时,必须在许多元素上调用equals(而equals本身还包含一定的计算量)。在基于散列的容器中,如果hashCode不是很均匀的分布散列值,那么容器中的元素就不会均匀的分布在整个容器中,讴歌糟糕的散列函数还会把一个散列表变成线性链表,当遍历很长的链表并且在某些或者全部元素上调用equals方法时,会花费很长的时间。
ConcurrentHashMap也是一个基于散列的Map,但是它是使用的分段锁实现的共享。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
尽管有这些改进,但仍然有一些需要权衡的因素。对于一些需要在整个Map上进行计算方法,例如size和isEmpty,这些方法的语义被略微减弱以反映容器的并发特性。size返回的时一个近似值而不是一个精确值。size和isEmpty这样的方法在并发环境的用户很小,因为它们的返回值总在不断变化。因此这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,包含get、put、containsKey和remove等。
ConcurrentHashMap中没有实现对Mao加锁以提供独占访问。在HashTable和synchronizedMap中,获得Map的锁能防止其他线程访问这个Map。用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性,只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。
CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制(类似地,CopyOnWriteArraySet的作用是替代同步Set)。
“写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器腹部,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数据的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此对其进行同步时只需确保数组内容的可见性。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。这个准则很好的描述了许多事件通知系统:在分发通知时需要迭代已注册监听器链表,并调用每一个监听器,在大多数情况下,注册和注销时间监听器的操作远远少于接受事件通知的操作。
阻塞队列提供了可阻塞的put和take方法,以及支持定时offer和poll方法。如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。
生成这-消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上有所不同。
阻塞队列简化了消费者程序的编码,因为take操作会一直阻塞直到有可用的数据。
如果生产者生产工作的速率比消费者处理工作的速率快,那么工作会在队列中积累起来,最终会耗尽内存。put方法的阻塞性也极大的简化了生产者的编码。
阻塞队列统一提供给了一个offer方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理符合过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建高可用的应用程序时,有界队列是一种强大的资源管理工作:它们能抑制并防止产生过多的工作项,使应用程序在符合过载的情况下变得更加健壮。
在类库中包含了BlockingQueue的多种实现,其中LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,二者分别是LinkedList和ArrayList类似,但比同步List拥有更好的并发性性能。PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来处理元素时,这个队列将非常有用。正如其他有序容器一样,PriorityBlockingQueue既可以处理元素的自然顺序来比较元素(如果它们实现了Comparable方法),也可以使用Comparator来比较。
最后一个BlockingQueue实现时SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中的元素维护存储空间。与其他队列不同的时,它维护一组线程,这些线程在等待着把元素加入或移除队列。因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另外一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付工作时,才适合使用同步队列。
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。串行封闭对象只能由单个线程拥有,但可以通过安全地发布该对象“转移”所有权。在转移“所有权”后,也只有另外一个线程能够获得这个对象的访问权限,并且发布对象的线程不会再访问它。
对象池利用了串行线程封闭,将对象“借给”一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象,并且客户端代码本身不会发布对象,在将对象返回给对象池后就不再使用它,那么就可以安全地在线程值传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能够接受被转移的对象。阻塞队列简化了这项工作。除此之外,还可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来完成这项工作。5.3.3 双端队列与工作密取
Deque(发音为“deck”)和BlockingDeque,它们分别对Dueue和BlockingQueue进行了扩展。Duque是一个双端队列,实现了在队列投和队列为的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
双端队列适用于生产者-消费者模式,双端队列同样适用于另外一种相关模式,即工作密取(Work Stealing)。在生产者-消费者设计中,所有消费者共享一个工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作线程不会在单个共享的任务队列上发生竞争。在大多数的时候,它们都只是访问自己的双端队列,从而极大地减少竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作密取非常适用于既是生产者也是消费者的问题——当执行某个工作时可能导致出现更多工作。例如,在网页爬虫程序中处理一个也没时,通常会发现有更多页面需要处理。例如垃圾回收阶段对堆的标记,都可以通过工作密取来实现高效并行。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者再工作共享设计模式中,放入其他工作者线程队列中)。当双端队列为空时,它会在另一个线程的队列尾查找新的任务,从而确保每个线程都保持忙碌状态。
线程的阻塞状态或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKED、WAITING或TIMED_WAITING)。阻塞操作与执行时间很长的普通操作差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行。
BlockingQueue的put和take等方法会抛出受检查异常 Interrupted-Exception,这与类库中其他一些方法相同,例如Thread.sleep。当某个方法抛出Interrupted-Exception时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将提前结束阻塞状态。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已被中断。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他操作,只是通知。
当在代码中调用了一个将抛出InterruptedException异常方法时,你自己的方法也就变成了阻塞方法,并且必须要处理中断的响应。对于库代码来说,有两种基本选择:
传递InterruptedException。把这个异常传递给方法调用者,或者捕获后,执行简单清理工作后再次抛出这个异常恢复中断。有时候不能抛出InterruptedException,例如当代码是Runnable的一部分时。在这些情况下,必须捕获InterruptedException,并调用通过当前线程上的interrupt方法恢复中断状态,这样在调用栈的更高层代码将看到引发了一个中断。出现InterruptedException时不应该做的事情,捕获它但不做任何响应。这将使调用栈更高层的代码无法对中断采取处理措施。只有在一种特殊的情况中才能屏蔽中断,即对Thread进行扩展,并且能够控制调用栈上所有更高层的代码。
除了阻塞队列,还包括其他类型的同步工具类,信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。所有同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另外一些方法用于高效地等待同步工具类进入到预期状态。
闭锁,可以用来确保某些活动直到其他活动都完成后才继续执行,例如:
确保某个计算在其需要的所有资源都被初始化之后才继续执行。确保某个服务在其依赖的所有其他服务都已经启动之后才启动。等到某个操作的所有参与者都就绪再继续执行。FutureTask也可以用作闭锁,FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于一下3种状态:等待运行(Waiting to run),正在运行(Running)和运行完成(Completed)。Future.get()行为取决于任务状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
Semaphore可以将任何一中容器变成有界阻塞容器。acquire将阻塞知道有许可。release将返回一个许可给信号量。
public class BoundedHashSet<T> { private final Set<T> set; private final Semaphore sem; public BoundedHashSet(int bound) { this.set = Collections.synchronizedSet(new HashSet<T>()); sem = new Semaphore(bound); } public boolean add(T o) throws InterruptedException { sem.acquire(); boolean wasAdded = false; try { wasAdded = set.add(o); return wasAdded; } finally { if (!wasAdded) { sem.release(); } } } public boolean remove(Object o) { boolean wasRemoved = set.remove(o); if (wasRemoved) { sem.release(); } return wasRemoved; } }栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
CyclicBarrier可以使一定数量参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时,将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有的阻塞的await调用都将终止并抛出BrokenBarrierException,那么await将为每个线程返回一个唯一到达的索引号,我们可以利用这些索引选举一个领导线程,并在下一次迭代中由该领导线程执行一些特殊工作。
例如某个步骤中的计算可以并行执行,但是必须等到该步骤的所有计算都执行完毕才能进入下一个步骤。
另外一种形式的栅栏是Exchanger,它是一种两方栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用。例如当一个线程向缓冲区写入数据,而另外一个线程从缓冲区读取数据。通过Exchanger,达到满的缓冲区与空的缓冲区交换,当两个线程通过Exchanger较好对象时,这种交换就把两个对象安全地发布给另一方。
缓存看上去非常简单,然而简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈。
使用HashMap和同步机制来初始化缓存,由于HashMap是线程不安全的,所以通过synchronized来实现同步,但是这样每次只有一个线程能执行compute。
public interface Computable<A, V> { V compute(A arg) throws InterruptedException; } public class ExpensiveFunction<A, V> implements Computable<String, BigInteger> { public BigInteger compute(String arg) throws InterruptedException { // 在经过长时间计算后 return new BigInteger(arg); } } public class Memoizer1<A, V> implements Computable<A, V> { private final Map<A, V> cache = new HashMap<A, V>(); private final Computable<A, V> c; public Memoizer1(Computable<A, V> c) { this.c = c; } public synchronized V compute(A arg) throws InterruptedException { V result = cache.get(arg); if (result == null) { result = c.compute(arg); cache.put(arg, result); } return result; } }通过把Map改为ConcurrentHashMap,多线程就可以并发的使用它。但是作为一个缓存有一个漏洞——当两个线程同时调用compute时存在一个漏洞,可能会导致计算得到相同的值。如果某个线程启动了一个开销很大的计算,而其他线程在判断result==null的时候,并不直到线程在计算,所以很可能重复计算。
public interface Computable<A, V> { V compute(A arg) throws InterruptedException; } public class ExpensiveFunction<A, V> implements Computable<String, BigInteger> { public BigInteger compute(String arg) throws InterruptedException { // 在经过长时间计算后 return new BigInteger(arg); } } public class Memoizer2<A, V> implements Computable<A, V> { private final Map<A, V> cache = new ConcurrentHashMap<A, V>(); private final Computable<A, V> c; public Memoizer1(Computable<A, V> c) { this.c = c; } public V compute(A arg) throws InterruptedException { V result = cache.get(arg); if (result == null) { result = c.compute(arg); cache.put(arg, result); } return result; } }我们通过FutureTask来表示一个计算过程,来实现其他线程不重复计算,通过FutureTask.get将立即返回结果,否则它会一直阻塞,直到计算结果出来。但是它有个缺陷,仍然存在两个线程计算出相同的值的漏洞,虽然发生概率远远小于Memoizer2。由于compute方法中的if代码块仍然是非原子的“先检查再执行”操作,因此两个线程仍然可能在同一时间内调用compute来计算相同的值。
public class Memoizer3<A, V> implements Computable<A, V> { private final Map<A, Future<V>> cache = new HashMap<A, Future<V>>(); private final Computable<A, V> c; public Memoizer3(Computable<A, V> c) { this.c = c; } public synchronized V compute(final A arg) throws InterruptedException { Future<V> f = cache.get(arg); if (f == null) { Callable<V> eval = new Callable<V>() { public V call() throws Exception { return c.compute(arg); } }; FutureTask<V> ft = new FutureTask<V>(eval); f = ft; cache.put(arg, ft); ft.run(); } try { return f.get(); } catch (ExecutionException e) { // TODO Auto-generated catch block e.printStackTrace(); } return null; } }通过concurrentMap的putIfAbsent来避免Memoizer3的漏洞。
public class Memoizer4<A, V> implements Computable<A, V> { private final Map<A, Future<V>> cache = new HashMap<A, Future<V>>(); private final Computable<A, V> c; public Memoizer4(Computable<A, V> c) { this.c = c; } public synchronized V compute(final A arg) throws InterruptedException { Future<V> f = cache.get(arg); if (f == null) { Callable<V> eval = new Callable<V>() { public V call() throws Exception { return c.compute(arg); } }; FutureTask<V> ft = new FutureTask<V>(eval); f = cache.putIfAbsent(arg, ft); if (f == null) { ft.run(); } } try { return f.get(); } catch (ExecutionException e) { // TODO Auto-generated catch block e.printStackTrace(); } return null; } }当缓存的时Future而不是值时,将导致缓存污染问题:如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免这种情况,如果Memoizer发现计算被取消,或者检测到RuntimException,那么也移除Future,这样将来的计算才可能成功。Memoizer同样没有解决逾期问题,可以通过使用FutureTask子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素。它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间
并发小技巧:
可变状态是至关重要的。所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。尽量将域声明为final类型,除非需要他们是可变的。不可变对象一定是线程安全的封装有助于管理复杂性用锁来包含每个可变变量当保护同一个不变形条件中的所有变量时,要使用同一个锁在执行复合操作时,要持有锁如果从多个线程中访问同一个可变变量时,没有同步机制,那么程序会出问题在设计过程中考虑线程安全,或者再文档中明确指出它不是线程安全的将同步策略文档化