Lock

锁(lock)作为用于保护临界区(critical section)的一种机制,被广泛应用在多线程程序中。


竞争锁是造成多线程应用程序性能瓶颈的主要原因!
  • 区分竞争锁和非竞争锁对性能的影响非常重要。如果一个锁自始至终只被一个线程使用,那么 JVM 有能力优化它带来的绝大部分损耗。如果一个锁被多个线程使用过,但是在任意时刻,都只有一个线程尝试获取锁,那么它的开销要大一些。我们将以上两种锁称为非竞争锁。而对性能影响最严重的情况出现在多个线程同时尝试获取锁时。这种情况是 JVM 无法优化的,而且通常会发生从用户态到内核态的切换。现代 JVM 已对非竞争锁做了很多优化,使它几乎不会对性能造成影响。
 
常见的优化:
  • 如果一个锁对象只能由当前线程访问,那么其他线程无法获得该锁并发生同步 , 因此 JVM 可以去除对这个锁的请求。
  • 逸出分析 (escape analysis) 可以识别本地对象的引用是否在堆中被暴露。如果没有,就可以将本地对象的引用变为线程本地的 (thread local) 。
  • 编译器还可以进行锁的粗化 (lock coarsening) 。把邻近的 synchronized 块用相同的锁合并起来,以减少不必要的锁的获取和释放。
因此,不要过分担心非竞争锁带来的开销,要关注那些真正发生了锁竞争的临界区中性能的优化。
 
降低锁竞争的方法
  • 很多开发人员因为担心同步带来的性能损失,而尽量减少锁的使用,甚至对某些看似发生错误概率极低的临界区不使用锁保护。这样做往往不会带来性能提高,还会引入难以调试的错误。因为这些错误通常发生的概率极低,而且难以重现。
  • 因此,在保证程序正确性的前提下,解决同步带来的性能损失的第一步不是去除锁,而是降低锁的竞争。通常,有以下三类方法可以降低锁的竞争:减少持有锁的时间,降低请求锁的频率,或者用其他协调机制取代独占锁。
避免在临界区中进行耗时计算
  • 通常使代码变成线程安全的技术是给整个函数加上一把“大锁”。例如在 Java 中,将整个方法声明为 synchronized 。但是,我们需要保护的仅仅是对象的共享状态,而不是代码。
  • 过长时间的持有锁会限制应用程序的可扩展性。 Brian Goetz 在《 Java Concurrency in Practice 》一书中提到,如果一个操作持有锁的时间超过 2 毫秒,并且每一个操作都需要这个锁,那么无论有多少个空闲处理器,应用程序的吞吐量都不会超过每秒 500 个操作。如果能够减少持有这个锁的时间到 1 毫秒,就能将这个与锁相关的吞吐量提高到每秒 1000 个操作。事实上,这里保守地估计了过长时间持有锁的开销,因为它并没有计算锁的竞争带来的开销。例如,因为获取锁失败带来的忙等和线程切换,都会浪费 CPU 时间。减小锁竞争发生可能性的最有效方式是尽可能缩短持有锁的时间。这可以通过把不需要用锁保护的代码移出同步块来实现, 尤其是那些花费“昂贵”的操作,以及那些潜在的阻塞操作,比如 I/O 操作。
 
分拆锁和分离锁
  • 降低锁竞争的另一种方法是降低线程请求锁的频率。分拆锁 (lock splitting) 和分离锁 (lock striping) 是达到此目的两种方式。相互独立的状态变量,应该使用独立的锁进行保护。有时开发人员会错误地使用一个锁保护所有的状态变量。这些技术减小了锁的粒度,实现了更好的可伸缩性。但是,这些锁需要仔细地分配,以降低发生死锁的危险。
  • 如果一个锁守护多个相互独立的状态变量,你可能能够通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性。通过这样的改变,使每一个锁被请求的频率都变小了。分拆锁对于中等竞争强度的锁,能够有效地把它们大部分转化为非竞争的锁,使性能和可伸缩性都得到提高。
  • 在例 2 中,我们将原先用于保护两个独立的对象变量的锁分拆成为单独保护每个对象变量的两个锁。在 JLM 结果中,可以看到原先的一个锁 SplittingLock@D6DD3078 变成了两个锁 java/util/HashSet@D6DD7BE0 和 java/util/HashSet@D6DD7BE0 。并且申请锁的次数 (GETS) 和锁的竞争程度 (SLOW, TIER2, TIER3) 都大大降低了。最后,程序的执行时间由 12981 毫秒下降到 4797 毫秒。
  • 当一个锁竞争激烈时,将其分拆成两个,很可能得到两个竞争激烈的锁。尽管这可以使两个线程并发执行,从而对可伸缩性有一些小的改进。但仍然不能大幅地提高多个处理器在同一个系统中的并发性。
  • 分拆锁有时候可以被扩展,分成若干加锁块的集合,并且它们归属于相互独立的对象,这样的情况就是分离锁。例如,ConcurrentHashMap 的实现使用了一个包含 16 个锁的数组,每一个锁都守护 HashMap 的 1/16 。假设 Hash 值均匀分布,这将会把对于锁的请求减少到约为原来的 1/16 。这项技术使得 ConcurrentHashMap 能够支持 16 个的并发 Writer 。当多处理器系统的大负荷访问需要更好的并发性时,锁的数量还可以增加。
