风人园

弱水三千,只取一瓢,便能解渴;佛法无边,奉行一法,便能得益。
随笔 - 99, 文章 - 181, 评论 - 56, 引用 - 0
数据加载中……

多线程编程的设计模式 临界区模式

多线程编程的设计模式 临界区模式(一)

临界区模式 Critical Section Pattern 是指在一个共享范围中只让一个线程执行的模式.
它是所有其它多线程设计模式的基础,所以我首先来介绍它.
把着眼点放在范围上,这个模式叫临界区模式,如果把作眼点放在执行的线程上,这个模式就叫
单线程执行模式.

首先我们来玩一个钻山洞的游戏,我 Axman,朋友 Sager,同事 Pentium4.三个人在八角游乐场
循环钻山洞(KAO,减肥训练啊),每个人手里有一个牌子,每钻一次洞口的老头会把当前的次序,
姓名,牌号显示出来,并检查名字与牌号是否一致.

OK,这个游戏的参与者有游乐场老头Geezer,Player,就是我们,还有山洞 corrie.

public class Geezer {
    public static void main(String[] args){
       
        System.out.println("预备,开始!");
        Corrie c = new Corrie();//只有一个山洞,所以生存一个实例后传给多个Player.
        new Player("Axman","001",c).start();
        new Player("Sager","002",c).start();
        new Player("Pentium4","003",c).start();
    }
}

这个类暂时没有什么多说的,它是一个Main的角色.

public class Player extends Thread{
    private final String name;
    private final String number;
    private final Corrie corrie;
    public Player(String name,String number,Corrie corrie) {
        this.name = name;
        this.number = number;
        this.corrie = corrie;
    }
   
    public void run(){
        while(true){
            this.corrie.into(this.name,this.number);
        }
    }
}
在这里,我们把成员字段都设成final的,为了说明一个Player一旦构造,他的名字和牌号就不能改
变,简单说在游戏中,我,Sager,Pentium4三个人不会自己偷偷把自己的牌号换了,也不会偷偷地去
钻别的山洞,如果这个游戏一旦发生错误,那么错误不在我们玩家.

import java.util.*;
public class Corrie {
    private int count = 0;
    private String name;
    private String number;
    private HashMap lib = new HashMap();//保存姓名与牌号的库
   
    public Corrie(){
       
        lib.put("Axman","001");
        lib.put("Sager","002");
        lib.put("Pentium4","003");
 
    }
   
    public void into(String name,String number){
        this.count ++;
        this.name = name;
        this.number = number;
        if(this.lib.get(name).equals(number))
 test():
    }
   
    public String display(){
        return this.count+": " + this.name + "(" + this.number + ")";
    }

    private void test(){
        if(this.lib.get(name).equals(number))
            ;
            //System.out.println("OK:" + display());
        else
            System.out.println("ERR:" + display());
    }
}
这个类中增加了一个lib的HashMap,相当于一个玩家姓名与牌号的库,因为明知道Corrie只有一个实例,
所以我用了成员对象而不是静态实例,只是为了能在构造方法中初始化库中的内容,从真正意义中说应
该在一个辅助类中实现这样的数据结构封装的功能.如果不提供这个lib,那么在check的时候就要用
if(name.equasl("Axman")){
 if(!number.equals("001")) //出错
}
else if .......
这样复杂的语句,如果player大多可能会写到手抽筋,所以用一个lib来chcek就非常容象.


运行这个程序需要有一些耐心,因为即使你的程序写得再差在很多单线程测试环境下也能可是正确的.
而且多线程程序在不同的机器上表现不同,要发现这个例子的错识,可能要运行很长一段时间,如果你的
机器是多CPU的,那么出现错误的机会就大好多.

在我的笔记本上最终出现错误是在11分钟以后,出现的错误有几钟情况:
1: ERR:Axman(003)
2: ERR:Sager(002)
第一种情况是检查到了错误,我的牌号明明是001,却打印出来003,而第二种明明没有错误,却打印了错误.

事实上根据以前介绍的多线程知识,不难理解这个例子的错误出现,因为into不是线程安全的,所以在其中
一个线程执行this.name = "Axman";后,本来应该执行this.numner="001",却被切换到另一个线程中执行
this.number="003",然后又经过不可预知的切换执行其中一个的if(this.lib.get(name).equals(number))
而出现1的错误,而在打印这个错误时因为display也不是线程安全的,正要打印一个错误的结果时,由于
this.name或this.number其中一个字段被修改却成了正确的匹配而出现错误2.

