栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

4.对象的组合

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

4.对象的组合

文章目录
    • *对象的组合*
      • *4.1设计线程安全的类*
        • *4.1.1收集同步需求*
        • *4.1.2依赖状态的操作*
        • *4.1.3状态的所有权*
      • *4.2实例封闭*
        • *4.2.1Java监视器模式*
        • *4.2.2示例:车辆追踪*
      • *4.3线程安全性的委托*
        • *4.3.1示例:基于委托的车辆追踪器*
        • *4.3.2独立的状态变量*
        • *4.3.3当委托失效时*
        • *4.3.4发布底层的状态变量*
        • *4.3.5示例:发布状态的车辆追踪器*
      • *4.4在现有的线程安全类中添加功能*
        • *4.4.1客户端加锁机制*
        • *4.4.2组合*
      • *4.5将同步策略文档化*

对象的组合 4.1设计线程安全的类

通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。在设计线程安全类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量。
  • 找出约束状态变量的不变性条件。
  • 建立对象状态的并发访问管理策略。

要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。

@ThreadSafe
public class Counter {
	// 只有一个域value,因此这个域就是Counter的全部状态
    @GuardedBy("this")
    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;
    }
}

如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。例如,LinkedList的状态就包括该链表中所有节点对象的状态。

同步策略定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同,同时规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写为正式文档。

4.1.1收集同步需求

要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final类型的域使用得越多,就越能简化对象可能状态的分析过程。
在许多类中都定义了一些不可变条件,用于判断状态是有效的还是无效的。Counter中的value域是long类型的变量,其状态空间为Long.MIN_VALUE到Long.MAX_VALUE,但Counter中value在取值范围上存在着一个限制,即不能是负值。

由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。另外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。
在类中也可以包含同时约束多个状态变量的不变性条件。在一个表示数值范围的类中可以包含两个状态变量,分别表示范围的上界和下界。这些变量必须遵循的约束是,下界值应该小于或等于上界值。类似于这种包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后释放锁并再次获得锁,然后再更新其他的变量。因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。

4.1.2依赖状态的操作

类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。在某些对象的方法中还包含了一些基于状态的先验条件。例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于非空的状态。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中,要一直等到先验条件为真,然后再执行该操作。

在java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用它们并不容易。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列[BlockingQueue]或信号量[Semaphore]来实现依赖状态的行为)。

4.1.3状态的所有权

如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象图中所有对象包含的域的一个子集。
在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。所有权在java中并没有得到充分的体现,而是属于类设计的一个要素。如果分配并填充了一个HashMap对象,那么就相当于创建了多个对象:HashMap对象,在HashMap对象中包含的多个对象,以及在Map.Entry中可能包含的内部对象。HashMap对象的逻辑状态包括所有的Map.Entry对象以及内部对象,即使这些对象都是一些独立的对象。
无论如何,垃圾回收机制避免了如何处理所有权的问题。在c++中,当把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权,是短期的所有权还是长期的。在java中,同样存在这些所有权模型,只不过垃圾回收器减少了许多在引用共享方面常见的错误,因此降低了在所有权处理上的开销。
许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制器。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是共享控制权。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如,同步容器封装器的工厂方法)。

容器类通常表现出一种所有权分离的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。Servlet框架中的ServletContext就是其中一个示例。ServletContext为servlet提供了类似于Map形式的对象容器服务,在ServletContext中可以通过名称来注册(setAttribute)或获取(getAttribute)应用程序对象。由servlet容器实现的ServletContext对象必须是线程安全的,因为它肯定会被多个线程同时访问。当调用setAttribute和getAttribute时,servlet不需要使用同步,但当使用保存在ServletContext中的对象时,则可能需要使用同步。这些对象由应用程序拥有,servlet容器只是替应用程序保管它们。与所有共享对象一样,它们必须安全地被共享。为了防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象。

4.2实例封闭

如果对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全地使用。可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的,对数据的访问限制在对象的方法上,因此更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。
被封闭对象一定不能超出它们既定的作用域。对象可以封闭在类的一个实例(例如作为类的一个私有成员)中,或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在某个线程中将对象从一个方法传递到另一个方法,而不是在多个线程之间共享该对象)。当然,对象本身不会逸出。出现逸出情况的原因通常是由于开发人员在发布对象时超出了对象既定的作用域。

@ThreadSafe
public class PersonSet {
	// mySet是私有的并且不会逸出
    @GuardedBy("this")
    private final Set mySet = new HashSet<>();

    public synchronized void addPerson(Person person) {
        mySet.add(person);
    }

