点赞再看,已成习惯。

序言

我们在前面的文章中详细介绍了 jdk 自带的可重入读写锁使用及其源码。

本节就让我们一起来实现一个读写锁。

最基础的版本

思路

我们先实现一个最基础版本的读写锁,便于大家理接最核心的部分。

后续将在这个基础上持续优化。

接口定义

为了后续拓展,我们统一定义基础的接口,一共 4 个方法:

package com.github.houbb.lock.api.core;

/**
 * 读写锁定义接口
 * @author binbin.hou
 * @since 0.0.2
 */
public interface IReadWriteLock {

    /**
     * 获取读锁
     * @since 0.0.2
     */
    void lockRead();

    /**
     * 释放读锁
     */
    void unlockRead();

    /**
     * 获取写锁
     * @since 0.0.2
     */
    void lockWrite();

    /**
     * 释放写锁
     */
    void unlockWrite();

}

类定义

/**
 * 读写锁实现
 *
 * @author binbin.hou
 * @since 0.0.2
 */
public class LockReadWrite implements IReadWriteLock {

    private static final Log log = LogFactory.getLog(LockReadWrite.class);

    /**
     * 读次数统计
     */
    private int readCount = 0;

    /**
     * 写次数统计
     */
    private int writeCount = 0;

}

我们这里实现 IReadWriteLock 接口,LockReadWrite 定义了两个属性,用于计算读写的次数。

获取读锁

这里通过 tryLock 获取读锁,通过 wait 进入等待。

如果获取读锁成功,则 readCount++,这个值主要用于标识是否有读操作。

/**
 * 获取读锁,读锁在写锁不存在的时候才能获取
 *
 * @since 0.0.2
 */
@Override
public synchronized void lockRead() {
    try {
        // 写锁存在,需要wait
        while (!tryLockRead()) {
            wait();
        }
        readCount++;
    } catch (InterruptedException e) {
        Thread.interrupted();
        // 忽略打断
    }
}

/**
 * 尝试获取读锁
 *
 * @return 是否成功
 * @since 0.0.2
 */
private boolean tryLockRead() {
    if (writeCount > 0) {
        log.debug("当前有写锁,获取读锁失败");
        return false;
    }
    return true;
}

tryLockRead 尝试获取读锁,读写互斥,读读不互斥,所以有写锁操作的时候,会导致获取读锁失败。

释放读锁

释放读锁的操作非常简单,直接 readCount-1,然后唤醒所有等待的线程。

/**
 * 释放读锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void unlockRead() {
    readCount--;
    notifyAll();
}

获取写锁

尝试获取写锁的条件和读写有一些差异:

写操作和读写操作都是互斥的,所以当前如果存在其他操作,都会获取锁失败。

/**
 * 获取写锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void lockWrite() {
    try {
        // 写锁存在,需要wait
        while (!tryLockWrite()) {
            wait();
        }
        // 此时已经不存在获取写锁的线程了,因此占坑,防止写锁饥饿
        writeCount++;
    } catch (InterruptedException e) {
        Thread.interrupted();
    }
}

/**
 * 尝试获取写锁
 *
 * @return 是否成功
 * @since 0.0.2
 */
private boolean tryLockWrite() {
    if (writeCount > 0) {
        log.debug("当前有其他写锁,获取写锁失败");
        return false;
    }
    // 读锁
    if (readCount > 0) {
        log.debug("当前有其他读锁,获取写锁失败。");
        return false;
    }
    return true;
}

释放写锁

释放写锁的操作也非常简单,写操作计数器-1,并且唤醒所有的等待线程。

/**
 * 释放写锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void unlockWrite() {
    writeCount--;
    notifyAll();
}

思考

为什么使用 notifyAll() 而不是 notify()?

要解释这个原因,我们可以想象下面一种情形:

如果有线程在等待获取读锁,同时又有线程在等待获取写锁。如果这时其中一个等待读锁的线程被notify方法唤醒,但因为此时仍有请求写锁的线程存在(writeRequests>0),所以被唤醒的线程会再次进入阻塞状态。然而,等待写锁的线程一个也没被唤醒,就像什么也没发生过一样(译者注:信号丢失现象)。如果用的是notifyAll方法,所有的线程都会被唤醒,然后判断能否获得其请求的锁。

用notifyAll还有一个好处。如果有多个读线程在等待读锁且没有线程在等待写锁时,调用unlockWrite()后,所有等待读锁的线程都能立马成功获取读锁 —— 而不是一次只允许一个。

锁是属于谁的?

问题

上面的实现是一个最基本的读写锁实现流程,但是存在一个很大的问题,没有校验释放锁的归属权问题。

想想你正在带薪,忽然一位哥们把你的们直接打开了,多尴尬。

所以我们需要通过 CAS 进行比较是否为预期的线程信息,然后才能进行替换和锁的释放等操作。

类定义

我们重新实现一个可以校验锁归属的实现读写锁实现:


/**
 * 读写锁实现-保证释放锁时为锁的持有者
 *
 * @author binbin.hou
 * @since 0.0.2
 */