另外还有可能会出现序号颠倒或不对应,但这个错误我们无法直观地观察,因为你根本不知道哪个序号"应该"
给哪个Player,而序号颠倒则有可能被滚动的屏幕所掩盖.


[正确的Critical Section模式的例子]
我们知道出现这些错误是因为Corrie类的方法不是线程安全的,那么只要修改Corrie类为线程安全的类就行
了.其它类则不需要修改,上面说过,如果出现错误那一定不是我们玩家的事:

 

import java.util.*;
public class Corrie {
    private int count = 0;
    private String name;
    private String number;
    private HashMap lib = new HashMap();//保存姓名与牌号的库
   
    public Corrie(){
       
        lib.put("Axman","001");
        lib.put("Sager","002");
        lib.put("Pentium4","003");
 
    }
   
    public synchronized void into(String name,String number){
        this.count ++;
        this.name = name;
        this.number = number;
 test();
    }
   
    public synchronized String display(){
        return this.count+": " + this.name + "(" + this.number + ")";
    }

    private void test(){
        if(this.lib.get(name).equals(number))
            ;
            //System.out.println("OK:" + display());
        else
            System.out.println("ERR:" + display());
    }
}

运行这个例子,如果你的耐心,开着你的机器运行三天吧.虽然测试100天并不能说明第101天没有出错,
at least,现在的正确性比原来那个没有synchronized 保护的例子要可靠多了!

到这里我们对Critical Section模式的例程有了直观的了解,在详细解说这个模式之前,请想一下,test
方法安全吗?为什么?

所谓模式就是脱离特定的例子使用更一般化的,通用化的表达方式来察看,描述,总结相同的问题.现在
我们来研究这个模式:

共享资源(sharedResource)参与者:
在临界区模式中,一定有一个或一个以上的共享资源角色的参与.在上面这个例子中就是山洞(Corrie).

共享资源参与者会被多个线程访问,这个角色的访问方法有两种类型,一种是多个线程访问也不会发生问
题的方法,称为线程安全的方法,另一种就是在多个线程同时访问时会发生问题需要保护的方法,称为不安
全的方法.


这里所说的线程安全和不安全的方法,不用多说大家都知道是指公开的方法.对上节最后我留下的问题而
言,test方法是安全的,因为它是private的,只会被into方法调用,而into方法是同步的,简单说test中的
代码一定会在同步块中执行,而display方法是public的,有可能被任何线程调用,所以它需要同步.

对于线程安全的方法,不需要多说.而对于不安全的方法,只要定义为synchronized的就可以达到保护的
目的.也就是多个线程同时执行该段代码时,只有一个线程有机会执行,具体机制我们在多线程中同步对象
锁中已经说明过.我们把这种只有一个线程能进入的程序范围,称为[临界区]


尽管JDK5以后提供了很多功能更强,语义更准确的并发控制的接口供程序员调用,但我还是极力推荐在大
多数情况下(除非需要有效的控制)还是使用synchronized来保护临界区,因为synchronized块的开始和结
束是自动控制的,在离开同步块时会自动释放同步对象锁.而使用java的lock对象时,你不得不每时每刻小
心地在finally从句中调用lock对象的unlock方法,这比在finally从句中释放数据库连结更重要!

[适用环境]

1.单线程环境:单线程环境中肯定只有一个线程执行,无论是否在临界区中反正只有一个线程执行,所以没
有必要用synchronized保护,当然如果你非想用synchronized保护没有问题,只是会引起性能的降低,但不
会降低太大.这就象一个人在家里已经关上了大门,还关着卧室的小门,除了会给你带来一些不便之处,没有
什么太大的损失.

2.多线程环境:如果这些多线程环境中各自完全独立地运行,当然没有问题.但如果多个线程可能访问同一
SharedResource对象时,就需要使用临界区模式来保护.有时管理线程的环境会提供一种SafeThread环境来
确保线程的独立,这种情况就不需要使用临界区模式.

3.SharedResource的状态会发生改变的情况才需要使用这个模式,如果SharedResource对象一经生成就不
会改变,当然不需要保护.(只读模式)

4.在必要的确保安全性的时候使用这个模式.比如java数据结构类大多数都不是线程安全的.因为很多情况
下发生多个线程共享冲突对程序本身并无大碍,比如用一个ArrayList或HashMap存放在线人数,对于在线
人数这种数据本来就不可能精确地计算,只是相对时间内的一个概数,所以多个线程访问对产生冲突对其几
乎没有影响.
但是对于需要确保线程安全的时候,java仍然提供了大量的线程安全的数据结构的封装,由Collections类
提供的synchronizedXXX()方法可以将传入的数据结构封装为线程安全的.


