第11章 性能与可伸缩性

对性能的思考

性能提升意味着用更少的资源做更多的事情。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,如:CPU 密集型,IO 密集型,数据库密集型。

尽管使用多线程目标是提升整体性能,但与单线程的方法相比,多线程总是会引入额外的性能开销,包括:线程之间的协调(加锁,触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度。

要想通过并发来获得更好的性能,需要努力做好两件事情:更有效的利用现有处理资源,以及在出现新的处理资源时使程序尽可能的利用这些新资源。从性能监视的角度来看,CPU 需要尽可能的保持忙碌状态。

性能与可伸缩性

可伸缩性指的是:当增加计算资源时(CPU、内存、存储容量或 IO 宽带)程序的吞吐量或者处理能力响应的增加

评估各种性能权衡因素

避免不成熟的优化。首先使程序正确,然后再提高运行速度–如果它还运行得不够快。

以测试为基准,不要猜测

Amdahl 定律

在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定 F 是必须被串行执行的部分,那么根据 Amdahl 定律,在包含 N 个处理器的机器中,最高的加速比为: \(Speedup\leq\frac{1}{F+\frac{(1-F)}{N}}\) 当 N 趋近无穷大时,最大加速比趋近于1/F。

在所有并发程序中都包含一些串行部分。

示例:在各种框架中隐藏的串行部分

可以比较当增加线程时吞吐量的变化

ConcurrentLinkedQueue 的吞吐量不断提升,直到到达处理器数量上限,之后将基本保持不变。另一方面,当线程数量小于3时,同步 LinkedList 的吞吐量也会在某种程度的提升,但是之后会由于同步开销的增加而下跌。当线程数量达到4个或5个时,竞争将非常激烈,甚至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。

吞吐性的差异来源于两个队列中不同比例的串行部分。同步的 LinkedList 采用单个锁来保护整个队列的状态,并且在 offer remove 等方法的调用期间都将持有这个锁。ConcurrentLinkedQueue 使用了一种更复杂的非阻塞队列算法,该算法使用原子引用来更新各个链接指针。在第一个队列中,整个的插入或删除操都将串行执行,而在第二个队列中,只有对指针的更新操作需要串行执行。

Amdahl 定律的应用

直接测量串行部分的比例非常困难

线程引入的开销

对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销

上下文切换

上下文切换,这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文

切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。

在大多数通用的处理器中,上下文切换的开销相当于 5000~10000 个时钟周期,也就是几微秒。

如果内核占用率较高(超过 10%),那么通常表示调度活动发生得很频繁,这很可能由 IO 或竞争锁导致的阻塞引起的

内存同步

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

有竞争的同步和无竞争的同步,synchronized 机制针对无竞争的同步进行了优化(volatile 通常是非竞争的)。一个“快速通道(Fast-Path)”的非竞争同步将消耗 20~250 个时钟周期。无竞争同步的开销对应用程序整体性能的影响微乎其微。

更完备的 JVM 能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)

// 通过锁消除优化去掉的锁获取操作
public String getStoogeNames() {
  List<String> stooges = new Vector<String>();
  stooges.add("Moe");
  stooges.add("Larry");
  stooges.add("Curly");
  return stooges.toString();
}

编译器执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来

不要过度担心非竞争同步带来的开销,这个基本的机制已经非常快了,并且 JVM 还能进行额外的优化以进一步降低或消除开销。

阻塞

非竞争的同步可以完全在 JVM 中进行处理。

竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM 在实现阻塞行为时,如果等待时间较短,则适合采用自旋等待方式。而等待时间较长,则适合采用线程挂起方式。

当线程无法获取某个锁或者由于在某个条件等待或在 I/0 操作上阻塞时,需要被挂起,这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。〈由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。〉

减少锁的竞争

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

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

降低锁的竞争程序:

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

缩小锁的范围(“快进快出”)

降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如 I/O 操作。

减小锁的粒度

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


@ThreadSafe
public class ServerStatus {
  // 两种类型的信息是完全独立的
  @GuardedBy("users")
  public final Set<String> users;
  @GuardedBy("queries")
  public final Set<String> queries;
  
  public synchronized void addUser(String u) {
    users.add(u);
  }
  
  // 锁分解
  public void addUser(String u) {
    synchronized (users) {
      users.add(u);
    }
  }
  
  public void addQuery(String q) {
    synchronized (queries) {
      queries.add(q);
    }
  }
}

锁分段

例如,在 ConcurrentHashMap 的实现中使用了一个包含 16 个锁的数组,每个锁保护所有散列桶的 1/16 ,其中第 N个散列桶由第( N mod 16 )个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来1/16.

锁分段的一个劣势在于: 与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些精况下需要加锁整个容器,例如当 ConcurrentHashMap 需要扩展映射范围,以及重新计算键值的散到值要分布到更大的桶集合中时,就需要获取分段锁集合中所有的锁。

避免热点域

当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引人一些“热点域(Hot Field)”,而这些热点域往往会限制可伸缩’性。

为了避免枚举每个元素, ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。

一些替代独占锁的方法

ReadWriteLock:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。

原子变量:原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语( 例如比较并交换[compare-and-swap]) 原子变量能降低热点域的更新开销,但并不能完全消除。

监测 CPU 的利用率

如果CPU没有得到充分利用,通常由于:

  • 负载不充足 测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化
  • I/O密集
  • 外部限制 如果依赖外部服务
  • 锁竞争 线程转储存在相应的栈帧,包含信息如“waiting to lock monitor”

向对象池说不

JVM 的早期版本中,对象分配和垃圾回收等操作的执行速度非常慢。但在后续的版本中,这段操作的性能得到了极大提高。

通常,对象分配操作的开销比同步的开销更低

示例:比较 Map 的性能

ConcurrentHashMap

ConcurrentSkipListMap

synchronized HashMap

synchronized TreeMap

同步容器的数量并非越多越好。单线程情况下的性能与 ConcurrentHashMap 的性能基本相当,但当负载情况由非竞争性转变成竞争性时一一这里是两个线程,同步容器的性能将变得糟糕。

减少上下文切换的开销

当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换

请求服务的时间不应该过长,主要有以下原因。首先,服务时间将影响服务质量:服务时间越长,就意味着有程序在获得结果时需要等待更长的时间。更重要的是,服务时间越长,也就意味着存在越多的锁竞争。在锁获取操作上发生竟争时将导致更多的上下文切换

通过将 I/0 操作从处理请求的线程中分离出来,井将 I/0 操作移到了另一个用户感知不到开销的线程上。通过把所有记录日志的I/O转移到一个线程,还消除了输出流上的竞争,因此又去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。