三、Java并发编程实战之对象的组合

对象的组合

我们并不希望对每一次内存访问都进行分析以确保程序是线程安全的,而是希望通过一些现有的线程安全组件组合为更大规模的组件或者程序。
本章节将介绍一些组合模式,这些模式可以把一个类更容易组合成线程安全的。

一、设计线程安全的类(在思想上如何设计线程安全的类)

通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。

如何设计线程安全的类

在设计线程安全类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量,如果对象中所有的域都是基本类型的变量,那么这些域(基本变量)将构成对象的全部状态。final类型的域使用越多,就越能简化对象可能状态分析过程。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。
  • 找出约束状态变量的不变性条件(不变性条件用于判断变量状态有效还是无效,判断条件必须同步)
  • 建立对象状态的并发访问策略。

1、收集同步需求

要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏(不变性条件用于判断变量状态有效还是无效,判断条件必须同步)。

由于不变性条件(比如下面if(value==Long.MAX_VALUE)???)以及后验条件在状态及状态转换上施加了各种约束,因此需要额外的同步与封装。
比如if(x<=0) …;如果只能由一个线程让x<=0,但是如果在多线程的情况下,可能会有多个线程同时让x<=0的情况发生,状态变量的不变性条件将被破坏。故需要额外的同步和封装。

public class Counter
{
	private long value = 0;
	public synchronized long getValue()
	{
		return value;
	}
	public synchronized long increment()
	{
		if(value==Long.MAX_VALUE)
			throw new IllegalStateException("counter overflow");
		return ++value;
	}
}

如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能使对象处于无效状态。如果某个操作中存在着无效的状态转换,那么该操作必须是原子的。

例子(帮助容易理解不变性条件):在一个表示数值范围的类,可以包含两个状态变量,分别表示范围的上界和下界。两个变量必须遵循的约束是:下界小于等于上界值。类似这种包含多个变量的不变性条件将带来原子性需求:这些相关变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后再更新其他的变量(不然可能不变性条件会遭到破坏,使得下界大于上界)。因为释放锁后,可能会使对象处于无效状态。如果一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。

2、依赖状态的操作(基于先验条件)

如果某个操作中包含有基于状态的先验条件,那么这个操作被称为依赖状态的操作。例如:不能从空队列中移除一个元素,在删除元素前,队列必须处于“非空的”状态。在java中,很多先验条件(包括等待某个条件为真的各种内置机制)与内置加锁机制紧密关联,一种更简单的方法是通过现有库中的类(例如堵塞队列Blocking Queue)或者信号量Semaphore来实现依赖状态的行为。

3、状态的所有权

考虑状态的所有权问题:在面向对象编程中,所有权是属于类设计的一个要素。在C++中,把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权。java的垃圾回收机制替我们处理了所有权的问题。
容器类通常表现出一种“所有权分离”形式,其中容器类拥有其自身的状态,而客户代码则拥有容器各个对象的的状态。而容器类拥有的自身状态指容器类里的内部类的状态。以Servlet框架中ServletContext就是其中一个例子。(由于一个WEB应用中的所有Servlet共享同一个ServletContext对象,它是一个全局的储存信息的空间,因此Servlet对象之间可以通过ServletContext对象来实现通讯。)ServletContext为Servlet提供了类似Map形式的对象容器服务,在ServletContext中可以通过setAttribute或getAttribute应用程序对象,而一个ServletContext是被多个线程同时访问,所以有由Servlet容器实现的ServletContext对象必须是线程安全的。保存在ServletContext中的对象由应用程序拥有,Servlet容器替应用程序保管它们,这些对象要么是事实不可变对象、要么是线程安全的对象、要么是由锁保护的对象。

二、如何封装非线程安全对象(实例封闭)

