您的位置:首页 > 编程语言 > Java开发

Java并发编程实战(学习笔记十 第十一章 性能与可伸缩性)

2017-08-13 13:56 344 查看
线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。线程还可以在使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。

本章将介绍各种分析,监测以及提升并发程序性能的技术。

11.1 对性能的思考

提升性能意味着用更少的资源做更多的事。

对于一个给定的操作,通常会缺乏某种特定的资源,例如CPU时钟周期,内存,网络带宽,I/O带宽,数据库请求,磁盘空间以及其他资源。

当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU密集型数据库密集型

使用多个线程会移入一些额外的开销

造成这些开销的操作包括:线程之间的协调(例如加锁,触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。

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

11.1.1 性能与可伸缩性(Performance Versus[对抗] Scalability)

应用程序的性能可以用多个指标来衡量,例如服务时间,延迟时间,吞吐率,效率,可伸缩性以及容量等。其中一些指标(服务时间,等待时间)用于衡量程序的“运行速度(多快)”,另一些指标(生产量,吞吐量)衡量程序的“处理 能力(多少)”。

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

在进行性能调优时,其目的是用更小的代价完成先相同的工作,例如通过缓存来重用之前的计算的结果,或者采用复杂度更低的算法

在进行可伸缩性调优时,其目的时是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作

然而,大多数提高单线程程序性能的技术,往往都会破坏可伸缩性(11.4.4)

11.1.2 评估各种性能权衡因素

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

当进行决策时,有时候会通过某种形式的成本来降低另一种形式的开销(例如,增加内存使用量来降低服务时间),也会通过增加开销来换取安全性。

11.2 Amdahl 定律(Amdahl’s Law)

有些问题中,如果可用资源越多问题的解决速度就越快,例如,如果参与收割庄稼的工人越多工作越快完成。

有些任务本质上时串行的,例如,即使工人再多也无法加快庄稼的生长速度。

多数并发程序是由一系列的并发工作和串行工作组成的。

Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决与程序中可并行组件与串行组件所占的比重。假定F时必须被串行执行的部分,那么根据Amdahl定律。在包含N个处理器的机器中,最高的加速比为:



当N趋近无穷大时,最大的加速趋近与1/F。

Amdahl定律还量化了串行化的效率开销:在拥有10个处理器中系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3(53%的使用率),在拥用100个处理器的系统中,加速比可以达到9.2(9%的使用率),即使拥有无限多的CPU,加速比也不可能为10.

下图给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义:加速比除以处理器的数量)。



随着处理器数量的增加,可以很明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源是能提升的吞吐率。

假设应用程序中N个线程正在执行11-1中的doWork,这些线程从一个共享的工作队列中取出任务进行处理,而且这里的任务都不依赖于其他任务的执行结果或影响。

初看上去,这个程序似乎能完成并行化:各任务之间不会互相等待,因此处理器越多,能够并发处理的任务也越多。

然而,这个过程中包含了一个串行部分——从队列中获取任务

所有工作者线程都共享一个工作队列,因此在对该线程进行并发访问时需要采用某种同步机制来维持队列的完整性。

如果通过加锁来保护队列的状态,那么当一个线程从队列中取出任务时没其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。

//     11-1  对任务队列的串行访问
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; /*允许线程退出 */
}
}
}
}


单个任务的处理不仅包括执行任务Runnable的事件,也包括从共享队列中取出任务的时间。

如果使用LinkedBlockingQueue的工作队列,那么出列操作被阻塞的可能性小于使用同步的LinkedList时发生阻塞的可能性,因为LinkedBlockingQueue使用了一种可伸缩性更高的算法。

然而,无论访问何种共享数据结构,基本上都会在程序中引入一个串行部分。

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

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

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

下图给出了一个简单的应用程序,其中多个线程反复地从一个共享的Queue中取出元素进行处理,这与11-1很相似。

在访问共享队列的过程中显然存在这一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。



图11-2的两个曲线对两个线程安全的Queue的吞吐量进行了比较,其中一个采用synchronizedList包装的LinkedList,另一个时ConcurrentLinkedQueue。

可以看到,只需改变队列的实现方式,就能对可伸缩性产生明显的影响。

吞吐量的差异来源与两个队列中不同比例的串行部分

同步的LinkedList采用单个锁来保护整个队列的状态,并在offer和remove等方法的调用期间都将持有这个锁。

ConcurrentLinkedQueue采用了一种更复制的非阻塞队列算法(15.4.2),该算法使用原子引用来更新各个链接指针。

在第一个队列中,整个的插入或删除操作都将串行执行,而在第二个队列中,只有对指针的更新操作需要串行执行。

11.3 线程引入的开销(Costs Introduced by Threads)

单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。

在多个线程的调度和协调过程中都需要一定的性能开销:为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

11.3.1 上下文切换(Context Switching)

上下文切换(Context Switch),也称为PCB,性质为环境切换。

上下文切换,有时也称做进程切换或任务切换,是指CPU 从一个进程或线程切换到另一个进程或线程。

