分类
内部锁 / 显式锁
内部锁通常指Synchronized锁。
显式锁通常指Lock接口实现的锁,如ReentrantLock。
公平锁 / 非公平锁
一种获取锁的策略。
公平锁指按照线程申请锁的顺序获取锁。
非公平锁指不严格按照申请锁的顺序获取锁,如可按优先级获取锁,可能造成饥饿。
Synchronized 是一个典型的非公平锁。
ReentrantLock 可通过构造方法指定是否为公平锁,默认为非公平锁。
可重入锁 / 不可重入锁
可重入锁指线程可以进入它已经获取的锁守护的其他代码块。
不可重入锁指当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会因获取不到而被阻塞。
Synchronized和ReentrantLock都是可重入锁。
互斥锁 / 读写锁
互斥锁指一次最多只有一个线程能够占有锁,如Synchronized、ReentrantLock。
读写锁指一个资源可以能够被多个读取线程访问,或被一个写入线程访问,二者不能同时进行,如ReentrantReadWriteLock。
乐观锁 / 悲观锁
非具体类型的锁,而是理解并发操作的两个角度。
乐观锁认为不加锁的并发操作是可以容忍的,比如原子类,通过CAS实现原子操作。
悲观锁认为不加锁的并发操作一定是不安全的,如使用内部锁或显示锁。
分拆锁 / 分离锁
非具体类型的锁,而是一种对于锁的设计。
分拆锁指当一个锁对应多个独立的的独占资源时,可以考虑为每个独占资源分配一个锁。
分离锁指将一个独占资源分为N份,每份分别对应一个独占锁,如JDK1.7中ConcurrentHashMap的锁分离实现,使用了一个包含16个锁的Array,每个锁对应HashMap的1/16。
偏向锁 / 轻量级锁 / 重量级锁
表示内部锁的三种状态,JDK1.6为Synchronized锁引入了锁升级策略,提高了Synchronized锁的性能,这三种锁的状态是通过对象监视器在对象头中的Mark Word字段来表明的。
偏向锁:当一个线程访问同步代码块获取锁时,首先判断MarkWord字段重的偏向线程ID是否为空,为空则表示锁未被访问过,那么就通过CAS操作将自己的ID写入MarkWord中,如果不为空,那么就去判断是否和自己的ID相同,如果相同的话就直接进入同步代码块中,这就是偏向锁和可重入的体现,否则的话则表示这个锁被其他线程访问过,就需要检查持有锁的线程是否存活,如果线程已经终止的话,那么就可以重置偏向,否则的话锁就升级为轻量级锁。
轻量级锁:当一个线程申请的锁为轻量级锁时,线程不会立即阻塞,而是通过自旋和CAS操作尝试获取锁,线程首先会在自己的栈帧中创建一个锁记录,然后,通过CAS操作尝试将MarkWord字段中的锁记录指针指向自己栈帧中的锁记录,如果CAS操作失败,那么需要判断此时锁记录指针是否已经指向自己的锁记录,是的话表示该线程已经持有锁,这也是可重入的表现,否则的话则表示锁由其他线程持有,该线程开始自旋。如果自旋达到一定次数,或者又有其他线程来竞争锁,那么就会升级为重量级锁,所有未获得锁的线程进入阻塞状态。
重量级锁:重量级锁会让其他申请锁的线程进入阻塞,性能降低。
自旋锁
自旋锁指申请获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
JDK1.6中引入了自适应自旋锁,它会根据前一次在同一个锁上的自旋时间和锁的拥有者的状态来决定自旋的时间和是否自旋。
内部锁
内部锁是由JVM管理的,我们可以通过Synchronized关键字使用内部锁。Synchronized关键字可以用来同步方法或代码块,这也分别代表着不同的同步行为。
同步一个对象
同步一个对象分为两种情况:
其一,类中创建一个对象作为锁对象,如下:
1 | Object lock = new Object(); |
其二,使用this指代当前对象,如下:
1 | public void f1() { |
在这两种情况下,当多个线程访问同一个对象的f1()方法时锁才会生效。
同步一个方法
1 | public synchronized void f2() { |
与同步一个对象作用相同。
同步一个类
1 | public void f3() { |
作用于整个类,当多个线程调用该类的不同对象的f3() 方法时也会进行同步。
同步一个静态方法
1 | public synchronized static void f4() { |
静态方法为类所属,因此与同步一个类相同。
显示锁
java.util.concurrent.locks包中的Lock接口定义了一系列显式锁操作,如下:
1 | public interface Lock { |
内部锁虽然使用简单,但锁的获取和释放由JVM实现,在无法获得锁时会无限等待,不能进行中断,而显式锁提供了更加灵活的方法,如可响应中断的锁获取方法,可设定超时的锁获取方法等。
显式锁在使用上更加灵活,能够减小锁的粒度,但也特别需要注意将业务处理放在try代码块中,并在finally代码块中释放锁,如下:
1 | public void f1() { |
减少锁的竞争
缩小锁的范围(减少锁持有的时间)
尽可能将与锁无关的代码移出synchronized块,尤其是可能存在阻塞的操作。
减小锁的粒度(减少请求锁的频率)
通常采用分拆锁和锁分离来实现。
分拆锁:当一个锁对应多个独立的的独占资源时,可以考虑为每个独占资源分配一个锁。
锁分离:将一个独占资源分为N份,分别对应一个独占锁。
如ConcurrentHashMap的锁分离实现,使用了一个包含16个锁的Array,每个锁对应HashMap的1/16,虽然锁分离能够提供更好的并发性能,但同时也使得对整个容器的独占访问变得困难,比如需要对Map中的数据进行重排。
使用协调机制代替独占锁,从而允许更强的并发性
条件队列
wait()、notify()、notifyAll()都是Object类的本地final方法,构成了条件队列的API。
wait()使当前线程阻塞,前提是必须先获得锁,一般在synchronized 同步代码块里使用 wait()、notify/notifyAll()方法。
notify/notifyAll() 的执行只是唤醒沉睡的线程,而不会立即释放锁,锁的释放要看代码块的具体执行情况。在编程中,尽量在使用了notify/notifyAll()后立即退出临界区,以唤醒其他线程。
notify()方法只唤醒一个等待(对象的)线程并使该线程开始执行。notifyAll()会唤醒所有等待(对象的)线程。
通常用while循环探测某个条件的变化,并根据状态阻塞或唤醒线程。
典型例子生产者–消费者模型如下:
1 | public class Resources { |
AbstractQueuedSynchronizer
AQS是JDK1.5提供的一个基于FIFO双端队列实现的一个抽象队列式同步器,它定义了一套多线程访问共享资源的同步器框架,许多同步类的实现都依赖于它,比如ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue、FutureTask。
AQS的核心属性如下:
1 | private transient volatile Node head; |
head和tail构成了FIFO的双端阻塞队列,这个队列通过节点间的前后关系构成。
state为同步状态变量,== 0表示没有线程持有该锁,!= 0表示有线程已经持有了该锁
AQS通过基于CAS的Lock-Free算法设置核心属性的值,下面通过ReentrantLock非公平锁来学习一下AQS的实现。
ReentrantLock把所有Lock接口的操作都委派到一个Sync类上,该类继承了AbstractQueuedSynchronizer:
1 | static abstract class Sync extends AbstractQueuedSynchronizer |
Sync又有两个子类:NonFairSync和FairSync,分别代表非公平锁(默认)和公平锁。
关注一下NonFairSync的lock()方法,代码如下:
1 | final void lock() { |
首先通过CAS操作尝试将state从0置为1,若成功则将持有锁的线程设为当前线程,表示当前线程持有了锁;若失败则表示有其他线程已占有锁,调用父类AQS的acquire()方法,代码如下:
1 | public final void acquire(int arg) { |
上面的代码中,首先调用tryAcquire()方法尝试获得锁,这个方法的在NonFairSync类中进行了覆盖,调用了nonfairTryAcquire()方法,代码如下:
1 | final boolean nonfairTryAcquire(int acquires) { |
在尝试获得锁失败后,AQS会通过addWaiter()方法将Node添加到队列末尾,代码如下:
1 | private Node addWaiter(Node mode) { |
将Node添加到队列末尾后,AQS会调用acquireQueued()方法将线程阻塞,如下:
1 | final boolean acquireQueued(final Node node, int arg) { |
到此,加锁的过程就完成了,下面分析解锁的过程:
解锁是通过调用ReentrantLock.unlock()方法触发的,最终调用了AQS的release()方法,如下:
1 | public final boolean release(int arg) { |
tryRelease()方法在Sync类中进行了覆盖,代码如下:
1 | protected final boolean tryRelease(int releases) { |
释放锁成功后,会调用unparkSuccessor()来找到阻塞队列中第一个可以唤醒的线程,通常为head.next。
总结
锁是Java并发编程的基础内容,对锁有一定的了解能够构建更好的并发程序,在权衡使用内部锁或显式锁时,要充分考虑使用锁的场景,内部锁由JVM实现,能满足绝大多数使用场景,但如果需要使用可中断、可定时的特性时可以考虑使用显式锁代替内部锁。
参考资料
《Java并发编程实战》