前言
最近在看并发编程艺术这本书,对看书的一些笔记及个人工作中的总结。
Lock接口
锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。
public class LockUseCase {
public void lock() {
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock(); //保证锁一定能释放
}
}
}
Lock接口提供的synchronized关键字所不具备的主要特性
特性 | 描述 |
---|---|
尝试非阻塞地获取锁 | 当前线程尝试获取锁,如果这一时刻没有被其他线程获取到,则成功获取并持有锁 |
能被中断获取锁 | 与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放。 |
超时获取锁 | 在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回。 |
Lock是一个接口,它定义了锁获取和释放的基本操作,Lock的API如下:
方法名称 | 描述 |
---|---|
void lock() | 获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回 |
void lockInterruptibly | 可中断地获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程. |
boolean tryLock() | 尝试非阻塞的获取锁,调用该方法后立即返回,如果能够获取则返回true,否则返回false |
boolean tryLock(long time, TimeUnit unit) | 超时获取锁,具体解释看官方api |
void unlock() | 释放锁 |
Condition newCondition() | 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将释放锁。 |
ReentrantLock(重入锁)
- 重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁的还支持获取锁时的公平和非公平性选择。
- synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。
/**
* 在加锁的方法中再次获得加锁的方法
*/
public class ReentrantTest {
public synchronized void test1(){
System.out.println("test1..");
test2();
}
public synchronized void test2(){
System.out.println("test2..");
}
public static void main(String[] args) {
final ReentrantTest reentrantTest = new ReentrantTest();
new Thread(() -> reentrantTest.test1()).start();
}
}
/**
* 子类中调用父类带synchronized也是线程安全的
*/
public class ReentrantTest2 {
static class Main {
public synchronized void operationSup(){
System.out.println("main类的方法");
}
}
static class Sub extends Main {
public synchronized void operationSub(){
super.operationSup();
System.out.println("Sub类的方法");
}
}
public static void main(String[] args) {
final Sub sub = new Sub();
new Thread(() -> sub.operationSub()).start();
}
}
- ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
public class ReentrantTest3 {
private ReentrantLock lock = new ReentrantLock();
public void test1(){
try {
lock.lock();
System.out.println("进入m1方法,holdCount数为:" + lock.getHoldCount()); //1
//调用m2方法
test2();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void test2(){
try {
lock.lock();
System.out.println("进入m2方法,holdCount数为:" + lock.getHoldCount()); //2
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantTest3 thc = new ReentrantTest3();
thc.test1();
}
}
这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。
事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以TPS作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
原理:
重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,
1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态值进行增加并返回true,表示获取同步状态成功。
以上面的ReentrantTest3代码分析,
/**
* Acquires the lock.
*
* <p>Acquires the lock if it is not held by another thread and returns
* immediately, setting the lock hold count to one.
*
* <p>If the current thread already holds the lock then the hold
* count is incremented by one and the method returns immediately.
*
* <p>If the lock is held by another thread then the
* current thread becomes disabled for thread scheduling
* purposes and lies dormant until the lock has been acquired,
* at which time the lock hold count is set to one.
*/
public void lock() {
sync.lock();
}
然后进入非公平锁的实现:
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1)) //第一次执行是true,进入if逻辑
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
如果当前状态值等于期望值,则将同步状态原子设置为给定的更新值。 此操作具有内存可见性(volatile)的内存语义。
/**
* Atomically sets synchronization state to the given updated
* value if the current state value equals the expected value.
* This operation has memory semantics of a {@code volatile} read
* and write.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that the actual
* value was not equal to the expected value.
*/
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
设置当前线程为独占访问权限。
/**
* Sets the thread that currently owns exclusive access.
* A {@code null} argument indicates that no thread owns access.
* This method does not otherwise impose any synchronization or
* {@code volatile} field accesses.
* @param thread the owner thread
*/
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
重入锁的时候由进入了lock方法
final void lock() {
if (compareAndSetState(0, 1)). //false
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); //执行这一步
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
非公平锁的实现
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
/**
* Performs non-fair tryLock. tryAcquire is implemented in
* subclasses, but both need nonfair try for trylock method.
*/
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);
return true;
}
return false;
}
很明显判断此时去尝试获得锁的对象是否是占据锁的对象,是则表明可以重入。分析到此结束,其他的细节这边就不分析了。
ReentrantReadWriteLock(读写锁)
排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。
在没有读写锁支持的(Java 5之前)时候,如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock,
特性 | 说明 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁的获取方式,吞吐量还是非公平优于公平 |
重进入 | 该锁支持重进入,以读写线程为列,读线程在获取读锁之后,能够再次获取读锁,而写线程在获取了写锁之后能够再次获得写锁,同时也可以获得读锁。 |
锁降级 | 遵循获取写锁,获取读锁在释放写锁的次序,写锁能够降级为读锁 |
** 示列 **
//Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作get(String key)方法中,
//需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,
//当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。
//Cache使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。
public class Cache {
private static final Map<String, Object> map = new HashMap<>();
private static final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private static final Lock r = rwl.readLock();
private static final Lock w = rwl.writeLock();
//获取一个key对应的value,使用的读锁
public static final Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
//设置key对应的value,并返回旧的value,写锁
public static final Object put(String key, Object value) {
w.lock();
try {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
return null;
}
return map.put(key, value);
} finally {
w.unlock();
}
}
//清空所有的内容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
//验证了写锁一旦获取锁,其他线程的读写都处于阻塞
public static void main(String[] args) {
new Thread(() -> Cache.put("username","miaozhihao")).start();
new Thread(() -> {
String username = (String)Cache.get("username");
System.out.println(username);
}).start();
}
}
这个列子很好的展示了写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行。也就是如果读取操作时候有写操作,那么必须等待写操作完成之后才能进行读取操作。
读写锁的实现分析
主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级
读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
锁降级:锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
代码示例:
public class ProcessData {
private static final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private static final Lock readLock = rwl.readLock();
private static final Lock writeLock = rwl.writeLock();
private volatile boolean update = false;
public void processData() {
readLock.lock();
if (!update) {
//必须先释放读锁
readLock.unlock();
//锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}
}
如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。
一个先后顺序,如果是写锁先占用,那么所有的读锁就被阻塞;如果是读锁被占用,那么此后的写锁修改的内容读锁线程不可见。