Java并发编程实战 性能与可伸缩性总结

    xiaoxiao2022-07-03  126

    对性能的思考 要想通过并发来获得更好的性能 需要努力做好两件事情:更有效地利用现有处理资源 以及在出现新的处理资源时使程序尽可能地利用这些新资源

    性能与可伸缩性 应用程序的性能可以采用多个指标来衡量 例如服务时间 延迟时间 吞吐率 效率 可伸缩性以及容量等

    可伸缩性指的是:当增加计算资源时(例如CPU 内存 存储容量或I/O带宽) 程序的吞吐量或者处理能力能相应地增加

    评估各种性能权衡因素 避免不成熟的优化 首先使程序正确 然后再提高运行速度——如果它还运行得不够快

    以测试为基准 不要猜测

    Amdahl定律 Amdahl定律描述的是:在增加计算资源的情况下 程序在理论上能够实现最高加速比 这个值取决于程序中可并行组件与串行组件所占的比重

    对任务队列的串行访问

    public class WorkerThread extends Thread { private final BlockingQueue<Runnable> queue; public WorkerThread(BlockingQueue<Runnable> queue) { this.queue = queue; } public void run() { while (true) { try { Runnable task = queue.take(); task.run(); } catch (InterruptedException e) { break; /* Allow thread to exit */ } } } }

    在所有并发程序中都包含一些串行部分 如果你认为在你的程序中不存在串行部分 那么可以再仔细检查一遍

    示例:在各种框架中隐藏的串行部分 要想知道串行部分是如何隐藏在应用程序的架构中 可以比较当增加线程时吞吐量的变化 并根据观察到的可伸缩性变化来推断串行部分中的差异

    Amdahl定律的应用 如果能准确估计出执行过程中串行部分所占的比例 那么Amdahl定律就能量化当有更多计算资源可用时的加速比 虽然要直接测量串行部分的比例非常困难 但即使在不进行测试的情况下Amdahl定律仍然是有用的

    线程引入的开销 单线程程序既不存在线程调度 也不存在同步开销 而且不需要使用锁来保证数据结构的一致性 在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说 并行带来的性能提升必须超过并发导致的开销

    上下文切换 如果主线程是唯一的线程 那么它基本上不会被调度出去 另一方面 如果可运行的线程数大于CPU的数量 那么操作系统最终会将某个正在运行的线程调用出来 从而使其他线程能够使用CPU 这将导致一次上下文切换 在这个过程中将保存当前运行线程的执行上下文 并将新调度进来的线程的执行上下文设置为当前上下文

    内存同步 同步操作的性能开销包括多个方面 在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令 即内存栅栏(Memory Barrier) 内存栅栏可以刷新缓存 使缓存无效 刷新硬件的写缓冲 以及停止执行管道 内存栅栏可能同样会对性能带来间接的影响 因为它们将抑制一些编译器优化操作 在内存栅栏中 大多数操作都是不能被重排序的

    没有作用的同步(不要这么做)

    synchronized (new Object()) { ...... }

    可通过锁消除优化去掉的锁获取操作

    @Immutable public final class ThreeStooges { private final Set<String> stooges = new HashSet<String>(); public ThreeStooges() { stooges.add("Moe"); stooges.add("Larry"); stooges.add("Curly"); } public boolean isStooge(String name) { return stooges.contains(name); } public String getStoogeNames() { List<String> stooges = new Vector<String>(); stooges.add("Moe"); stooges.add("Larry"); stooges.add("Curly"); return stooges.toString(); } }

    不要过度担心非竞争同步带来的开销 这个基本的机制已经非常快了 并且JVM还能进行额外的优化以进一步降低或消除开销 因此 我们应该将优化重点放在那些发生锁竞争的地方

    阻塞 非竞争的同步可以完全在JVM中进行处理 而竞争的同步可能需要操作系统的介入 从而增加开销 当在锁上发生竞争时 竞争失败的线程肯定会阻塞 JVM在实现阻塞行为时 可以采用自旋等待(Spin-Waiting 指通过循环不断地尝试获取锁 直到成功) 或者通过操作系统挂起被阻塞的线程 这两种方式的效率高低 要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间 如果等待时间较短 则适合采用自旋等待方式 而如果等待时间较长 则适合采用线程挂起方式 有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择 但是大多数JVM在等待锁时都只是将线程挂起

    减少锁的竞争 串行操作会降低可伸缩性 并且上下文切换也会降低性能 在锁上发生竞争时将同时导致这两种问题 因此减少锁的竞争能够提高性能和可伸缩性

    在并发程序中 对可伸缩性的最主要威胁就是独占方式的资源锁

    有两个因素将影响在锁上发生竞争的可能性:锁的请求频率 以及每次持有该锁的时间

    有3种方式可以降低锁的竞争程度:

    减少锁的持有时间降低锁的请求频率使用带有协调机制的独占锁 这些机制允许更高的并发性

    缩小锁的范围(快进快出) 降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间

    将一个锁不必要地持有过长时间

    @ThreadSafe public class AttributeStore { @GuardedBy("this") private final Map<String, String> attributes = new HashMap<String, String>(); public synchronized boolean userLocationMatches(String name, String regexp) { String key = "users." + name + ".location"; String location = attributes.get(key); if (location == null) return false; else return Pattern.matches(regexp, location); } }

    减少锁的持有时间

    @ThreadSafe public class BetterAttributeStore { @GuardedBy("this") private final Map<String, String> attributes = new HashMap<String, String>(); public boolean userLocationMatches(String name, String regexp) { String key = "users." + name + ".location"; String location; synchronized (this) { location = attributes.get(key); } if (location == null) return false; else return Pattern.matches(regexp, location); } }

    通过缩小userLocationMatches方法中锁的作用范围 能极大地减少在持有锁时需要执行的指令数量 根据Amdahl定律 这样消除了限制可伸缩性的一个因素 因为串行代码的总量减少了

    减小锁的粒度 另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性) 这可以通过锁分解和锁分段等技术来实现 在这些技术中将采用多个相互独立的锁来保护独立的状态变量 从而改变这些变量在之前由单个锁来保护的情况 这些技术能减小锁操作的粒度 并能实现更高的可伸缩性 然而 使用的锁越多 那么发生死锁的风险也就越高

    对锁进行分解

    @ThreadSafe public class ServerStatusBeforeSplit { @GuardedBy("this") public final Set<String> users; @GuardedBy("this") public final Set<String> queries; public ServerStatusBeforeSplit() { users = new HashSet<String>(); queries = new HashSet<String>(); } public synchronized void addUser(String u) { users.add(u); } public synchronized void addQuery(String q) { queries.add(q); } public synchronized void removeUser(String u) { users.remove(u); } public synchronized void removeQuery(String q) { queries.remove(q); } }

    将ServerStatus重新改写为使用锁分解技术

    @ThreadSafe public class ServerStatusAfterSplit { @GuardedBy("users") public final Set<String> users; @GuardedBy("queries") public final Set<String> queries; public ServerStatusAfterSplit() { users = new HashSet<String>(); queries = new HashSet<String>(); } public void addUser(String u) { synchronized (users) { users.add(u); } } public void addQuery(String q) { synchronized (queries) { queries.add(q); } } public void removeUser(String u) { synchronized (users) { users.remove(u); } } public void removeQuery(String q) { synchronized (users) { queries.remove(q); } } }

    如果在锁上存在适中而不是激烈的竞争时 通过将一个锁分解为两个锁 能最大限度地提升性能 如果对竞争并不激烈的锁进行分解 那么在性能和吞吐量等方面带来的提升将非常有限 但是也会提高性能随着竞争提高而下降的拐点值 对竞争适中的锁进行分解时 实际上是把这些锁转变为非竞争的锁 从而有效地提高性能和可伸缩性

    锁分段 在某些情况下 可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解 这种情况被称为锁分段

    锁分段的一个劣势在于:与采用单个锁来实现独占访问相比 要获取多个锁来实现独占访问将更加困难并且开销更高

    在基于散列的Map中使用锁分段技术

    @ThreadSafe public class StripedMap { // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS] private static final int N_LOCKS = 16; private final Node[] buckets; private final Object[] locks; private static class Node { Node next; Object key; Object value; } public StripedMap(int numBuckets) { buckets = new Node[numBuckets]; locks = new Object[N_LOCKS]; for (int i = 0; i < N_LOCKS; i++) locks[i] = new Object(); } private final int hash(Object key) { return Math.abs(key.hashCode() % buckets.length); } public Object get(Object key) { int hash = hash(key); synchronized (locks[hash % N_LOCKS]) { for (Node m = buckets[hash]; m != null; m = m.next) if (m.key.equals(key)) return m.value; } return null; } public void clear() { for (int i = 0; i < buckets.length; i++) { synchronized (locks[i % N_LOCKS]) { buckets[i] = null; } } } }

    避免热点域 锁分解和锁分段技术都能提高可伸缩性 因为它们都能使用不同的线程在不同的数据(或者同一个数据的不同部分)上操作 而不会相互干扰 如果程序采用锁分段技术 那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率 如果一个锁保护两个独立变量X和Y 并且线程A想要访问X 而线程B想要访问Y(这类似于在ServerStatus中 一个线程调用addUser 而另一个线程调用addQuery) 那么这两个线程不会在任何数据上发生竞争 即使它们会在同一个锁上发生竞争 当每个操作都请求多个变量时 锁的粒度将很难降低 这是在性能与可伸缩性之间相互制衡的另一个方面 一些常见的优化措施 例如将一些反复计算的结果缓存起来 都会引入一些 热点域(Hot Field) 而这些热点域往往会限制可伸缩性

    一些替代独占锁的方法 第三种降低竞争锁的影响的技术就是放弃使用独占锁 从而有助于使用一种友好并发的方式来管理共享状态 例如 使用并发容器 读-写锁 不可变对象以及原子变量

    监测CPU的利用率 如果CPU没有得到充分利用 那么需要找出其中的原因 通常有以下几种原因:

    负载不充足I/O密集外部限制锁竞争

    向对象池说 不 通常 对象分配操作的开销比同步的开销更低

    示例:比较Map的性能 在单线程环境下 ConcurrentHashMap的性能比同步的HashMap的性能略好一些 但在并发环境中则要好得多 在ConcurrentHashMap的实现中假设 大多数常用的操作都是获取某个已经存在的值 因此它对各种get操作进行了优化从而提供最高的性能和并发性 在同步Map的实现中 可伸缩性的最主要阻碍在于整个Map中只有一个锁 因此每次只有一个线程能够访问这个Map 不同的是 ConcurrentHashMap对于大多数读操作并不会加锁 并且在写入操作以及其他一些需要锁的读操作中使用了锁分段技术 因此 多个线程能并发地访问这个Map而不会发生阻塞

    减少上下文切换的开销 在许多任务中都包含一些可能被阻塞的操作 当任务在运行和阻塞这两个状态之间转换时 就相当于一次上下文切换

    小结 由于使用线程常常是为了充分利用多个处理器的计算能力 因此在并发程序性能的讨论中 通常更多地将侧重点放在吞吐量和可伸缩性上 而不是服务时间 Amdahl定律告诉我们 程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例 因为Java程序中串行操作的主要来源是独占方式的资源锁 因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间 降低锁的粒度 以及采用非独占的锁或非阻塞锁来代替独占锁

    最新回复(0)