实例封闭,此处的实例封闭机制不同于第三章的栈封闭(线程封闭,确保对象只能由单个线程访问),而是一种类的封装。而是将一个对象a(或者数据)封装到另一个对象b中,b中能访问a对象(或者数据)的所有代码路径都是已知),在通过合适的加锁策略对这些访问a对象(或者数据的)路径进行限制,从而确保以线程安全的方式来使用非线程安全的对象b。

将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。

例子:PersonSet说明了如何通过封闭和加锁机制使PersonSet成为线程安全的,即使PersonSet的状态变量并不是线程安全的。PersonSet的状态由HashSet来管理,而HashSet并非线程安全的,但是mySet是私有不会逸出,并且能访问mySet的代码路径已经被上锁。所以PersonSet是一个线程安全的类。

	@ThreadSafe
	public class PersonSet
	{
		@GuardedBy("this")
		private final Set<Person> mySet = new HashSet<Person>();  //HashSet是线程不安全的,但是对它的访问都加锁了,所以这个类是线程安全的。
		public synchronized void addPerson(Person p)
		{
			mySet.add(p);
		}
		public synchronized boolean containsPerson(Person p)
		{
			return mySet.contains(p);
		}
	}

在Java平台的类库中还有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。例如一些基本的容器类ArrayList、 HashMap等并非线程安全,但类库中提供了包装器工厂方法Collections.synchronizedList等方法,使得这些非线程安全的类可以在多线程的情况下安全的使用。这些工厂方法通过装饰器模式将容器类封装在一个同步的包装器对象中,只要包装器对象拥有对底层容器的唯一引用,那么它就是线程安全的,在javadoc也指出,对底层容器对象的访问必须通过包装器)。包装器对象就类似上面的PersonSet,包装对象即Person。

1、Java监视器模式(内置锁,类中的组件不一定是线程安全的)

java的内置锁也被称为监视器锁或监视器。遵循java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。内置锁获得锁和释放锁是隐式的,进入synchronized修饰的代码就获得锁,走出相应的代码就释放锁。内置锁可以是指定当前对象、当前类的Class对象、指定任意对象。

下面的这个例子就是一个监视器模式。

public class Counter
{
	private long value = 0;
	public synchronized long getValue()
	{
		return value;
	}
	public synchronized long increment()
	{
		if(value==Long.MAX_VALUE)
			throw new IllegalStateException("counter overflow");
		
		return ++value;
	}
}

在很多类中都使用了java监视器模式,例如Vector和Hashtable,在某些情况下,程序需要更加复杂的同步策略。第11章将介绍如何通过细粒度的加锁策略来提高可伸缩性。

注意:内置锁不同于私有锁(在类内部声明一个私有属性如private Object lock,在需要加锁的代码段synchronized(locka) )。

书中举的例子:客户端代码还是可以通过公有方法访问私有锁,若错误获得了另外一个对象的锁,可能会产生活跃性问题。要想验证某个公有访问的锁在程序中是否正确的使用,则需要检查整个程序,而不是单个类。
公有访问的锁也就是对象可以在其他地方获得,比如一个public属性的对象,它可以在任意地方获得,那么也可以在任意地方被作为锁使用,如果需要检查的话,则需要检查整个程序。

2、示例:车辆跟踪

内置锁被如何使用查看上面如何封装非线程安全对象。书中还举了个例子:
  首先有一个表示车辆坐标的类MultablePoint,这个类线程不安全。

class MultablePoint
{
    public int x,y;
    public MultablePoint() 
    {
    	x = 0;y = 0;
    }
    public MultablePoint(MultablePoint p) {
        this.x = p.x;
        this.y = p.y;
    }
}

但是,追踪器类是线程安全的,它所包含的Map对象和可变的Point对象都未曾发布.当需要返回车辆的位置时,通过MutablePoint拷贝或者deepCopy方法类复制正确的值,从而生成一个新的Map对象。补充:java.util.Collections类中提供了UnmodifiableMap方法,UnmodifiableMap方法会映射一个新的、只读的、不可修改的Map对象,当你调用此map的put方法时会抛错。这个不可修改的Map指的是Map中的对象地址不可修改,里面的对象若支持修改的话,其实也还是可以修改的。

