Java并发编程实战 基础构建模块总结

同步容器类
这些类实现线程安全的方式是:将它们的状态封装起来 并对每个公有方法都进行同步 使得每次只有一个线程能访问容器的状态

同步容器类的问题
容器上常见的复合操作包括:迭代(反复访问元素 直到遍历完容器中所有元素) 跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算 例如 若没有则添加(检查在Map中是否存在键值K 如果没有 就加入二元组(K V)) 在同步容器中 这些复合操作在没有客户端加锁的情况下仍然是线程安全的 但当其他线程并发地修改容器时 它们可能会表现出意料之外的行为

Vector上可能导致混乱结果的复合操作

public class UnsafeVectorHelpers {
    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);
    }
}

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

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

public class SafeVectorHelpers {
    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);
        }
    }
}

在调用size和相应的get之间 Vector的长度可能会发生变化 这种风险在对Vector中的元素进行迭代时仍然会出现

可能抛出ArrayIndexOutOfBoundsException的迭代操作

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

这种迭代操作的正确性要依赖于运气 即在调用size和get之间没有线程会修改Vector
我们可以通过在客户端加锁来解决不可靠迭代的问题 但要牺牲一些伸缩性 通过在迭代期间持有Vector的锁 可以防止其他线程在迭代期间修改Vector 然而 这同样会导致其他线程在迭代期间无法访问它 因此降低了并发性

带有客户端加锁的迭代

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

迭代器与ConcurrentModificationException
当迭代器发现容器在迭代过程中被修改时 就会抛出一个ConcurrentModificationException异常 并且它们表现出的行为是 及时失败(fail-fast)的
它们采用的实现方式是 将计数器的变化与容器关联起来:如果在迭代期间计数器被修改 那么hasNext或next将抛出ConcurrentModificationException 然而 这种检查是在没有同步的情况下进行的 因此可能会看到失效的计数值 而迭代器可能并没有意识到已经发生了修改 这是一种设计上的权衡 从而降低并发修改操作的检测代码对程序性能带来的影响 在单线程代码中也可能抛出ConcurrentModificationException异常 当对象直接从容器中删除而不是通过Iterator.remove来删除时 就会抛出这个异常

通过Iterator来迭代List

List<Widget> widgetList = Collections.synchronizedList(new ArrayList<Widget>());
...
//可能抛出ConcurrentModificationException
for (Widget w : widgetList )
 doSomething(w);

与迭代Vector一样 要想避免出现ConcurrentModificationException 就必须在迭代过程持有容器的锁
然而 有时候开发人员并不希望在迭代期间对容器加锁 例如 某些线程在可以访问容器之前 必须等待迭代过程结束 如果容器的规模很大 或者在每个元素上执行操作的时间很长 那么这些线程将长时间等待
如果不希望在迭代期间对容器加锁 那么一种替代方法就是 克隆 容器 并在副本上进行迭代 由于副本被封闭在线程内 因此其他线程不会在迭代期间对其进行修改 这样就避免了抛出ConcurrentModificationException(在克隆过程中仍然需要对容器加锁) 在克隆容器时存在显著的性能开销 这种方式的好坏取决于多个因素 包括容器的大小 在每个元素上执行的工作 迭代操作相对于容器其他操作的调用频率 以及在相应时间和吞吐量等方面的需求

隐藏迭代器
虽然加锁可以防止迭代器抛出ConcurrentModificationException 但你必须要记住在所有对共享容器进行迭代的地方都需要加锁 实际情况要更加复杂 因为在某些情况下 迭代器会隐藏起来

隐藏在字符串连接中的迭代操作(不要这么做)

public class HiddenIterator {
    @GuardedBy("this") private final Set<Integer> set = new HashSet<Integer>();

    public synchronized void add(Integer i) {
        set.add(i);
    }

    public synchronized void remove(Integer i) {
        set.remove(i);
    }

    public void addTenThings() {
        Random r = new Random();
        for (int i = 0; i < 10; i++)
            add(r.nextInt());
        System.out.println("DEBUG: added ten elements to " + set);//此处迭代
    }
}

addTenThings方法可能会抛出ConcurrentModificationException 因为在生成调试消息的过程中 toString对容器进行迭代
如果HiddenIterator用synchronizedSet来包装HashSet 并且对同步代码进行封装 那么就不会发生这种错误

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

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

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

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

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

