概述
编写正确的程序很难,而编写正确的并发程序则难上加难。与串行程序相比,在并发程序中存在更多容易出错的地方。
那么,为什么还要编写并发程序?
线程是Java语言中不可或缺的重要功能,它们能使复杂的异步代码变得更简单,从而极大地简化了复杂系统的开发。此外,要想充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用并发正变得越来越重要。
线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
然而,许多提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。更糟糕的是,虽然某些技术的初衷是提升性能,但事实上却与最初的目标背道而驰,或者又带来了其他新的性能问题。虽然我们希望获得更好的性能——提升性能总会令人满意,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。
软件的思考
软件或多或少的承载着人们这样那样的需求,如何去衡量软件的质量属性应该是软件人员一直都在思考的内容。
McCall质量属性模型将软件的质量属性划分为产品修正、产品运行、产品转移三个部分,其实更简单的划分,可以将其分为开发态质量属性与运行态质量属性。
1. 正确性
正确性是软件质量的基础,但仅能够满足正确的代码,不过是程序世界中的一堆垃圾
克劳士比说过:“质量是一组特性满足要求的程度”,满足“客户要求”、即正确性是所有软件质量的基础。
但是,往往并不是所有的要求都是明确的。没有客户有耐心详细的提出有哪些质量要求,往往只是提出“需要什么样的功能”,至于怎么实现,用什么实现从来是不关心的。所以, 一个仅能满足正确性的软件/代码只不过是计算机世界中的一堆垃圾。
2. 开发态质量属性
开发态质量属性狭义上可以理解为“代码的质量”,如 可读性 ,代码不仅是写给计算机运行的,更多的时候是写给人看的。写一份不需要说明文档的代码,让所有维护的人能够轻松的看懂就是成功。此外如 可扩展性 ,随需求的变更代码的改动情况,这里面设计模式的东西可以派上一些用场,Design For Change的思想也由此而来。再如 可移植性 ,写了一份代码,32位机器上可以跑,到64位机器上就出问题,或者在Linux上可以执行,到Unix上就需要大刀阔斧的改动,这样或多或少都是有些问题的。其它如 可测性 ,这里不写了。
3. 运行态质量属性
运行态质量属性指在程序运行期间的“满足要求”的表现,常见的如:
性能: 12306的购票系统就是典型的反面教材,在需要的时候顶不上,影响性能的如IO、数据库、内存操作使用是否恰当;
可靠性: 程序是否容易出问题,出问题能否及时恢复;
兼容性: 这个对于做平台或中间件层的软硬件要求尤为突出,没有用户愿意为底层的升级买单。不兼容的直接恶果是客户不愿意升级,最终导致版本无法收编,产生巨大的维护成本;
可维护性: 出了问题能否快速定位,快速分析,还是人海战术,全部成为救火队员。
4. 软件性能的可伸缩性
运行态的质量属性除了这些还有很多,如易用性、易升级等。
这里再提到的两点质量属性:一个是 软件性能的可伸缩性 ,其中一层含义可以理解为软件随外部压力增大所表现的性能表现,如100W用户在线时,系统的响应时长是1秒钟,1000W用户在线的时候,系统的响应时长是否是简单的1*10秒钟?
另外一层含义,可以理解成软件性能随硬件的扩充所产生的性能变化情况。
举例而言:在CPU是1G Hz的机器上,系统每秒钟可以处理1000个请求,如果CPU升级到2G Hz的处理速度,是否每秒钟就可以处理2*1000=2000个请求?现实情况下,这个伸缩性一定不是线性关系的,在前一层含义里面,可能还会出现拐点,也就是所谓的“雪崩”。
另外一个值得一提的应该是软件的开放与易集成 ,当前像微信、微博,都在构筑自己的软件平台,生态系统,如何能够打造一个开放的平台,当前也是一个思考和尝试的途径。
上面这些更多的是一些通用的质量属性,质量属性之间可能相互有矛盾的地方,产品实现时更多的是方方面面的平衡。也可以理解为:产品的质量属性决定了软件的架构。
另外,对于不同的产品而言,其关注的质量属性可能是不一样的,如电信产品更关注的可能是可靠性,而互联网产品可能更侧重于体验和快速响应。对于同一产品而言,不同时期关注的质量属性也可能随需求的变更发生或多或少的变化。
Amdahl 定律
1. 问题和资源的关系
在某些问题中,资源越多解决速度越快;而有些问题则相反:
注意:每个程序中必然有串行的部分,而合理的分析出串行和并行的部分对程序的影响极大;串行部分占比和多核执行效率之间是指数级别的关系
收割可以靠并行提高性能,而作物生长则不行。这是一个很简单的自然界的问题,在计算机界也存在,需要对问题进行合理的分解,发现潜在的并行能力。
Amdahl定律:并行计算中的加速比是用并行前的执行速度和并行后的执行速度之比来表示的,它表示了在并行化之后的效率提升情况。
speedup <= 1 / F + (1 - F) /N
F表示被串行化的部分,N表示处理器数量。
如果N无穷大,那么最大的加速比例是1/F。理论上如果50%是串行的,那么最大的加速比只能是2。
如果10%串行。那么最大加速比接近10,如果N=10也就是说有10个处理器资源,那么最高的加速比是5.4,在100个处理器的情况下是9.2。
但是任何程序都存在串行部分,例如从队列中take数据,访问数据库的操作等,这是绝对的。
作用
在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件的比重。
例子
/**
* 对任务队列的串行访问
*/
public class WorkThread extends Thread {
private final BlockingQueue<Runnable> queue;
public WorkThread(BlockingQueue<Runnable> queue){
this.queue = queue;
}
public void run(){
while (true){
try {
Runnable task = queue.take(); //此处为程序的串行部分
task.run();
} catch (InterruptedException e) {
break;
}
}
}
}
在所有并发程序中都包含一些串行部分,如果你认为你的程序中不存在串行部分,那么可以再仔细检查一遍。
在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。下图给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue中取出元素进行处理,处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放入队列,因而其他线程在下一次访问时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
虽然每次运行都表现相同的”工作量“,但我们可以看到,只需改变队列的实现方式,就能对可伸缩性产生明显的影响。
Amdahl 定律的应用
如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化当有更多计算资源可用时的加速比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下Amdahl定律仍然是有用的。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,降低锁粒度的两种技术:锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。当通过Amdahl定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)
1. 对性能的思考
提升性能意味着用更少的资源做更多的事情。“资源”的含义很广。
对于一个给定的操作,通常会缺乏某种特定的资源,例如CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间以及其他资源。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型、数据库密集型等。
尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引人一些额外的性能开销。
造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。
如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面,一个并发设计很糟糕的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。
要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。
从性能监视的视角来看,CPU需要尽可能保持忙碌状态。(当然,这并不意味着将CPU时钟周期浪费在一些无用的计算上,而是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。因为如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有CPU都保持忙碌态。
1.1 性能与可伸缩性
应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况下,能完成“多少”工作。
可伸缩性指的是:当增加计算机资源时(例如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应地增加。
在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用时间复杂度为O(n2)算法来代替复杂度为O(nlogn)的算法。在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作。
性能的这两个方面 “多快”和“多少”,是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个“流水线”子任务时。具讽刺意味的是,大多数提高单线程程序性能的技术,往往都会破坏可伸缩性。
我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层和持久化层是彼此独立的,并且可能由不同的系统来处理,这很好地说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工作单元时,其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少许多开销(例如在任务排队、线程协调以及数据复制时存在的开销)。
然而,当这种单一的系统到达自身处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。因此,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
对于服务器应用程序来说,“多少”这个方面一一可伸缩性、吞吐量和生产量,往往比“多快”这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条的指定,并奇怪程序究竟在执行哪些操作。)
1.2 评估各种性能权衡因素:
-
避免不成熟地优化,首先使程序正确,然后再提高运行速度–如果它还运行得不够快。
-
以测试为基准,不要猜测。
2.线程引入的开销
单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。
在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引人的线程来说,并行带来的性能提升必须超过并发导致的开销。
2.1 上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减小上下文切换。参见之前的文章非阻塞同步)
上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于5000、10000个时钟周期,也就是几微秒。
UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。
2.2 内存同步
同步操作的性能开销包括多个方面。
在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏〔Memo Barrier)o内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。
synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),而在编写本书时,一个“快速通道(Fast-Path) ”的非竞争同步将消耗20、250个时钟周期。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且还会使你(或者后续开发人员)经历非常痛苦的除错过程。
jvm 会自动清空无用锁操作
现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。
/**
* 对于无用的同步 JVM 会自动优化掉
* @author houbinbin
* @version 1.0
* @since 1.7
*/
public class UselessSyncDemo extends Thread {
/**
* 不要这么使用
*/
@Deprecated
public void errorDemo() {
synchronized (new Object()) {
}
}
}
逸出分析(EscapeAnalysis)
一些更完备的JVM能通过逸出分析(EscapeAnalysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。
在代码getStoogeNames方法中,对List的唯一引用就是局部变量stooges,并且所有封闭在栈中的变量都会自动成为线程本地变量。在 getstoogeNames的执行过程中,至少会将vector上的锁获取/释放4次,每次调用add或 toString时都会执行1次。然而,一个智能的运行时编译器通常会分析这些调用,从而使 stooges及其内部状态不会逸出,因此可以去掉这4次对锁获取操作。
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("");
stooges.add("");
stooges.add("");
return stooges.toString();
}
即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来。
在 getStoogeNames 中,如杲JVM进行锁粒度粗化,那么可能会把3个add与1个toString调用合并为单个锁获取/释放操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而可能实现进一步的优化。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
2.3 阻塞
非竞争的同步可以完全在JVM中进行处理(Bacon等,1998),而竞争的同步可能需要操作系统的介人,从而增加开销。
当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-waiting,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)
3.减少锁的竞争
我们已经看到,串行操作会降低可伸缩性,并且上下文切换也会降低性能。
在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式一一每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的锁资源。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有3种方式可以降低锁的竞争程度:
-
减少锁的持有时间。
-
降低锁的请求频率。
-
使用带有协调机制的独占锁,这些机制允许更高的并发性。
3.1 缩小锁的范围(“快进快出”)
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。
例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如l/O操作。
我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性。如果某个操作持有锁的时间超过2亳秒并且所有操作都需要这个锁,那么无论拥有多少个空闲处理器,吞吐量也不会超过每秒500个操作。如果将这个锁的持有时间降为1毫秒,那么能够将这个锁对应的吞吐量提高到每秒 1000 个操作。
下面给出了一个示例,其中锁被持有过长的时间。userLocationMatches方法在一个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。
整个userLocationMatches方法都使用了synchronized来修饰,但只有Map.get这个方法才真正需要锁。
import com.github.houbb.thread.learn.jcip.annotation.GuardeBy;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* @author houbinbin
* @version 1.0
* @since 1.7
*/
public class AttributStore {
@GuardeBy("this")
private final Map<String, String> attributes = new HashMap<>();
/**
* 这种方法的同步占范围太大
* @see #usersLocaltionMatchLessScope(String, String) 更小的范围
* @param name
* @param regex
* @return
*/
public synchronized boolean usersLocaltionMatch(final String name, final String regex) {
String key = String.format("users.%s.location", name);
String attr = attributes.get(key);
if(attr == null) {
return false;
} else {
return Pattern.matches(regex, attr);
}
}
}
在下面的 usersLocaltionMatchLessScope() 中重新编写了AttributeStore,从而大大减少了锁的持有时间。
第一个步骤是构建Map中与用户位置相关联的键值,这是一个字符串,形式为users.name.location。
这个步骤包括实例化一个StringBuilder对象,向其添加几个字符串,并将结果实例化为一个string类型对象。在获得了位置后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值字符串以及处理正则表达式等过程中都不需要访问共享状态,因此在执行时不需要持有锁。
通过在 usersLocaltionMatchLessScope() 中将这些步骤提取出来并放到同步代码块之外,从而减少了锁被持有的时间。
public boolean usersLocaltionMatchLessScope(final String name, final String regex) {
String key = String.format("users.%s.location", name);
String attr;
synchronized (this) {
attr = attributes.get(key);
}
if(attr == null) {
return false;
} else {
return Pattern.matches(regex, attr);
}
}
通过缩小userLocationMatches方法中锁的作用范围,能极大地减少在持有锁时需要执行的指令数量。
根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
由于在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。
通过用线程安全的Map(Hashtable、 synchronizedMap或ConcurrentHashMap)来代替attributes,AttributeStore可以将确保线程安全性的任务委托给顶层的线程安全容器来实现。这样就无须在AttributeStore中采用显式的同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(例如在访问attributes之前忘记获得相应的锁)。
尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小。
一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。
此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。
在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。
3.2 减小锁的粒度
另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。
这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
设想一下,如果在整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
在程序ServerStatus中给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时,都会调用相应的add和remove等方法来更新ServerStatus对象。这两种类型的信息是完全独立的,ServerStatus甚至可以被分解为两个类,同时确保不会丢失功能。
/**
* 锁的粒度不够细致
* @author houbinbin
*/
@ThreadSafe
public class ServerStatus {
private final Set<String> users;
private final Set<String> queries;
public ServerStatus(Set<String> users, Set<String> queries) {
this.users = users;
this.queries = queries;
}
public synchronized void addUser(String user) {
users.add(user);
}
public synchronized void addQuery(String query) {
queries.add(query);
}
}
在代码中不是用ServerStatus锁来保护用户状态和查询状态,而是每个状态都通过一个锁来保护,如下方程序所示。
在对锁进行分解后,每个新的细粒度锁上的访问量将比最初的访问量少。
(通过将用户状态和查询状态委托给一个线程安全的Set,而不是使用显式的同步,能隐含地对锁进行分解,因为每个Set都会使用一个不同的锁来保护其状态。)
/**
* @author houbinbin
*/
@ThreadSafe
public class ServerStatusGrading {
private final Set<String> users;
private final Set<String> queries;
public ServerStatusGrading(Set<String> users, Set<String> queries) {
this.users = users;
this.queries = queries;
}
public void addUser(String user) {
synchronized (users) {
users.add(user);
}
}
public void addQuery(String query) {
synchronized (queries) {
queries.add(query);
}
}
}
如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。
如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。
对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。
3.3 锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写人线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。)
锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。
下面的StripedMap中给出了基于散列的Map实现,其中使用了锁分段技术。
它拥有N-LOCKS个锁,并且每个锁保护散列桶的一个子集。
大多数方法,例如get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如clear方法的实现。
/**
* 通过锁分段技术来保证性能。
*
* @author houbinbin
*/
@ThreadSafe
public class StripedMap {
private static final int LOCK_NUM = 16;
private final Node[] buckets;
private final Object[] locks;
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[LOCK_NUM];
for (int i = 0; i < LOCK_NUM; i++) {
Object lock = new Object();
locks[i] = lock;
}
}
public Object get(Object key) {
int hash = hash(key);
//1. 对锁进行分段
synchronized (locks[hash % LOCK_NUM]) {
for (Node node = buckets[hash]; node != null; node = node.next) {
if (node.getKey().equals(key)) {
return node;
}
}
}
return null;
}
/**
* 清空
*/
public void clear() {
for(int i = 0; i < buckets.length; i++) {
synchronized (locks[i % LOCK_NUM]) {
buckets[i] = null;
}
}
}
private static class Node {
private String key;
private Node next;
public String getKey() {
return key;
}
public Node next() {
return next;
}
}
/**
* 保证 hash 的大小不大于整个元素的个数
*
* @param object
* @return
*/
private final int hash(final Object object) {
return Math.abs(object.hashCode()) % buckets.length;
}
}
3.4 避免热点域
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。
如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量X和Y,并且线程A想要访问X,而线程B想要访问Y(这类似于在ServerStatus中,一个线程调用addUser,而另一个线程调用addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。
当每个操作都请求多个变量时,锁的粒度将很难降低。
这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引人一些 “热点域(Hot Field) ”,而这些热点域往往会限制可伸缩性。
当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插人和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是最新的值,但这将把size方法的开销从O(n)降低到O(1)。
在单线程或者采用完全同步的实现中,使用一个独立的计数能很好地提高类似size和 isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
3.5 一些替换独占锁的方法
第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。
例如,使用并发容器、读·写锁、不可变对象以及原子变量。
ReadWriteLock实现了一种在多个读取操作以及单个写人操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写人操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构, ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
原子变量提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。
(AtomicLong )原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换[compare-and-swap])。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)
3.6 监测CPU的利用率:
linux下可用vmstat或mpstat, windows下可用perfmon查看cpu状况。
3.6.1 cpu 利用不充分的原因:
-
负载不充足。
-
I/O密集。*nix可用iostat, windows用perfmon。
-
外部限制。如数据库服务,web服务等。通过Profiler工具来判断等待外部服务结果的用时。
-
锁竞争。可通过jstack等查看栈信息。使用Profiling,查看程序中存在多少个锁的竞争。
vmstat,kill -3 pid
”waiting to lock monitor…“有这句就证明竞争太激烈了。
3.6.2 用后台程序处理任务
比如log,单启线程来进行打印操作,使用线程池和队列的方式。需要考虑如下的问题:
-
服务担保, 保证成功加入队列的消息都能在服务终止前被记录?
-
饥饿策略,如果生产者比消费者快怎么办?
-
生命周期, 如果在服务器停止的时候关闭消费者
-
中断,如果线程被中断(如服务器重启)会怎么办
3.7 向对象池说”不”
通常,对象分配操作的开销比同步的开销更低。
3.8 示例:比较Map的性能
比较了ConcurrentHashMap和synchronized hashmap的性能对比。
串行访问Map一个锁 pk 多个线程能并发的访问Map通过分段锁。
竞争非常激烈的时候,synchronized hashmap伸缩性非常差,吞吐量不会随着线程数增加而增加,反而降低,因为每个操作消耗的时间大部分都用于上下文切换和调度延迟上了。
4. 减少上下文切换的开销
在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。为了说明如何通过减少上下文切换的次数来提高吞吐量,我们将对两种日志方法的调度行为进行分析。
在大多数日志框架中都是简单地对println进行包装,当需要记录某个消息时,只需将其写人日志文件中。有另一种方法:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程完成。从开发人员的角度来看,这两种方法基本上是相同的。但二者在性能上可能存在一些差异,这取决于日志操作的工作量,即有多少线程正在记录日志,以及其他一些因素,例如上下文切换的开销等。
日志操作的服务时间包括与I/O流类相关的计算时间,如果I/O操作被阻塞,那么还会包括线程被阻塞的时间。操作系统将这个被阻塞的线程从调度队列中移走并直到I/O操作结束,这将比实际阻塞的时间更长。当I/O操作结束时,可能有其他线程正在执行它们的调度时间片,并且在调度队列中有些线程位于被阻塞线程之前,从而进一步增加服务时间。如果有多个线程在同时记录日志,那么还可能在输出流的锁上发生竞争,这种情况的结果与阻塞I/O的情况一样——线程被阻塞并等待锁,然后被线程调度器交换出去。在这种日志操作中包含了I/O操作和加锁操作,从而导致上下文切换次数的增多,以及服务时间的增加。
请求服务的时间不应该过长,主要有以下原因。首先,服务时间将影响服务质量:服务时间越长,就意味着有程序在获得结果时需要等待更长的时间。但更重要的是,服务时间越长,也就意味着存在越多的锁竞争。上文3.1节中的“快进快出”原则告诉我们,锁被持有的时间应该尽可能地短,因为锁的持有时间越长,那么在这个锁上发生竞争的可能性就越大。如果一个线程由于等待I/O操作完成而被阻塞,同时它还持有一个锁,那么在这期间很可能会有另一个线程想要获得这个锁。如果在大多数的锁获取操作上不存在竞争,那么并发系统就能执行得更好,因为在锁获取操作上发生竞争时将导致更多的上下文切换。在代码中造成的上下文切换次数越多,吞吐量就越低。
通过将I/O操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。调用 log方法的线程将不会再因为等待输出流的锁或者I/O完成而被阻塞,它们只需将消息放人队列,然后就返回到各自的任务中。另一方面,虽然在消息队列上可能会发生竞争,但put操作相对于记录日志的I/O操作(可能需要执行系统调用)是一种更为轻量级的操作,因此在实际使用中发生阻塞的概更小(只要队列没有填满)。由于发出日志请求的线程现在被阻塞的概率降低,因此该线程在处理请求时被交换出去的概率也会降低。我们所做的工作就是把一条包含I/O操作和锁竞争的复杂且不确定的代码路径变成一条简单的代码路径。
从某种意义上讲,我们只是将工作分散开来,并将I/O操作移到了另一个用户感知不到开销的线程上(这本身已经获得了成功)。通过把所有记录日志的I/O转移到一个线程,还消除了输出流上的竞争,因此又去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。
通过把I/O操作从处理请求的线程转移到一个专门的线程,类似于两种不同救火方案之间的差异:第一种方案是所有人排成一队,通过传递水桶来救火;第二种方案是每个人都拿着一个水桶去救火。在第二种方案中,每个人都可能在水源和着火点上存在更大的竞争(结果导致了只能将更少的水传递到着火点),此外救火的效率也更低,因为每个人都在不停的切换模式(装水、跑步、倒水、跑步… …)。在第一种解决方案中,水不断地从水源传递到燃烧的建筑物,人们付出更少的体力却传递了更多的水,并且每个人从头至尾只需做一项工作。正如中断会干扰人们的工作并降低效率,阻塞和上下文切换同样会于扰线程的正常执行。
5. 总结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。
了解了性能的提升的几个方面,也了解性能的开销后,应用程序就要根据实际的场景进行取舍和评估。没有一劳永逸的优化方案,不断的进行小范围改进和调整是提高性能的有效手段。当前一些大的架构调整也会导致较大的性能的提升。
性能提升考虑的方面:
- 系统平台的资源利用率
一个程序对系统平台的资源利用率是指某一个设备繁忙且服务于此程序的时间占所有时间的比率。从物理学的角度讲类似于有用功的比率。简单的说就是:资源利用率=有效繁忙时间/总耗费时间。
也就说尽可能的让设备做有用的功,同时榨取其最大值。无用的循环可能会导致CPU 100%的使用率,但不一定是有效的工作。有效性通常难以衡量,通常只能以主观来评估,或者通过被优化的程序的行为来判断是否提高了有效性。
- 延迟
延迟描述的是完成任务所耗费的时间。延迟有时候也成为响应时间。如果有多个并行的操作,那么延迟取决于耗费时间最大的任务。
- 多处理
多处理是指在单一系统上同时执行多个进程或者多个程序的能力。多处理能力的好处是可以提高吞吐量。多处理可以有效利用多核CPU的资源。
- 多线程
多线程描述的是同一个地址空间内同时执行多个线程的过程。这些线程都有不同的执行路径和不同的栈结构。我们说的并发性更多的是指针对线程。
- 并发性
同时执行多个程序或者任务称之为并发。单程序内的多任务处理或者多程序间的多任务处理都认为是并发。
- 吞吐量
吞吐量衡量系统在单位之间内可以完成的工作总量。对于硬件系统而言,吞吐量是物理介质的上限。在没有达到物理介质之前,提高系统的吞吐量也可以大幅度改进性能。同时吞吐量也是衡量性能的一个指标。
- 瓶颈
程序运行过程中性能最差的地方。通常而言,串行的IO、磁盘IO、内存单元分配、网络IO等都可能造成瓶颈。某些使用太频繁的算法也有可能成为瓶颈。
- 可扩展性
这里的可扩展性主要是指程序或系统通过增加可使用的资源而增加性能的能力。
个人总结
平衡
性能和可伸缩性有时候是一个平衡的问题。
线程的切换+同步会影响性能。
拓展阅读
参考资料
《Java并发编程实战》
Java并发编程实战系列11之性能与可伸缩性Performance and Scalability
https://www.cnblogs.com/peterxiao/p/7707501.html
http://www.cnblogs.com/zhangxinly/p/6938045.html
http://www.voidcn.com/article/p-ssgfaqvc-bop.html
https://www.doc88.com/p-7592063805885.html
https://yq.aliyun.com/articles/31842
https://blog.csdn.net/coslay/article/details/48225539