Java并发编程学习笔记(三)锁

分类

内部锁 / 显式锁

内部锁通常指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
2
3
4
5
6
Object lock = new Object();
public void f1() {
synchronized (lock) {

}
}

其二,使用this指代当前对象,如下:

1
2
3
4
5
public void f1() {
synchronized (this) {

}
}

在这两种情况下,当多个线程访问同一个对象的f1()方法时锁才会生效。

同步一个方法

1
2
3
public synchronized void f2() {
// Do something
}

与同步一个对象作用相同。

同步一个类

1
2
3
4
5
public void f3() {
synchronized (Sync.class) {

}
}

作用于整个类,当多个线程调用该类的不同对象的f3() 方法时也会进行同步。

同步一个静态方法

1
2
3
public synchronized static void f4() {

}

静态方法为类所属,因此与同步一个类相同。

显示锁

java.util.concurrent.locks包中的Lock接口定义了一系列显式锁操作,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface Lock {

void lock();

void lockInterruptibly() throws InterruptedException;

boolean tryLock();

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

void unlock();

Condition newCondition();
}

内部锁虽然使用简单,但锁的获取和释放由JVM实现,在无法获得锁时会无限等待,不能进行中断,而显式锁提供了更加灵活的方法,如可响应中断的锁获取方法,可设定超时的锁获取方法等。

显式锁在使用上更加灵活,能够减小锁的粒度,但也特别需要注意将业务处理放在try代码块中,并在finally代码块中释放锁,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void f1() {
lock.lock();
try {
// Do something
} finally {
lock.unlock();
}
}

public void f2() {
if (lock.tryLock()) {
try {
// Do something
} finally {
lock.unlock();
}
} else {

}
}

减少锁的竞争

缩小锁的范围(减少锁持有的时间)

尽可能将与锁无关的代码移出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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Resources {  
final int LENGTH = 10;
private List<Integer> resources = new LinkedList<>();

public void produce(int product) {
synchronized (resources) {
while (resources.size() >= LENGTH) {
try {
resources.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
resources.add(product);
System.out.println(Thread.currentThread().getId() + " 生产:" + product);
resources.notifyAll();
}
}

public void consume() {
synchronized (resources) {
while (resources.size() <= 0) {
try {
resources.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getId() + " 消费:" + resources.remove(0));
resources.notifyAll();
}
}
}

AbstractQueuedSynchronizer

AQS是JDK1.5提供的一个基于FIFO双端队列实现的一个抽象队列式同步器,它定义了一套多线程访问共享资源的同步器框架,许多同步类的实现都依赖于它,比如ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue、FutureTask。

AQS的核心属性如下:

1
2
3
private transient volatile Node head;  
private transient volatile Node tail;
private volatile int state;

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
2
3
4
5
6
final void lock() {  
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

首先通过CAS操作尝试将state从0置为1,若成功则将持有锁的线程设为当前线程,表示当前线程持有了锁;若失败则表示有其他线程已占有锁,调用父类AQS的acquire()方法,代码如下:

1
2
3
4
5
public final void acquire(int arg) {  
if (!tryAcquire(arg) && // 尝试获得锁
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 构造Node加到队列并阻塞线程
selfInterrupt();
}

上面的代码中,首先调用tryAcquire()方法尝试获得锁,这个方法的在NonFairSync类中进行了覆盖,调用了nonfairTryAcquire()方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final boolean nonfairTryAcquire(int acquires) {      
final Thread current = Thread.currentThread();
int c = getState(); // 获得锁的状态
if (c == 0) { // 没有线程持有锁
if (compareAndSetState(0, acquires)) { // 尝试设置锁的状态
setExclusiveOwnerThread(current); // 当前线程作为锁的持有者
return true;
}
} else if (current == getExclusiveOwnerThread()) { // 已有线程持有锁,且是当前线程
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc); // 不通过CAS,直接设置状态,这是偏向锁的体现
return true;
}
return false;
}

在尝试获得锁失败后,AQS会通过addWaiter()方法将Node添加到队列末尾,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private Node addWaiter(Node mode) {  
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail; // 获得队列尾节点
if (pred != null) { // 尾节点不空
node.prev = pred;
if (compareAndSetTail(pred, node)) { // 把当前节点设为尾节点
pred.next = node;
return node;
}
}
enq(node); // 如果设置失败则自旋直到设置成功
return node;
}
private Node enq(final Node node) {
for (;;) { // 一直尝试直到设置成功
Node t = tail;
if (t == null) { // 尾节点为空说明头结点也为空,需要初始化头结点
if (compareAndSetHead(new Node()))
tail = head;
} else { // 不空则直接将该节点设置为尾节点
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

将Node添加到队列末尾后,AQS会调用acquireQueued()方法将线程阻塞,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
final boolean acquireQueued(final Node node, int arg) {  
boolean failed = true;
try {
boolean interrupted = false;
for (;;) { // 一直尝试
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 当前节点的前驱为头结点,尝试获得锁
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) && // 检查线程(Node)状态
parkAndCheckInterrupt()) // 调用LockSupport.park()阻塞线程
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

到此,加锁的过程就完成了,下面分析解锁的过程:

解锁是通过调用ReentrantLock.unlock()方法触发的,最终调用了AQS的release()方法,如下:

1
2
3
4
5
6
7
8
9
public final boolean release(int arg) {  
if (tryRelease(arg)) { // 尝试释放锁
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒队列中的下一个线程
return true;
}
return false;
}

tryRelease()方法在Sync类中进行了覆盖,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
protected final boolean tryRelease(int releases) {  
int c = getState() - releases; // 计算释放后的state值,由于锁是可重入的,线程多次lock后state的值会>1,需要多次unlock
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) { // 只有state为0才表示锁释放了
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}

释放锁成功后,会调用unparkSuccessor()来找到阻塞队列中第一个可以唤醒的线程,通常为head.next。

总结

锁是Java并发编程的基础内容,对锁有一定的了解能够构建更好的并发程序,在权衡使用内部锁或显式锁时,要充分考虑使用锁的场景,内部锁由JVM实现,能满足绝大多数使用场景,但如果需要使用可中断、可定时的特性时可以考虑使用显式锁代替内部锁。

参考资料

《Java并发编程实战》