Java并发编程性能扩展性和响应.docx

上传人:b****1 文档编号:20137015 上传时间:2023-04-25 格式:DOCX 页数:17 大小:25.94KB
下载 相关 举报
Java并发编程性能扩展性和响应.docx_第1页
第1页 / 共17页
Java并发编程性能扩展性和响应.docx_第2页
第2页 / 共17页
Java并发编程性能扩展性和响应.docx_第3页
第3页 / 共17页
Java并发编程性能扩展性和响应.docx_第4页
第4页 / 共17页
Java并发编程性能扩展性和响应.docx_第5页
第5页 / 共17页
点击查看更多>>
下载资源
资源描述

Java并发编程性能扩展性和响应.docx

《Java并发编程性能扩展性和响应.docx》由会员分享,可在线阅读,更多相关《Java并发编程性能扩展性和响应.docx(17页珍藏版)》请在冰豆网上搜索。

Java并发编程性能扩展性和响应.docx

Java并发编程性能扩展性和响应

1、介绍

本文讨论的重点在于多线程应用程序的性能问题。

我们会先给性能和扩展性下一个定义,然后再仔细学习一下Amdahl法则。

下面的内容我们会考察一下如何用不同的技术方法来减少锁竞争,以及如何用代码来实现。

2、性能

我们都知道,多线程可以用来提高程序的性能,背后的原因在于我们有多核的CPU或多个CPU。

每个CPU的内核都可以自己完成任务,因此把一个大的任务分解成一系列的可彼此独立运行的小任务就可以提高程序的整体性能了。

可以举个例子,比如有个程序用来将硬盘上某个文件夹下的所有图片的尺寸进行修改,应用多线程技术就可以提高它的性能。

使用单线程的方式只能依次遍历所有图片文件并且执行修改,如果我们的CPU有多个核心的话,毫无疑问,它只能利用其中的一个核。

使用多线程的方式的话,我们可以让一个生产者线程扫描文件系统把每个图片都添加到一个队列中,然后用多个工作线程来执行这些任务。

如果我们的工作线程的数量和CPU总的核心数一样的话,我们就能保证每个CPU核心都有活可干,直到任务被全部执行完成。

对于另外一种需要较多IO等待的程序来说,利用多线程技术也能提高整体性能。

假设我们要写这样一个程序,需要抓取某个网站的所有HTML文件,并且将它们存储到本地磁盘上。

程序可以从某一个网页开始,然后解析这个网页中所有指向本网站的链接,然后依次抓取这些链接,这样周而复始。

因为从我们对远程网站发起请求到接收到所有的网页数据需要等待一段时间,所以我们可以将此任务交给多个线程来执行。

让一个或稍微更多一点的线程来解析已经收到的HTML网页以及将找到的链接放入队列中,让其他所有的线程负责请求获取页面。

与上一个例子不同的是,在这个例子中,你即便使用多于CPU核心数量的线程也仍然能够获得性能提升。

上面这两个例子告诉我们,高性能就是在短的时间窗口内做尽量多的事情。

这个当然是对性能一词的最经典解释了。

但是同时,使用线程也能很好地提升我们程序的响应速度。

想象我们有这样一个图形界面的应用程序,上方有一个输入框,输入框下面有一个名字叫“处理”的按钮。

当用户按下这个按钮的时候,应用程序需要重新对按钮的状态进行渲染(按钮看起来被按下了,当松开鼠标左键时又恢复原状),并且开始对用户的输入进行处理。

如果处理用户输入的这个任务比较耗时的话,单线程的程序就无法继续响应用户其他的输入动作了,比如,来自操作系统传送过来的用户单击鼠标事件或鼠标指针移动事件等等,这些事件的响应需要有独立的线程来响应。

可扩展性(Scalability)的意思是程序具备这样的能力:

通过添加计算资源就可以获得更高的性能。

想象我们需要调整很多图片的大小,因为我们机器的CPU核心数是有限的,所以增加线程数量并不总能相应提高性能。

相反,因为调度器需要负责更多线程的创建和关闭,也会占用CPU资源,反而有可能降低性能。

2.1 Amdahl法则

上一段提到了在某些情形下,添加额外的运算资源可以提高程序的整体性能。

为了能够计算出当我们添加了额外的资源的时候到底能获得多少性能提升,我们有必要来检查一下程序有哪些部分是串行运行(或同步运行),有哪些部分是并行运行的。

如果我们把需要同步执行的代码占比量化为B(例如,需要同步执行的代码的行数),把CPU的总核心数记为n,那么,根据Amdahl法则,我们可以获得的性能提升的上限是:

如果n趋于无穷大的话,(1-B)/n就收敛于0。

因此,我们可以忽略这个表达式的值,因此性能提升位数收敛于1/B,这里面的B代表是那些必须同步运行的代码比例。

