Java并发之性能和可伸缩性

一.概述

线程的最主要的目的就是提高程序的运行性能。 本章将学习各种分析,监测以及提升并发程序性能的技术。但是我们在提升性能的同时要考虑到:程序的安全性才是第一位的。 我们要首先保证我们的程序在正确的前提下提升性能。 所以我们需要一些专业的知识去分析如何在不破坏程序的正确性的前提下提升性能。 这也是我们学习本章的目的。

二.具体学习

1.对性能的思考
提升性能意味着用更少的“资源”做更多的事情。 这里 “资源” 的含义很广,对于一个给定的操作,通常会缺乏某种特定的资源,例如:CPU时钟周期,内存,网络带宽,I/O带宽,数据库请求,磁盘空间以及其他资源。

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

2)尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。 造成这些性能开销的操作有:线程之间的协调(加锁机制,内存同步机制等),增加的上下文切换,线程的创建和销毁,线程的调度等。
在有些时候如果过度的使用多线程,那么多线程的开销甚至会超过由于提高吞吐量,响应性或者计算能力带来的性能提升。(这时就得不偿失) 由此可见:一个并发设计糟糕的应用程序的性能甚至与实现相同功能的串行程序的性能还要差。

3)要想通过并发来获得更好的性能,我们需要努力做好:更有效的利用现有处理资源和在出现新的处理资源时使程序尽可能地利用这些新资源。

2.性能和可伸缩性
1)性能
应用程序的性能可以采用多个指标来衡量,例如服务时间,延迟时间,吞吐率,效率等。简单来说就是 处理速度处理能力。

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

3)性能为主和伸缩性为主的差别
在并发应用程序种针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。

当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用时间复杂度为O(nlogn)的算法来代替复杂度为O(n^2)的算法。

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

4)我们熟悉的三层程序模型(表现层,业务逻辑层,持久化层)是彼此独立的,并且可能由不同的系统来处理,它很好的说明了提高可伸缩性通常会造成性能损失的原因。 如果把表现层,业务逻辑层和持久化层都融合到单个应用程序中,那么在处理第一个工作单元时,其性能肯定要高于将应用程序分为多层并将不同层次分布到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此可以减少许多开销。
然而,但是,当这种单一的系统到达自身处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。 所以我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。

对于服务器应用程序来说,“多少” 这个方面------可伸缩性,吞吐量和生产量,往往比 “多快” 这个方面更受重视。

3.评估各种性能权衡因素
所有的工程决策中都会涉及某些形式的权衡,在建设桥梁时,使用更粗的钢筋可以提高桥的负载能力和安全性,但同时也会提高建造成本。 类似,在软件工程中也会做相应的权衡,例如 快速排序 算法 在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,冒泡排序貌似更加高效。 如果要实现一个高效的排序算法,那么就需要知道我们要处理的数据集的大小,还有衡量优化的指标,包括:平均计算时间,最差时间,可预知性。 然而,编写某个库中排序算法的开发人员通常无法获得这些需求信息。 这就是为什么大多数优化措施都不成熟的原因之一:他们无法获得一组明确的需求。

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

很多性能优化措施通常都是以牺牲或者可维护性为代价 (代码越聪明,就越难以理解和维护)。有时候,优化措施会破坏面向对象的设计原则,例如需要打破封装,有时候它们又会带来更高的错误风险,因为通常越快的算法就越复杂。(如果我们无法找出其中的代价或风险,那么或许还没有对这些优化措施进行彻底的思考和分析。)

在大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案 “更快” 之前,首先问自己一些问题:

“更快” 的含义是什么?
该方法在什么条件下运行得更快? 在低负载还是高负载的情况下? 大数据集还是小数据集? 能否通过测试结果来验证你得答案?
这些条件在运行环境中的发生频率? 能否通过测试结果来验证你的答案?
在其他不同条件的环境中能否使用这里的代码?
在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销? 这种权衡是否合适?

在进行任何与性能相关的决策时,都应该考虑上面的这些问题,我们为什么要推荐这种保守的优化方法?因为对性能的提升可能是并发错误的最大来源。有人认为同步机制 “太慢” ,因而采用一些看似聪明实则危险的方法来减少同步的使用,这也通常作为不遵守同步规则的一个常见借口,然而,由于并发错误是最难追踪和消除的错误,因此对于任何可能会引入这类错误的措施,我们都需要谨慎!!

注意:并发有风险,优化需谨慎。

在对性能的调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优以及什么时候应该停止),此外还需要一个测试程序以及真实的配置和负载等环境。

注意:在对性能优化后,我们需要再此测量以验证是否到达了预期的性能提升目标。 已测试为基准,不要猜测。

4.Amdahl定律
在有些问题里,如果可用资源越多,那么问题的解决速度就越快。 例如参与收割庄稼的工人越多,那么就能越快地完成收割工作。 但是有些任务的本质上是串行的,例如即使增加再多的工人也无法增加作物的生长速度。

所以:如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效地使用这种潜在地并行能力

大多数并发程序都与农业耕作有着许多相似之处,它们都是由一系列地并行工作和串行工作组成的。
Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。

我们假设F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高加速比为:
Speedup<= 1/{F+(1-F)/N}

当N趋近于无穷大时,加速比大,为1/F。 因此程序有50%的计算需要串行执行时,那么最高加速比为2,实际的加速比肯定小于2的。

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;}}}
}

