Java并发篇(5)各种锁的入门使用大全

写在前面:上两篇我们花费了非常大的力气讲解了Java的两大关键字synchronizedvolatile,学习了Java的锁机制和内存可见性,对并发安全有一定的知识基础,本篇就讲解在Java中如何使用这些锁,在下一篇会专门讲Java各种锁的底层实现

Lock接口

Lock的使用

  1. lock():阻塞式地获取锁

  2. lockInterruptbly():可中断式的获取锁

  3. boolean tryLock():非阻塞式的获取锁,获取成功返回true,否则返回false

  4. boolean tryLock(long, TimeUnit):在限定时间内非阻塞式地获取锁,如果超时或者被中断则返回false,获取成功返回true

  5. unlock():释放锁

  6. newCondition():获取等待/通知组件,该组件与当前的锁绑定,当前线程只有获取了锁,才可以调用该组件里的wait()方法,而调用后,当前线程会释放锁(稍后会详细讲解)

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
public static void main(String[] args) {
Lock lock = new ReentrantLock();
LockTest test = new LockTest(lock);
Thread a = new Thread(()->{
boolean successLock = test.tryLock();
if (successLock) {
System.out.println("Thread-A succeeded in locking LockTest->test");
lock.unlock();
System.out.println("Thread-A succeeded in releasing LockTest->test");
} else {
System.out.println("Thread-A failed in locking LockTest->test");
}
});
Thread b = new Thread(()->{
boolean successLock = test.tryLock();
if (successLock) {
System.out.println("Thread-B succeeded in locking LockTest->test");
test.unlock();
System.out.println("Thread-B succeeded in releasing LockTest->test");
} else {
System.out.println("Thread-B failed in locking LockTest->test");
}
});
a.start();
b.start();
}

tryLock()是非阻塞的,所以获取锁失败后会返回false,上面的代码运行后会得到下面的结果:

与synchroinzed对比的优势

  1. 非阻塞式获取锁

当前线程尝试获取锁,如果这一时刻没有其它线程获取到,则成功获取并持有锁

  1. 支持中断式获取锁

获取到锁的线程可以响应中断,中断异常抛出时会自动释放锁资源,synchronized无法做到这一点

  1. 超时获取锁

在指定时间内尝试获取锁,截止时间到了以后如果没有获取到锁则会自动返回,不会一直阻塞

使用注意事项

  1. 不要尝试在try{}内部进行加锁

如果在try{}内部调用lock()方法,如果发生异常加锁失败,但是finally也会执行unlock(),因为没有加锁而抛出异常。

Lock使用总结

  1. Lock接口可实现非阻塞获取锁
  2. Lock接口必须显式调用lock()unlock()进行加锁和写锁
  3. 可以使用tryLock()给尝试获取锁的时间加上期限

队列同步器AQS

理解锁和同步器的关系

  1. 同步器是实现锁的关键
  2. 锁是面向使用者的,同步器是面向锁的实现者的,实现者可以根据自身需求不同自定义锁的实现方式,例如共享锁、排它锁等等。

AQS中的模板方法

访问和修改同步状态

  1. getState():获取当前同步状态
  2. setState():设置当前同步状态
  3. compareAndSetState():CAS设置当前状态,保证此次操作的原子性

独占式获取和释放同步状态

  1. boolean tryAcquire():独占式获取同步状态
  2. boolean tryRelease():独占式释放同步状态

共享式获取同步状态

  1. int tryAcquireShared(int):共享式获取同步状态,返回值大于0则表示获取成功

  2. boolean tryReleaseShared(int):共享式释放同步状态,返回值为true表示释放成功

  3. booleean isHeldExclusively():判断是否是独占式同步状态

查询同步队列中的等待线程情况

  1. Collection<Thread> getQueuedThreads():获取等待在同步队列上的线程集合

我们可以通过上面的模板方法自定义同步组件,下面是一个独占锁的实现Mutex,在同一时刻只允许一个线程占有锁。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class Mutex implements Lock {
// 自定义同步组件
private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int acquires) {
// 状态为0时可获取锁
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
// 释放锁, 将状态设置为0
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
@Override
protected boolean isHeldExclusively() {
// 声明这是一个独占式锁
return true;
}
}

private final Sync sync = new Sync();
@Override
public void lock() { sync.acquire(1); }
@Override
public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); }
@Override
public boolean tryLock() { return sync.tryAcquire(1); }
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}
@Override
public void unlock() { sync.tryRelease(0); }
@Override
public Condition newCondition() { return null; }

public static void main(String[] args) {
Mutex mutex = new Mutex();
Thread a = new Thread(() -> {
boolean success = mutex.tryLock();
if (success) {
System.out.println("Thread-A succeeded in locking Mutex->mutex");
} else {
System.out.println("Thread-A failed in locking Mutex->mutex");
}
});
Thread b = new Thread(() -> {
boolean success = mutex.tryLock();
if (success) {
System.out.println("Thread-B succeeded in locking Mutex->mutex");
} else {
System.out.println("Thread-B failed in locking Mutex->mutex");
}
});
a.start();
b.start();
}

}

