深入了解Java并发——《Java Concurrency in Practice》学习笔记 5.基础构建模块

14 篇文章 0 订阅

5.1 同步容器类

同步容器类包括Vector、Hashtable,以及JDK1.2中添加的一些功能相似的类,这些同步的封装器类是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个共有方法都进行同步,使得每次只有一个线程能访问容器的状态。(但这些类基本都被淘汰了)

5.1.1 同步容器类的问题

同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代、跳转以及条件运算。在同步容器中这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发的修改容器时,它们可能会表现出意料之外的行为。

由于同步容器类要遵守同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪一个锁,那么这些新操作就与容器的其他操作一样都是原子操作。

*因此在处理复合操作或者多线程可能引起问题(尽管这些问题属于容器类正确的表现)时,要在这些操作的客户端代码上加锁。

5.1.2 迭代器与ConcurrentModificationException

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

这种 及时失败 的迭代器并不是一种完备的处理机制,而只是 善意的 捕获并发错误,只能作为并发问题的预警指示器。

在容器上加锁会引发很多潜在的问题,如果不希望在迭代期间对容器加锁,一种替代方法是 克隆 容器,并在副本上进行迭代。副本被封闭在县城内,其他线程无法在迭代期间对其进行修改。注意克隆时是需要对容器加锁的

5.1.3 隐藏迭代器

在所有对共享容器进行迭代的地方都需要加锁。

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

容器类的toString、hashCode、equals、containsAll、removeAll、retainAll等方法以及把容器作为参数的构造函数,通常都会对容器进行迭代,所有这些间接的操作都可能抛出ConcurrentModificationException。

并发容器

Java 5.0 提供了多种并发容器类改进同步容器的性能。同步容器将所有对容器的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。

5.2.1 ConcurrentHashMap

与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。

ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用了粒度更细的分段锁 Lock Striping 机制来实现更大程度共享。ConcurrentHashMap带来的结果是在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。

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

对于一些需要在整个Map上进行计算的方法,如size和isEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。它们可能返回一个近似值而不是精确值。这些方法通常在并发环境下用处很小。

ConcurrentHashMap中没有实现对Map加锁以提供独占访问。

大多数情况下,用ConcurrentHashMap来代替同步HashMap能进一步提高代码的可伸缩性,只有当应用程序需要加锁Map以进行独占访问或依赖于同步Map带来的一些其他作用时,才应该放弃ConcurrentHashMap。

5.2.2 额外的原子Map操作

由于ConcurrentHashMap不能被加锁来执行独占访问,因此无法使用客户端加锁创建新的原子操作。但是一些常见的复合操作,如 若没有则添加 - putIfAbsent、若相等则移除 - remove 、若相等则替换 replace 等都已经实现为原子操作并且在ConcurrentHashMap的接口中声明了。

5.2.3 CopyOnWriteArrayList

CopyOnWriteArrayList用于替代同步List,在某些情况下提供了更好的并发性能,迭代期间不需要对容器加锁或复制。

写入时复制 Copy-On-Write 容器的线程安全性在于,只要正确的发布一个事实不可变的对象,那么在访问该对象时就不需要进一步的同步。每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。写入时复制 容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰,不会抛出ConcurrentModificationException,返回的元素与迭代器创建时的元素完全一致,不必考虑修改操作所带来的影响。

每当修改容器时都会复制低层数组,会带来一定的开销,特别是容器规模较大时。因此,**仅当迭代操作远远多于修改操作时,才应该使用 写入时复制 容器。

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

阻塞队列提供了可阻塞的put和take方法,支持定时的offer和poll方法。队列已满时,put方法将阻塞直到有空间可用,队列为空时,take方法会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,put方法永远不会阻塞。

阻塞队列支持生产者-消费者模式。BlockingQueue简化了生产者-消费者设计的实现过程它支持任意数量的生产者和消费者。一种最常见的生产者-消费者设计模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现了这种模式。

offer方法将在数据项不能被添加到队列中时返回一个失败状态,这样使我们可以创建更多灵活的策略来处理负荷过载的情况。

通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计系统架构,应该今早的通过阻塞队列在设计中构建资源管理机制

类库中包含了BlockingQueue的多种实现,LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,分别类似于LinkedList和ArrayList,但比同步List拥有更好的并发性能。PriorityBlockingQueue是一个按优先级排序的队列,当希望按照某种顺序而不是FIFO处理元素时可以使用。PriorityBlockingQueue可以根据元素的自然顺序来比较元素(如果它们实现了Comparable),也可以使用Comparator来比较。

SyncrhronousQueue实际上不是一个真正的队列,它不会为队列中的元素维护存储空间。它维护一组线程,这些线程在等待着把元素一处队列。这种方式由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列

5.3.2 串行线程封闭

java.util.concurrent中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全的将对象从生产者线程发布到消费者线程。

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

5.3.3 双端队列与工作密取

Java 6 增加了Deque和BlockingDeque两种容器类型,扩展了Queue和BlockingQueue。Deque是一个双端队列,实现了在队头和队尾的搞笑插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。

双端队列适用于 工作密取 Work Stealing 模式。工作密取 的设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列的末尾秘密的获取工作。密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会再单个共享的任务队列上发生竞争。大多数时候,它们都只是访问自己的双端队列,从而极大的减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,进一步降低了队列上的竞争程度。

