4实战java高并发程序设计--4.锁的优化及注意事项

锁是最常用的同步方法之一。在高并发的环境下,激烈的锁竞争会导致程序的性能下降,因此我们有必要讨论一些有关锁的性能问题,以及一些注意事项,比如避免死锁、减小锁粒度、锁分离等

对于单任务或者单线程的应用而言,其主要资源消耗都花在任务本身。它既不需要维护并行数据结构间的一致性状态,也不需要为线程的切换和调度花费时间。但对于多线程应用来说,系统除了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。

事实上,在单核CPU上采用并行算法的效率一般要低于原始的串行算法的效率,其根本原因也在于此。因此,并行计算之所以能提高系统的性能,并不是因为它“少干活”了,而是因为并行计算可以更合理地进行任务调度,充分利用各个CPU资源。因此,合理的并发,才能将多核CPU的性能发挥到极致。


4.1 有助于提高锁性能的几点建议

4.1.1 减少锁持有时间

对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。如果线程持有锁的时间越长,那么相对地,锁的竞争程度也就越激烈。以下面的代码段为例:

4实战java高并发程序设计--4.锁的优化及注意事项

在syncMethod()方法中,假设只有mutextMethod()方法是有同步需要的,而othercode1()方法和othercode2()方法并不需要做同步控制。如果othercode1()和othercode2()分别是重量级的方法,则会花费较长的CPU时间。如果在并发量较大时,使用这种对整个方法做同步的方案,则会导致等待线程大量增加。因为一个线程,在进入该方法时获得内部锁,只有在所有任务都执行完后,才会释放锁

一个较为优化的解决方案是,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量

4实战java高并发程序设计--4.锁的优化及注意事项

注意:减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。

 

4.1.2 减小锁粒度

减小锁粒度也是一种削弱多线程锁竞争的有效手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现

对于HashMap来说,最重要的两个方法就是get()和put()。一种最自然的想法就是,对整个HashMap加锁从而得到一个线程安全的对象,但是这样做,加锁粒度太大。对于ConcurrentHashMap类,它内部进一步细分了若干个小的HashMap,称之为段(SEGMENT)。在默认情况下,一个ConcurrentHashMap类可以被细分为16个段

如果需要在ConcurrentHashMap类中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()方法操作。在多线程环境中,如果多个线程同时进行put()方法操作,只要被加入的表项不存放在同一个段中,线程间便可以做到真正的并行

由于默认有16个段,因此,如果够幸运的话,ConcurrentHashMap类可以接受16个线程同时插入(如果都插入不同的段中),从而大大提升其吞吐量。下面代码显示了put()方法操作的过程。第5~6行代码根据key获得对应段的序号。接着在第9行得到段,然后将数据插入给定的段中。

4实战java高并发程序设计--4.锁的优化及注意事项

但是,减小锁粒度会带来一个新的问题,即当系统需要取得全局锁时,其消耗的资源会比较多。仍然以ConcurrentHashMap类为例,虽然其put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap类的全局信息时,就需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap类的size()方法,它将返回ConcurrentHashMap类的有效表项的数量,即ConcurrentHashMap类的全部有效表项之和。要获取这个信息需要取得所有子段的锁,因此,其size()方法的部分代码如下:

4实战java高并发程序设计--4.锁的优化及注意事项

可以看到在计算总数时,先要获得所有段的锁再求和。但是,ConcurrentHashMap类的size()方法并不总是这样执行的,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方法。但不管怎么说,在高并发场合ConcurrentHashMap类的size()方法的性能依然要差于同步的HashMap。

因此,只有在类似于size()方法获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能在真正意义上提高系统的吞吐量。

注意:所谓减小锁粒度,就是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。

4.1.3 用读写分离锁来替换独占锁

如果说减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割

注意:在读多写少的场合使用读写锁可以有效提升系统的并发能力。

4.1.4 锁分离

如果将读写锁的思想进一步延伸,就是锁分离。读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现(我们在之前已经讨论了它的近亲ArrayBlockingQueue的内部实现)。在LinkedBlockingQueue的实现中,take()函数和put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。

通过takeLock和putLock两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。

4.1.5 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化

为此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫作锁的粗化.4实战java高并发程序设计--4.锁的优化及注意事项

在开发过程中,大家也应该有意识地在合理的场合进行锁的粗化,尤其当在循环内请求锁时。以下是一个循环内请求锁的例子,在这种情况下,意味着每次循环都有申请锁和释放锁的操作。但在这种情况下,显然是没有必要的

4实战java高并发程序设计--4.锁的优化及注意事项

注意:性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程。锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同,因此要根据实际情况进行权衡。


4.2 Java虚拟机对锁优化所做的努力

4.2.1 锁偏向

锁偏向是一种针对加锁操作的优化手段。它的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式。当这个线程再次请求锁时,无须再做任何同步操作。这样就节省了大量有关锁申请的操作,从而提高了程序性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳。因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。这样偏向模式会失效,因此还不如不启用偏向锁。使用Java虚拟机参数-XX:+UseBiasedLocking可以开启偏向锁。

4.2.2 轻量级锁(说的啥玩意)

如果偏向锁失败,那么虚拟机并不会立即挂起线程,它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很方便,它只是简单地将对象头部作为指针指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

4.2.3 自旋锁

锁膨胀后,为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力—自旋锁。当前线程暂时无法获得锁,而且什么时候可以获得锁是一个未知数,也许在几个CPU时钟周期后就可以得到锁。如果这样,简单粗暴地挂起线程可能是一种得不偿失的操作。系统会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能获得锁,才会真的将线程在操作系统层面挂起。