[性能因素]
在程序设计中,大多数情况下,各种优点无法共存,事实上如果使用一个模式能给其它方面的优点也带来提
升那简单就没有理由不使用该模式了.对于安全性的提升往往要以牺牲性能为代价,所以临界区模式会带来
一些性能方面的损失.如何权衡这它们之间的比例,要看程序运行的环境,目的等各方面的因素.

1.获取对象锁的操作本身是要花时间的.一个线程在获取同步对象锁时,其实就是一个全局对象的自旋锁,这
个全局对象是要注册到线程管理系统中的.这个过程本身需要一定的时间.但这个过程性能影响并不大.

2.同步对象锁被其它线程占用时需要等待.当一个线程进入同步块时,获取该同步对象的锁,如果该锁被其它
线程拥有测当前线程必须等待,从而降低性能,这方面性能的降低较大.

提高性能的方法一是尽量减少共享资源的数量.二是尽量减小临界区的范围.双检锁模式就是减小临界区范

围的一种手段.



[死锁问题]
临界区模式中非常重要的一点是多线程程序的生命指数.再安全的程序如果运行一定时间就结束自己的生命
而不能继续运行,那就根本不能达到设计的目的.除去系统突发因素,影响生命指数的最大原因就是死锁.
对于大家都熟悉的五个哲学家(好象是故意调侃哲学家)吃面条的例子,我们用最简单的模型简单为两个哲学
家.然后从中抽象出死锁的最一般的条件:

1.有多个共享资源被多线程共享.对于两个吃面的哲学家而言就是刀和叉两上以上的共享资源.

2.对一个共享资源的占用还没有释放锁又获取另一个共享资源.占用了刀的时候又要获取叉.

3.对共享资源的占用顺序是不固定的.如果哲学家按一定顺序使用刀和叉,一个用完了思考时再让给另一个
用那就能很好地完成目标而不会发生死锁,正时因为对共享资源占用的顺序是无法确定的.当一个结程占用
一个共享资源时,要获取另一个线程占用的共享资源,而另一个线程释放这个共享资源的条件是以获取被原
先被占用的共享资源时,才会发生死锁.

所以如果我们破坏上面其中之一的条件就不会发生死锁问题,也就是在设计时要考虑不要同时发生上面的
三程情况.

[嵌套锁定]
对于同一对象的嵌套锁定,例子如下:
synchronized(this){//1
    System.out.println("outter");
    synchronized(this){//2
        System.out.println("inner");
    }
}
这个例子能运行吗?答案是可以很好地运行.
一般以为线程运行到1时,获取了当前对象锁,打印outter后,运行到2,又要获取当前对象锁,而此时当前对象
锁还没有释放,所以线程一直等在这儿发生死锁.
其实java是一种smart language,在编译的时候,它就会检查对同一对象的嵌套锁定.因为不可能发生在层同
步块中有多个线程进入而其中一个线程要进入内层同步块的情况,也就是外层同步块本身就可以保证只有一
个线程获取同步对象的锁,所以内层同一对象的同步块在编译的时候已经失去它的作用.

[继承和扩展]
对于临界区模式而言,即使我们已经使用synchronized方法对共享资源进行保护,但是子类在扩展接口时很可
能将共享资源以不安全方式暴露出去.这是非常值得注意的问题.设计时应该尽时将对共享资源的访问方法加
以保护,可以使用private和final等限制,另外在子类设计时也要充分考虑对父类共享资源的访问.

[高级主题:关于synchronized]

其实在多线程编程基础部分,我已经谈过synchronized相关的内容.但临界区模式是其它多线程编程模式的基
础,所以在这里继续深入一下谈谈synchronized相关的一些内容.

只要见到synchronized关键字,第一要想到的问题就是,synchronized在保护谁?

在上面的例子中,synchronized保护的是Corrie对象的counter,name,number三个字段不被"交差赋值",也就是
这三个字段同时只能被一个线程访问.
其次我们要考虑的问题是:这些对象都被妥善地保护了吗?

这是非常重要的问题.无论你花巨资打造一把高安全性锁,把自己的家门牢牢地锁住,可是你却把门旁边的窗子
敞开着,那么你花巨资打造的锁又要什么意义呢?所以要确保从任何一个通道访问被保护的对象都被加锁控制
的,比如字段是否都private或protected的,对于protected的子类中的扩展方法是否能保护被保护对象.

