欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java线程安全杂谈——锁、状态依赖与协同以及锁优化

程序员文章站 2022-07-12 19:00:32
...

从谈Java并发开始synchronized和锁就时常被谈到,上篇讲Java内存模型特点的时候,也说道用synchronized几乎可以同时满足原子性、可见性和有序性三点,那本篇就来说一下锁的概念、synchronized和API层面Lock锁框架的比较选择。后面也会讲到状态依赖与协同问题、条件队列和锁优化。

先说说synchronized。synchronized关键字可谓是并发里的常见词,但synchronized的用法可能这里还有很多大家不熟悉的细节,这里整理一下:

  • synchronized保证当前块中代码内容(对外部)的原子性、可见性和happens-before规则的顺序
  • 通常在非静态情况下,synchronized的是锁住对象,即以对象充当锁,所以有synchronized(Object){…}代码块编码方式,其中Object既可以是当前对象this也可以是其它对象
  • synchronized修饰的非静态方法使用的锁是当前对象
  • 需要注意的是一个对象只对应一个synchronized锁,即所有synchronized同一个对象的方法会竞争这同一个锁
  • 未获得锁的线程需要阻塞等待,关于线程阻塞的解除前面线程终止的文章提到过
  • 可冲入性,已经进入锁定的线程可直接获得当前锁,无须阻塞等待,重入采用计数机制
  • 修饰静态方法的synchronized关键字锁住的是当前类对应的class对象,静态代码块需要锁住对应类.class
  • synchronized是JVM提供的锁保证,实现机制无须再编码层面关心

1. 相应的,再说下Lock接口及其对应的一个实现ReentrantLock。ReentrantLock顾名思义,至少保证了synchronized的可重入性,实际上Lock的实现基本上保证了synchronized的在并发开发中线程安全的所有特性,需要注意,也是在两者选择中需要注意的特点,整理如下:

  • Lock(及其ReentrantLock实现)锁提供了lock()和unlock()方法,和synchronized的代码块进入和退出对应。为了保证锁在发生异常的情况下锁得到解除,通常采用try-finally代码块的形式,并在finally中执行unlock()方法的调用
  • Lock(及其ReentrantLock实现)支持非阻塞等待,提供了tryLock()方法和按时间尝试的方法,获得锁失败则可以按需要直接返回,而不是死等下去,这在一定程度上避免了死锁饿死的情况
  • 不像Java提供的synchronized内在锁,Lock(及其ReentrantLock实现)还支持可中断锁,提供了lockInterruptibly()方法,在等待锁阻塞的时候,可以通过中断使其放弃等待
  • 和内在锁以及内在锁对应的监视器等待队列机制不同,Lock和其对应的Condition接口都支持一个对象多个锁和条件对应,而非固有的一对一
  • 还有,ReentrantLock的实现区分了公平锁非公平锁,更灵活地适应需求场景

特点上,先整理这几点。更多的,就像《Java Concurrency in Practice》中所说,JavaSE5的java.util.concurrent中这套Java代码实现的锁框架,不是用来彻底取代synchronized的固有内在锁的,而是给开发者提供了特定需求场景下更灵活更方便的选择,Lock本身也有其缺点,比如语法上需要try-finally支持,用法复杂,提高了使用的学习成本和门槛,需要开发者维护,使用风险高,而且性能上靠JUC代码实现来维护等,而内在锁保持了原有代码的兼容并且性能可以随着JVM实现的改进优化提高,可谓各有所长,而且在能够满足需求的情况下,应当优先选择synchronzied内在锁。

对于Java的java.util.concurrent.locks中的详细锁实现,我会在后面的文章详细给出源码要点分析。

2. 有了锁,我们解决了线程安全方面的问题,但并不是所有的并发需求仅仅用锁就能得到解决,下面我们说说状态依赖和条件队列。

比如在线程协作方面经典的案例“生产者-消费者”。在生产者和消费者之间有一个循环的传送带,生产者生产出产品消费者才能消费,消费者将传送带上的产品有消费,生产者才能生产新产品并放到传送带上。对于传送带来讲,它是生产者和消费者共享的,而且有满和空两种状态,如果满了则生产者需要停下来,如果空了消费者也没有可消费的产品。实际上,传送带既然是两者共享,则需要加锁使用保证线程安全和状态一致性,而生产者和消费者又同时依赖于传送带的状态。那么生产者或者消费者发现传送带状态不满足的情况下,需要释放锁,因为只有这样才能让对方来处理,只有这样才能使不满足的状态得到改变,有机会满足所需的状态。

一个简单的解决方案就是循环检查状态是否满足。假如有两个线程P和C,分别代表生产者和消费者,而一个数据结构Q代表传送带。对于P,需要获得Q的锁,循环判断Q是否为满,未满则可以继续执行,否则释放锁,继续循环判断。C也如此,只不过条件是Q未空。

对于处理器来说,循环的判断是十分消耗计算资源的。为了解决这个问题,我们可以让线程P和C每次尝试失败后释放锁并等待一段时间,等计时器到了之后再重新尝试获取锁并判断。这样至少看起来比前一种更有效果,但有至少有如下两个问题:

  • 在通常的实现中,线程不断的重复切换和睡眠唤醒状态的调度是对性能有损耗的
  • 睡眠(等待)时间的长短设定是一个问题,过短则和前一种情况没什么区别,徒增了一些调度开销,而过长会出现响应度低,延迟过长