如果B等于0.5的话,那意味着程序的一半代码无法并行运行,0.5的倒数是2,因此,即使我们添加无数个CPU核心,我们获得的性能提升也最多是2倍。

假设我们现在把程序修改了一下,修改之后只有0.25的代码必须同步运行,现在1/0.25=4,表示我们的程序如果在具有大量CPU的硬件上运行时速度将会比在单核的硬件上快大概4倍。

另一方面,通过Amdahl法则,我们也能根据我们想获得的提速的目标计算出程序应该的同步代码的比例。

如果我们想要达到100倍的提速,而1/100=0.01,意味着,我们程序同步执行的代码的数量最多不能超过1%。

总结Amdahl法则我们可以看出,我们通过添加额外CPU来获得性能提升的最大值取决于程序同步执行部分代码所占的比例有多小。

虽然在实际中,想要计算出这个比例并不总是那么容易,更别说面对一些大型的商业系统应用了,但是Amdahl法则给了我们很重要的启示,那就是,我们必须非常仔细地去考虑那些必须同步执行的代码,并且力图减少这部分代码。

2.2 对性能的影响

文章写到这里,我们已经表明这样一个观点:

增加更多的线程可以提高程序的性能和响应速度。

但是另一方面,想要取得这些好处却并非轻而易举,也需要付出一些代价。

线程的使用对性能的提升也会有所影响。

首先,第一个影响来自线程创建的时候。

线程的创建过程中,JVM需要从底层操作系统申请相应的资源,并且在调度器中初始化数据结构,以便决定执行线程的顺序。

如果你的线程的数量和CPU的核心数量一样的话,每个线程都会运行在一个核心上,这样或许他们就不会经常被打断了。

但是事实上,在你的程序运行的时候,操作系统也会有些自己的运算需要CPU去处理。

所以,即使这种情形下,你的线程也会被打断并且等待操作系统来重新恢复它的运行。

当你的线程数量超过CPU的核心数量的时候,情况有可能变得更坏。

在这种情况下,JVM的进程调度器会打断某些线程以便让其他线程执行,线程切换的时候,刚才正在运行的线程的当前状态需要被保存下来,以便等下次运行的时候可以恢复数据状态。

不仅如此,调度器也会对它自己内部的数据结构进行更新,而这也需要消耗CPU周期。

所有这些都意味着,线程之间的上下文切换会消耗CPU计算资源,因此带来相比单线程情况下没有的性能开销。

多线程程序所带来的另外一个开销来自对共享数据的同步访问保护。

我们可以使用synchronized关键字来进行同步保护,也可以使用Volatile关键字来在多个线程之间共享数据。

如果多于一个线程想要去访问某一个共享数据结构的话,就发生了争用的情形,这时,JVM需要决定哪个进程先,哪个进程后。

如果决定该要执行的线程不是当前正在运行的线程,那么就会发生线程切换。

当前线程需要等待,直到它成功获得了锁对象。

JVM可以自己决定如何来执行这种“等待”,假如JVM预计离成功获得锁对象的时间比较短,那JVM可以使用激进等待方法,比如,不停地尝试获得锁对象,直到成功,在这种情况下这种方式可能会更高效,因为比较进程上下文切换来说,还是这种方式更快速一些。

把一个等待状态的线程挪回到执行队列也会带来额外的开销。

因此,我们要尽力避免由于锁竞争而带来的上下文切换。

下面一节将阐述两种降低这种竞争发生的方法。

2.3 锁竞争

像上一节所说的那样,两个或更多线程对锁的竞争访问会带来额外的运算开销,因为竞争的发生逼迫调度器来让一个线程进入激进等待状态,或者让它进行等待状态而引发两次上下文切换。

有某些情况下,锁竞争的恶果可以通过以下方法来减轻:

1,减少锁的作用域;

2,减少需要获取锁的频率;

3,尽量使用由硬件支持的乐观锁操作,而不是synchronized;

4,尽量少用synchronized;

5,减少使用对象缓存

2.3.1 缩减同步域

如果代码持有锁超过必要的时间,那么可以应用这第一种方法。

通常我们可以将一行或多行代码移出同步区域来降低当前线程持有锁的时间。

在同步区域里运行的代码数量越少,当前线程就会越早地释放锁,从而让其他线程更早地获得锁。

这与Amdahl法则相一致的,因为这样做减少了需要同步执行的代码量。

为了更好地理解,看下面的源码:

publicclassReduceLockDurationimplementsRunnable{

privatestaticfinalintNUMBER_OF_THREADS=5;

privatestaticfinalMapmap=newHashMap();

publicvoidrun(){

for(inti=0;i<10000;i++){

synchronized(map){

UUIDrandomUUID=UUID.randomUUID();

Integervalue=Integer.valueOf(42);

Stringkey=randomUUID.toString();

map.put(key,value);

}

Thread.yield();

}

}

publicstaticvoidmain(String[]args)throwsInterruptedException{

Thread[]threads=newThread[NUMBER_OF_THREADS];

for(inti=0;i

threads[i]=newThread(newReduceLockDuration());

}

longstartMillis=System.currentTimeMillis();

for(inti=0;i

threads[i].start();

}

for(inti=0;i

threads[i].join();

}

System.out.println((System.currentTimeMillis()-startMillis)+"ms");

}

}

在上面的例子中,我们让五个线程来竞争访问共享的Map实例,为了在同一时刻只有一个线程可以访问到Map实例,我们将向Map中添加Key/Value的操作放到了synchronized保护的代码块中。

当我们仔细察看这段代码的时候,我们可以看到,计算key和value的几句代码并不需要同步执行,key和value只属于当前执行这段代码的线程,仅仅对当前线程有意义,并且不会被其他线程所修改。

因此,我们可以把这几句移出同步保护。

如下:

publicvoidrun(){

for(inti=0;i<10000;i++){

UUIDrandomUUID=UUID.randomUUID();

Integervalue=Integer.valueOf(42);

Stringkey=randomUUID.toString();

synchronized(map){

map.put(key,value);

}

Thread.yield();

}

}

降低同步代码所带来的效果是可以测量的。

在我的机器上,整个程序的执行时间从420ms降低到了370ms。

看看吧,仅仅把三行代码移出同步保护块就可以将程序运行时间减少11%。

Thread.yield()这句代码是为了诱发线程上下文切换的,因为这句代码会告诉JVM当前线程想要交出当前使用的计算资源,以便让其他等待运行的线程运行。

这样也会带来更多的锁竞争的发生,因为,如果不如此的话某一个线程就会更久地占用某个核心继而减少了线程上下文切换。

2.3.2 分拆锁

另外一种减少锁竞争的方法是将一块被锁定保护的代码分散到多个更小的保护块中。

如果你的程序中使用了一个锁来保护多个不同对象的话,这种方式会有用武之地。

假设我们想要通过程序来统计一些数据,并且实现了一个简单的计数类来持有多个不同的统计指标,并且分别用一个基本计数变量来表示(long类型)。

因为我们的程序是多线程的,所以我们需要对访问这些变量的操作进行同步保护,因为这些操作动作来自不同的线程。

要达到这个目的,最简单的方式就是对每个访问了这些变量的函数添加synchronized关键字。

publicstaticclassCounterOneLockimplementsCounter{

privatelongcustomerCount=0;

privatelongshippingCount=0;

publicsynchronizedvoidincrementCustomer(){

customerCount++;

}

publicsynchronizedvoidincrementShipping(){

shippingCount++;

}

publicsynchronizedlonggetCustomerCount(){

returncustomerCount;

}

publicsynchronizedlonggetShippingCount(){

returnshippingCount;

}

}

这种方式也就意味着,对这些变量的每次修改都会引发对其他Counter实例的锁定。

其他线程如果想要对另外一个不同的变量调用increment方法,那也只能等待前一个线程释放了锁控制之后才能有机会去完成。

在此种情况下,对每个不同的变量使用单独的synchronized保护将会提高执行效率。

publicstaticclassCounterSeparateLockimplementsCounter{

privatestaticfinalObjectcustomerLock=newObject();

privatestaticfinalObjectshippingLock=newObject();

privatelongcustomerCount=0;

privatelongshippingCount=0;

publicvoidincrementCustomer(){

synchronized(customerLock){

customerCount++;

}

}

publicvoidincrementShipping(){

synchronized(shippingLock){

shippingCount++;

}

}

publiclonggetCustomerCount(){

synchronized(customerLock){

returncustomerCount;

}

}

publiclonggetShippingCount(){

synchronized(shippingLock){

returnshippingCount;

}

}

}

这种实现为每个计数指标引入了一个单独synchronized对象,因此,一个线程想要增加Customer计数的时候,它必须等待另一个正在增加Customer计数的线程完成,而并不用等待另一个正在增加Shipping计数的线程完成。

使用下面的类,我们可以非常容易地计算分拆锁所带来的性能提升。