与HashMap和synchronizedMap相比 ConcurrentHashMap有着更多的优势以及更少的劣势 因此在大多数情况下 用ConcurrentHashMap来代替同步Map能进一步提高代码的可伸缩性 只有当应用程序需要加锁Map以进行独占访问时 才应该放弃使用ConcurrentHashMap

额外的原子Map操作
由于ConcurrentHashMap不能被加锁来执行独占访问 因此我们无法使用客户端加锁来创建新的原子操作 但是 一些常见的复合操作 例如 若没有则添加 若相等则移除(Remove-If-Equal) 和 若相等则替换(Replace-If-Equal) 等 都已经实现为原子操作并且在ConcurrentMap的接口中声明 如果你需要在现有的同步Map中添加这样的功能 那么很可能就意味着应该考虑使用ConcurrentMap了

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

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

显然 每当修改容器时都会复制底层数组 这需要一定的开销 特别是当容器的规模较大时 仅当迭代操作远远多于修改操作时 才应该使用 写入时复制 容器 这个准则很好地描述了许多事件通知系统:在分发通知时需要迭代已注册监听器链表 并调用每一个监听器 在大多数情况下 注册和注销事件监听器的操作远少于接收事件通知的操作

阻塞队列和生产者-消费者模式
阻塞队列支持生产者-消费者这种设计模式 该模式将 找出需要完成的工作 与 执行工作 这两个过程分离开来 并把工作项放入一个 待完成 列表中以便在随后处理 而不是找出后立即处理 生产者-消费者模式能简化开发过程 因为它消除了生产者类和消费者类之间的代码依赖性 此外 该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理 因为这两个过程在处理数据的速率上有所不同

在基于阻塞队列构建的生产者-消费者设计中 当数据生成时 生产者把数据放入队列 而当消费者准备处理数据时 将从队列中获取数据 生产者不需要知道消费者的标识或数量 或者它们是否是唯一的生产者 而只需将数据放入队列即可 同样 消费者也不需要知道生产者是谁 或者工作来自何处 BlockingQueue简化了生产者-消费者设计的实现过程 它支持任意数量的生产者和消费者 一种最常见的生产者-消费者设计模式就是线程池与工作队列的组合 在Executor任务执行框架中就体现了这种模式

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

示例:桌面搜索
有一种类型的程序适合被分解为生产者和消费者 例如代理程序 它将扫描本地驱动器上的文件并建立索引以便随后进行搜索 类似于某些桌面搜索程序或者Windows索引服务

桌面搜索应用程序中的生产者任务和消费者任务

public class ProducerConsumer {
    static class FileCrawler implements Runnable {
        private final BlockingQueue<File> fileQueue;
        private final FileFilter fileFilter;
        private final File root;

        public FileCrawler(BlockingQueue<File> fileQueue,
                           final FileFilter fileFilter,
                           File root) {
            this.fileQueue = fileQueue;
            this.root = root;
            this.fileFilter = new FileFilter() {
                public boolean accept(File f) {
                    return f.isDirectory() || fileFilter.accept(f);
                }
            };
        }

        private boolean alreadyIndexed(File f) {
            return false;
        }

