重温《并发编程实战》---对象的组合

来源:互联网 发布:成捷讯通信概预算软件 编辑:程序博客网 时间:2024/06/05 05:16

1.在类中可能包含约束多个变量的不变性条件,此时这种不变性条件将带来原子性需求,这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后再释放锁并再次获得锁然后再更新其他的变量。因为释放锁后,可能会使对象处于无效状态。

结论:如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作的时候,都必须持有保护这些变量的锁。

 

 

2.对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象。容器类通常表现出一种”所有权分离”的形式,其中容器类拥有其自身的状态,客户代码拥有容器类中各个对象的状态。

 

 

3.实例封闭机制:

个人理解:什么叫实例封闭机制呢?我们可以理解为把我们想使用的一些目标对象(我们想使用的对象)封装到一些特定的对象(例如容器类)中,这样我们想要访问我们的目标对象的时候,只能通过这个特定对象的方式来访问,即所有能够访问被封装对象的代码路径都是已知的。此时如果我们对特定对象的方法加上适当的锁机制,可以实现线程安全的方式来使用我们被封装的对象。

 

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

 

java平台中有很多线程封闭的实例,有些类的唯一用途就是将非线程安全的类转化为线程安全的类。例如Collections.synchronizedList及其类似方法,使得非线程安全的对象(ArrayList或者HashMap)封闭在同步的包装器对象中,最后达到线程安全的目的。

 

因此,线程封闭技术是实现线程安全类一个最简单的方式。

 

 

 

4.私有锁:

 

 

private final ObjectmyLock =new Object();

Object something;

void someMethod(){

//使用私有锁

synchronized(myLock){

//访问或者修改something的状态

}

}

 

 

在这里我们称myLock为私有的锁对象,当然每个对象都有内置锁(即this),私有锁有很多好处:

a.)客户代码无法得到锁,仅能通过一些公有方法来访问锁,以便参与到它的同步策略中。

b.)当你要检查某个锁在程序中是否被正确的使用的时候,如果是共有锁对象,那么可能你需要检查的是整个程序,如果是私有的锁对象,只检查1个类可能就好了。

 

 

5.如果一个类中使用了一个对象但是却未发布这个对象,那么是可以接受的。

 

 

 

6.有的时候我们会使用复制copy()等方法,为的是不发布一些对象,而用一种复制方法来生成一个和原对象相同或相似的对象来避免原对象的发布。

 

 

 

7.多个独立状态变量:由多个状态变量组合而成的类,如果这些状态变量是独立的,即类并没有在多个状态变量上增加任何不变性条件,那么我们可以把线程安全性独立的委托给各个独立的状态变量。

 

 

8.多个相关联状态变量:大多数的组合对象的状态可能不是彼此独立的,而是彼此相互关联的,例如我们有2个状态变量,上界up和下届low,他们俩的不变性条件就是up >  low,即使我们的uplow是使用线程安全的对象修饰的,但是当他们两个组和起来的时候,未必就是线程安全的。

比如我们设置上界为4,下界为5,虽然setup(int i )方法里可能会检查if(i < low){throw Exception}setLow同理,但是由于线程时序的不确定性,我们可能最后真的就得到了(5,4)的取值范围,这是个无效的状态,不是吗? 正确的方式是如果我们的类有相关联的状态变量,我们必须保证复合操作是原子的,方式有a.)提供自己的加锁机制b.)如前面提到的,提供一个不可变容器,里面原子性的更新多个关联状态变量的状态,再使用这个容器的地方加上volatile提供可见性,即不可变容器+volatile

 

需要注意的是,即使单个状态变量是线程安全的,当涉及到多个状态变量的复合操作的时候,线程安全没那么简单。

 

 

9.什么样的状态变量适合被发布:

如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全的发布这个变量。

 

 

10.三类对象的对比:我们在书中的车辆追踪例子中,发现了Point的不同实现方式,我们写下来进行一些对比。

 

第一种---不安全对象:

 

 

public class UnsafePoint {

public int x,y;

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

public UnsafePoint(UnsafePointp){

this.x =p.x;

this.y =p.y;

}

}

 

第一种的Point对象是不安全的,它既不是不可变对象,也不是线程安全对象,它的使用非常的依赖于它的发布方式,如果发布方式不正确,那么将产生问题。

 

不安全的发布方式:

 

//不安全的发布方式

UnsafePoint point;

public void init(){

point = new UnsafePoint();

}

