从谈Java并发开始synchronized和锁就时常被谈到,上篇讲Java内存模型特点的时候,也说道用synchronized几乎可以同时满足原子性、可见性和有序性三点,那本篇就来说一下锁的概念、synchronized和API层面Lock锁框架的比较选择。后面也会讲到状态依赖与协同问题、条件队列和锁优化。
先说说synchronized。synchronized关键字可谓是并发里的常见词,但synchronized的用法可能这里还有很多大家不熟悉的细节,这里整理一下:
1. 相应的,再说下Lock接口及其对应的一个实现ReentrantLock。ReentrantLock顾名思义,至少保证了synchronized的可重入性,实际上Lock的实现基本上保证了synchronized的在并发开发中线程安全的所有特性,需要注意,也是在两者选择中需要注意的特点,整理如下:
特点上,先整理这几点。更多的,就像《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()。下面一起说下线程协同需要注意的地方:
线程协同和条件队列就先说到这里。
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中的一些锁优化方案。
5. 在这篇文章的最后,重申一下对于线程协同需求场景的处理。从前文状态依赖和线程协同介绍中,大家可以看到条件队列的实际使用细节还是蛮多的,很容易出现问题。
“工欲善其事,必先利其器”,我们实际上站在“巨人的肩膀”。java.util.concurrent中已经有很好的工具,比如各类BlockingQueue实现。另外也可以考虑用管道等机制来解决我们的需求,这样就免去了我们在使用条件队列面临的各类细节技术问题,提高解决问题的效率。