        public void run() {
            try {
                crawl(root);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        private void crawl(File root) throws InterruptedException {
            File[] entries = root.listFiles(fileFilter);
            if (entries != null) {
                for (File entry : entries)
                    if (entry.isDirectory())
                        crawl(entry);
                    else if (!alreadyIndexed(entry))
                        fileQueue.put(entry);
            }
        }
    }

    static class Indexer implements Runnable {
        private final BlockingQueue<File> queue;

        public Indexer(BlockingQueue<File> queue) {
            this.queue = queue;
        }

        public void run() {
            try {
                while (true)
                    indexFile(queue.take());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        public void indexFile(File file) {
            // Index the file...
        };
    }
}

生产者-消费者模式提供了一种适合线程的方法将桌面搜索问题分解为更简单的组件 将文件遍历与建立索引等功能分解为独立的操作 比将所有功能都放到一个操作中实现有着更高的代码可读性和可重用性:每个操作只需完成一个任务 并且阻塞队列将负责所有的控制流 因此每个功能的代码都更加简单和清晰 生产者-消费者模式同样能带来许多性能优势

启动桌面搜索

	private static final int BOUND = 10;
    private static final int N_CONSUMERS = Runtime.getRuntime().availableProcessors();

    public static void startIndexing(File[] roots) {
        BlockingQueue<File> queue = new LinkedBlockingQueue<File>(BOUND);
        FileFilter filter = new FileFilter() {
            public boolean accept(File file) {
                return true;
            }
        };

        for (File root : roots)
            new Thread(new FileCrawler(queue, filter, root)).start();

        for (int i = 0; i < N_CONSUMERS; i++)
            new Thread(new Indexer(queue)).start();
    }

串行线程封闭
在java.util.concurrent中实现的各种阻塞队列都包含了足够的内部同步机制 从而安全地将对象从生产者线程发布到消费者线程
对于可变对象 生产者-消费者这种设计与阻塞队列一起 促进了串行线程封闭 从而将对象所有权从生产者交付给消费者 线程封闭对象只能由单个线程拥有 但可以通过安全地发布该对象来 转移 所有权 在转移所有权后 也只有另一个线程能获得这个对象的访问权限 并且发布对象的线程不会再访问它

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

阻塞方法与中断方法
线程可能会则塞或暂停执行 原因有多种:等待I/O操作结束 等待获得一个锁 等待从Thread.sleep方法中醒来 或是等待另一个线程的计算结果
Thread提供了interrupt方法 用于中断线程或者查询线程是否已经被中断 每个线程都有一个布尔类型的属性 表示线程的中断状态 当中断线程时将设置这个状态
中断是一种协作机制 一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作
当在代码中调用了一个将抛出InterruptedException异常的方法时 你自己的方法也就变成了一个阻塞方法 并且必须要处理对中断的响应 对于库代码来说 有两种基本选择:
传递InterruptedException 避开这个异常通常是最明智的策略-只需把InterruptedException传递给方法的调用者 传递InterruptedException的方法包括 根本不捕获该异常 或者捕获该异常 然后在执行某种简单的清理工作后再次抛出这个异常
恢复中断 有时候不能抛出InterruptedException 例如当代码是Runnable的一部分时 在这些情况下 必须捕获InterruptedException 并通过调用当前线程上的interrupt方法恢复中断状态 这样在调用栈中更高层的代码将看到引发了一个中断

恢复中断状态以避免屏蔽中断

public class TaskRunnable implements Runnable {
    BlockingQueue<Task> queue;

    public void run() {
        try {
            processTask(queue.take());
        } catch (InterruptedException e) {
            // restore interrupted status
            Thread.currentThread().interrupt();
        }
    }

    void processTask(Task task) {
        // Handle the task
    }

    interface Task {
    }
}

同步工具类
同步工具类可以是任何一个对象 只要它根据其自身的状态来协调线程的控制流 阻塞队列可以作为同步工具类 其他类型的同步工具类还包括信号量(Semaphore) 栅栏(Barrier)以及闭锁(Latch)
所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态 这些状态将决定执行同步工具类的线程是继续执行还是等待 此外还提供了一些方法对状态进行操作 以及另一些方法用于高效地等待同步工具类进入到预期状态

闭锁
闭锁是一种同步工具类 可以延迟线程的进度直到其到达终止状态 闭锁的作用相当于一扇门:在闭锁到达结束状态之前 这扇门一直是关闭的并且没有任何线程能通过 当到达结束状态时 这扇门会打开并允许所有的线程通过 当闭锁到达结束状态后 将不会再改变状态 因此这扇门将永远保持打开状态 闭锁可以用来确保某些活动直到其他活动都完成后才继续执行

在计时测试中使用CountDownLatch来启动和停止线程

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

为什么要在TestHarness中使用闭锁 而不是在线程创建后就立即启动? 或许 我们希望测试n个线程并发执行某个任务时需要的时间 如果在创建线程后立即启动它们 那么先启动的线程将 领先 后启动的线程 并且活跃线程数量会随着时间的推移而增加或减少 竞争程度也在不断发生变化 启动门将使得主线程能够同时释放所有工作线程 而结束门则使主线程能够等待最后一个线程执行完成 而不是顺序地等待每个线程执行完成

FutureTask
FutureTask也可以用做闭锁(FutureTask实现了Future语义 表示一种抽象的可生成结果的结算) FutureTask表示的计算是通过Callable来实现的 相当于一种可生成结果的Runnable 并且可以处于以下3种状态:等待运行(Waiting to run) 正在运行(Running)和运行完成(Completed) 执行完成 表示计算的所有可能结束方式 包括正常结束 由于取消而结束和由于异常而结束等 当FutureTask进入完成状态后 它会永远停止在这个状态上
Future.get的行为取决于任务的状态 如果任务已经完成 那么get会立即返回结果 否则get将阻塞直到任务进入完成状态 然后返回结果或者抛出异常 FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程 而FutureTask的规范确保了这种传递过程能实现结果的安全发布
FutureTask在Executor框架中表示异步任务 此外还可以用来表示一些时间较长的计算 这些计算可以在使用计算结果之前启动

使用FutureTask来提前加载稍后需要的数据

public class Preloader {
    ProductInfo loadProductInfo() throws DataLoadException {
        return null;
    }

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

    interface ProductInfo {
    }
}

class DataLoadException extends Exception { }

Preloader创建了一个FutureTask 其中包含从数据库加载产品信息的任务 以及一个执行运算的线程 由于在构造函数或静态初始化方法中启动线程并不是一种好方法 因此提供了一个start方法来启动线程 当程序随后需要ProductInfo时 可以调用get方法 如果数据已经加载 那么将返回这些数据 否则将等待加载完成后再返回

强制将未检查的Throwable转换为RuntimeException

public class LaunderThrowable {

    /**
     * Coerce an unchecked Throwable to a RuntimeException
     * <p/>
     * If the Throwable is an Error, throw it; if it is a
     * RuntimeException return it, otherwise throw IllegalStateException
     */
    public 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);
    }
}

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

Semaphore中管理着一组虚拟的许可(permit) 许可的初始数量可通过构造函数来指定 在执行操作时可以首先获得许可(只要还有剩余的许可) 并在使用以后释放许可 如果没有许可 那么acquire将阻塞直到有许可(或者直到被中断或者操作超时) release方法将返回一个许可给信号量 计算信号量的一种简化形式是二值信号量 即初始值为1的Semaphore 二值信号量可以用做互斥体(mutex) 并具备不可重入的加锁语义:谁拥有这个唯一的许可 谁就拥有了互斥锁

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

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

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

通过CyclicBarrier协调细胞自动衍生系统汇中的计算

public class CellularAutomata {
    private final Board 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() {
                    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 ex) {
                    return;
                } catch (BrokenBarrierException ex) {
                    return;
                }
            }
        }