那么有没有一种更有效的解决方案使得这装状态依赖的线程间协作更有效率呢?那就是条件队列。当一个线程发现自己不满足条件时,将其挂载到某个条件下的队列中,直到条件满足时得到系统的通知。这样的方式就避免了对线程不必要的唤醒、锁获取和检查。当然,要做到这些需要底层的支持。在java.lang.Object中,采用native的方式实现了wait()/notify()/notifyAll()方法。这三个方法结合操作系统的实现给我们使用条件队列提供了方便。wait()所做的就是释放锁,等待条件发生,当前线程阻塞。notify()/notifyAll()则是通知满足条件的队列中的线程,将其唤醒。

类似的java.util.concurrent.locks中也给出了对应的实现,就是Condition,提供了更灵活的方法await()/signal()/signalAll()。下面一起说下线程协同需要注意的地方:

  • wait()/notify()/notifyAll()和await()/signal()/signalAll()都是会自动释放锁的,这就要求他们的执行必需在已经获得锁的代码块中
  • 为了不错过通知信号,通常wait()会包含在一个循环中,而循环的条件往往就是可以继续向下执行的条件
  • 对于JVM内在实现的条件队列,是和synchronized内在锁绑定的,只能对一个对象wait(),但条件可能是各种各样,这个就需要在while循环的条件中做不同的判断
  • notify()/notifyAll()的对比,后者更安全,保证能够被通知到,前者更有效率,不做不必要的唤醒。通常的建议是,除非保证等待条件和后续任务处理只有一类并且每次只需要一个线程被唤醒,否则优先考虑notifyAll(),惯例这样做是为了保证逻辑的正确性
  • Condition的await()/signal()和Object的wait()/notify()相比,是Java代码实现的框架,解开了一个对象只有一个条件队列的对应关系,可以根据需求调用Lock的newCondition()方法,每次调用开启一个新的条件队列,提高了队列的精确性
  • synchronized和wait()/notify()都是JVM的内在实现,是绑在一起的;Condition及其队列操作是基于AQS实现的,在SunJDK实现中用到了sun.misc.Unsafe类的park()和unpark()方法;内在条件队列与j.u.c的Condition实现的选择理由和synchronized和Lock的对比基本一致

线程协同和条件队列就先说到这里。

3. 回头再看看锁的实现,简要比较和分析一下内在锁和Lock框架的实现原理和理念。对于通常情况下的锁,我们把一个对象锁住,使得其他线程没有机会获得锁从而不能做加锁才能进行的操作,更重要的,对这些线程进行阻塞,这种锁我们可以称其为“悲观锁”,就是不能背弃锁而做锁后的操作,只能阻塞。我们知道,Java的线程实现最终都是基于硬件和操作系统平台之上的,这种阻塞和唤醒开销都是非常大的

与其对应,有一种所叫做“乐观锁”,基于冲突检测的道理。我们尝试不考虑加锁直接去做锁后的操作,操作修改时做一个对比,如果没有问题直接就改了,没有明显的加锁过程,如果对比发生了变化,也就意味着其他线程做了修改,则这个操作失败,做失败后的处理,可以考虑循环尝试。其实在ReentrantLock的tryLock()中,就是用sun.misc.Unsafe的compareAndSwapInt()方法调用,这里的实现就有了“乐观锁”的味道。另外,在java.util.concurrent.atomic中的大部分类都是基于乐观锁的思路做出实现。

显然考虑到系统底层的阻塞和唤醒的成本考虑,乐观锁通常会比悲观锁效率更好一些。

另外,对于synchronized和java.util.concurrent.locks包中的Lock实现的性能对比,在JDK1.5之前,并发量较大的时候,后者明显优于前者。但JavaSE6之后,JDK的实现对内在锁做了很大优化,单纯在性能方面的考虑,两种锁实现已经没有绝对的优劣差异了。

4. 下面就说说JavaSE6中的一些锁优化方案

  • 自旋锁。其实自旋简单理解就是线程不停地自己循环尝试获得锁,而非自旋锁未能获得的情况下一般是阻塞掉,之后再唤醒尝试获得锁。上面提到阻塞和唤醒是基于底层实现的,是有成本的。那在一定程度上,自旋虽然有计算资源上的损失但综合考虑这个成本,在多次自选后未能获得锁后再为避免浪费处理器计算资源将其阻塞掉。在JDK1.6之后,引入了自适应的概念,这点得到了一定优化。
  • 锁消除。锁可能会带来阻塞等成本,那么没有锁自然就没有这些成本,在JDK判断得到对象只被单线程安全使用的情况下会把锁消除掉,以削减锁成本。
  • 锁粗化。通常我们认为锁的粒度越小越好,以减少对不必要锁住的资源锁住,但有些情况刚好相反,锁过于细会导致一系列操作反复加锁解锁加锁解锁,在这种情况下,我们可以统一加锁一次解锁一次,削减了不必要的加锁解锁带来的成本。
  • 轻量级锁和偏向锁则是在考虑仅仅在无竞争条件下的特殊优化处理,在发现竞争的时候又恢复到“重量级”锁状态,要结合情况考虑是否采用。更详细的原理这里不展开介绍。

5. 在这篇文章的最后,重申一下对于线程协同需求场景的处理。从前文状态依赖和线程协同介绍中,大家可以看到条件队列的实际使用细节还是蛮多的,很容易出现问题。

“工欲善其事,必先利其器”,我们实际上站在“巨人的肩膀”。java.util.concurrent中已经有很好的工具,比如各类BlockingQueue实现。另外也可以考虑用管道等机制来解决我们的需求,这样就免去了我们在使用条件队列面临的各类细节技术问题,提高解决问题的效率。