如果主线程是唯一的线程,那么它基本不会被调度出去。另一方面,如果可运行的线程大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。

这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文(execution context),并将新调度进来的线程的执行上下文设置为当前上下文。

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

应用程序,操作系统以及JVM都使用一组相同的CPU。

在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。

但上下文切换的开销并不只是包含JVM和操作系统本身的开销。当一个新的线程被切换进来时,它所需要的数据可能不再当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。

这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有很多其他线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完成的调度时间片。

在程序中发生越多的阻塞,与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此降低吞吐量。

11.3.2 内存同步(Memory Synchronization)

同步操作的性能开销包括多个方面,在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。

内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。

内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。

JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁发生同步。

例如,JVM通常会去掉11-2中的锁获取操作

//   11-2  没有作用的同步(不要这样做)
synchronized (new Object()) {
// do something
}


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

Vector是线程安全的。

在11-3中,对List的唯一引用(reference)就是局部变量stooges,并且所有封闭在栈中的变量都会自动称为线程本地变量。在getStoogeNames的执行过程中,至少会将Vector上的锁获取/释放4次,每次调用add或toString时都会执行一次。

JVM通常会分析这些调用,从而使stooges及其内部状态不会逸出,因此可以去掉这4次锁获取操作。

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


即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将临近的同步代码快用同一个锁合并起来

getStoogeNames中,如果JVM进行锁粒度粗化,那么可能会把3个add和一个toString合并为单个锁获取/释放操作,并采用启发方式来评估同步代码块中采用同步操作以及指令之间的相对开销。

这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而可能实现进一步的优化。

某个线程中的同步可能会影响其他线程的性能。

同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理都共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受影响。

11.3.3 阻塞

非竞争的同步可以完全在JVM中进行处理,而竞争的同步可能需要操作系统的介入,从而增加开销。

当在锁上发生竞争时,竞争失败的线程肯定会阻塞。

JVM出现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功),或者通过操作系统挂起被阻塞的线程。

当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。

11.4 减少锁的竞争

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

在对某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。

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

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

减少锁的持有时间

降低锁的请求频率

使用带有协调机制的独占锁,这些机制运行更高的并发性

11.4.1 缩小锁的范围(Narrowing Lock Scope)(“快进快出”)

降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间

例如,可以将一些无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作

如果将一个“高度竞争”的锁持有过长的时间,会限制可伸缩性。

11-4中,其中锁被持有过长的时间。userLocationMatches方法在一个Map对象中查找用户的位置,并使用正在表达式进行匹配以判断结果值是否匹配所提供的模式。

整个userLocationMatches方法都是用synchronized来修饰,但只有Map.get这个方法才真正需要锁

//        将一个锁不必要地持有过长时间
@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);//将正则表达式与位置字符串进行匹配
}
}


11-5重写了AttributeStore,从而大大减少了锁的持有时间。

第一个步骤时构建Map中与用户位置相关联的键值,这是一个字符串,形式为users.name.location。这个步骤包括实例化一个StringBuilder对象,向其添加几个字符串,并将结果实例化为一个String类型对象

在获得位置后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值以及处理正则表达式等过程中都不需要访问共享状态,因此在执行时不需要持有锁。

通过在BetterAttributeStore中将这些步骤提取出来放到同步代码块之外,从而减少了锁被持有的时间。

//    11-5   减少锁的持有时间
@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方法中锁的作用范围,能极大地减少在持有锁时需要执行的指令数量。

因此在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委托给线程安全的类进一步提升它的性能。(4.3节)

通过用线程安全的Map(Hashtable,synchronizedMap或ConcurrentHashMap)来代替attributes,AttributeStore可以将确保线程安全性的任务委托给底层的线程安全类来事项,这样就无需在AttributeStore中采用显式的同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险。

尽管缩小同步代码块能提升可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。

此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时,反而会对性能提升产生负面影响。

11.4.2 减少锁的粒度(Reducing Lock Granularity)

另一中减少锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。

这可以通过锁分解(lock splitting)锁分段(lock striping)等技术来实现,在这些技术中将采用多个互相独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,锁越多,发生死锁的风险就越高。

由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程更少,因此可伸缩性将提高。

如果一个锁需要保护多个互相独立的状态变量,那么可以将这个分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

11-6给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。

当一个用户登录,注销,开始查询和结束查询时,都会调用相依的add和remove等方法来更新ServerStates对象。

这两种类型的信息是完全独立的,ServerStates甚至还可以被分为两个类,同时确保不会丢失功能。

//     11-6   对锁进行分解
@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);
}
}


11-7中,不是用ServerStates锁来保护用户状态和查询状态,而是每个状态都通过一个锁来保护。

在对锁进行分解后,每个新的细粒度锁上的访问量将比最初的访问量少(通过将用户状态和查询状态委托给一个线程安全的Set,而不是使用显式的同步,能隐含地对锁进行分解,因为每个Set都会使用一个不同的锁来保护其状态

//        11-7  将ServerStates重新改写为使用锁分解技术
@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 (queries) {
queries.remove(q);
}
}
}