public class LockReadWriteOwner implements IReadWriteLock {

    private static final Log log = LogFactory.getLog(LockReadWriteOwner.class);

    /**
     * 如果使用类似 write 的方式,会导致读锁只能有一个。
     * 调整为使用 HashMap 存放读的信息
     *
     * @since 0.0.2
     */
    private final Map<Thread, Integer> readCountMap = new HashMap<>();

    /**
     * volatile 引用,保证线程间的可见性+易变性
     *
     * @since 0.0.2
     */
    private final AtomicReference<Thread> writeOwner = new AtomicReference<>();

    /**
     * 写次数统计
     */
    private int writeCount = 0;

}

获取读锁

/**
 * 获取读锁,读锁在写锁不存在的时候才能获取
 *
 * @since 0.0.2
 */
@Override
public synchronized void lockRead() {
    try {
        // 写锁存在,需要wait
        while (!tryLockRead()) {
            log.debug("获取读锁失败,进入等待状态。");
            wait();
        }
    } catch (InterruptedException e) {
        Thread.interrupted();
    }
}

/**
 * 尝试获取读锁
 *
 * 读锁之间是不互斥的,这里后续需要优化。
 *
 * @return 是否成功
 * @since 0.0.2
 */
private boolean tryLockRead() {
    if (writeCount > 0) {
        log.debug("当前有写锁,获取读锁失败");
        return false;
    }
    Thread currentThread = Thread.currentThread();
    // 次数暂时固定为1,后面如果实现可重入,这里可以改进。
    this.readCountMap.put(currentThread, 1);
    return true;
}

每次尝试获取读锁的时候,我们都将当前线程作为 key 放入 readCountMap 中,对应的值暂时为1。

释放读锁

释放读锁的时候,我们就会进行归属权校验。

如果获取失败,则说明不是当前锁的持有者,则直接释放失败。

/**
 * 释放读锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void unlockRead() {
    Thread currentThread = Thread.currentThread();
    Integer readCount = readCountMap.get(currentThread);
    if (readCount == null) {
        throw new RuntimeException("当前线程未持有任何读锁,释放锁失败!");
    } else {
        log.debug("释放读锁,唤醒所有等待线程。");
        readCountMap.remove(currentThread);
        notifyAll();
    }
}

获取写锁

/**
 * 获取写锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void lockWrite() {
    try {
        // 写锁存在,需要wait
        while (!tryLockWrite()) {
            wait();
        }
        // 此时已经不存在获取写锁的线程了,因此占坑,防止写锁饥饿
        writeCount++;
    } catch (InterruptedException e) {
        Thread.interrupted();
    }
}

/**
 * 尝试获取写锁
 *
 * @return 是否成功
 * @since 0.0.2
 */
private boolean tryLockWrite() {
    if (writeCount > 0) {
        log.debug("当前有其他写锁,获取写锁失败");
        return false;
    }
    // 读锁
    if (!readCountMap.isEmpty()) {
        log.debug("当前有其他读锁,获取写锁失败。");
        return false;
    }
    Thread currentThread = Thread.currentThread();
    boolean result = writeOwner.compareAndSet(null, currentThread);
    log.debug("尝试获取写锁结果:{}", result);
    return result;
}

尝试获取写锁时,判断是否有写的条件不变。

如果 readCountMap 不为空,则说明存在写锁。

通过 CAS 设置对应的写线程持有信息,返回是否设置成功。

释放写锁

释放写锁的逻辑和原来类似,只不过添加了一个 owner 持有权的校验。

/**
 * 释放写锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void unlockWrite() {
    boolean toNullResult = writeOwner.compareAndSet(Thread.currentThread(), null);
    if (toNullResult) {
        writeCount--;
        log.debug("写锁释放,唤醒所有等待线程。");
        notifyAll();
    } else {
        throw new RuntimeException("释放写锁失败");
    }
}

可重入的支持

说明

我们上一小节实现了一个支持验证锁持有者的读写锁。

下面来看一下如何实现一个可重入的读写锁。

类定义

/**
 * 读写锁实现-可重入锁
 *
 * @author binbin.hou
 * @since 0.0.2
 */
public class LockReadWriteRe implements IReadWriteLock {

    private static final Log log = LogFactory.getLog(LockReadWriteRe.class);