        private int computeValue(int x, int y) {
            // Compute the new value that goes in (x,y)
            return 0;
        }
    }

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

    interface Board {
        int getMaxX();
        int getMaxY();
        int getValue(int x, int y);
        int setNewValue(int x, int y, int value);
        void commitNewValues();
        boolean hasConverged();
        void waitForConvergence();
        Board getSubBoard(int numPartitions, int index);
    }
}

另一种形式的栅栏是Exchanger 它是一种两方(Two-Party)栅栏 各方在栅栏位置上交换数据 当两方执行不对称的操作时 Exchanger会非常有用 例如当一个线程向缓冲区写入数据 而另一个线程从缓冲区中读取数据 这些线程可以使用Exchanger来汇合 并将满的缓冲区与空的缓冲区交换 当两个线程通过Exchanger交换对象时 这种交换就把这两个对象安全地发布给另一方
数据交换的时机取决于应用程序的响应需求 最简单的方案是 当缓冲区被填满时 由填充任务进行交换 当缓冲区为空时 由情况任务进行交换 这样会把需要交换的次数降至最低 但如果新数据的到达率不可预测 那么一些数据的处理过程就将延迟 另一个方法是 不仅当缓冲被填满时进行交换 并且当缓冲被填充到一定程序并保持一定时间后 也进行交换

构建高效且可伸缩的结果缓存
几乎所有的服务器应用程序都会使用某种形式的缓存 重用之前的计算结果能降低延迟 提高吞吐量 但却需要消耗更多的内存

像许多 重复发明的轮子 一样 缓存看上去都非常简单 然而 简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈 即使缓存是用于提升单线程的性能

使用HashMap和同步机制来初始化缓存

public class Memoizer1 <A, V> implements Computable<A, V> {
    @GuardedBy("this") private final Map<A, V> cache = new HashMap<A, V>();
    private final Computable<A, V> c;

    public Memoizer1(Computable<A, V> c) {
        this.c = c;
    }

    public synchronized V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}


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

class ExpensiveFunction
        implements Computable<String, BigInteger> {
    public BigInteger compute(String arg) {
        // after deep thought...
        return new BigInteger(arg);
    }
}