我们看上面代码,初看上去,这个程序似乎能完全并行化:各个任务之间不会相互等待,因此处理器越多,能够并发处理的任务也就越多。 然而,在这个过程中包含了一个串行部分(从队列中获取任务),所有的工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。 如果通过加锁机制来保护队列的状态,那么当一个线程从队列中取出任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理的过程中的串行部分。

注意:在所有的并发程序中都包含一些串行部分,如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。所以我们的任务是让串行部分尽可能的少

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

在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。 例如有两种减低锁粒度的技术:锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。当通过Amdahl定律来分析这两项技术时,我们会发现如果将一个锁分解为两个锁,似乎并不能充分利用多个处理器的能力。 锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。

5.线程引入的开销
我们知道单线程即不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。
而在多个线程中的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。这样我们的并行才有意义

1)上下文切换
如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文,因为这样能保证原来线程做的工作不会丢失 这个过程将需要一定的开销。

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

在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。
synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的)。

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

synchronized (new Object()){
//执行一些操作.............
}

一个更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。
我们看下面代码:

public String getStoogeNames(){List<String> stooges = new Vector<String>();Stooges.add("Moe");stooges.add("Larry");stooges.add("Curly");return stooges.toString();
}

在上面的方法中,对List的唯一引用就是局部变量stooges,并且所有封闭在栈中的变量都会自动成为线程本地变量。在这个方法执行的过程中,至少会将Vector上的锁获取/释放4次,每次调用add或toString时都会执行1次。 然而,一个智能的运行时编译器通常会分析这些调用,从而使stooges及其内部状态不会逸出,因此可以去掉这4次对锁获取的操作。

实际上即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将临近的同步代码用同一个锁合并起来。在上面的方法中,如果JVM进行锁粒度粗化,那么可能会把3个add与一个toString调用合并为单个锁获取/释放操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。 这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而可能实现进一步的优化。

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

3)阻塞

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

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

6.如何减少锁的竞争
我们通过前面的学习知道在锁上发生竞争时将同时导致两个问题:降低可伸缩性和上下文切换。
所以我们可以通过减少锁的竞争来提高性能和可伸缩性。
注意:在并发程序中,对可伸缩性的最主要的威胁就是独占方式的资源锁。

那么如何减少锁的竞争呢?
其实有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,每次持有该锁的时间。 如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重的影响。

所以,我们有三种方式可以降低锁的竞争程度:
1)减少锁的持有时间
2)降低锁的请求频率
3)使用带有协调机制的独占锁,这些机制允许更高的并发性。

那么具体是怎样的呢? 我们接着往下看:
策略一:快进快出(减少锁的持有时间)
如果将一个高竞争的锁持有过长的时间会限制可伸缩性,如下:

public class AttributeStore{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;elsereturn Pattern.matches(regexp,location);}}

上面的代码中的同步方法中只有attributes.get(key); 真正需要锁,所以为了这一个操作让一个方法持有过长时间的锁是不好的。

所以我们是可以通过下面方式限制这个方法持有锁的时间来降低锁的竞争:

public class BetterAttributeStore{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;elsereturn Pattern.matches(regexp,location);}
}

注意:在缩小锁的范围之前一定要考虑缩小之后的安全性是否收到影响。

策略二:减小锁的粒度
这可以通过锁分解和锁分段的技术来实现,这些技术将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。

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

例如下面的代码没有使用锁分解技术:

public class ServerStatus{public final Set<String> users;public final Set<String> queries;public synchronized void addUser(String u){user.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);}
}

我们可以看到上面代码中的对象锁需要保护两个相互独立的状态变量,所以我们可以进行下面的操作:

public class ServerStatus{public final Set<String> users;public final Set<String> queries;public void addUser(String u){synchronized(users){user.add(u);}}public void addQuery(String q){synchronized(queries){queries.add(q);}}
......
}

如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁能最大限度地提升性能。

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

例如:在ConcurrentHashMap的实现使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第 N%16个锁来保护。 假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。 正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个)。

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

下面代码给出了基于散列的Map实现,其中使用了锁分段技术:

public class StripedMap{private static final int N_LOCKS = 16;private final Node[] buckets;private final Object[] locks;private static class Node{...}//构造方法,初始化元素数组,创建16个锁对象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();}}//计算hash值方法private final int hash(Object key){return Math.abs(key.hashCode()%buckets.length);}//根据键得到值public Object get(Object key){int hash = hash(key);//计算该键得hash值synchronized(locks[hash % N_LOCKS]){//根据hash值获得对应的那个锁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++){sychronized(locks[i % N_LOCKS]){buckets[i]=null;}}}...
}

3)避免热点域
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据上操作,而且不会相互干扰。
如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。

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

例如当实现HashMap时,我们需要考虑如何在size方法中计算Map中的元素数量。
我们可以想到的最简单的方法就是在每次调用时都统计一次Map中元素的数量。 当然这样的性能不好,我们下一步就是想办法优化这个方法,一种常见的优化策略是:在插入和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加一些开销,以确保计数器是最新的值,但这将把size方法的开销从O(n)降低到O(1)了。

在单线程或者采用完全同步的实现中,使用一个独立的计数能很好的提高类似size和isEmpty这些方法的执行速度,但是它却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享计数器(这就是热点域)。这种情况下一个看似性能优化的措施已经变成了一个可伸缩性问题。

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

策略三:一些代替独占锁的方法
我们可以放弃使用独占锁而使用一种友好的并发的方式来管理共享状态,例如:使用并发容器,读写锁,不可变对象以及原子变量等方式来代替独占锁。

总结:在考虑并发程序的性能时,要考虑全面,切忌冲动优化!!


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部