@ThreadSafe
public class MonitorVehicleTracker {
	@GuardedBy("this")
    private Map<String,MultablePoint> locations;
    public MonitorVehicleTracker(HashMap<String,MultablePoint> locations) {
        this.locations = deepCopy(locations);
    }
    public synchronized void setLocation(String id,int x,int y) {
        MutablePoint loc = locations.get(id);
        if(loc == null)
        {
        	throw new IllegalArgumentException("no such id "+id);
        }
    }
    public synchronized Map<String,MultablePoint> getLocations() {
        return deepCopy(locations);
    }
    public synchronized MultablePoint getLocation(String id){
        MultablePoint mPoint = locations.get(id);
        return mPoint==null?null:new MultablePoint(mPoint);
    }
    private static Map<String, MultablePoint> deepCopy(Map<String, MultablePoint> m) {
		Map<String,MutablePoint> result = new HashMap<String,MutablePoint>();
		for(String id:m.keySet())
			result.put(id,new MutablePoint(m.get(id)));
			return Collections.unmodifiableMap(result);
    }
}

三、线程安全性的委托(类中的各组件已经是线程安全的)

线程安全性的委托和java监视器模式一样也是一种对象组合的方式,不同的是委托要求组合的对象是线程安全的。委托是指将线程的安全性委托给现有的线程安全类。

public class CountingFactorizer implements Servlet
{
	private final AtomicLong count = new AtomicLong(0);
	public long getCount()
	{
		return count.get();
	}
	public void service(ServletRequest req,ServletResponse resp)
	{
		count.incrementAndGet(); //	count是AtomicLong的实例
	}
}

在上面的代码中,我们在无状态的类中增加了一个AtomicLong的域,并且得到的组合对象仍然是线程安全的,可以说将CountingFactorizer
的线程安全性委托给了AtomicLong来保证,之所以CountingFactorizer是线程安全的,是因为AtomicLong是线程安全的。

1、例子:基于委托的车辆追踪器