**工作密取非常适用于既是生产者又是消费者的问题——当执行某个工作时可能导致出现更多的工作。

5.4 阻塞方法与中断方法

线程可能会阻塞或暂停执行。线程阻塞时,通常被挂起,并处于某种阻塞状态(BLOCKED/WAITING/TIMED_WAITING)。

BlockingQueue的put和take等方法会抛出受检查的异常 InterruptedException,某方法抛出InterruptException代表该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。

Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态

中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他操作,仅仅能要求在执行到某个可以暂停的地方停止正在执行的操作,前提是被请求的线程愿意停止下来。API或语言规范中没有为中断定义任何特定应用级别的定义,但最常用中断的情况就是取消某个操作。

当在代码中调用了一个将抛出InterruptedException异常的方法时,自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的相应。对于库代码来说,有两种基本选择:
- 传递InterruptedException。不捕获该异常直接抛出,或捕获完进行一些清理工作后再次抛出
- 恢复中断 在不能抛出InterruptedException时(比如代码时Runnable的一部分时)

public class TaskRunnable implements Runnable {

    BlockingQueue<Task> queue;

    @Override
    public void run() {
        try {
            processTask(queue.take());
        } catch (InterruptedException e) {
            // 恢复被中断的状态

            Thread.currentThread().interrupt();
        }
    }

}

完整代码

在出现InterruptedException时不应该捕获却不作出任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况下才能屏蔽中断,即对thread进行扩展,并且能控制调用栈上所有更高层的代码。

5.5 同步工具类

同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括 信号量 Semaphore 、栅栏 Barrier 、以及 闭锁 Latch。在平台类库中还包含其他一些同步工具类的类,如果这些类还无法满足需要,那么可以按照第14章给出的机制来创建自己的同步工具类。

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

}

完整代码

使用 开始门 Starting Gate 的意义在于让各线程同时开始任务。

5.5.2 Future Task

FutureTask也可以用作闭锁。FutureTask表示的计算时通过Callable来实现的,可以以下三种状态:等待运行 Wating to run、 正在运行 Running、 运行完成 Completed。执行完成 表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。FutureTask在进入完成状态后,会永远停止在这个状态上。

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

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

FutureTask使用示例

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 InterruptedException, DataLoadException {
        try {
            return future.get();
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof DataLoadException) {
                throw (DataLoadException) cause;
            } else {
                throw launderThrowable(cause);
            }
        }
    }

    private static RuntimeException launderThrowable(Throwable t) {
        if(t instanceof RuntimeException) {
            return (RuntimeException) t;
        } else if(t instanceof Error) {
            throw (Error) t;
        } else {
            throw new IllegalStateException("Not unchecked",t);
        }
    }

    private ProductInfo loadProductInfo() {
        return null;
    }

完整代码

Callable表示的任务可以抛出受检查的或未受检查的异常,并且任何代码都可能抛出一个Error。无论任务代码抛出什么异常,都会被封装到一个ExecutionException中,并在Future.get中重新抛出。

5.5.3 信号量

计数信号量 Counting Semaphore 用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。

Semaphore中管理着一组虚拟的许可 permit ,许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用后释放。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。在这种实现中不包含真正的许可对象,Semaphore也不会将许可与线程关联起来,因此在一个线程中获得的许可可以在另一个线程中释放。可以将acquire操作视为是消费一个许可,而release是创建一个许可,Semaphore并不受限于它在创建时的初始许可数量。计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用作 互斥体 mutex,并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。

Semaphore可以用于实现资源池。可以构造一个固定长度的资源池,当池为空时,请求资源时阻塞,非空时解除阻塞。将Semaphore的计数值初始化为池的大小,并在池中获取一个资源之前首先调用acquire方法获取一个许可,在将资源返回给池之后调用release释放许可,那么acquire将一直阻塞直到资源池不为空。有界缓冲类中使用了此项技术,但在构造阻塞对象池时,BlockingQueue保存池的资源是一种更简单的方法。

可以使用Semaphore将任何一种容器变成有界阻塞容器。信号量的计数值初始化为容器容量的最大值,add操作在向底层容器中添加一个元素之前,首先要获取一个许可。如果add操作没有添加任何元素,那么会立刻释放许可。remove操作释放一个许可,使更多的元素能够添加到容器中。

示例代码:使用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.5.4 栅栏

栅栏 Barrier 类似于闭锁,能阻塞一组线程知道某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置才能继续执行。闭锁用于等待事件,栅栏用于等待其他线程。栅栏通常用于实现一些协议。

CyclicBarrier

CyclicBarrier可以使一定数量的参与方反复的在栅栏位置汇集,它在并行迭代算法中非常有用,这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。当所有线程都到达了栅栏位置,栅栏会打开,此时所有线程都被释放,栅栏会被重置以便下次使用

如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏被打破,所有阻塞的await调用都将终止并抛出BrokenBarrierException。

如果成功的通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,可以利用索引号来 选举 产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。

CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会在一个子任务线程中执行它,(类似于回调机制?)但在阻塞线程被释放前是不能执行的。

在模拟程序中通常需要使用栅栏,如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算都完毕才能进入下一个步骤。

示例代码

Exchanger

Exchanger 是 两方 Two-Party 栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用。

例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger进行回合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把两个对象安全的发布给另一方。

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

5.6 构建高效且可伸缩的结果缓存

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值