Java中的锁

Java中的锁

Java中的锁分类

在Java中,锁大致可以分为这些:

  • 公平锁/非公平锁
  • 可重入锁/非可重入锁
  • 排他锁(独享锁)/共享锁
  • 乐观锁/悲观锁
  • 分段锁
  • 无锁/偏向锁/轻量级锁/重量级锁
  • 自旋锁/适应性自旋锁

这里并不是指的锁的状态,有些针对的是锁的特性。

公平锁/非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。采用的是先到先得的策略。在线程来获取锁的时候,直接进入队里排队,锁被其他线程释放了之后,队列中的第一个线程才能获取到锁。

公平锁具有以下优缺点:

优点:所有等待锁的线程都具有获取到锁的机会,不会处于一直阻塞的状态;

缺点:整体的吞吐率比非公平锁第。等待队列中除第一个线程以外的所有线程都会阻塞,而CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁

非公平锁是线程在要加锁的时候,直接去尝试是否能获取到锁,无视队列里面是否存在比它早到的其他线程。如果此时锁刚好被释放,那么此线程就直接获取到锁,不需要阻塞。

非公平锁具有以下优缺点:

优点:可以减少CPU唤醒阻塞线程的开销,整体的吞吐率比公平锁高。有一定的概率,线程在获取锁的时候,刚好锁被其他线程释放,那么当前线程就减少了阻塞和被唤醒的开销。

缺点:存在一定的概率导致队列中等待的线程一直等不到或者等很久才能拿到想要的锁。

公平锁/非公平锁示例

可重入锁/非可重入锁

可重入锁

可重入锁又名递归锁,是指同一个线程在外层方法获取到了锁之后,在进入内部的同步方法时,会自动获取锁。在Java中,synchronized和ReentrantLock都是可重入锁。

比如下面这段代码

1
2
3
4
5
6
7
8
9
10
11
public class LockTest {
private String testString = "Do something";
public synchronized void doSomethingOutter() {
System.out.println(testString);
doSomethingInner();
}

public synchronized void doSomethingInner() {
System.out.println(testString);
}
}

上面两个类方法中,doSomethingOutter和doSomethingInner都是被synchronized关键字修饰的。线程在调用doSometingOutter时,获取到了锁,此时在它内部在调用方法doSomethingInner就不需要等doSomethingOutter释放锁,可以直接进入该方法执行。

synchronized实现可重入参见文章synchronized关键字解析

ReentrantLock实现可重入请参见文章ReentrantLock解析

非可重入锁

如果是非可重入锁的话,在进入doSomethingOutter之后,进入方法doSomethingInner之前,doSomethingInner方法需要获取锁,而此时doSomethingOutter方法还无法释放锁,就会造成死锁。

借用参考文献[1]中打水的例子。许多人在排队打水的时候,管理员允许一个人在获取锁之后可以给多个桶打水。当某个人打水时,先第一个桶和锁绑定打水,接着第二个,第三个,直到所有水桶都打完水之后,才将锁还给管理员。然后管理员把锁分配给下一个人。

可重入方式打水

如果管理员只允许一把锁只能给一个桶打水的话,在第一个人打完第一桶水之后,不会释放锁,这时候,打第二桶水又需要锁,却无法和锁绑定。导致当前线程出现死锁。剩下的所有线程都无法被唤醒。

非可重入方式打水

排他锁(独享锁)/共享锁

排他锁指的是这个锁一次只能被一个线程所持有,其他线程如果需要的话,必须等当前线程释放该锁才可。如果线程T对数据A加上排他锁之后,其他线程不可再对数据A加任何类型的锁。获得排他锁的线程T即能读取数据又能修改数据。

共享锁指的是这个锁可以被多个线程所持有。

对ReentrantLock来说,它是独享锁。而另外一个ReadWriteLock。它的ReadLock是共享锁,可以被多个线程同时持有,而WriteLock是独享,排他的。如果线程T对数据A加上共享锁之火,其他线程只能对数据A加上共享锁,不能加排他锁。获得共享锁的线程只能读取数据,不能修改数据。

独享锁和共享锁也是通过AQS实现的,通过实现不同的方法,来实现独享或者共享。详情可参见ReentrantReadWriteLock解析

乐观锁/悲观锁

乐观锁和悲观锁不是什么锁的种类,而是看待并发同步的角度。

对于同一个数据的并发操作,悲观锁认为其在使用数据的时候,一定会有其他的线程来修改数据,因此,它在获取数据的时候会先加上锁,确保其他线程无法修改数据。在Java中,synchronized和Lock的实现类都是悲观锁。

而乐观锁则认为其在使用数据时,不会有其他的线程来修改数据,所以不会加锁,只是在更新数据的识货判断之前有没有别的线程已经修改了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据写入。否则,根据不同的实现方式执行不同的操作(例如报错或者重试)。

乐观锁在Java中时通过无锁编程来实现的,最常采用的是CAS(Compare And Set)算法,Java原子类中的递增操作就是通过CAS自旋实现的。

乐观锁/悲观锁图示

根据上面的图示,可以得出:

  • 悲观锁适合写操作比较多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升

CAS算法可参考CAS算法解析

分段锁

分段锁是一种锁的设计,并不是一种具体的锁。是在JDK 1.7之中,ConcurrentHashMap采用的一种锁。ConcurrentHashMap就是通过它来实现并发的高效操作。

具体可参考ConcurrentHashMap 在 Java 1.7 和 Java 1.8 中的区别

无锁/偏向锁/轻量级锁/重量级锁

无锁

无锁,即没有对资源进行锁定,所有的线程都可以访问并修改同一个资源,但同时只能有一个线程修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断地尝试比较待修改的值是不是和预期的值一致,是的话就修改成功,并退出,否则,继续循环尝试。

偏向锁

偏向锁指的是同一段代码一直被同一个线程所访问,那么该线程后续再来访问的话,就会自动获取锁,减轻锁获取的代价。

大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁或轻量级锁(升级)的状态。

偏向锁在JDK 1.6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:**-XX:-UseBiasedLocking=false**,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

轻量级锁是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

重量级锁

重量级锁是将除了拥有锁的线程以外的线程都阻塞。

这四种状态时针对synchronized关键字的,在Java1.5中引入了锁升级的机制来实现高效地synchronized。

锁升级顺序

自旋锁/适应性自旋锁

自旋锁

自旋锁的产生基于两个前提:

  • 有些时候线程切换所消耗的CPU周期比用户代码执行时间还长
  • 大部分场景下,锁定资源的时间很短,在短时间内频繁地切换上下文,很可能得不偿失

在这种前提下,就有了自旋锁。如果当前线程T去获取某个锁的时候,发现该锁已经被另外一个线程S所占有,此时它不阻塞自己,而是不放弃CPU的所有权,定期的去轮询线程S是否已经释放锁了,如果已经释放了,那么线程T就可以直接获取同步资源的锁了,直接略过线程休眠和唤起的开销。

自旋锁也存在缺点。如果线程S长时间没有释放锁的时候,线程T就会长时间占用CPU时间,白白浪费计算资源。所以,应该给自旋锁自旋时间加上上限,如果超过了一定次数,则挂起线程T。可以使用参数(**-XX:PreBlockSpin**)配置,默认是10次。

自旋锁示例

适应性自旋锁

适应性自旋锁意味着锁自旋的时间不再收参数控制,而是根据线程上一次获取到锁所耗费的自旋时间及锁的拥有者状态决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

概述

下图是根据参考资料[1]稍微修改而来的脑图。

Java中的锁

参考文章

[1].不可不说的Java“锁”

[2].Java中的锁分类

0%