    /**
     * 如果使用类似 write 的方式,会导致读锁只能有一个。
     * 调整为使用 HashMap 存放读的信息
     *
     * @since 0.0.2
     */
    private final Map<Thread, Integer> readCountMap = new HashMap<>();

    /**
     * volatile 引用,保证线程间的可见性+易变性
     *
     * @since 0.0.2
     */
    private final AtomicReference<Thread> writeOwner = new AtomicReference<>();

    /**
     * 写次数统计
     */
    private int writeCount = 0;

}

基本的属性和上一小节是一样的。

获取读锁

/**
 * 获取读锁,读锁在写锁不存在的时候才能获取
 *
 * @since 0.0.2
 */
@Override
public synchronized void lockRead() {
    try {
        // 写锁存在,需要wait
        while (!tryLockRead()) {
            log.debug("获取读锁失败,进入等待状态。");
            wait();
        }
    } catch (InterruptedException e) {
        Thread.interrupted();
    }
}

/**
 * 尝试获取读锁
 *
 * 读锁之间是不互斥的,这里后续需要优化。
 * 
 * @return 是否成功
 * @since 0.0.2
 */
private boolean tryLockRead() {
    if (writeCount > 0) {
        log.debug("当前有写锁,获取读锁失败");
        return false;
    }
    Thread currentThread = Thread.currentThread();
    Integer count = readCountMap.get(currentThread);
    if(count == null) {
        count = 0;
    }
    // 可重入实现
    count++;
    this.readCountMap.put(currentThread, count);
    return true;
}

这里和以前的区别就是支持可重入,通过 count 来维护每一个线程对应的读总数。

释放读锁

/**
 * 释放读锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void unlockRead() {
    Thread currentThread = Thread.currentThread();
    Integer readCount = readCountMap.get(currentThread);
    if (readCount == null) {
        throw new RuntimeException("当前线程未持有任何读锁,释放锁失败!");
    } else {
        readCount--;
        // 已经是最后一次
        if(readCount == 0) {
            readCountMap.remove(currentThread);
        } else {
            readCountMap.put(currentThread, readCount);
        }
        log.debug("释放读锁,唤醒所有等待线程。");
        notifyAll();
    }
}

这里每次释放锁,会比较是否为当前锁的持有者。

如果 readCount 已经为0,就直接从 readCountMap 中移除。

获取写锁

/**
 * 获取写锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void lockWrite() {
    try {
        // 写锁存在,需要wait
        while (!tryLockWrite()) {
            log.debug("获取写锁失败,进入等待状态。");
            wait();
        }
        // 此时已经不存在获取写锁的线程了,因此占坑,防止写锁饥饿
        writeCount++;
    } catch (InterruptedException e) {
        Thread.interrupted();
    }
}
/**
 * 尝试获取写锁
 *
 * @return 是否成功
 * @since 0.0.2
 */
private boolean tryLockWrite() {
    if (writeCount > 0) {
        log.debug("当前有其他写锁,获取写锁失败");
        return false;
    }
    // 读锁
    if (!readCountMap.isEmpty()) {
        log.debug("当前有其他读锁,获取写锁失败。");
        return false;
    }
    Thread currentThread = Thread.currentThread();
    // 多次重入
    if(writeOwner.get() == currentThread) {
        log.debug("为当前写线程多次重入,直接返回 true。");
        return true;
    }
    boolean result = writeOwner.compareAndSet(null, currentThread);
    log.debug("尝试获取写锁结果:{}", result);
    return result;
}

如果当前线程时持有锁的线程,则直接返回 true。

释放写锁

释放写锁的时候,支持多次写锁释放。

/**
 * 释放写锁
 *
 * @since 0.0.2
 */
@Override
public synchronized void unlockWrite() {
    Thread currentThread = Thread.currentThread();
    // 多次重入释放(当次数多于1时直接返回,否则需要释放 owner 信息)
    if(writeCount > 1 && (currentThread == writeOwner.get())) {
        log.debug("当前为写锁释放多次重入,直接返回成功。");
        unlockWriteNotify();
        return;
    }
    boolean toNullResult = writeOwner.compareAndSet(currentThread, null);
    if (toNullResult) {
        unlockWriteNotify();
    } else {
        throw new RuntimeException("释放写锁失败");
    }
}

/**
 * 释放写锁并且通知
 */
private synchronized void unlockWriteNotify() {
    writeCount--;
    log.debug("释放写锁成功,唤醒所有等待线程。");
    notifyAll();
}

小结

本节主要是为了让大家理解一下读写锁的实现原理。

从最基本的实现开始,逐步改进,实现了最基本的一个可重入的读写锁。

希望本文对你有帮助,如果有其他想法的话,也可以评论区和大家分享哦。

各位极客的点赞收藏转发,是老马持续写作的最大动力!