publicclassLockSplittingimplementsRunnable{

privatestaticfinalintNUMBER_OF_THREADS=5;

privateCountercounter;

publicinterfaceCounter{

voidincrementCustomer();

voidincrementShipping();

longgetCustomerCount();

longgetShippingCount();

}

publicstaticclassCounterOneLockimplementsCounter{...}

publicstaticclassCounterSeparateLockimplementsCounter{...}

publicLockSplitting(Countercounter){

this.counter=counter;

}

publicvoidrun(){

for(inti=0;i<100000;i++){

if(ThreadLocalRandom.current().nextBoolean()){

counter.incrementCustomer();

}else{

counter.incrementShipping();

}

}

}

publicstaticvoidmain(String[]args)throwsInterruptedException{

Thread[]threads=newThread[NUMBER_OF_THREADS];

Countercounter=newCounterOneLock();

for(inti=0;i

threads[i]=newThread(newLockSplitting(counter));

}

longstartMillis=System.currentTimeMillis();

for(inti=0;i

threads[i].start();

}

for(inti=0;i

threads[i].join();

}

System.out.println((System.currentTimeMillis()-startMillis)+"ms");

}

}

在我的机器上,单一锁的实现方法平均花费56ms,两个单独锁的实现是38ms。

耗时大约降低了大概32%。

另外一种提升方式是,我们甚至可以更进一步地将读写分开用不同的锁来保护。

原来的Counter类提供了对计数指标分别提供了读和写的方法,但是事实上,读操作并不需要同步保护,我们可以放心让多个线程并行读取当前指标的数值,同时,写操作必须得到同步保护。

java.util.concurrent包里提供了有对ReadWriteLock接口的实现,可以方便地实现这种区分。

ReentrantReadWriteLock实现维护了两个不同的锁,一个保护读操作,一个保护写操作。

这两个锁都有获取锁和释放锁的操作。

仅仅当在没有人获取读锁的时候,写锁才能成功获得。

反过来,只要写锁没有被获取,读锁可以被多个线程同时获取。

为了演示这种方法,下面的Counter类使用了ReadWriteLock,如下:

publicstaticclassCounterReadWriteLockimplementsCounter{

privatefinalReentrantReadWriteLockcustomerLock=newReentrantReadWriteLock();

privatefinalLockcustomerWriteLock=customerLock.writeLock();

privatefinalLockcustomerReadLock=customerLock.readLock();

privatefinalReentrantReadWriteLockshippingLock=newReentrantReadWriteLock();

privatefinalLockshippingWriteLock=shippingLock.writeLock();

privatefinalLockshippingReadLock=shippingLock.readLock();

privatelongcustomerCount=0;

privatelongshippingCount=0;

publicvoidincrementCustomer(){

customerWriteLock.lock();

customerCount++;

customerWriteLock.unlock();

}

publicvoidincrementShipping(){

shippingWriteLock.lock();

shippingCount++;

shippingWriteLock.unlock();

}

publiclonggetCustomerCount(){

customerReadLock.lock();

longcount=customerCount;

customerReadLock.unlock();

returncount;

}

publiclonggetShippingCount(){

shippingReadLock.lock();

longcount=shippingCount;

shippingReadLock.unlock();

returncount;

}

}

所有的读操作都被读锁保护,同时,所有的写操作都被写锁所保护。

如果程序中执行的读操作要远大于写操作的话,这种实现可以带来比前一节的方式更大的性能提升,因为读操作可以并发进行。

2.3.3 分离锁

上面一个例子展示了如何将一个单独的锁分开为多个单独的锁,这样使得各线程仅仅获得他们将要修改的对象的锁就可以了。

但是另一方面,这种方式也增加了程序的复杂度,如果实现不恰当的话也可能造成死锁。

分离锁是与分拆锁类似的一种方法,但是分拆锁是增加锁来保护不同的代码片段或对象,而分离锁是使用不同的锁来保护不同范围的数值。

JDK的java.util.concurrent包里的ConcurrentHashMap即使用了这种思想来提高那些严重依赖HashMap的程序的性能。

在实现上,ConcurrentHashMap内部使用了16个不同的锁,而不是封装一个同步保护的HashMap。

16个锁每一个负责保护其中16分之一的桶位(bucket)的同步访问。

这样一来,不同的线程想要向不同的段插入键的时候,相应的操作会受到不同的锁来保护。

但是反过来也会带来一些不好的问题,比如,某些操作的完成现在需要获取多个锁而不是一个锁。

如果你想要复制整个Map的话,这16个锁都需要获得才能完成。

2.3.4 原子操作

另外一种减少锁竞争的方法是使用原子操作,这种方式会在其他文章中详细阐述原理。

java.util.concurrent包对一些常用基础数据类型提供了原子操作封装的类。

原子操作类的实现基于处理器提供的“比较置换”功能(CAS),CAS操作只在当前寄存器的值跟操作提供的旧的值一样的时候才会执行更新操作。

这个原理可以用来以乐观的方式来增加一个变量的值。

如果我们的线程知道当前的值

展开阅读全文
相关资源
猜你喜欢
相关搜索

当前位置:首页 > 总结汇报 > 工作总结汇报

copyright@ 2008-2022 冰豆网网站版权所有

经营许可证编号:鄂ICP备2022015515号-1