避免热点域
  • 在某些应用中,我们会使用一个共享变量缓存常用的计算结果。每次更新操作都需要修改该共享变量以保证其有效性。例如,队列的 size,counter,链表的头节点引用等。在多线程应用中,该共享变量需要用锁保护起来。这种在单线程应用中常用的优化方法会成为多线程应用中的“热点域 (hot field) ”,从而限制可伸缩性。如果一个队列被设计成为在多线程访问时保持高吞吐量,那么可以考虑在每个入队和出队操作时不更新队列 size 。 ConcurrentHashMap 中为了避免这个问题,在每个分片的数组中维护一个独立的计数器,使用分离的锁保护,而不是维护一个全局计数。
独占锁的替代方法
  • 用于减轻竞争锁带来的性能影响的第三种技术是放弃使用独占锁,而使用更高效的并发方式管理共享状态。例如并发容器,读 - 写锁,不可变对象,以及原子变量
  • java.util.concurrent.locks.ReadWriteLock 实现了一个多读者 - 单写者锁:多个读者可以并发访问共享资源,但是写者必须独占获得锁。对于多数操作都为读操作的数据结构,ReadWriteLock 比独占锁提供更好的并发性。
  • 原子变量提供了避免“热点域”更新导致锁竞争的方法,如计数器、序列发生器、或者对链表数据结构头节点引用的更新。
使用并发容器
  • 从 Java1.5 开始,java.util.concurrent 包提供了高效地线程安全的并发容器。并发容器自身保证线程安全性,同时为常用操作在大量线程访问的情况下做了优化。这些容器适合在多核平台上运行的多线程应用中使用,具有高性能和高可扩展性。 Amino 项目提供的更多的高效的并发容器和算法。
使用 Immutable 数据和 Thread Local 的数据
  • Immutable 数据在其生命周期中始终保持不变,所以可以安全地在每个线程中复制一份以便快速读取。
  • ThreadLocal 的数据,只被线程本身锁使用,因此不存在不同线程之间的共享数据的问题。 ThrealLocal 可以用来改善许多现有的共享数据。例如所有线程共享的对象池、等待队列等,可以变成每个 Thread 独享的对象池和等待队列。采用 Work-stealing scheduler 代替传统的 FIFO-Queue scheduler 也是使用 Thread Local 数据的例子。
 
synchronized关键字,语言层面支持的同步代码写起来比较方便,退出同步代码块时jvm自动帮你释放锁,而Lock需要自己保证锁的释放,否则可能出现死锁的情况。
Lock在应用层实现,支持的语义比较多,可以支持条件等待,lock由于是通过java语言在应用层实现的,会受到垃圾回收等情况的影响
 
最早引入juc包的一个主要原因是1.6之前的synchronized关键字jvm实现在性能上被人诟病,而juc通过自旋,cas和线程park/unpark方式实现了灵活的同步api 并且在性能上比jvm的synchronized实现有很大的提升,所以大家纷纷转向juc的lock。
但1.6之后jvm针对synchronized性能进行了优化,优化手段有偏向锁,轻量级锁,自旋等,最终在性能方面现在lock和synchronized已经没有多少区别,而由于synchronized是jvm支持的,jvm还能从底层进行的持续优化未来表现还可能更好,也可以通过jvm提供的jstack,jconsole等监控工具能够方便的查看死锁情况,而juc是通过java实现的,还存在占用内存导致垃圾回收等情况,从某种程度上来说,1.6之后在普通使用场景上还是使用synchronized更有优势一点,ReentrantLock是在语言之上通过库的方式提供支持,所以使用的灵活性上有优势。
 
相比synchronized、重入锁有如下几个优势:
1.可以反复进入,同一个线程获得几个锁,在释放的时候也需要同等释放,要不然会死锁的;
2.支持锁中断lockInterruptiblity(),防死锁,优先响应中断;
3.支持限时获取锁,lock.tryLock(int,TimeUnit);在给定的时间范围捏尝试获取锁;
4.支持尝试获取锁,tryLock不带参数,如果获取不到则立刻返回false,而不等待锁;
5.公平锁ReentrantLock(boolean fair),排队获取锁,性能相对低下;
6.配合Condition,可以让线程在合适的时间等待,得到通知后继续执行

 

 
ReentrantLock分为公平锁和非公平锁,我们首先分析公平锁。
使用公平锁时,加锁方法lock()的方法调用轨迹如下:
  • ReentrantLock : lock()
  • FairSync : lock()
  • AbstractQueuedSynchronizer : acquire(int arg)
  • ReentrantLock : tryAcquire(int acquires)
 
在使用公平锁时,解锁方法unlock()的方法调用轨迹如下:
  • ReentrantLock : unlock()
  • AbstractQueuedSynchronizer : release(int arg)
  • Sync : tryRelease(int releases)
 
非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。
使用非公平锁时,加锁方法lock()的方法调用轨迹如下:
  • ReentrantLock : lock()
  • NonfairSync : lock()
  • AbstractQueuedSynchronizer : compareAndSetState(int expect, int update)
底层是native方法:unsafe.compareAndSwapInt(this, stateOffset, expect, update);
 
 

posted on 2018-08-29 10:33  xiaowater  阅读(163)  评论(0)    收藏  举报

导航