Java的Lock小结
编者注:本文为历史博文归档;涉及 JDK、框架与工具链版本请以当前官方文档为准。引用外链图片可能失效,阅读时请注意时效性。
多核时代
摩尔定律告诉我们:当价格不变时,集成电路上可容纳的晶体管数目,约每隔 18 个月便会增加一倍,性能也将提升一倍。换言之,每一美元所能买到的电脑性能,将每隔 18 个月翻两倍以上。然而最近摩尔定律似乎遇到了麻烦,目前微处理器的集成度似乎到了极限,在目前的制造工艺和体系架构下很难再提高单个处理器的速度了,否则它就被烧坏了。所以现在的芯片制造商改变了策略,转而在一个电路板上集成更多的处理器,也就是我们现在常见的多核处理器。
这就给软件行业带来麻烦(也可以说带来机会,比如说就业机会,呵呵)。原来的情况是:我买一台频率比原来快一倍的处理器,那么我的程序就比原来快一倍,软件工程师什么也不用干。现在不一样了,我买一台双核的处理器,我的程序和原来一样慢,当然这台机器同时处理的任务可以变多了,但是对于单个任务来说并没有帮助。
在几年前,并发 (Concurrency) 和并行 (Parallelism) 程序设计还是在少量的地方使用,现在在个人的 PC 机上已经是很常见了。(Concurrency 和 Parallelism 的区别参考 这个帖子)
造个诸葛亮的价钱远远高于造三个臭皮匠!多核是在一台机器上的并发,但是单机也是会到极限,所以分布式的计算也是类似的思路,用大量普通的机器协作完成一项任务。
但是要想编写一个正确并且高效的能利用多核的多线程程序不是件容易的事,更别说分布式的情况(网络问题,机器故障,负载均衡,...)。现在的编译器没有办法把单线程的程序自动编译成一个多线程的版本(如果到了那一天,估计所有的程序员就失业了)。所以只能提供一些语言上的支持(比如 Scala/Erlang) 或者 MapReduce 这样的框架。
Java 虽然没有提供 Scala 那样的基于消息的模型,但是也提供了丰富的 Concurrent 特性,并且屏蔽了平台的相关性(这不是件容易的事,比如多个处理器有自己的缓存,他们写的东西不会立刻被其它处理器看到),下面我们看看 Java 的内存模型 (JMM)。
Java 内存模型 (JMM)
并行程序有很多模型,比如共享内存模型,消息传递模型等等。这些模型或多或少的利用了平台相关的特性(在并行程序设计里很难回避平台的特性以便高效的通信),Java 抽象出了自己的内存模型,使得开发人员看不到平台的差异(这不是件容易的事),不过即使这样,和传统程序不同,我们还是不能完全不了解一些体系架构的细节问题,至少我们得了解一些。
在共享内存的多处理器体系架构里(我们现在用的服务器甚至笔记本都是),每个处理器都有自己的局部缓存并定期的使之与内存同步。不同的处理器架构保证了不同程度的缓存一致性 (Cache Coherence),所以操作系统,编译器和运行时环境必须一起努力来弥补平台的差异性。
让每个处理器都知道其它处理器的状态的代价是非常昂贵的,所以大多数架构都不会保证一致性,这通常不会有什么问题:进程/线程直接并不共享信息,编译器可以调整代码执行顺序以便提高效率,我们都很开心。当然也有需要在线程之间进行同步的时候,比如某个线程要读取到另一个线程写入的信息,这个时候缓存里的数据就得同步到内存里才行。所以这些体系架构都提供了一些指令来完成数据的同步(当然这些指令是非常费时的,能不做就尽量不做)。这些指令一般叫做 Memory Barriers or Fences。当然只是很底层的一些东西,所幸 Java 提供了一些高层的抽象,让我们的生活变得容易一些。
Sequential Consistency:我们假设一个线程执行(可能在多个处理器上切换),每个变量读取到的值都是最新的修改(也就是 Cache 里的立马生效),这样得到的结果是我们预期的。
但是让我们意外的事情是:如果我们不做任何事情,那么很可能会出现错误,比如下面的这个例子:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}我们在主线程里先让 number=42 (初始值是 0),然后让 ready=true,而另一个线程不断检查是否 ready,如果 ready,那么读出 number。很自然的我们期望子线程打印出 42,但是很可能结果会令我们失望。编译器可能会调换 number=42 和 ready=true 的顺序(思考一下为什么它要这么干?为什么在单线程的情况下没有问题?),另外子线程可能永远在 while 里死循环。为什么?子线程会永远看不到 ready 的变化?这也许让很多人吃惊,事实确实如此,JSR 并不保证这一点(虽然大多数时候子线程能够退出),参考 这个帖子 和 JMM 的文章。
volatile 和 synchronized (内置锁)
volatile 关键字告诉编译器,一个线程对某个变量的修改立即对所有其它线程看见,加上这个能保证上面的程序不会死循环。但是不能保证读到 42,也就是保证 number=42 和 ready=true 的执行顺序,要保证这点就要用到 synchronized。
synchronized 能够保证执行的顺序,除此之外,它也能保证可见性。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
boolean r = false;
while (true){
synchronized(NoVisibility.class){
r = ready;
}
if(r) break;
else Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
synchronized(NoVisibility.class){
number = 42;
ready = true;
}
}
}synchronized(NoVisibility.class){
number = 42;
ready = true;
}这段代码保证了两个语句的执行顺序。
synchronized(NoVisibility.class){
r = ready;
}这保证子线程能看到 ready 的变化。注意他们必须 synchronized 同一个对象,如果是下面的代码,则不能有任何保障。为什么?试想任何 synchronized 里的变量必须立即对所有的可见,那么代价太大,比如我有这样的需求:我只要求两个语句顺序执行,它是否对别人可见我并不关心。
synchronized(AnotherObject){
r = ready;
}每个对象都有个 Monitor,所以 synchronized 也经常叫 Monitor Lock,另外这个锁是语言内置的,所以也叫 Intrinsic Lock。这两个关键字是 Java 1.5 之前就有了,在 Java 1.5 之后新引进了 java.util.concurrent 包,这里有我们需要关注的很多东西,这里我们只关心 Lock 相关的接口和类。不过 synchronized 来解决互斥不是很完美吗?我为什么要花力气搞这些新鲜东西呢?下面我们来看看 synchronized 解决不了(或者很难解决)的问题。
银行转账的例子
// Warning: deadlock-prone!
public void transferMoney(Account fromAccount,
Account toAccount,
DollarAmount amount)
throws InsufficientFundsException {
synchronized (fromAccount) {
synchronized (toAccount) {
if (fromAccount.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}比如我要在两个用户之间转账,为了防止意外,我必须同时锁定两个账户。但是这可能造成死锁。比如:
A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);当线程 A 锁住 myAccount 时,B 锁住了 toAccount,这个时候 A 尝试锁住 toAccount,但是已经被 B 锁住,所以 A 不能继续运行,同理 B 也不能运行,造成死锁。
怎么解决呢?你也许会想,我先锁住一个账户,然后"尝试"锁定另一个账户,如果“失败”,那么我释放所有的锁,“休息”一下再继续尝试,当然两个线程节拍一致的话,可能造成“活锁”。
可惜 synchronized 不能提供这样的语义,它一旦尝试加锁,只能拿到锁,你不能控制它,比如你可能有这样的需求:尝试拿锁 30s,如果拿不到就算了,synchronized 是没办法满足这样的需求的。另外你使用“鸵鸟”策略来解决死锁:什么也不干,如果死锁了,kill 他们,重启他们。这种策略看起来很疯狂,不过如果死锁的概率很多,而避免死锁的算法很复杂,那这也是可以一试的策略(那一堆死锁发生的充分必要条件太麻烦了!!!)。下面我们仔细的来看看 Java 1.5 后提供的 Lock 接口及其相关类。
Lock 接口
Lock 的基本用法如下,为了防止异常退出时没有释放锁,一般都在拿到锁后立马 try,try 住所有临界区的代码,然后 finally 释放锁。
主要和 synchronized 的区别,synchronized 里我们不用操心这些,如果 synchronized 保护的代码抛出异常,那么 JVM 会释放掉 Monitor Lock。
Lock l = ...;
l.lock();
try {
// access the resource protected by this lock
} finally {
l.unlock();
}Lock.lock() 在锁定成功后释放锁之前,它所保护的代码段必须与使用 synchronized 保护的代码段有相同的语义(可见性,顺序性)。
所以从这个角度来说,Lock 完全可以代替 synchronized,那么是否应该抛弃掉 synchronized 呢?答案是否定的。
是否应该抛弃 synchronized?
在 Java 5 引进 Lock 后,实现了 Lock 接口的类就是 ReentrantLock(待会再解释 Reentrant),因为 Java 5 之前 synchronized 的实现很烂,同样是为了实现互斥,ReentrantLock 会比 synchronized 速度上快很多,不过到了 JDK 6 之后就不是这样了,下面是一个测试结果:
from book "Java Concurrency in Practice"
横轴是线程数,纵轴是 ReentrantLock 的吞吐量/IntrinsicLock 的吞吐量。
可以看出,JDK 5 中,ReentrantLock 快很多,但是到了 JDK 6,他们就没什么大的差别了。
synchronized 的优点:锁的释放是语言内置的,不会出现忘记释放锁的情况,另外由于是语言内置的支持,调试时能很快知道锁被哪个线程持有,它加锁的次数。而 Lock 只是 util.concurrent 一个普通的类,所以调试器并不知道这个锁的任何信息,它只是一个普通的对象(当然你可以仔细观察每个线程的 stack frame 来看它在等待锁)。
所以建议:如果只是为了实现互斥,那么使用 synchronized(扔掉 JDK 5 吧,现在都 Java 7 了),如果想用 Lock 附加的功能,那么才使用 Lock。
下面回来继续看 Lock 接口。
Lock 接口方法详解
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}void lock()
尝试获取锁。如果锁被别人拿着,那么当前线程不在执行,也不能被调度,直到拿到锁为止。
void lockInterruptibly() throws InterruptedException
尝试获取锁,除非被 interrupted。如果锁可以获取,那么立刻返回。
如果无法获取锁,那么线程停止执行,并且不能被再调度,直到:
- 当前线程获得锁
- 如果锁的实现支持 interruption,并且有其它线程 interrupt 当前线程。
仔细阅读 javadoc 的第二个情况:Lock 接口并不要求 Lock 的实现支持 interruption,不过 Sun JDK 的实现都是支持的。
这个函数在下面两个情况下抛出 InterruptedException:
- 如果锁的实现支持 interruption,并且有其它线程 interrupt 当前线程。
- 线程调用这个函数之前就被设置了 interrupted 状态位。
可以发现这个方法并不区分这个 interrupted 状态位是之前就有的还是 lock 过程中产生的。不管如何,抛出异常后会清除 interrupted 标记。
使用这个方法,我们可以中断某个等锁的线程,比如我们检测到了死锁,那么我们可以中断这个线程。
boolean tryLock()
尝试获取锁,如果可以,那么锁住对象然后返回 true,否则返回 false,不管怎么样,这个方法会立即返回。下面的例子展示了用这个方法来解决前面转账的死锁:
public boolean transferMoney(Account fromAcct,
Account toAcct,
DollarAmount amount,
long timeout,
TimeUnit unit)
throws InsufficientFundsException, InterruptedException {
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModulusNanos(timeout, unit);
long stopTime = System.nanoTime() + unit.toNanos(timeout);
while (true) {
if (fromAcct.lock.tryLock()) {
try {
if (toAcct.lock.tryLock()) {
try {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
return true;
}
} finally {
toAcct.lock.unlock();
}
}
} finally {
fromAcct.lock.unlock();
}
}
if (System.nanoTime() < stopTime)
return false;
TimeUnit.NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}boolean tryLock(long time, TimeUnit unit) throws InterruptedException
和 tryLock() 类似,不过不是立即返回,而是尝试一定时间后还拿不到锁就返回。
unlock()
释放锁。
newCondition()
暂且不管。
ReentrantLock 类
这是 Sun JDK(Open JDK)里唯一直接实现了 Lock 接口的类,所以如果你想用 Lock 的那些特性,比如 tryLock,那么就应该首先考虑它。
首先我们解释一下 Reentrant。
Reentrant 翻译成中文应该是“可重入”,对于锁来说,可重入是指如果一个线程已经拿到过一把锁,那么它可以再次拿到锁。
听起来似乎没有什么意思,让我们来看看“不可重入”锁可能的一些问题和需要使用“可重入”锁的场景吧。
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
Widget widget = new LoggingWidget();
widget.doSomething();设想这样一个应用场景:我们有一个图的数据结构,我们需要遍历所有节点,找到满足某些条件的节点,锁定所有这些节点,然后对他们进行一些操作。由于图的遍历可能重复访问某个节点,如果简单的锁定每个满足条件的节点,那么可能死锁。当然我们可以自己用程序记下哪些节点已经访问过了,不过也可以把这就事情交给 ReentrantLock,第二次锁定某个对象也会成功并立即返回。那么你可能会问,我释放锁的时候怎么记得它锁定过了多少次呢?如果释放少了,那么会死锁;释放多了,可能也会有问题(有些锁实现会抛出异常,但是 JMM 好像没有定义)。
【上面的场景参考 http://stackoverflow.com/questions/1312259/what-is-the-re-entrant-lock-and-concept-in-general】
不用担心,ReentrantLock 提供了 getHoldCount 方法,最后释放这么多次就好了。
ReentrantLock 会记下当前拿锁的线程,已经拿锁的次数,每次 unlock 都会减一,如果为零了,那么释放锁,另一个线程拿到锁并且计数器值为一。
ReentrantLock 的构造函数可以接受一个 fairness 的参数。如果为 true,那么它会倾向于把锁给等待时间最长的线程。但是这样的代价也是巨大的:
横轴是并发线程数,参考方法是 ConcurrentHashMap,另外分别用 Nonfair Lock 和 Fair Lock 封装普通的 HashMap,可以看到,是否 fair 的差别是非常巨大的。
正如前面所说的,ReentrantLock 是支持 Interrupted 的。
ReadWriteLock 接口
有的应用场景下,有两类角色:Reader 和 Writer。Reader 读取数据,Writer 更新数据。多个 Reader 同时读取是没有问题的,但是 Reader 们和 Writer 是互斥的,并且 Writer 和 Writer 也是互斥的。而且很多应用中,Reader 会很多,而 Writer 会比较少。这个接口就是为了解决这类特殊场景的。
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}用法:
ReadWriteLock rwl = ...;
// Reader threads
void read(){
rwl.readLock().lock();
try{
// entering critical section
} finally {
rwl.readLock().unlock();
}
}
// Writer threads
void write(){
rwl.writeLock().lock();
try{
// entering critical section
} finally {
rwl.writeLock().unlock();
}
}ReentrantReadWriteLock 类
这是 Sun JDK 里唯一实现 ReadWriteLock 接口的类。
这个类的特性:
获取锁的顺序
这个类并不倾向 Reader 或者 Writer,不过有个 fairness 的策略。
非公平模式(默认)
如果很多 Reader 和 Writer 的话,很可能 Reader 一直能获取锁,而 Writer 可能会饥饿。
公平模式
这种模式下,会尽量以请求锁的顺序来保证公平性。当前锁释放以后,等待时间最长的 Writer 或者一组 Reader(Reader 是一伙的!)获取锁。
如果锁被拿着,这时 Writer 来了,他会开始排队;如果 Reader 来了,如果它之前没有 Writer 并且当前拿锁的是 Reader,那么它直接就拿到锁,当然如果是 Writer 拿着,那么它也只能排队等锁。不过如果 Reader 拿着锁,Writer 排队,然后 Reader 排在 Writer 后,但是 Writer 放弃了排队(比如它用的是 tryLock 30s),那么 Reader 直接拿到锁而不用排队。
还有就是 ReentrantReadWriteLock.ReadLock.tryLock() 和 ReentrantReadWriteLock.WriteLock.tryLock() 方法不管这些,一旦调用的时候能拿到锁,那么它们就会插队!!
Reentrancy
从名字就知道它支持可重入。
以前拿过锁的 Reader 和 Writer 可以继续拿锁。另外拿到 WriteLock 的线程可以拿到 ReadLock,但是反之不然。
Lock Downgrading
拿到 WriteLock 的可以直接变成 ReadLock,不用释放 WriteLock 再从新请求 ReadLock(这样需要重新排队),实现的方法是先拿到 WriteLock,接着拿 ReadLock(上面的特性保证了不会死锁),然后释放 WriteLock,这样就得到一个 ReadLock 并立马持有。
Interruption of lock acquisition
支持。
一个使用读写锁的例子:
class CachedData {
Object data;
volatile boolean cacheValid;
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
// Recheck state because another thread might have acquired
// write lock and changed state before we did.
if (!cacheValid) {
data = ...;
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
rwl.writeLock().unlock(); // Unlock write, still hold read
}
use(data);
rwl.readLock().unlock();
}
}一个 Cache 数据的例子,读取数据时首先拿读锁,如果 cache 是有效的(volatile boolean cacheValid),直接使用数据。
如果失效了,那么释放读锁,获取写锁【这个类不支持 upgrading】,然后 double check 一下是否 cache 有效,如果还是无效(说明它应该更新),那么更新数据,并且修改变量 cacheValid,让其它线程看到。
臭名昭著的双重检查锁定 (Double-Checked Locking)
前面提到了 double check,这里也顺便讨论一下:
@NotThreadSafe
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
if (resource == null) {
synchronized (DoubleCheckedLocking.class) {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}很多"hacker"再提到延迟加载的时候都会提到它,上面的代码看起来没有什么问题:首先检查一下 resource,如果为空,那么加锁,因为检查 resource==null 没有加锁,所以可能同时两个线程进入 if 并且请求加锁,所以第一个拿到锁的初始化一次,第二次拿锁的会再次 check。这看起来很完美:大多数情况下 resource 不为空,很少的情况(刚开始时)resource 为空,那么再加锁,这比一上来就加锁要高效很多。
不过千万别高兴地太早了,因为编译器对引用的赋值可能会做优化,可能这个对象还没有正确的构造好,值已经赋好了(为什么要这么做?也许构造对象需要 IO,IO 等待的时间把值赋好了能提高速度)。这个时候别的线程就惨了!
另外很多讲延迟加载的文章都比较早(早于 JDK 6),那个年代 Java 的 synchronized 确实很不给力。如果你实在在乎这点性能的话,应该用 JVM 的静态类加载机制来实现:
@ThreadSafe
public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceHolder.resource;
}
}说明
- 版本时效:本文内容主要基于 JDK 5 至 JDK 7 时期的技术背景。现代 JDK(如 JDK 17/21)在
synchronized锁优化、并发工具类等方面已有显著改进,部分性能结论可能不再适用。 - 链接有效性:文中部分外部链接及图片资源源自历史归档,可能已失效,请以官方文档为准。
版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/java-de-lock-xiao-jie.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。