通过定义静态内部类Sync继承同步队列器实现自定义同步组件,将锁的操作代理给Mutex完成,所有锁的操作都是通过Mutex去调用,上面代码执行后效果与Lock相同,在同一时刻只允许一个线程获取锁,但是Mutex类有一个弊端,无法实现可重入,线程获取到锁之后如果再次调用tryLock()等方法,就会阻塞自己,我阻塞我自己。

AQS使用总结

  1. AQS用于自定义同步组件,可以在开发中按照自己的意愿开发出同步组件

可重入锁(ReetrantLock)

可重入锁支持同一个线程对已经获取到锁的资源重复加锁,具体有公平锁和非公平锁两种实现。通过构造函数指定锁的公平性。

公平锁和非公平锁的区别在于线程获取锁的优先级,非公平锁可能会导致线程饥饿,公平锁则按线程请求顺序获取锁,先请求先获取。

1
2
3
4
5
6
Lock fairLock = new ReentrantLock(true);
// ReentrantLock的构造函数
public ReentrantLock(boolean fair) {
// FairSync和NonFairSync为两种自定义同步组件实现公平锁和非公平锁
sync = fair ? new FairSync() : new NonfairSync();
}

两种可重入锁

非公平锁

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
/**
* @author Zeng
* @date 2020/4/27 16:51
*/
public class NonFairReentrantLockTest {

private static final Integer THREAD_COUNTS = 10;

Lock lock = new ReentrantLock(false);

private void testLock() {
lock.lock();
System.out.println(Thread.currentThread().getName() + "成功获取锁");
lock.unlock();
}

public static void main(String[] args) {
ReentrantLockTest test = new ReentrantLockTest();
Runnable runnable = () -> {
test.testLock();
};
Thread[] threads = new Thread[THREAD_COUNTS];
for (int i = 0; i < THREAD_COUNTS; i++) {
threads[i] = new Thread(runnable, "Sub-Thread-"+i);
threads[i].start();
}
}
}

可以看到线程之间不是按顺序获取锁的

公平锁

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
package Lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
* @author Zeng
* @date 2020/4/27 16:51
*/
public class FairReentrantLockTest {

private static final Integer THREAD_COUNTS = 10;

Lock lock = new ReentrantLock(true);

private void testLock() {
lock.lock();
System.out.println(Thread.currentThread().getName() + "成功获取锁");
lock.unlock();
}

public static void main(String[] args) {
FairReentrantLockTest test = new FairReentrantLockTest();
Runnable runnable = () -> {
test.testLock();
};
Thread[] threads = new Thread[THREAD_COUNTS];
for (int i = 0; i < THREAD_COUNTS; i++) {
threads[i] = new Thread(runnable, "Sub-Thread-"+i);
threads[i].start();
}
}
}

可以看到线程请求顺序获取锁,其原理是在同步组件内部维护着一个同步请求队列,在下一篇会详细说明。

两种可重入锁的优缺点

非公平锁的性能普遍高于公平锁的性能,公平锁保证了锁的获取按照请求顺序FIFO原则,而代价是大量的线程切换,将挂起的线程状态转换成运行态需要恢复现场,延迟很大;而非公平锁可以更好地利用CPU的时间片,减少CPU空闲时间,但容易导致线程饥饿,因为一个线程在释放锁后有极大的概率再次获取锁。

使用场景

公平锁可以对线程进行更强的可控性,而非公平锁则可以提高性能,假如需要对用户的请求顺序进行控制,那么公平锁是一个不错的选择,如果追求任务执行的速度,那么非公平锁将会是一个更佳的选择。

读写锁(ReadWriteLock)

读锁是共享锁(所有线程都可以获取锁),写锁为排他锁(同一时刻只有一个线程可以获取到锁)

应用场景

对集合的访问操作多于更新(插入、更新、删除)操作时可以使用读写锁,典型的Java中的CopyOnWriteArrayList就是采用读写锁实现的

ReentrantReadWriteLock

ReentrantReadWriteLock特性

  1. 公平性选择

支持公平性/非公平(默认)的获取方式,吞吐量是非公平高于公平

  1. 重进入

读线程获取读锁后可以再次获取读锁,写线程获取写锁后可以再次获取写锁,也可以再次获取读锁

  1. 锁降级

遵循获取写锁、获取读锁再释放写锁的顺序,写锁能降级为读锁

ReentrantReadWriteLock使用方式

  1. readLock():获取读锁
  2. writeLock():获取写锁
  3. int getReadLockCount():返回读锁被获取的次数
  4. int getReadHoldCount():返回当前线程获取读锁的次数
  5. boolean isWriteLocked():判断写锁是否被获取
  6. int getWriteHoldCount():返回当前线程获取写锁的次数

ReentrantReadWriteLock示例

