多线程的目标是提升整体性能,提升资源利用率;但是,多线程会引入一些额外的资源消耗,比如:线程之间的协调(加锁、触发信号以及内容同步等)、上下文切换开销、线程的创建与销毁、线程的调度等;
关于性能:
应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性等,其中一些指标例如服务时间、等待时间等用于衡量程序的运行速度,一些指标例如吞吐率用于衡量程序的处理能力也就是在一定资源的情况下,能完成多少工作;
关于可伸缩性:
当增加计算资源(例如CPU、内存、存储容量或者IO宽带),程序的吞吐量或者处理能力能响应的增加;
1:线程引入的开销
1.1上下文切换
1.2内存同步
在synchronized和volative提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存使缓存无效、刷新硬件的写缓冲、以及停止执行管道。内存栅栏可能同样会对缓存带来间接的影响,因为它们将抑制一些编译优化操作。在大多数的内存栅栏中,大多数操作都是不能被重排序的。
评估同步操作带来的性能影响时,区分有竞争的同步和非竞争的同步非常重要;无竞争的同步对应用程序整体性能影响微乎其微,有竞争的同步对性能带了来较大的影响;
现在的JVM能通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销(可以参看http://marlonyao.iteye.com/blog/342608);
不要过度担心非竞争同步带来的开销,JVM已经进行了额外的优化以进一步降低或者消除开销,因此优化的重点放在有锁竞争的地方;
1.3阻塞
非竞争的同步完全可以在JVM中进行,但是竞争的同步可能需要操作系统的介入,从而增加开销。在锁上发生竞争时竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(spin-waiting 通过循环不断的尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低取决于上下文切换的开销以及成功获得锁之前需要等待的时间,如果等待时间较短则适合采用自旋方式,如果等待时间较长则适合采用线程挂起的方式。有些JVM将根据历史等待时间的分析数据在两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
当线程无法获取某个锁需要被挂起,这个过程包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其时间片还未用完之前就要被交换出去,而后当获取所请求的锁时又被切换回来(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须要告诉操作系统恢复运行阻塞的线程)
2:减少锁的竞争
可以通过三种方式减少锁的竞争:减少锁的持有时间、降低锁的请求频率、使用带协调机制的独占锁
2.1缩小锁的范围(快进快出)
减少锁的持有时间
同步的代码块尽量小,不要包含大量的计算操作或者阻塞操作;
2.2减小锁的粒度
降低锁的请求频率;
锁分解或者锁分段,采用多个相互独立的锁来保护独立的状态变量,从而改变这些状态变量有单个锁来保护的情况,可以减小锁的粒度,但是发送死锁的风险提高;
锁分解:如果一个锁需要保护多个相互独立的状态变量,可以将锁分解为多个锁,每个锁只保护一个变量,降低锁的请求频率;
锁分段:可以将锁进一步扩展为对一组独立对象上得锁进行分解,被称为所分段;例如ConcurrentHashMap的机制,使用了一个包含16个锁的数组,每个锁保护所有散列桶的16分之一,其中第N个散列桶有第N mod 16锁来保护,可以允许16个并发的写入器;
锁分段一个缺点是,与采用单个锁实现独占访问相比,需要获得多个锁来实现独占访问将更加困难并且开销更高;
2.3避免热点域
在某些应用中,我们会使用一个共享变量缓存常用的计算结果。每次更新操作都需要修改该共享变量以保证其有效性。例如,队列的 size,counter,链表的头节点引用等。在多线程应用中,该共享变量需要用锁保护起来。这种在单线程应用中常用的优化方法会成为多线程应用中的“热点域 (hot field) ”,从而限制可伸缩性。如果一个队列被设计成为在多线程访问时保持高吞吐量,那么可以考虑在每个入队和出队操作时不更新队列 size 。 ConcurrentHashMap 中为了避免这个问题,在每个分片的数组中维护一个独立的计数器,使用分离的锁保护,而不是维护一个全局计数
2.4放弃独占锁
例如并发容器、读-写锁、不可变对象、原子变量等(后面的文章会进行学习)
ReadWriteLock:实现了一种在多个读取操作以及单个写入操作情况下得加锁机制:可以并发读,写操作独占锁
原子变量:提供了一种方式来降低更新“热点域”时的开销,原子变量类提供了在整数或者对象引用上得细粒度的原子操作,并且使用了现代处理器中提供的底层并发原语;
总结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序的性能讨论中,通常更多的将侧重点放到吞吐量和可伸缩性上,而不是服务时间;
Amdahl定律告诉我们,程序的可伸缩性取决于所有代码中必须被串行执行的代码比例。
因为java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间、降低锁的粒度、以及采用非独占锁或者非阻塞锁来代替独占锁;
可以参考下这两篇文章
http://marlonyao.iteye.com/blog/342608
http://www.ibm.com/developerworks/cn/java/j-lo-lock/