    public synchronized boolean containsPerson(Person person) {
        return mySet.contains(person);
    }

	// 如果Person类是可变的,那么在访问从PersonSet中获得的Person对象时,还需要额外的同步。
	// 要想安全地使用Person对象,最可靠的方法就是使Person成为一个线程安全的类。另外,也可以
	// 使用锁来保护Person对象,并确保所有客户代码在访问Person对象之前都已经获得正确的锁
    interface Person {
    }
}

实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有了更多地灵活性。在PersonSet中使用了它的内置锁来保护它的状态,但对于其他形式的锁来说,只要自始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态变量可以由不同的锁来保护。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。

4.2.1Java监视器模式

遵循java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。

public class PrivateLock {
    private final Object myLock = new Object();
    @GuardedBy("myLock")
    Widget widget;

    void someMethod() {
        synchronized (myLock) {
            // 访问或修改Widget的状态
        }
    }
}

Java监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。
使用私有的锁对象而不是对象的内置锁(或任何其他可通过公有方式访问的锁),有许多优点。私有的锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码可以通过公有方法来访问锁,以便(正确或者不正确地)参与到它的同步策略中。如果客户代码错误地获得了另一个对象的锁,那么可能会产生活跃性问题。此外,要想验证某个公有访问的锁在程序中是否被正确地使用,则需要检查整个程序,而不是单个的类。

4.2.2示例:车辆追踪
@ThreadSafe
public class MonitorVehicleTracker {
	// 每台车都由一个String对象来标识,并且拥有一个相应的位置坐标
    @GuardedBy("this")
    private final Map locations;

    public MonitorVehicleTracker(Map locations) {
        this.locations = deepCopy(locations);
    }

	
    private Map deepCopy(Map m) {
        HashMap result = new HashMap<>();

        for (String id : m.keySet()) {
        	// 生成一个新的MutablePoint实例,并拷贝原有的值
            result.put(id, new MutablePoint(m.get(id)));
        }

        return Collections.unmodifiableMap(result);
    }

    public synchronized Map getLocations() {
        return deepCopy(locations);
    }

    public synchronized MutablePoint getLocation(String id) {
        MutablePoint loc = locations.get(id);
        return loc == null ? null : new MutablePoint(loc);
    }

    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);
        }

        loc.x = x;
        loc.y = y;
    }
}


@NotThreadSafe
public class MutablePoint {
    public int x;
    public int y;

    public MutablePoint() {
        x = 0;
        y = 0;
    }

    public MutablePoint(MutablePoint p) {
        this.x = p.x;
        this.y = p.y;
    }
}

在某种程度上,这种实现方式是通过在返回客户代码之前复制可变的数据来维持线程安全性的。通常情况下,这并不存在性能问题,但在车辆容器非常大的情况下将极大地降低性能,由于deepCopy是从一个synchronized方法中调用的,因此在执行时间较长的复制操作中,tracker的内置锁将一直被占用,当有大量车辆需要追踪时,会严重降低用户界面的响应灵敏度。此外,由于每次调用getLocation就要复制数据,因此将出现一种错误情况,虽然车辆的实际位置发生了变化,但返回的信息却保持不变。这种情况是好是坏,要取决于实际需求。如果在location集合上存在内部的一致性需求,那么这就是优点,在这种情况下返回一致的快照就非常重要。然而,如果调用者需要每辆车的最新信息,那么这就是缺点,因为这需要非常频繁地刷新快照。

4.3线程安全性的委托

大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时,java监视器模式是非常有用的。但是,如果类中的各个组件都已经是线程安全的,此时是否需要再增加一个额外的线程安全层视情况而定。在某些情况下,通过多个线程安全类组合而成的类是线程安全的,而在某些情况下,这仅仅是一个好的开端。

4.3.1示例:基于委托的车辆追踪器
@Immutable
public class Point {
    public final int x;
    public final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}


@ThreadSafe
public class DelegatingVehicleTracker {
    private final ConcurrentHashMap locations;
    private final Map unmodifiableMap;

    public DelegatingVehicleTracker(Map points) {
        locations = new ConcurrentHashMap<>(points);
        unmodifiableMap = Collections.unmodifiableMap(locations);
    }

	
    public Map getLocations() {
        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);
        }
    }

	
    public Map getLocationsAsStatic() {
        return Collections.unmodifiableMap(new HashMap<>(locations));
    }
}
4.3.2独立的状态变量

到目前为止,这些委托示例都仅仅委托给了单个线程安全的状态变量。还可以将先安全性委托给多个变量,只要这些变量是彼此独立的,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。

