基础构建模块

1.同步容器类

同步容器类包括Vector和Hashtable,二者是早期JDK一部分,此外还包含在JDK 1.2中添加的一些功能相似的类,这些同步的封装器类是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

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

从调用者角度来看。如果线程A在包含10个元素地Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast,这些操作的交替执行,在调用size与调用getLast这两个操作之间,Vector变小了,因此在调用size时得到的索引值将不再有效。

由于同步容器类要遵守同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。同步容器类通过其自身的锁来保护它的每个方法。通过获得容器类地的锁,我们可以使getLast和deleteLast成为原子操作,并确保Vector的大小在调用size和get之间不会发生变化。


在使用客户端加锁的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);
	}
}

在Vector中的元素进行迭代时也会出现错误


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

进行修改
带有客户端的迭代

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

1.2 迭代器与ConcurrentModificationException

在设计同步容器类的迭代器时并没有考虑到并发修改问题,并且它们表现出的行为是“及时失败”(fail-fast)的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。

这种"及时失败"的迭代器并不是一种完备的处理机制,而只是“善意地”捕获并发错误,因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器地变化与容器关联起来;如果在迭代期间计数器被修改,那么hasNext或next将抛出ConcurrentModificationException.然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。

如果不希望对其进行容器加锁,那么可以采用“克隆”容器的方法,并在副本上进行迭代。因为副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出ConcurrentModificationException(在克隆过程中任然需要对容器加锁)。在克隆容器时存在显著的性能开销。

1.3 隐藏迭代器

如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。

正如封装对象的状态有助于维持不变条件一样,封装对象的同步机制同样有助于确保实施同步策略。

容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll,removeAll和retainAll等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出ConcurrentModificationException。

2. 并发容器

Java 5.0提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。
另一方面,并发容器是针对多个线程并发访问设计的。在Java5.0中增加了ConcurrentHashMap,用来代替同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List.在新的ConcurrentMap接口中增加了对一些常见复合操作的支持,例如“若没有则添加”,替换以及条件删除等。

通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。

Java5.0增加了两种新的容器类型:Queue和BlockingQueue。Queue用来临时保存一组等待处理的元素。它提供了几种实现,包括:
ConcurrentLinkedQueue,这时一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List来模拟Queue的行为——事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的随机访问需求,从而实现更高效的并发。

BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。

正如ConcurrentHashMap用于代替基于散列的同步Map,Java6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发代替品。(例如用synchronizedMap包装的TreeMap或TreeSet)。

2.1 ConcurrentHashMap

同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如HashMap.get或List.contains,可能包含大量的工作:当遍历散列桶或链表来查找某个特定的对象时,必须在许多元素上调用equals。在基于散列的容器中,如果hashCode不能很均匀地分布散列值,那么容器中的元素就不会均匀地分布在整个容器中。某些情况下,某个糟糕的散列函数还会把一个散列变成线性链表。当遍历很长的链表并且在某些或者全部元素上调用equals方法时,会花费很长的时间,而其他线程在这段时间内都不能访问该容器。

与HashMp一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的枷锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制成为分段锁。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map.ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。

ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要再迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。

2.2 额外的原子Map操作

由于ConcurrentHashMap不能被枷锁来执行独占访问,因此我们无法使用客户端枷锁来创建新的原子操作。但是一些常见的复合操作,例如“若没有则添加”等都已经实现为原子操作并且在ConcurrentMap的接口中声明。

public interface ConcurrentMap<K, V> extends Map<K, V> {
    //仅当K没有相应的映射值时才插入
    V putIfAbsent(K key, V value);

    //仅当K被映射到V时才移除
    boolean remove(K key, V value);

    //仅当K被映射到oldValue时才替换为newValue
    boolean replace(K key, V oldValue, V newValue);

    //仅当K被映射到某个值时才替换为newValue
    V replace(K key, V newValue);
}

2.3 CopyOnWriteArrayList

CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。类似地CopyOnWriteArraySet是用来替代Set。
“写入时赋值(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步地同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时赋值”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需要确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写入时赋值”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。

3. 阻塞队列和生产者——消费者模式

在基于阻塞队列构成的生产者——消费者设计中,当数据生成时,生产者把数据放入队列,而消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需将数据放入队列即可。同样,消费者与不需要知道生产者是谁,或者工作来自何处。

在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。

BlockingQueue的多种实现
1.LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,而这分别与LinkList和ArrayList类似,但比同步List拥有更好的并发性能。
2.PriorityBlockingQueue是一个优先级排序的队列。
3.SynchronousQueue它不为队列中元素维护存储空间。它维护一组线程,这些线程在等待着把元素加入或移除队列。

3.2 串行线程封闭

对于可变对象,生产者——消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有 权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来“转移”所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会在访问它,因此对象将被封闭在新的线程中。新的所有者线程可以对该对象做任意修改,因为它具有独占的访问权。

对象池利用了串行线程封闭,将对象“借给”一个请求线程。只要对象池包含足够的内部同步来安全地发布池中的对象,并且只要可会代码本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全地在线程之间传递所有权。

3.3 双端队列与工作密取

Java 6 增加了两种容器类型,Deque 和 BlockingDeque,他们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque.

正如阻塞队列适用于生产者——消费者模式,双端队列同样适用于另一种相关模式,即工作密取。在生产者——消费者设计中,所有消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统地生产者——消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。

能确保在双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。

4. 阻塞方法于中断方法

线程可能会阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKED,WAITING或TIMED_WAITING)。阻塞操作与执行时间很长的区别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行。当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。

