Java并发编程性能详解

一、介绍

创新互联建站专注为客户提供全方位的互联网综合服务,包含不限于成都做网站、网站制作、饶河网络推广、成都微信小程序、饶河网络营销、饶河企业策划、饶河品牌公关、搜索引擎seo、人物专访、企业宣传片、企业代运营等,从售前售中售后,我们都将竭诚为您服务,您的肯定,是我们最大的嘉奖;创新互联建站为所有大学生创业者提供饶河建站搭建服务,24小时服务热线:18980820575,官方网址:www.cdcxhl.com

本文重点讨论多线程应用程序的性能问题。如用何种技术方法来减少锁竞争,以及如何用代码来实现。

二、性能

我们都知道,多线程可以提高线程的性能。性能提升的根本原因在于我们有多核的CPU或多个CPU。每个CPU的内核都可以自己完成任务,因此把一个大的任务分解成一系列的可彼此独立运行的小任务就可以提高程序的整体性能了。可以举个例子,比如有个程序用来将硬盘上某个文件夹下的所有图片的尺寸进行修改,应用多线程技术就可以提高它的性能。使用单线程的方式只能依次遍历所有图片文件并且执行修改,如果我们的CPU有多个核心的话,毫无疑问,它只能利用其中的一个核。使用多线程的方式的话,我们可以让一个生产者线程扫描文件系统把每个图片都添加到一个队列中,然后用多个工作线程来执行这些任务。如果我们的工作线程的数量和CPU总的核心数一样的话,我们就能保证每个CPU核心都有活可干,直到任务被全部执行完成。

对于另外一种需要较多IO等待的程序来说,利用多线程技术也能提高整体性能。假设我们要写这样一个程序,需要抓取某个网站的所有HTML文件,并且将它们存储到本地磁盘上。程序可以从某一个网页开始,然后解析这个网页中所有指向本网站的链接,然后依次抓取这些链接,这样周而复始。因为从我们对远程网站发起请求到接收到所有的网页数据需要等待一段时间,所以我们可以将此任务交给多个线程来执行。让一个或稍微更多一点的线程来解析已经收到的HTML网页以及将找到的链接放入队列中,让其他所有的线程负责请求获取页面。

高性能就是在短的时间窗口内做尽量多的事情。这个当然是对性能一词的最经典解释了。但是同时,使用线程也能很好地提升我们程序的响应速度。想象我们有这样一个图形界面的应用程序,上方有一个输入框,输入框下面有一个名字叫“处理”的按钮。当用户按下这个按钮的时候,应用程序需要重新对按钮的状态进行渲染(按钮看起来被按下了,当松开鼠标左键时又恢复原状),并且开始对用户的输入进行处理。如果处理用户输入的这个任务比较耗时的话,单线程的程序就无法继续响应用户其他的输入动作了,

可扩展性(Scalability)的意思是程序具备这样的能力:通过添加计算资源就可以获得更高的性能。想象我们需要调整很多图片的大小,因为我们机器的CPU核心数是有限的,所以增加线程数量并不总能相应提高性能。相反,因为调度器需要负责更多线程的创建和关闭,也会占用CPU资源,反而有可能降低性能。

1、对性能的影响

写到这里,我们已经取得这样一个观点:增加更多的线程可以提高程序的性能和响应速度。但是另一方面,想要取得这些好处却并非轻而易举,也需要付出一些代价。线程的使用对性能的提升也会有所影响。

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

如果你的线程的数量和CPU的核心数量一样的话,每个线程都会运行在一个核心上,这样或许他们就不会经常被打断了。但是事实上,在你的程序运行的时候,操作系统也会有些自己的运算需要CPU去处理。所以,即使这种情形下,你的线程也会被打断并且等待操作系统来重新恢复它的运行。当你的线程数量超过CPU的核心数量的时候,情况有可能变得更坏。在这种情况下,JVM的进程调度器会打断某些线程以便让其他线程执行,线程切换的时候,刚才正在运行的线程的当前状态需要被保存下来,以便等下次运行的时候可以恢复数据状态。不仅如此,调度器也会对它自己内部的数据结构进行更新,而这也需要消耗CPU周期。所有这些都意味着,线程之间的上下文切换会消耗CPU计算资源,因此带来相比单线程情况下没有的性能开销。

多线程程序所带来的另外一个开销来自对共享数据的同步访问保护。我们可以使用synchronized关键字来进行同步保护,也可以使用Volatile关键字来在多个线程之间共享数据。如果多于一个线程想要去访问某一个共享数据结构的话,就发生了争用的情形,这时,JVM需要决定哪个进程先,哪个进程后。如果决定该要执行的线程不是当前正在运行的线程,那么就会发生线程切换。当前线程需要等待,直到它成功获得了锁对象。JVM可以自己决定如何来执行这种“等待”,假如JVM预计离成功获得锁对象的时间比较短,那JVM可以使用激进等待方法,比如,不停地尝试获得锁对象,直到成功,在这种情况下这种方式可能会更高效,因为比较进程上下文切换来说,还是这种方式更快速一些。把一个等待状态的线程挪回到执行队列也会带来额外的开销。

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

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