当一个线程调用init方法初始化这个point对象的时候,由于“可见性问题”,有的线程可能看到point指向旧的对象引用,有的线程看到point指向正确的对象引用,但是状态可能是失效的。

 

正确的对象发布方式包括:

a.)在静态初始化函数中初始化一个对象引用

    public static UnsafePointpoint =new UnsafePoint();

 

 

b.)将对象的引用保存到volatile类型的域或者AtomicReference对象中。

AtomicReference<UnsafePoint> atomic = new AtomicReference<UnsafePoint>(this);

 

或者

 

volatile UnsafePointsafepoint;

 

 

c.)将对象的引用保存到某个正确构造对象的final类型域中。

 

Final 变量在并发当中,原理是通过禁止cpu的指令集重排序(重排序详解http://ifeve.com/java-memory-model-1/ http://ifeve.com/java-memory-model-2/),来提供现成的可见性,来保证对象的安全发布,防止对象引用被其他线程在对象被完全构造完成前拿到并使用。

 

d.)将对象的引用保存到由一个锁保护的线程。

 

第二种---不可变对象:

 

public class UnChangePoint {

public final int x,y;

public UnChangePoint(int x,int y){

this.x =x;

this.y =y;

}

}

UnchangePoint对象是不可变对象,一旦生成无法改变状态,线程安全性的状态是不可变对象的固有属性。

 

不可变对象的三大特性:

a.)所有域都是final的。

b.)对象创建以后状态不可更改。

c.)对象是正确创建的(在对象创建期间,this引用没有逸出)。

 

 

第三种---安全可变对象:

 

 

 

//线程安全且状态可变的类

//这个类代表坐标类

public class SafePoint {

private  int x;

private  int y;

public SafePoint(int x,int y){

this.x =x;

this.y =y;

}

//拷贝构造函数,这里有很多可以说

public  SafePoint(SafePointp){

this(p.get());

}

private SafePoint(int[]is) {

this(is[0],is[1]);

}

 

public synchronized void set(int x,int y){

this.x =x;

this.y =y;

}

//get()方法保证了我们得到原子性的x,y的一组结果,如果我们单个getX(),getY()分成2个方法,那么由于x,y的坐标的不停变化,我们可能从来没到达过(x,y),

//所以要得到这种一组的结果,最好也以一组的方式获取。

public synchronized int[] get(){

return new int[]{x,y};

}

 

第一个注意点 我们get()方法返回的x,y的数组,这样使得我们得到的(x,y)坐标是原子性的,如果每个属性都有个get()方法,那么我们可能不会得到我们想要的(x,y),因为x,y是不断变换的。所以想得到复合属性(可以理解(x,y)是point类的一个复合属性),你必须返回复合属性。

 

 

第二个注意点: 我们在拷贝构造函数中调用了类中的private的构造函数,如果我们的拷贝构造函数改成SafePoint(SafePoint p){this(p.x,p.y)},此时就会产生竞态条件。

        那么什么是竞态条件呢?一句话:当某个计算的结果取决于多个线程的交替执行时序的时候,那么就会发生竞态条件。

最常见的竞态条件:先检查后执行,比如if(a=10){b=20} if(b=10){a=20},这种先检查后执行最大的问题是,你会基于一张可能已经失效的观察结果来做出判断或者执行某个计算,你根据这个结果来采用相应的动作,但事实上,在你观察到这个结果以及做反应的时候,观察结果可能已经失效,从而导致各种文体(异常,数据被覆盖,文件被破坏等)

 

最典型的先检查后执行的竞态条件是延迟初始化,if(xx == null){init()},如果线程a看到xx = null,由于线程之间时序的问题,此时可能在a初始化的过程中,b也看到xx=null,b也初始化,我们初始化了2次,这将导致严重的问题。

 

另一种常见的竞态条件:”读取-修改-写入”,比如递增一个计数器,基于对象之前的状态来定义对象状态的转换。你需要确保在执行更新的过程中没有其他线程会修改或者使用这个值。

 

 

 

如果我们的拷贝构造函数改成SafePoint(SafePoint p){this(p.x,p.y)},p.x,p.y,使得我们的最终产生的SafePoint是基于构造函数穿进来的p的状态决定的,如果其他线程在我们执行更新的时候改变了p的值,那么我们就很有可能产生竞态条件,这是一个严重的问题。

 

避免竞态条件的问题,就要在某个线程修改这个变量的时候,防止其他线程使用这个变量,从而确保其他线程只能在修改操作开始之前或者修改操作完成之后读取和修改状态,而不是修改状态中。

 

我们用p.get()方式导致使得p加上锁,其他线程无法获得p,使得我们成功避免了竞态条件,最后我们的这个类是线程安全且可变的。

 

 

 

11.在现有的线程安全类中添加功能:

 

a.)修改原始类:要添加一个新的原子操作,最安全的方法是修改原值的类。

   缺点:这通常无法做到,因为你可能无法访问或修改类的源代码,要想修 改原始类,必须理解原始类中的同步策略,这样增加的功能才能与原有保 持一致。

         优点:如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码 仍然处于一个源代码文件中,从而更易于理解和维护。

 

 