当代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应,对于代码库来说,有两种基本选择:
1.传递InterruptedException.避开这个异常通常是最明智的策略——只需把InterruptedException传递给方法的调用者。传递InterruptedException的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常。
2.恢复中断。有时候不能抛出InterruptedException,比如当代码是Runnable的一部分时。在这些情况下,必须捕获InterruptedException,并通过调用当前线程上的interrupt方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断。
3.对Thread进行扩展,并且能控制调用栈上所有更高层的代码。才能屏蔽中断。

5. 同步工具类

所有的工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。

5.1 闭锁

闭锁可以延迟线程的进度直到其到达终止状态。

  • 确保某个计算在其需要的所有资源都被初始化之后才继续执行。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。
  • 等待直到某个操作的所有参与者都就绪在继续执行。

CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或者多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。

public class TestHarness {
    public long timeTasks(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.countDown();
                            ;
                        }
                    } catch (InterruptedException ignored) {
                    }
                }
            };
            t.start();
        }
        long start = System.nanoTime();
        startGate.countDown();
        endGate.await();
        long end = System.nanoTime();
        return end - start;
    }
}

5.2 FutureTask

FutureTask也可以用做闭锁。FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:等待运行(Waiting to run),正在运行(Running)和运行完成(Completed)。“执行完成”表示计算的所有可能结束方式,包括正常结束,由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。

Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现的结果的安全发布。

FutureTask在Executor框架中表示异步任务,此外还可以来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。

public class Preloader {
    private final FutureTask<ProductInfo> future = new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
        @Override
        public ProductInfo call() throws Exception {
            return loadProductInfo();
        }
    });
    private final Thread thread = new Thread(future);

    public void start() {
        thread.start();
    }

    public ProductInfo get() throws DataLoadException, InterruptedException {
        try {
            return future.get();
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof DataLoadException)
                throw (DataLoadException) cause;
            else
                throw launderThrowable(cause);
        }
    }
}

5.3 信号量

计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore中管理者一组虚拟机的许可,许可的初始数量可通过构造器函数来指定。在执行操作时可以首先获得许可,并在使用以后释放许可。如果没有许可,那么aquire将阻塞直到有许可。release方法将返回一个许可给信号量。计算信号量得一种简化形式是二值信号量,即初始值为1得Semaphore。二值信号量可以用做互斥体,并具备不可重入得加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。

Semaphore可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度得资源池,当池为空时,请求资源将会失败,但你真正希望看到的行为是阻塞而不是失败,并且当池非空时解除阻塞。如果将Semaphore的计数值初始化为池的大小,并在从池中获取一个资源之前首先调用acquire方法获取一个许可,在将资源返回给池之后调用release释放许可,那么acquire将一直阻塞直到资源池不为空。

同样,也可以使用Semaphore将任何一种容器变为有界阻塞容器。

public class BoundedHashSet<T> {
    private final Set<T> set;
    private final Semaphore sem;

    public BoundedHashSet(int bound) {
        this.set = Collections.synchronizedSet(new HashSet<T>());
        sem = new Semaphore(bound);
    }

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

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

5.4 栅栏

栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。

CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列互相独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都达到栅栏位置。如果所有线程都达到,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下一次使用。如果对await的调用超时时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。

public class CellularAutomata {
    private final Boaed mainBoard;
    private final CyclicBarrier barrier;
    private final Worker[] workers;

    public CellularAutomata(Board board) {
        this.mainBoard = board;
        int count = Runtime.getRuntime().availableProcessors();
        this.barrier = new CyclicBarrier(count, new Runnable() {
            @Override
            public void run() {
                mainBoard.commitNewValues();
            }
        });
        this.workers = new Worker[count];
        for (int i = 0; i < count; i++)
            workers[i] = new Worker(mainBoard.getSubBoard(count, i));
    }

    private class Worker implements Runnable {
        private final Board board;

        public Worker(Board board) {
            this.board = board;
        }

        public void run() {
            while (!board.hasConverged()) {
                for (int x = 0; x < board.getMaxX(); x++)
                    for (int y = 0; y < board.getMaxY; y++)
                        board.setNewValue(x, y, computeValue(x, y));


                try {
                    barrier.await();
                } catch (InterruptedException e) {
                    return;
                } catch (BrokenBarrierException e) {
                    return;
                }
            }
        }
    }

    public void start() {
        for (int i = 0; i < workers.length; i++) {
            new Thread(workers[i]).start();
            mainBoard.waitForConvergence();
        }
    }
}

另一种形式的栅栏是Exchanger,它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全地发布给另一方。

数据交换的时机取决于应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数降至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲被填满时进行交换,并且当缓冲被填充到一定程度并保持一定时间后,也进行交换。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值