使用监视器模式的MonitorVehicleTracker只能返回的是“车辆位置的快照”(原话),个人觉得,监视模式中所有公有方法都使用了synchronized,同步明明是可以保证内存可见性,所以返回的也是最新更改的位置。监视模式给的例子getLocation会发布一个指向可变状态的引用,而且这个引用不是线程安全的,这才是其缺点。而使用委托的车辆追踪器DelegatingVehicleTracker返回的是一个不可修改Point,但是ConcurrentHashMap可以保证可见性可,因此可以获得实时的、不可修改的车辆位置视图。如果不需要委托的实时位置,可以采用浅拷贝。由于Map的内容是不可变的,因此只复制Map的结构,不用复制它的内容。
ConcurrentHashMap实现高并发的原理:参考 [http://www.importnew.com/16147.html]

@Immutable
public class Point{
	public final int x,y;
	public Point(int x,int y)
	{
		this.x = x;
		this.y = y;
	}
}
@ThreadSafe
public class DelegatingVehicleTracker {
	private final ConcurrentMap<String,Point> locations;
	private final Map<String,Point> unmodifiableMap;
	
	public DelegatingVehicleTracker(Map<String,Point> Points){
	locations = new ConcurrentHashMap<String,Point>(points);
	unmodifiableMap = Collections.unmodifiableMap(locations);
	}
	public Map<String,Point> getLocation(){ //返回实时的车辆位置
		return unmodifiableMap;
	}
	public Point getLocation(String id){
		return locations.get(id);
	}
	public void setLocation(String id,int x, int y){
		if(locations.replace(id,new Point(x,y)) == null) 
		   throw new IllegalArgumentException("invalid vehicle name :"+id);
	}
}

//shallow copy
public Map<String,Point> getLocation(){
	return Collections.unmodifiableMap(
		new HashMap<String,Point>(locations));
}

2、独立的状态变量(将线程安全性委托给多个状态变量)

将线程安全性委托给多个状态变量: VisualComponent使用CopyOnWriteArrayList来保存各个监听器列表。每个链表都是线程安全的,此外,由于各个状态之间不存在耦合关系,鼠标事件监听器和键盘事件监听器之间不存在任何关联,因此VisualComponent可以将它线程安全性委托给keyListeners 和mouseListeners 两个状态对象。

public class VisualComponent{
	private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<KeyListener>();
	private final List<MouseListener> mouseListeners = new CopyOnWriteArrayList<MouseListener>();
	public void addKeyListener(KeyListener listener){
		keyListeners.add(listener);
	}
	public void addMouseListener(MouseListener listener)
	{
		mouseListeners.add(listener);
	}
	public void removeKeyListener(KeyListener listener)
	{
		keyListeners.remove(listener);
	}
	public void removeMouseListener(MouseListener listener)
	{
		mouseListeners.remove(listener);
	}
}

CopyOnWriteArrayList是一个安全的链表

3、当委托失效时

当多个状态变量线程安全,但是之间存在着某些不变性条件,可能会出现委托失效。例如:NumberRange,setLower、setUpper都是“先检查后执行”操作,但是它们没有使用足够的加锁机制来保证这些操作的原子性。例如一个线程调用setLower(5),setUpper(4),两个调用都能通过检查,最终得到的取值范围就是(5,4);这是一个无效的状态,虽然AtomicInteger是线程安全的,但状态变量lower和upper不是彼此独立的,因此NumberRange不能讲线程安全性委托给他的线程安全状态变量。

public class NumberRange
{
	//不变性条件:lower<=upper
	private final AtomicInteger lower = new AtomicInteger(0);
	private final AtomicInteger upper = new AtomicInteger(0);
	public void setLower(int i)
	{
		if(i > upper.get())//特别容易犯的并发下不安全的“先检查后执行”
		{
			throw new IllegalArgumentException("can not set lower to"+ i+" > upper");
		}
		lower.set(i);
	}
	public void setUpper(int i){
	//
	if(i < lower.get())
		throw new IllegalArgumentException("can not set upper to "+ i+" > lower");
	upper.set(i);
	}
	public boolean isInRange(int i)
	{
		return (i >= lower.get() && i <= upper.get())
	}
}

如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

4、发布底层的状态变量

当把线程安全性委托给某个对象的底层的状态变量的时候,在什么条件下可以发布这些变量从而使得其他类可以修改他们?
答案取决于在类中对这些变量添加了哪些不变性条件。

public class Counter
{
	private long value = 0;
	public synchronized long getValue()
	{
		return value;
	}
	public synchronized long increment()
	{
		if(value==Long.MAX_VALUE)
			throw new IllegalStateException("counter overflow");
		
		return ++value;
	}
}

在上面的代码中虽然Counter的value可以是任意值,但是对Counter设置的约束条件是只能取正值。如果将value声明为一个公有域,那么客户端代码可以将它修改为一个无效值,因此发布value会导致这个类错误。
如果是当前温度或者最近登陆用户的ID,那么即使另外一个类在某个时刻修改了这个值,也不会破坏任何不变性条件。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全的发布这个变量。

5、示例:发布状态的车辆跟踪器

@ThreadSafe
public class SafePoint{
	@GuardedBy("this") private int x,y;
	private SafePoint(int[] a){this(a[0],a[1]);}
	public SafePoint(SafePoint p){this(p.get());}
	public SafePoint(int x, int y){
		this.x = x;
		this.y = y;
	}
	public synchronized int[] get(){
	return new int[] { x, y};
}
	public synchronized void set(int x, int y){
	this.x = x;
	this.y = y;
	}
}

上面的代码中get方法同时获得x和y的值,并将两者放在一个数组中返回,如果在get方法之间x的值可能在方法中发生变化,那么车辆的坐标可能x发生了改变而y没有发生改变,导致并发安全问题。

@ThreadSafe
public class PublishingVehicleTracker{
	private final Map<String,SafePoint> locations;
	private final Map<String,SafePoint> unmodifiableMap;
	public PublishVehicleTracker(Map<String,SafePoint> locations){
		this.locations = new ConcurrentHashMap<String,SafePoint>(locations);
		this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
	}
	public Map<String,SafePoint> getLocations(){ return unmodifiableMap;}
	public SafePoint getLocation(String id){
		return locations.get(id);
	}
	public void setLocation(String id,int x,int y)
	{
		if(!locations.containsKey(id))
			throw new IllegalArgumentException("invalid vehicle name:"+id);
			locations.get(id).set(x,y);
	}
}

四、在现有的线程安全的类中添加功能(原子操作)

要添加一个新的原子操作,最安全的方法就是直接修改原始的类,但是有时无法访问或者修改类的源代码。若修改原始的类,需要理解原始代码中同步策略,这样增加的功能才和原有的设计保持一致。
例如像在Vector中增加一个“若没有则添加”的方法,"扩展"方法比直接在源代码中修改更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。选择“扩展”的方式要选择原始类一致的同步策略,否则会出错。BetterVector的实现方法参考了Vector规范中的同步策略。

@ThreadSafe
public class BetterVector<E> extends Vector<E>
{
	public synchronized boolean putIfAbsent(E e){
		boolean absent = !contains(x);
		if(absent)
			add(x);
		return absent;
	}
}

1、客户端加锁机制

非线程安全的“若没有则添加”,虽然putIfAbsent已经声明为synchronzied类型的变量,但ListHelper只是带来了假象,问题在于在错误的锁上进行了同步,尽管所有的链表操作都被声明为synchronized,但却使用了不同的锁,意味着putIfAbsent相对于List的其他操作都不是原子。所以,putIfAbsent并不是原子操作。假设在那么在某一时刻,满足if(!absent)不变约束的同时准备add()某个对象的时候,已经有另一个线程通过ListHelper .list.add()过这个对象了,所以还是会出现add()两个相同对象的情况。


public static <T> List<T> synchronizedList(List<T> list) 
{
    return (list instanceof RandomAccess ?
            new SynchronizedRandomAccessList<>(list) :
            new SynchronizedList<>(list));
}

要想正确的执行,必须在List在实现客户端加锁或者外部加锁时使用同一个锁。客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户代码。修改后的ListHelper,要增加原子性操作的对象是list,使用list本身作为客户端代码的锁。要使用客户端加锁,你必须知道对象X使用的是哪一个锁。客户端加锁机制更加脆弱,因为它将类X的加锁代码放到与X完全无关的其他类中,此处List类的加锁代码不是放在List中,而是放在新建的ListHelper中。客户端加锁,就是在调用这个对象的地方,使用对象的锁确保线程安全。

public class ListHelper <E>{
    private final 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;
        }
    }
}

2、组合

通过ImprovedList对象来操作传进来的list对象,用的都是Improved的锁。即使传进来的list不是线程安全的,ImprovedList也能保证线程安全。Improved通过自身的内置锁增加一一层额外的加锁,它不用关心底层的List是否线程安全,虽然额外的同步导致轻微的性能损失但是相比客户端加锁方式更加健壮。

public class ImprovedList<T> implements List<T> {
    private final List<T> list;
    public ImprovedList(List<T> list) {
        this.list = list;
    }
    public synchronized boolean putIfAbsent(Object obj){
        boolean absent = list.contains(obj);
        if(absent){
            list.add((T) obj);
        }
        return absent;
    }
}

发布了105 篇原创文章 · 获赞 18 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_38367817/article/details/103743456