4.2.4 锁消除

锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间

说到这里,细心的读者可能会产生疑问,如果不存在竞争,为什么程序员还要加上锁呢?这是因为在Java软件开发过程中,我们必然会使用一些JDK的内置API,比如StringBuffer、Vector等。你在使用这些类的时候,也许根本不会考虑这些对象到底内部是如何实现的。比如,你很有可能在一个不可能存在并发竞争的场合使用Vector。而众所周知,Vector内部使用了synchronized请求锁,比如下面的代码:

4实战java高并发程序设计--4.锁的优化及注意事项

注意上述代码中的Vector,由于变量v只在createStrings()函数中使用,因此它只是一个单纯的局部变量。局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问。在这种情况下,Vector内部所有加锁同步都是没有必要的。如果虚拟机检测到这种情况,就会将这些无用的锁操作去除。

锁消除涉及的一项关键技术为逃逸分析。所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。在本例中,变量v显然没有逃出createStrings()函数之外。以此为基础,虚拟机才可以大胆地将变量v内部的加锁操作去除。如果createStrings()函数返回的不是String数组,而是变量v本身,那么就认为变量v逃逸出了当前函数,也就是说变量v有可能被其他线程访问。如果是这样,虚拟机就不能消除变量v中的锁操作。

逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks参数可以打开锁消除。


4.3 人手一支笔:ThreadLocal

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。比如,让100个人填写个人信息表,如果只有一支笔,那么大家就得挨个填写,对于管理人员来说,必须保证大家不会去哄抢这仅存的一支笔,否则,谁也填不完。从另外一个角度出发,我们可以准备100支笔,人手一支,那么所有人很快就能完成表格的填写工作。如果说锁使用的是第一种思路,那么ThreadLocal使用的就是第二种思路.

4.3.1 ThreadLocal的简单使用

从ThreadLocal的名字上可以看到,这是一个线程的局部变量。也就是说,只有当前线程可以访问。既然是只有当前线程可以访问的数据,自然是线程安全的。

4实战java高并发程序设计--4.锁的优化及注意事项

4实战java高并发程序设计--4.锁的优化及注意事项

4.3.2 ThreadLocal的实现原理

ThreadLocal如何保证这些对象只被当前线程访问呢?下面让我们一起深入ThreadLocal的内部实现。我们需要关注的自然是ThreadLocal的set()方法和get()方法。先从set()方法说起:

4实战java高并发程序设计--4.锁的优化及注意事项

4实战java高并发程序设计--4.锁的优化及注意事项

而设置到ThreadLocal中的数据,也正是写入了threadLocals的这个Map。其中,key为ThreadLocal当前对象,value就是我们需要的值。而threadLocals本身就保存了当前自己所在线程的所有“局部变量”,也就是一个ThreadLocal变量的集合

     在ThreadLocal类中有一个ThreadLocalMap, 用于存放每一个线程的变量副本,Map中元素的key为线程对象,value为对应线程的变量副本。

    另外,说ThreadLocal使得各线程能够保持各自独立的一个对象,并不是通过ThreadLocal.set()来实现的,而是通过每个线程中的new 对象 的操作来创建的对象,每个线程创建一个,不是什么对象的拷贝或副本。通过ThreadLocal.set()将这个新创建的对象的引用保存到各线程的自己的一个map中,每个线程都有这样一个map,执行ThreadLocal.get()时,各线程从自己的map中取出放进去的对象,因此取出来的是各自自己线程中的对象,ThreadLocal实例是作为map的key来使用的。 

  如果ThreadLocal.set()进去的东西本来就是多个线程共享的同一个对象,那么多个线程的ThreadLocal.get()取得的还是这个共享对象本身,还是有并发访问问题。

在了解了ThreadLocal的内部实现后,我们自然会引出一个问题:那就是这些变量是维护在Thread类内部的(ThreadLocalMap定义所在类),这也意味着只要线程不退出,对象的引用将一直存在。当线程退出时,Thread类会进行一些清理工作,其中就包括清理ThreadLocalMap.

因此,使用线程池就意味着当前线程未必会退出(比如固定大小的线程池,线程总是存在)。如果这样,将一些大的对象设置到ThreadLocal中(它实际保存在线程持有的threadLocals Map内),可能会使系统出现内存泄漏的可能(这里我的意思是:你设置了对象到ThreadLocal中,但是不清理它,在你使用几次后,这个对象也不再有用了,但是它却无法被回收)。

此时,如果你希望及时回收对象,最好使用ThreadLocal.remove()方法将这个变量移除。就像我们习惯性地关闭数据库连接一样。如果你确实不需要这个对象了,就应该告诉虚拟机,请把它回收,防止内存泄漏。

另外一种有趣的情况是JDK也可能允许你像释放普通变量一样释放ThreadLocal。比如,我们有时候为了加速垃圾回收,会特意写出类似obj=null的代码。如果这么做,那么obj所指向的对象就会更容易地被垃圾回收器发现,从而加速回收。同理,如果对于ThreadLocal的变量,我们也手动将其设置为null,比如tl=null,那么这个ThreadLocal对应的所有线程的局部变量都有可能被回收。这里面的奥秘是什么呢?先来看一个简单的例子。

ThreadLocalMap的实现使用了弱引用。弱引用是比强引用弱得多的引用。Java虚拟机在垃圾回收时,如果发现弱引用,就会立即回收.

4.3.3 对性能有何帮助