对竞争适中的锁分解时,实际上时把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

11.4.3 锁分段(Lock Striping)

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

例如,在ConcurrentHashMap的实现中使用额一个包含16个锁的数组,每个锁保护所有散列桶(hash buckets)的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。

假设散列函数具有合理的分布性,并且关键字能实现均匀分布,那么这大约能把对锁的请求减少到原来的1/16。

这是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个)

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

通过,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值分布到更大ed桶集合中时,就需要获取分段所集合中所有的锁。(唯一方式是递归)

11-8的StripedMap给出了基于散列的Map实现,其中使用了锁分段技术。

它拥有N_LOCKS个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如clear方法的实现(这种清除Map的方式并不是原子操作,要使该操作变成一个原子操作,必须同时获得所有的锁)。

//     11-8     在基于散列的Map中使用锁分段技术
@ThreadSafe
public class StripedMap {
// 同步策略: buckets
由 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]) {        /// 同步策略: buckets
由 locks[n%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
由 locks[n%N_LOCKS]来保护
buckets[i] = null;
}
}
}
}


11.4.4 避免热点域(Avoiding Hot Fields)

锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会互相干扰。

如果程序采用锁分段技术,那么一定要表现在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。

如果一个锁保护两个独立变量X和Y,并且线程A想要访问X而线程B想要访问Y(类似在ServerStates中,一个线程调用addUser,另一个线程调用addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们在同一个锁上发生竞争。

当每个操作都请求多个变量时,锁的粒度很难降低

这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。

当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量,最简单的方法就是,在每次调用时都统计一次元素的数量。

一种常见的优化措施时,在插入和移除元素时更新一个计数器,虽然增加了一些开销,但把size方法的开销从O(n)降低到O(1)

在单线程或采用完全同步的方法中,使用一个独立的计数器能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。

一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题,在这种情况下,计数器也被称为热点域,因为每个导致元素发生变化的操作都需要访问它

为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。

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

11.4.5 一些替代独占锁的方法(Alternatives to Exclusive Locks)

第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态

例如,使用并发容器,读-写锁,不可变对象以及原子变量。

ReadWriteLock(读-写锁,13章)实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:

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

对于读取操作占多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。

原子变量(Atomic variables,15章)提供了一种方式来降低更新“热点域”时的开销,例如静态计数器,序列计数器,或者对链表数据结构中头节点的引用(第2章中用AtomicLong来维护Servlet的计数器)。

原子变量类提供了在整数或对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现在处理器中提供的底层并发原语。

通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能减低热点域的更新开销,但并不能完全消除。

11.4.6 监测CPU的利用率

当测试可伸缩性时,通常要确保处理器得到充分利用。

如果CPU没有得到充分利用,通常有以下几种原因:

负载不充足

I/O密集

外部限制

锁竞争

11.4.7 向对象池说“不”

在单线程程序中,尽管对象池即使能降低垃圾收集操作的开销,但对于高开销对象以外的其他对象来说,仍存在性能缺失。

在并发应用程序中,对象池的表现更糟糕。

当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。

然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对象池数据结构的访问,从而可能使某个线程阻塞。

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

11.5 示例:比较Map的性能

在单线程环境下,ConcurrentHashMap的性能比同步的HashMap的性能略好一些,但在并发环境中则好得多。

在同步Map的实现中,可伸缩性的最主要阻碍在于整个Map中只有一个锁,因此每次只有一个线程可以访问这个Map。

而ConcurrentHashMap对于大多数读操作并不会加锁,并且在写入操作以及其他需要锁的读的操作中使用了锁分段技术。因此,多个线程能并发访问这个Map而不会发生阻塞。

下图给出了几种Map实现可伸缩性上的差异:ConcurrentHashMap,ConcurrentSkipListMap以及通过synchronizedMap来包装的HashMap和TreeMap。

前两种Map是线程安全的,而后两个Map则通过同步封装器来确保线程安全性



ConcurrentHashMap,ConcurrentSkipListMap的数据显示,它们在线程数量增加时能表现出很好的可伸缩性,并且吞吐量会随着线程数量的增加而增加。

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

11.6 减少上下文切换的开销

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

在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。

日志操作的服务时间包括与I/O流类相关的计算时间,如果I/O操作被阻塞,那么还会包括线程被阻塞的时间。

请求服务的时间不应该过长。

服务时间将影响服务质量:服务时间越长,就意味这有程序在获得结果时需要等待更长的时间,更重要的是,服务时间越长,意味着存在越多的锁竞争。

如果在大多数的锁获取操作上不存在竞争,那么并发系统能执行得更好,因为在锁获取操作发生竞争时将导致更多的上下文切换,上下文切换次数越多,吞吐量越低。

通过将I/O操作从处理请求的线程分离出来,可以缩短处理请求的平均服务时间。

小结:

在Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,减低锁的粒度,以及采用非独占的锁或非阻塞锁来替代独占锁。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java 性能 并发 线程