2、锁竞争

两个或更多线程对锁的竞争访问会带来额外的运算开销,因为竞争的发生逼迫调度器来让一个线程进入激进等待状态,或者让它进行等待状态而引发两次上下文切换。有某些情况下,锁竞争的恶果可以通过以下方法来减轻:

1.少锁的作用域;

2.少需要获取锁的频率;

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

4.量少用synchronized;

5.少使用对象缓存

2.1 缩减同步域

如果代码持有锁超过必要的时间,那么可以应用这第一种方法。通常我们可以将一行或多行代码移出同步区域来降低当前线程持有锁的时间。在同步区域里运行的代码数量越少,当前线程就会越早地释放锁,从而让其他线程更早地获得锁。这与Amdahl法则相一致的,因为这样做减少了需要同步执行的代码量。

2.2 分拆锁

另外一种减少锁竞争的方法是将一块被锁定保护的代码分散到多个更小的保护块中。如果你的程序中使用了一个锁来保护多个不同对象的话,这种方式会有用武之地。假设我们想要通过程序来统计一些数据,并且实现了一个简单的计数类来持有多个不同的统计指标,并且分别用一个基本计数变量来表示(long类型)。因为我们的程序是多线程的,所以我们需要对访问这些变量的操作进行同步保护,因为这些操作动作来自不同的线程。要达到这个目的,最简单的方式就是对每个访问了这些变量的函数添加synchronized关键字。

2.3 分离锁

上面一个例子展示了如何将一个单独的锁分开为多个单独的锁,这样使得各线程仅仅获得他们将要修改的对象的锁就可以了。但是另一方面,这种方式也增加了程序的复杂度,如果实现不恰当的话也可能造成死锁。

分离锁是与分拆锁类似的一种方法,但是分拆锁是增加锁来保护不同的代码片段或对象,而分离锁是使用不同的锁来保护不同范围的数值。JDK的java.util.concurrent包里的ConcurrentHashMap即使用了这种思想来提高那些严重依赖HashMap的程序的性能。在实现上,ConcurrentHashMap内部使用了16个不同的锁,而不是封装一个同步保护的HashMap。16个锁每一个负责保护其中16分之一的桶位(bucket)的同步访问。这样一来,不同的线程想要向不同的段插入键的时候,相应的操作会受到不同的锁来保护。但是反过来也会带来一些不好的问题,比如,某些操作的完成现在需要获取多个锁而不是一个锁。如果你想要复制整个Map的话,这16个锁都需要获得才能完成。

2.4 原子操作

另外一种减少锁竞争的方法是使用原子操作。java.util.concurrent包对一些常用基础数据类型提供了原子操作封装的类。原子操作类的实现基于处理器提供的“比较置换”功能(CAS),CAS操作只在当前寄存器的值跟操作提供的旧的值一样的时候才会执行更新操作。

这个原理可以用来以乐观的方式来增加一个变量的值。如果我们的线程知道当前的值的话,就会尝试使用CAS操作来执行增加操作。如果期间别的线程已经修改了变量的值,那么线程提供的所谓的当前值已经跟真实的值不一样了,这时JVM来尝试重新获得当前值,并且再尝试一次,反反复复直到成功为止。虽然循环操作会浪费一些CPU周期,但是这样做的好处是,我们不需要任何形式的同步控制。

2.5 避免热点代码段

一个典型的LIST实现通过会在内容维护一个变量来记录LIST自身所包含的元素个数,每一次从列表里删除或增加元素的时候,这个变量的值都会改变。如果LIST在单线程应用中使用的话,这种方式无可厚非,每次调用size()时直接返回上一次计算之后的数值就行了。如果LIST内部不维护这个计数变量的话,每次调用size()操作都会引发LIST重新遍历计算元素个数。

这种很多数据结构都使用了的优化方式,到了多线程环境下时却会成为一个问题。假设我们在多个线程之间共享一个LIST,多个线程同时地去向LIST里面增加或删除元素,同时去查询大的长度。这时,LIST内部的计数变量成为一个共享资源,因此所有对它的访问都必须进行同步处理。因此,计数变量成为整个LIST实现中的一个热点。

本文所讲述的这些优化方案再一次的表明,每一种优化方式在真正应用的时候一定需要多多仔细观测。不成熟的优化方案表面看起来好像很有道理,但是事实上很有可能会反过来成为性能的瓶颈。


文章题目:Java并发编程性能详解
本文路径:http://ybzwz.com/article/jeeeps.html