public class VisualComponent {
    private final List keyListeners = new CopyOnWriteArrayList<>();
    private final List mouseListeners = new CopyOnWriteArrayList<>();

    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);
    }
}
4.3.3当委托失效时

然而大多数组合对象在它们的状态变量之间可能都存在着某些不变性条件:

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't set lower to " + i + " > upper");
        }

        lower.set(i);
    }

    public void setUpper(int i) {
        // 注意:不安全的先检查,后执行
        if (i < lower.get()) {
            throw new IllegalArgumentException("can't set upper to " + i + " < lower");
        }

        upper.set(i);
    }

    public boolean isInRange(int i) {
        return (i >= lower.get() && i <= upper.get());
    }
}

如果某个类含有复合操作,那么仅靠委托并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
当一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

4.3.4发布底层的状态变量

如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。
例如,发布VisualComponent中的mouseListeners或keyListeners等变量就是安全的。由于VisualComponent并没有在其监听器链表的合法状态上施加任何约束,因此这些域可以声明为公有域或者发布,而不会破坏线程安全性。

4.3.5示例:发布状态的车辆追踪器
@ThreadSafe
public class SafePoint {
    @GuardedBy("this")
    private int x;
    @GuardedBy("this")
    private int 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;
    }
}


@ThreadSafe
public class PublishingVehicleTracker {
    private final Map locations;
    private final Map unmodifiableMap;

    public PublishingVehicleTracker(Map locations) {
        this.locations = new ConcurrentHashMap<>(locations);
        this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
    }

    public Map 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);
    }
}
4.4在现有的线程安全类中添加功能

Java类库包含许多有用的基础模块类。通常,应该优先选择重用这些现有的类而不是创建新的类。
例如,假设需要一个线程安全的链表,它需要提供一个原子的"若没有则添加的"的操作。其概念很简单,即添加元素之前先检查其是否存在。由于这个类必须是线程安全的,因为就隐含地增加了另一个需求,即这个操作必须是原子操作。
要添加一个新的原子操作,最安全的方法是修改原始的类,但这通常无法做到,因为可能无法访问或修改类的源代码。要想修改原始的类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于一个源代码文件中,从而更容易理解和维护。
另一种方法是扩展这个类,假定在设计这个类时考虑了可扩展性。

@ThreadSafe
public class BetterVector extends Vector {
    public synchronized boolean putIfAbsent(E x) {
        boolean absent = !contains(x);

        if (absent) {
            add(x);
        }

        return absent;
    }
}

扩展方法比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。

4.4.1客户端加锁机制

对于由Collections.synchronizedList封装的ArrayList,以上两种方法都行不通,因为客户代码并不知道在同步封装器工厂方法中返回的List对象的类型。第三种策略是扩展类的功能,但并不是扩展类本身,而是将扩展代码放入一个辅助类中。

@NotThreadSafe
public class ListHelper {
    public List list = Collections.synchronizedList(new ArrayList<>());

    // ...

    public synchronized boolean putIfAbsent(E x) {
        boolean absent = !list.contains(x);

        if (absent) {
            list.add(x);
        }

        return absent;
    }
}

客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户代码。要使用客户端加锁,必须知道对象X使用的是哪一个锁。

@ThreadSafe
public class ListHelper {
    public List list = Collections.synchronizedList(new ArrayList<>());

    // ...

	
    public boolean putIfAbsent(E x) {
        synchronized (list) {
            boolean absent = !list.contains(x);

            if (absent) {
                list.add(x);
            }

            return absent;
        }
    }
}

然而,客户端加锁相对于添加一个原子操作来扩展类来说显得更加脆弱,因为它将类的加锁代码放到与该类完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有许多共同点,二者都是将派生类的行为与基类的实现耦合在一起。正如扩展会破坏实现的封装性,客户端加锁同样如此。

4.4.2组合

当为现有的类添加一个原子操作时,有一种更好的方法:组合。

@ThreadSafe
public class ImprovedList implements List {
	private final List list;

	public ImprovedList(List list) {
		this.list = list;
	}

	public synchronized boolean putIfAbsent(T x) {
		boolean contains = list.contains(x);

		if (contains) {
			list.add(x);
		}
		
		return !contains;
	}

	public synchronized void clear() {
		list.clear();
	}

	// ...按照类似的方式委托List的其他方法
}
4.5将同步策略文档化

在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。
设计阶段是编写设计决策文档的最佳时间,在这之后的几周或几个月后,一些设计细节会逐渐变得模糊,因此一定要在忘记之前将它们记录下来。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/885857.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号