对于上面的例子因为display有可能被外面的方法单独调用,所以它也必须是同步的.而test方法只会在into中
调用,简单说它只是所有通道被加了锁的大房子中的一个小单元,所以不必担心有人会从外部访问它.

要注意保护的范围是三个同时需要保护的字段,如果它们被分别放在synchronized方法中保护,并不能保证它们
本个字段同时只有一个线程访问.

那么我们就有一个问题,获取谁的锁呢?

要保护一个对象,当然直接获取这个对象的锁,我们上面的例子可以理解为要同时保护三个对象,那么其实就是
要保护这个本个对象的容器.也就是它们所在的实例.如果不相关的三个对象要同时保护,一定要放在同时容纳
它们的容器中,否则无法同时保护它们的状态.对于上面的例子我们同样可以理解为要保护的是Corrie的实例,
因为这个实例是这三个字段的容器.所以我们用synchronized方法就是等同于synchronized(this){.......}
如果这个游戏中有多个山洞,而只有一块显示牌,那以我们就需要保护多个实例的三个字段同时只被一个线程
访问,我们就需要synchronized(Corrie.class)来保证多个实例被多个线程访问时只有一个对程能同时对三个
字段访问.
所以获取谁的锁定也是一个很重要的问题,如果你选择了错误的对象,就象你花巨资打了一把锁却锁了别人的
门.

synchronized就是原子操作,简单说在一个线程进行同步块中的代码时不能进入,这是很明显的.但同时,多个
同步方法或多个获取同一对象的同步块在同一时候也只能一个线程能访问其中之一,因为控制谁能访问的是要
获得那个同步对象的锁.如:
class C{
 synchronized  a(){}
 synchronized  b(){}
}

当一个线程进入同步方法a后那么其它线程当然不能进入a,同时也不能进入b,因为能进入的条件是获取this对
象的锁.一个结程进入a后this对象的锁被这个线程获取,其它线程进入b也同样要获取这个锁,而不仅仅是进入
a要获取这个锁.这一点一定要理解.

理解上面的知识我们再回过头来看原子操作.

JLS规定对于基本类型(除long和double)以外的赋值和引用都是原子操作,并且对于引用类型的赋值和引用也是
原子操作.

注意这里有两个方面的知识点:

1.对于long和double的操作非原子性的.需要说明这只是JLS的规定,但大多数JVM的实现其实已经保证了long和
double的赋值和引用也是原子性的,只是允许某种实现可以不是原子性的操作.

对于其它基本类型如int,如果一个线程执行x = 1;另一个线程执行x = 2;
由于可见性的问题(多线程编程系统中已经介绍),x要么就是1,要么就是2,看谁先同步到主存储区.

但对于long,l = 1;l = 2;分别由两个线程执行的结果有可能不是你想象的,它们有可能是0,或1,或2,或一个其
它的随机数,简单说两上线程中l的值的部分bit位可能被另一个线程改写.所以最可靠的是放在synchronized中
或用volatile 保护.当然这里说的是"有非常可靠的需要",一般而言现在的JVM已经能保证long和double也是原
子操作的.

2.我们看到,对于引用对象的赋值和引用也是原子的.

我们还是看javaworld上dcl的例子.

 那个错误的例子误了好多人,(JAVA与模式的作者就是受害人),我们先不说JAVA内存模型的原因(前面我已经从
JAVA内存模型上说明了那个例子是错误的,我是说对那个例子的分析是错误的).单从对于"引用对象的赋值和引
用也是原子的"这句话,就知道对于引用字段的赋值,绝对不可能出现先分配空间,然后再还没有被始化或还没有
调构造方法之前又被别的线程引用.因为当一个线程在执行赋值的时候是原子性的操作,其它线程的引用操作也是原子性的操作 的,在赋值操作没有完成之前其它线程根本不可能见到"分配了空间却没有
初始化或没有调用构造方法"的这个对象.

不知道什么原因,这样的一个例子从它诞生开始竟然是所有人都相信了,也许有人责疑过但我不知道.如果你有足
够的基础知识,就不必跟着别人的感觉走!

因为这是一个最最基础的模式,暂时不介绍它与其它模式的关系.在以后介绍其它模式时反过来再和它进行比较.

而一些复杂的模式都是在这个简单的模式的基础上延伸的.

posted on 2006-12-16 14:18 风人园 阅读(473) 评论(0)  编辑  收藏 所属分类: Java