java并发编程实践学习(4)构建块

一.同步容器

同步容器包括俩部分Vector和HashTable,这些类由Collection.synchronizedxxx工厂方法创建,这些类通过封装他们的状态,并对每一个公共方法进行同步而实现了线程的安全,这样一次只能有一个线程访问容器的状态。

1.同步容器中的问题

同步容器都是线程安全的。但是对于复合操作有时你可能需要使用额外的客户端加锁进行保护。这些复合操作即使没有客户端加锁技术上是线程安全的,但是有其它线程能并发修改容器的时候就不能按期望的方式运行。
操作Vector的复合操作可能导致混乱的结果

public static Object getLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.get(lastIndex);
}

public static void deleteLast(Vector list){
    int lastIndex = list.size() - 1;
    list.remove(lastIndex);
}

这里写图片描述

getLast和deleteLast交替发生,抛出ArrayIndexOutOfBoundsException
使用客户端加锁,对Vector进行复合操作

public static Object getLast(Vector list){
    synchronized(list){
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }
}

public static void deleteLast(Vector list){
    synchronized(list){
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}

迭代的过程中也可能抛出 ArrayIndexOutOfBoundsException

for(int i = 0;i < vector.size(); i++){
    doSomething(vector.get(i));
}

虽然Vector是线程安全的但是在迭代过程中可能由于其他线程修改vector。
这种造成迭代不可靠的问题可以通过客户端加锁解决,但是带迭代期间其他线程无法访问,这削弱了并发性。

2.迭代器和ConcurrentModificationException

对容器的标准迭代时使用Iterator,无论是显示的使用还是隐式的通过foreach循环。
在设计同步容器返回的迭代器时,没有考虑到并发修改的问题,它们是及时失败的:当它们察觉到容器在迭代期间被修改会抛出一个未检查的ConcurrentModificationException。不过这样的检查在没有同步带情况下进行,所以可能存在风险:看到过期数据,却没有发现修改。
解决迭代不可靠的方法是加锁,但是有时我们不愿意在迭代期间加锁。当其他线程需要访问容器的时候必须等待,直到迭代结束:如果容器很大,或者每一个元素执行的任务耗时比较长,他们可能需要等待很长时间。如果doSomething时还要持有其他锁,这是一个产生死锁的风险。
保持锁的时间越长,对锁的竞争就越激烈,如果很多线程在等待时阻塞,吞吐量和CPU的效能都会受到影响。

3.隐藏迭代器

很多时候迭代器是隐藏的,例如字符串的拼接操作经过编译转换为调用StringBuilder.append(Object)完成,它会调用容器的toString方法,标准容器的toString方法会迭代容器中的每个元素。
addTenThings,println,hashCode和equals方法也会间接调用迭代,类似的containsAll,removeAll,retainAll方法以及把容器作为参数的构造函数都会对容器进行迭代会抛出ConcurrentModificationException。

二.并发容器

同步容器是对容器所有的状态进行串行访问,这样会削弱并发性,降低吞吐量。
并发容器是为多线程访问设计,用并发容器替换同步容器可以用很小的风险取得可扩展性显著的提高。
java5.0添加了Queue和BlockingQueue.
Queue操作不会阻塞,如果队列为空,那么获取元素会返回空值。
BlockingQueue如果队列为空,获取操作会一直阻塞到队列中存在可用元素。

1.ConcurrentHashMap

ConcurrentHashMap以前,程序使用一个公共的锁同步每一个方法,并严格限制只能有一个线程访问容器。而ConcurrentHashMap使用了分离锁,这个机制允许任意数量的读线程可以并发访问Map,读者写者也可以并发访问Map,并且有限的写线程可以并发修改Map。这样并发访问带来更高的吞吐量,同时几乎没有损失单个线程访问的性能。
ConcurrentHashMap和一起并发容器返回的迭代器有弱一致性而非就“及时失败”的。弱一致性迭代器允许并发修改,当迭代器被创建时,它会遍历已有元素,并且可以(但是不能保证)感应到迭代器被创建后,对容器修改。

2.Map附加的原子操作

ConcurrentHashMap不能在独占访问中被加锁,我们不能使用客户端加锁来创建新的原子操作。但常见的“缺少即加入”,“相等便移除”,“相等便替换”被实现为原子操作。

3.CoopyOnWriteArrayList

CopyOnWriteArrayList是同步List的一个并发代替品,有更好的并发性,避免了在迭代期间对容器的加锁和复制。
“写入时复制”容器来源于只要有效的不可变对象被正确的发布,那么访问它将不需要更多的同步。在每次需要修改时会创建并从新发布新的容器拷贝来实现可变性。
“写入时复制”迭代器保留一个底层数组的引用。这个数组作为迭代器的起点,永远不修改,对它的同步是为了确保数组内容的可见性。因此,多个线程可以对这个容器迭代,并且不都其他线程干涉。
每次复制容器需要一定的开销。所以常用在容器的迭代操作远远高于修改的频率。

三.阻塞队列和生产者-消费者模式

阻塞队列(BlockingQueue)提供了可阻塞的put和take。如果队列已经满了,put方法会阻塞知道空间可用。如果队列为空,take方法会阻塞直到有元素可用。队列长度可以有限可以无限。
阻塞队列支持生产者-消费者设计模式。
该模式不会发现一个工作便立即处理,而是把工作置入一个任务清单中以备后期处理。该模式简化了开发,因为它解除了生产者类和消费者类之间互相依赖的代码。
最常见的的实现是线程池和工作队列的结合(后面细讲).
阻塞队列提供了offer方法,如果条目不能被加入到队列里会返回失败状态。这使你能创建更灵活的策略来处理超负荷工作。
生产者和消费者模式可以使生产者消费者代码互相解耦,但是它们的行为还是通过共享队列耦合在一起。如果阻塞队列不符合你的要求,也可以使用信号量来创建其他阻塞数据结构。
BlockingQueue的实现

  • LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列
  • PriorityBlockingQueue是按优先级顺序排序的,它可以使元素本身的自然顺序,也可以使用一个Comparator实现。
  • SynchronousQueue不是真正的队列,它没有为元素维护存储空间,但它维护一个排队线程清单,这些线程等待把元素加入或移除队列。

2.连续线程限制

对于可变对象,生产者-消费者模式和阻塞队列一起为生产者消费者之间移交对象所有权提供了连续的线程限制,一个线程约束的对象完全由唯一一个能访问到这个对象的权限,并且移交后原线程不能访问到他。

3.双端队列和窃取工作

java6引入了Deque和BolckingDeque,它们是双端队列,允许高效的在头和尾分别进行插入和删除。
它们与窃取工作模式相关联:每一个消费者有一个自己的双端队列,如果一个消费者完成了自己队列中的全部工作,它可以偷取其他消费者双端队列中末尾的任务。从而进一步降低对双端队列的争夺。

四.阻塞和可中断的方法

线程可能因为:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep中唤醒,或者等待另外一个线程的计算结果。被阻塞的线程必须等待外部事件发送才能回到RUNNABLE状态从新获得调度机会。
BlockingQueue的put和take会抛出一个受检查的InterruptedException,当一个方法能抛出这个异常说明这是一个可以阻塞的方法,如果它被中断可以提前结束阻塞状态。
Thread的interrupt方法用来中断一个线程或者查询一个线程是否已经被中断每一个线程有一个布尔类型的属性,代表了中断状态。
中断是一种协作机制,一个线程不能迫使其他线程停止正在做的事情或去做其他事情,当A中断B时,A仅仅是要求B在达成某一个方便停止的关键点时停止正在做的事。
当你调用了一个会抛出InterruptedException时你自己的方法也称为了一个阻塞方法,要为响应中断做好准备,有两种基本选择:
传递InterruptedException,恢复中断。还可以有更加复杂的处理方案,但你不应该捕获它,但不做任何响应,这样会丢失线程中断的证据,从而剥夺了上层代码处理中断的机会。
只有一种情况允许掩盖中断:你扩展了Thread并因此控制了所有处于调用栈上层的代码。

五.Synchronizer

Synchronizer是一个对象,他根据本身的状态和调节线程的控制流。阻塞队列,信号量(semaphore)。关卡(barrier)以及闭锁(latch)可以扮演Synchronizer的角色。
所有Synchronizer都有类似的结构特性:它们封装状态,而这些状态决定着线程执行到在某一点是通过还是被迫等待,他们还提供操控状态的方法,以及高效的等待Synchronizer进入到期望状态的方法。

1.闭锁

闭锁是一种Synchronzier,他可以延迟线程的进度直到线程到达终止状态。
CountDownLatch是一个灵活的闭锁实现:允许一个或多个线程等待一个事件集的发生。
闭锁状态包括一个计数器,初始化为一个正数,用来表现需要等待的事件数。countDown方法对计数器镜像减操作,表示一个事件已经发生,而await方法等待计数器达到零。此时所有需要等待的实际已经发生、如果计数器入口值非零,await会一直阻塞到计数器为零,或者等待线程中断以及超时。
在时序测试中,使用CountDownLatch来启动和停止线程

public class TestHarness{
    public longtimeTasks(int nThreads,final Runnable task) throws InterruptedException{
        final CountDownLatch startGate = new CountDownLatch(1);
        final CountDownLatch endGate = new CountDownLatch(nThreads);
        for(int i = 0;i < nThreads; i++){
            Thread t = new Thread(){
                public void run(){
                    try{
                        startGate.await();
                        try{
                            task.run();
                        }finally{
                            endGate.cuntDown();
                        }
                    }catch(){
                    }
                }
            };
            t.start();
        }
        long start = System.nanoTime();
        startGate.countDown();
        endGate.await();
        long end = System.nanoTime();
        return end - start;
    }
}

2.FurureTask

FurtureTask同样可以作为闭锁。它的计算是通过Callable实现,等价于一个可带结果的Runnable,有三个状态:等待,运行,和完成。
Fureure.get的行为依赖于任务的状态。如果他已经完成,get可以立刻得到返回的结果,否则会阻塞直到任务转入完成状态。
使用FutureTask于嘉在稍后需要的数据

public class Preloader{
    private final FutureTask<ProductInfo> future = new FutureTask<ProductInfo>(new Callable<ProductInfo>(){
        public ProductInfo call() throws DataLoadException(){
            return loadProductInfo();
        }
    });
    private final Thread thread = new Thread(future);
    public void start(){
        thread.start();
    }
    public ProductInfo get()throws DataLoadException ,InterruptedException{

    }
}

3.信号量

计数信号量(Counting semaphore)用来控制能够同时访问某特定资源的活动的数量或者同时执行某一给定操作的数量。技术信号量可以用来实现资源池或者给一个容器限定边界。
一个Semaphroe管理一个有效的许可集;许可集的初始量通过构造函数传递给Semaphroe。活动能够获得许可并在使用后释放许可,如果已经没有可用的许可了,那么acquier会被阻塞直到有可用的为止或者到被中断或者操作超时)。release方法向信号量返回一个许可。
如果一个初始计数唯一的Semaphroe可以用作互斥锁。你也可以用Semaphroe把任何一个容器转换为有界的阻塞容器。
使用信号量来约束容器

