《Java并发编程实战》-4

《Java并发编程实战》-4


5.1 同步容器类

Collections.synchronizedXxx等工厂方法创建的同步实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。

5.5.1 同步容器的问题

在使用客户端加锁的Vector上的复合操作

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

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

5.1.2 迭代器与ConcurrentModificationException

当容器类发现容器在迭代过程中被修改时,就会抛出一个”ConcurrentModificationException
“,这就是“快速失败”。

5.1.3 隐藏迭代器

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

5.2 并发容器

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

并发容器 同步容器 普通容器
BlockingQueue ConcurrentLinkedQueue PriorityQueue
ConcurrentHashMap, SkipListMap hashtable, Collections.synchroniedList(TreeSet set) hashmap,SortedMap, SortedSet
CopyOnWriteArrayList Vector, Collections.synchroniedList(List list) ArrayList, LinkedList
CopyOnWriteArraySet, ConcurrentSkipListListSet Collections.synchroniedSet(Set set) LinkedHashSet, HashSet, TreeSet

5.2.1 ConcurrentHashMap

ConcurrentHashMap使用更细粒度的加锁机制—分段锁来实现更大程度的共享。

ConcurrentHashMap具有弱一致性(Weakly Consistent),而非”及时失败”。

只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。

5.2.2 额外的原子Map操作

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

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

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

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

5.2.3 CopyOnWriteArrayList

CopyOnWriteArrayLsit用于替代同步List,某些情况下它提供了更好的并发行性能,并且在迭代期间不需要对容器进行加锁或复制(类似地,CopyOnWriteArraySet的作用是替代同步Set)。

每当修改容器时都会复制底层数组,这需要一定的开销。

仅当迭代操作远远多于修改操作时,才应该使用”写入时负责”容器。

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

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

BlockingQueue的方法:

  • put 如果队列满则阻塞
  • take 如果队列为空则阻塞
  • offer 如果队列满则返回一个失败状态,一般是false
  • poll 如果队列空则返回一个表示空的数据,一般是null

BlockingQueue的多种实现:

  • LinkedBlockingQueue 基于链表,FIFO
  • ArrayBlockingQueue 基于数组, FIFO
  • PriorityBlockingQueue 按优先级排序的队列
  • SynchronousQueue 它不会为队列中元素维护储存空间,它维护一组线程,这些线程在等待着吧元素加入或者移出队列。

5.3.1 示例:桌面搜索

5.3.2 串行线程封闭

对象池利用了串行线程封闭,将一个对象“借给”一个请求线程。

还可以通过ConcurrentMap的原子方法remove或者AtomicReference的原子方法compareAndSet来转笔可变对象的所有的所有权(但必须确保只有一个线程能接受被转移的对象)。

双端队列与工作密取

Deque是一个双端队列,实现了在队头和队尾的高效插入和移除。具体实现包括ArrayList和LinkedBlockingDeque。

双端队列适用于工作密取模式。

5.4 阻塞方法与中断方法

  • 传递
  • 恢复中断
public class TackRunnable implements Runnable {
    try {
            processTack(queue.take());
        } catch (InterruptedException e) {
            // 恢复被中断的状态
            Thread.currentThread().interrupt();
        }
}

5.5 同步工具类

5.5.1 闭锁

CoutDwonLatch,倒计时门闩,又名闭锁、倒计时计数器,是一种同步工具类,可以延迟线程大的进度直到其到达终止状态。

闭锁可以用来确保某些活动直到其他活动都完成后才能继续执行。

  • 确保某个计算在其需要得所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须现在这个闭锁上等待。
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其他服务的闭锁上等待,在所有依赖的服务启动后会释放锁S,这样其他依赖S的服务才能继续执行。
  • 等待直到某个操作的所有参与者(例如,在多个玩家游戏中的所有玩家)都就绪再继续执行。这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。

5.5.2 FutureTask

FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于一下三种状态:

  • 等待运行(Wating to run)
  • 正在运行(Running)
  • 运行完成(Completed)

信号量

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

5.5.4 栅栏

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

CyclicBarrier可以使一定数量的参与反复地在栅栏位置汇集,他在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。

另一种形式的栅栏使Exchange,他是一种两方栅栏,各方在栅栏位置上交换数据。当两分法执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区读取数据。

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

使用ConcurrentHashMap和FutureTask来构架缓存


public interface Computable<A, V> {
    V compute(A arg) throws InterruptedExcetion;
}

public class Menoizer<A, V> implements Computable<A, V> {
    private final ConcurrentMap<A, Future<V>> cache = new ConcurrentHashMap<>();
    private final Computable<A, V> c;

    public Menoizer(Computable<A, V> c) { this.c = c; }
    while (true) {
        Future<V> f = cache.get(arg);
        if (f == null) {
            Callable<V> eval = new Callable<V>() {
                public V call() throws InterruptedException {
                    return c.compute(args);
                }
            };
            FutureTask<V> ft = new FutureTask<V>(eval);
            // 复合操作(“若没有则添加”)
            f = cache.putIfAbsent(arg, ft);
            if(f == null) {
                f = ft;
                ft.run();
            }
        }
        try{
            // 若正在计算,则阻塞等待结果
            return f.get();
        } catch (CancellationException e) {
            // 出现异常则去除缓存,防止缓存污染
            cache.renove(arg,f);
        } catch (ExecutionException e) {
            throw launderThrowable(w.getCause())
        }
    }
}

第一部分小结

  • 可变状态是至关重要的。
    所有的并发问题都可以归结为如何协调对并发状态的访问,可变状态越少,就越容易确保线程安全。
  • 尽量将域声明为final类型,除非需要它们是可变的。
  • 不可变对象一定是线程安全的。
    不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
  • 封装有助于管理复杂性
    在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这么做?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
  • 用锁来保护每个可变变量。
  • 当保护同一个不变性条件中的所有变量时,要使用用一个锁。
  • 在执行复合操作期间,要持有锁。
  • 如果从多个线程中访问同一个可变变量没有同步机制,那么程序会出现问题。
  • 不要故作聪明地推断出不需要使用同步。
  • 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。
  • 将同步策略文档化。

猜你喜欢

转载自blog.csdn.net/weixin_35040169/article/details/81669855
今日推荐