HashMap不是线程安全的 因此要确保两个线程不会同时访问HashMap Memoizer1采用了一种保守的方法 即对整个compute方法进行同步 这种方法能确保线程安全性 但会带来一个明显的可伸缩性问题:每次只有一个线程能够执行compute 如果另一个线程正在计算结果 那么其他调用compute的线程可能被阻塞很长时间 如果有多个线程在排队等待还未计算出的结果 那么compute方法的计算时间可能比没有 记忆 操作的计算时间更长

用ConcurrentHashMap替换HashMap

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

    public Memoizer2(Computable<A, V> c) {
        this.c = c;
    }

    public V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
        }
        return result;
    }
}

Memoizer2比Memoizer1有着更好的并发行为:多线程可以并发地使用它 但它在作为缓存时仍然存在一些不足 当两个线程同时调用compute时存在一个漏洞 可能会导致计算得到相同的值 在使用memoization的情况下 这只会带来低效 因为缓存的作用是避免相同的数据被计算多次 但对于更通用的缓存机制来说 这种情况将更为糟糕 对于只提供单词初始化的对象缓存来说 这个漏洞就会带来安全风险

基于FutureTask的Memoizing封装器

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

    public Memoizer3(Computable<A, V> c) {
        this.c = c;
    }

    public V compute(final A arg) throws InterruptedException {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> eval = new Callable<V>() {
                    public V call() throws InterruptedException {
                        return c.compute(arg);
                    }
                };
                FutureTask<V> ft = new FutureTask<V>(eval);
                    f = ft;
                    cache.put(arg, ft);
                    ft.run(); //在这里将调用c.compute
            }
            try {
                return f.get();
            } catch (CancellationException e) {
                cache.remove(arg, f);
            } catch (ExecutionException e) {
                throw LaunderThrowable.launderThrowable(e.getCause());
            }
    }
}

Memoizer3的实现几乎是完美的:它表现出了非常好的并发性(基本上是源于ConcurrentHashMap高效的并发性) 若结果已经计算出来 那么将立即返回 如果其他线程正在计算该结果 那么新到的线程将一直等待这个结果被计算出来 它只有一个缺陷 即仍然存在两个线程计算出相同值的漏洞 这个漏洞的发生概率要远小于Memoizer2中发生的概率 但由于compute方法中的if代码块仍然是非原子(nonatomic)的 先检查再执行 操作 因此两个线程仍有可能在同一时间内调用compute来计算相同的值 即二者都没有在缓存中找到期望的值 因此都开始计算

Memoizer的最终实现

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

    public Memoizer(Computable<A, V> c) {
        this.c = c;
    }

    public V compute(final A arg) throws InterruptedException {
        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(arg);
                    }
                };
                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.remove(arg, f);
            } catch (ExecutionException e) {
                throw LaunderThrowable.launderThrowable(e.getCause());
            }
        }
    }
}

当缓存的是Future而不是值时 将导致缓存污染(Cache Pollution)问题:如果某个计算被取消或者失败 那么在计算这个结果时将指明计算过程被取消或者失败 为了避免这种情况 如果Memoizer发现计算被取消 那么将把Future从缓存中移除 如果检测到RuntimeException 那么也会移除Future 这样将来的计算才可能成功 Memoizer同样没有解决缓存逾期的问题 但它可以通过使用FutureTask的子类来解决 在子类中为每个结果指定一个逾期时间 并定期扫描缓存中逾期的元素 (同样 它也没有解决缓存清理的问题 即移除旧的计算结果以便为新的计算结果腾出空间 从而使缓存不会消耗过多的内存)

在因式分解servlet中使用Memoizer来缓存结果

@ThreadSafe
public class Factorizer extends GenericServlet implements Servlet {
    private final Computable<BigInteger, BigInteger[]> c =
            new Computable<BigInteger, BigInteger[]>() {
                public BigInteger[] compute(BigInteger arg) {
                    return factor(arg);
                }
            };
    private final Computable<BigInteger, BigInteger[]> cache
            = new Memoizer<BigInteger, BigInteger[]>(c);

    public void service(ServletRequest req,
                        ServletResponse resp) {
        try {
            BigInteger i = extractFromRequest(req);
            encodeIntoResponse(resp, cache.compute(i));
        } catch (InterruptedException e) {
            encodeError(resp, "factorization interrupted");
        }
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    void encodeError(ServletResponse resp, String errorString) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[]{i};
    }
}

第一部分小结

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值