在多核时代,使用多线程可以明显地提高系统的性能,但是也会额外增加系统的开销。
对于单任务或者单线程的应用而言,其主要资源消耗都源自任务本身。它既不需要维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。但对于多线程应用来说,系统除了处理功能需求,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度和线程上下文的切换等。
事实上,在单核CPU上采用并行算法的效率一般要低于原始的串行算法的效率,并行计算之所以能提高系统的性能,并不是因为它“少干活”了,而是因为它可以更合理地进行任务调度,充分利用各个CPU的资源。因此,只有合理设计并发,才能将多核CPU的性能发挥到极致。
对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接关系。线程持有锁的时间越长,那么相对地,锁的竞争程度也就越激烈。可以想象一下,如果要求100个人各自填写自己的身份信息,但是只给他们一支笔,那么如果每个人拿持笔的时间都很长,总体所花的时间就会很长。如果真的只有一支笔共享给100个人用,那么最好让每个人花尽量少的时间持笔,务必做到想好了再落笔,千万不能拿着笔才去思考信息应该怎么填。程序开发也是类似的,应该尽可能地减少对某个锁的持有时间,以降低线程间互斥的可能性。以下面的代码段为例:
在syncMethod()方法中,假设只有mutextMethod()方法是有同步需要的,而othercode1()方法和othercode2()方法并不需要做同步控制。如果othercode1()方法和othercode2()方法分别是重量级的方法,则会花费较长的CPU时间。如果在并发量较大时,使用这种对整个方法做同步的方案,则会导致等待线程大量增加。因为一个线程在进入该方法时获得内部锁,只有在所有任务都执行完后,才会释放锁。
一个较为优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量。
改进的代码只针对mutextMethod()方法做了同步,锁占用的时间相对较短,因此能有更高的并行度。这种技术手段在JDK的源码包中也很容易见到,比如处理正则表达式的Pattern类。
matcher()方法有条件地进行锁请求,只有在表达式未编译时,进行局部加锁。这种处理方式大大提高了matcher()方法的执行效率和可靠性。
注意:减少锁持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。
减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术的典型使用场景就是ConcurrentHashMap类的实现。对于一个线程安全的HashMap而言,在进行并发put()操作时,锁住整个Map自然是最简单也是最安全的一种实现,然而这种简单的实现却有着极其糟糕的并发性能。试想,如果在操作过程中,可以锁住局部而非整体,是不是可以既做到线程安全,又提高并行度呢?实际上,当ConcurrentHashMap插入元素时,它并不会锁住整个HashMap,而是仅仅在要操作的那个Node上加锁,因此在同一时刻,可以有多个不同的线程同时对Map进行操作而不产生数据冲突。它的伪代码如下:
注意:所谓减小锁粒度,就是缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。
使用读写分离锁ReadWriteLock可以提高系统的性能。使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割。
在读多写少的场合,读写锁对系统性能提升是很有好处的。因为如果系统在读写数据时均只使用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能实现真正的并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性,因此,从理论上讲,在大部分情况下,可以允许多线程同时读,读写锁便是实现这种功能的。
注意:在读多写少的场合使用读写锁可以有效提升系统的并发能力。
如果将读写锁的思想进一步延伸,就是锁分离。根据读写操作功能上的不同,我们对读写锁进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现。
在LinkedBlockingQueue的实现中,take()方法和put()方法分别实现了从队列中取得数据和向队列中添加数据的功能。虽然两个方法都对当前队列进行了修改,但由于LinkedBlockingQueue是基于链表的,因此两个操作分别作用于队列的头部和尾部,从理论上说,两者并不冲突。
如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()方法和put()方法就不可能真正并发执行,在运行时,它们会彼此等待对方释放锁资源。在这种情况下,锁竞争会相对比较激烈,从而影响程序在高并发时的性能。
因此,在JDK的实现中并没有采用这样的方式,取而代之的是用两把不同的锁分离take()方法和put()方法:
以上代码片段定义了takeLock和putLock,它们分别在take()方法和put()方法中使用。因此,take()方法和put()方法就此相互独立,它们之间不存在锁竞争关系,只在take()方法和take()方法间、put()方法和put()方法间存在对takeLock和putLock的竞争,从而削弱了锁竞争的可能性。
take()方法的实现如下,笔者在代码中给出了详细的注释,故不在正文中做进一步说明了:
put()方法的实现如下:
通过takeLock和putLock两把锁,LinkedBlockingQueue实现了读数据和写数据的分离,使两者在真正意义上成为可并发的操作。
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源去执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
为此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫作锁粗化。示例如下:
上面的代码段会被整合成如下形式:
在开发过程中,大家也应该有意识地在合理的场合进行锁粗化,尤其是在循环内请求锁时。以下是一个在循环内请求锁的例子,在这种情况下,意味着每次循环中都有请求锁和释放锁的操作,但显然是没有必要的:
所以,一种更加合理的做法应该是只在外层请求一次锁:
**注意:**性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,在不同的场合,它们的效果不同,因此要根据实际情况进行取舍。