public calss BoundedHashSet<T>(){
    private final Set<T> set;
    private fianl Seaphore sem;
    public BoundedHashSet(int bound){
        this.set = Collections.SynchornizedSet(new HashSet<T>());
        sem = new Semaphore(bound);
    }

    public  boolean add(T o) throw InterruptedException{
        sem.acquire();
        boolean wasAdded = false;
        try{
            wasAdded = set.add(o);
            return waAdded;
        }finally{
            if(!wasAdded){
                sem.release();
            }
        }
    }

    public boolean remove(Object o){
        boolean wasRemoved = set.Removed(o);
        if(wasRemoved)
            sem.release();
        return wasRemoved;
    }
}

4.关卡

关卡类似于闭锁,它们能阻塞一组线程,直到某些事件发生,不同的是所有线程必须都到达关卡点才能继续处理。闭锁等待的是事件,关卡等待的是线程。
关卡实现的协议就行家庭成员在商场中的集合地点 “我们每个人6点在麦当劳见”然后自己干自己的事情。
CyclicBarrier允许一个给定数量的成员多次集中在一个关卡点,这在并行迭代短发中非常有用,这个算法会把一个问题拆成一系列互相独立的子问题。当线程到达关卡点时调用await,await会被阻塞,直到所有线程都到达关卡点。如果所有线程都到达关卡点,关卡就被释放,关卡重置以备下一次使用。如果对await调用超时,或者阻塞中的线程被中断,关卡认为是失败的,所有对await未完成的调用都通过BrokenbarrierException终止。如果成功的通过关卡,await为每一个线程返回一个唯一的到达索引号,用它可以用来选举残生一个领导,在下一次迭代中承担一些特殊工作。CyclicBarrier也允许你向构造函数传递一个关卡行为;这是一个Run拿不了,当成功通过关卡的时候会执行,但是在阻塞线程被释放掉之前是不能执行的。关卡常被用来模拟一个步骤的并行执行,但是要求必须完成所有域一个步骤相关的工作才能进入下一步。
Exchanger是关卡的另一种形式,它是一种俩步关卡,在关卡点会交换数据。当俩方的活动不对称时,Exchange是非常有用的。比如当一个线程向缓冲写入一个数据,这时另一个线程充当消费者使用这个数据,这时可以使用Exchanger进行会面,并用完整的缓冲和空缓冲进行会面。交换为双方的对象建立了一个安全的发布。
交换的时机取决于程序的响应需求。最简单的方案就是缓冲满时交换,并且清除任务的缓冲清空后也交换;这样做交换的次数少,如果交换的达到率不可预测的话,处理数据延迟。另一个方案是,缓冲满了交换,但是没满但已经存在了特定的时间也会交换。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章