利用一个HashMap与Cache作组合实现线程安全的缓存

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
34
35
36
37
38
39
40
41
42
43
44
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
* @author Zeng
* @date 2020/4/27 18:34
*/
public class Cache {

static Map<String, Object> map = new HashMap<>(16);
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(true);
static Lock readLock = rwl.readLock();
static Lock writeLock = rwl.writeLock();
// 获取一个key对应的value
public static final Object get(String key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
// 设置key对应的value, 并返回旧的value
public static final Object put(String key, Object value) {
writeLock.lock();
try {
return map.put(key, value);
} finally {
writeLock.unlock();
}
}
// 清空所有的内容
public static final void clear() {
writeLock.lock();
try {
map.clear();
} finally {
writeLock.unlock();
}
}

}
  1. 在调用get()方法时会申请获取读锁,而读锁是共享锁,所有线程均可以获取到读锁,并发访问下不会阻塞
  2. 写操作put(String key, Object value)clear()方法,在被调用时必须提前获取写锁,获取写锁后,其它读锁和写锁请求操作都会被阻塞,只有在写锁释放后,其它读写操作才能继续操作。

读写锁总结

读写锁通过共享读锁提升并发性,使用排他写锁保证每一次写操作都对所有的读写操作可见,这就是该锁高效之处,在日常开发中如果访问操作较多,写操作较少的情况下,读写锁是一种非常高效的工具。

Condition接口(依赖Lock进行使用)

  1. Condition与Lock接口配合实现等待/通知机制,必须由Lock实例调用newCondition()返回Condition实例。

  2. Condition是一个等待队列,该队列中存储了请求操作未成功的线程,这些线程已经全部成功获取过Lock锁,由于没有完成操作而加入Condition等待队列,进而放弃Lock锁。

Condition使用方式

  1. await():释放锁并进入等待状态,其它线程调用signal()signalAll()方法,且当前线程被选中唤醒,则返回
  2. awaitUninterruptibly():不支持中断的等待状态
  3. long awaitNanos(long):等待nanos纳秒,超时、中断或被唤醒则返回,返回值为剩余时间
  4. boolean awaitUntil(Date):等待直到到达某一个时间点,如果被中断、到达终点、被唤醒则返回,返回值代表是否在终点前被通知
  5. signal():唤醒在该Condition对象上等待的某个线程
  6. signalAll():唤醒所有在Condition上等待的线程

Condition使用示例

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* @author Zeng
* @date 2020/4/27 21:07
*/
public class BoundedQueue<T> {

private Object[] items;
// 添加的下标,删除的下标和数组当前数量
private int addIndex, removeIndex, count;
private Lock lock = new ReentrantLock();
// 如果队列为空, 则请求删除线程加入到删除等待队列中, 只有队列有元素存在时才唤醒
private Condition notEmpty = lock.newCondition();
// 如果队列已满, 则请求插入线程加入到插入等待队列当中, 只有队列有空位时才可以插入
private Condition notFull = lock.newCondition();

public BoundedQueue(int size) {
items = new Object[size];
}
// 添加一个元素, 如果数组已满, 则添加线程加入到等待队列当中, 进入等待状态, 直到有“空位”
public void add(T t) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[addIndex] = t;
if (++addIndex == items.length) {
addIndex = 0;
}
count++;
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
// 从头部删除一个元素, 如果数组为空, 则删除线程加入到等待队列当中, 进入等待状态, 直到有“空位”
public T remove() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await();
}
Object x = items[removeIndex];
if (++removeIndex == items.length) {
removeIndex = 0;
}
--count;
notFull.signalAll();
return (T) x;
} finally {
lock.unlock();
}
}

}
  1. BoundQueue是一个顺序循环队列
  2. 使用notEmptynotFull两个等待队列存放等待操作的线程,如果被调用该对象的signal()/signalAll()方法会唤醒两个队列的线程重新尝试获取ReentrantLock锁进行相应操作。

总结

Condition是依赖Lock接口实现等待/通知机制的一个工具,通过Lock进行对象的获取,在使用等待/通知机制时可以考虑使用Lock + Condition进行使用。

LockSupport工具

LockSupport提供最基本的线程阻塞和唤醒功能

LockSupport使用方式

  1. void park():阻塞当前线程,只有调用unpark()方法或者被中断才能够返回
  2. void parkNanos(long nanos):阻塞当前线程,最长不超过nanos纳秒
  3. void unpark(Thread thread):唤醒处于阻塞状态的线程thread
  4. void parkUntil(long deadline):阻塞当前线程,直到deadline时间(从1970年到deadline的毫秒数)

Java6新增的3个方法

  1. void park(Object blocker):阻塞当前线程的某个对象blocker,该对象主要用于问题排查和系统监控
  2. void parkNanos(Object blocker, long nanos):阻塞blocker对象,最长不超过nanos纳秒
  3. void parkUntil(Object blocker, long deadline):阻塞blocker对象,直到deadline时间(从1970年到deadline的毫秒数)

使用场景

  1. 用于阻塞和唤醒某个线程,作为构建同步组件的基础工具

总结

这一篇文章对所有关于锁的组件如何使用以及应用场景进行详细的描述和总结,相信会对这些工具会有一个初步的了解,之后会对各个重要的工具的底层实现进行剖析!感谢你的阅读!

巨人的肩膀:

《Java并发编程的艺术》

Java多线程-公平锁与非公平锁

0%