b.)扩展类:比如继承,但是并非所有的类都能将自己的状态向子类公开。

          缺点:扩展方式别直接添加代码到类中更加脆弱,因为现在的同步策略被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略(例如换了不同的锁来保护状态),这个扩展类很大可能变成一个无效的类。

 

 

 

c.)客户端加锁机制:Collections.synchronizedList()封装的arrayList,修改它的原始类或者扩展类都不行(不知道Collections.synchronizedList返回的List对象是什么类型),这时候我们可以使用客户端加锁机制,扩展类的功能,而不是扩展类本身。

 

问题代码:

public List<E> list = Collections.synchronizedList(new ArrayList<E>());

public synchronized boolean putIfAbsent(Ex){

boolean absent = !list.contains(x);

if(absent){

list.add(x);

}

return absent;

}

 

这段代码看似没有问题,list是线程安全的,并且应该有的方法也加上同步机制了,那么应该OK了,实际上,这段代码出现了问题。

 

问题解析:我们可以看出,list是线程安全的,它的上面加了锁,它的同步机制来源于Collections.synchronizedList()方法,这个同步机制使用的锁是什么我们不得而知,但是我们可以确定,决定不是使用的是当前类的内置锁,即this锁。在synchronized pubIfAbsent()方法中,我们使用的是this锁,而list是由它自己的同步机制中的锁保护的,就会导致问题。什么问题呢?这 意味着putIfAbsent相对于List的其他操作来说并不是原子的,因此无法确保当putIfAbsent执行时另一个线程不会修改链表。(因为没有持有list该持有的锁,其他的操作比如delete()依然可以执行,delete()putIfAbsent()不会因为锁产生冲突(因为用的不是一个锁),此时可能把本存在的E xdelete)OK,你懂了吗?

 

所以在客户端加锁的时候要格外小心,必须使用同一个锁来保护对象状态。

 

 

 

正确代码:我们使用了list对象上自己的锁来保护了添加的功能。

 

 

public List<E> list = Collections.synchronizedList(new ArrayList<E>());

public boolean putIfAbsent(Ex){

synchronized(list){

boolean absent = !list.contains(x);

if(absent){

list.add(x);

}

return absent;

}

}

 

 

 

客户端加锁缺点:客户端加锁比扩展类更加脆弱,它直接把类c的枷锁代码放到了与c完全无关的带那么中,产生问题的可能性更大。

 

 

 

d.)组合(我更愿意叫包装器法):用代码说话吧

 

 

public class ImproveList<E>implements List<E> {

private final List<E>  list;

public ImproveList(List<E>  list) {

this.list =list;

}

 

@Override

public int size() {

// TODO Auto-generated method stub

return list.size();

}

 

@Override

public boolean isEmpty() {

// TODO Auto-generated method stub

return list.isEmpty();

}

 

@Override

public boolean contains(Objecto) {

// TODO Auto-generated method stub

return list.contains(o);

}

..................

..................

}

 

我们将ImprovedList对象的操作委托给了底层的List实例来实现List的各种操作,并添加了自己的逻辑操作。ImprovedListCollections.synchronizedList以及其他容器封装类一样,在把某个底层对象传给构造函数后,客户端的代码不会再直接使用这个底层对象,而是只能通过封装器类来访问它并进行操作)。

 

ImprovedList通过自己的内置锁对底层对象增加了一层额外的锁机制,它不关心底层对象list是否是线程安全的,即使list不是线程安全的或者修改了它的锁实现,ImprovedList提供了一致的加锁机制来实现线程安全性,虽然额外的同步层可能导致轻微的性能损失,但是ImprovedList更为健壮。