Java并发包下的锁(3)——重入锁和读写锁

重入锁 ReentrantLock 和读写锁 ReentrantReadWriteLock 是两个使用很广泛的同步组件,本文将详细介绍这两种锁特性、用法以及个别方法的源码分析

重入锁——ReentrantLock
1. ReentrantLock的特性
  • 支持对资源的重复加锁:已经获取锁的线程,再次调用lock()方法时,能够再次获取到锁而不被阻塞

  • 支持获取锁时公平性和非公平性的选择:默认是非公平性的锁

 synchronized关键字支持隐式的重进入,执行线程在获取了锁之后仍能连续多次的获取同一把锁而不被阻塞。ReentrantLock虽然不能隐式的重进入,但是支持显示的多次获取同一把锁。

2. 重进入的实现

实现重进入需要解决以下两个问题:

  • 线程再次获取锁: 锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是则再次获取锁成功

  • 锁的最终释放: 线程重复n次获取了锁,对应的需要n次解锁,第n次解锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,释放时则计数自减,当等于0时表示锁已经成功释放

 ReentrantLock 把聚合同步器的操作委托给了内部类 Sync,在构造器中会返回一个 Sync 对象,默认的是以非公平的方式获取锁,其源码如下:

// ReentrantLock 的无参构造器
public ReentrantLock() {
        // 返回一个非公平的Sync
        sync = new NonfairSync();
}

// 非公平获取锁的源码
final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        // 得到当前线程的同步状态
        int c = getState();
        // 如果为0,加锁
        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;
}

 ReentrantLock 加了几次锁,相对应的就要释放几次锁,下面是 ReentrantLock 释放锁的方法 tryRelease(int release) 的源码:

protected final boolean tryRelease(int releases) {
        // 没释放一次同步状态减1,直到为0
        int c = getState() - releases;
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        // 当同步状态为0,表示该线程已经释放完了锁
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        // 设置同步状态
        setState(c);
        return free;
}
3. 公平锁与非公平锁
  • 公平锁: 在一组线程里,保证每个线程都能拿到锁,即线程按照发出的请求顺序获取锁,不可抢占

  • 非公平锁: 在一组线程里,并不一定每个线程都能拿到锁,即允许线程插队获取锁,可抢占

下面是演示公平锁和非公平锁的例子:

public class TestFailUnfail {
    private ReentrantLock lock;

    public TestFailUnfail(boolean isFail) {
        lock = new ReentrantLock(isFail);
    }
    public void service() {
        lock.lock();
        try {
            System.out.println("ThreadName:" + Thread.currentThread().getName() + "获得了锁!");
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        // true : 公平锁
        // false : 非公平锁
        final TestFailUnfail test = new TestFailUnfail(true);

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("线程 " + Thread.currentThread().getName() + " 运行!");
                test.service();
            }
        };

        Thread[] threads = new Thread[5];

        for (int i = 0; i < 5; i++) {
            threads[i] = new Thread(runnable);
        }

        for (int i = 0; i < 5; i++) {
            threads[i].start();
        }
    }
}

// 公平锁:线程的启动顺序和加锁的顺序一致,说明先请求的线程先获得锁
线程 Thread-0 运行!
ThreadName:Thread-0获得了锁!
线程 Thread-1 运行!
ThreadName:Thread-1获得了锁!
线程 Thread-2 运行!
ThreadName:Thread-2获得了锁!
线程 Thread-3 运行!
ThreadName:Thread-3获得了锁!
线程 Thread-4 运行!
ThreadName:Thread-4获得了锁!


// 非公平锁:线程的启动顺序和加锁的顺序不一致,说明线程对锁发生了争抢,并且抢夺成功
线程 Thread-0 运行!
线程 Thread-2 运行!
线程 Thread-1 运行!
ThreadName:Thread-2获得了锁!
线程 Thread-3 运行!
ThreadName:Thread-1获得了锁!
线程 Thread-4 运行!
ThreadName:Thread-4获得了锁!
ThreadName:Thread-0获得了锁!
ThreadName:Thread-3获得了锁!

 上面的例子介绍了公平锁和非公平锁的区别,现在说说它是怎么实现的:其实要想先请求先获取,其原理不就是FIFO(先进先出)嘛,只要保证先入队列的线程先获得锁,后入队的线程等待就行,下面是公平获取锁 tryAcquire(int acquires) 的源码:

// 与 nonfairTryAcquire(int acquires) 相比,加了判断条件方法 hasQueuedPredecessors()
protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            // 判断一下该线程有没有头结点
            // 如果有:说明前面有线程在获取锁,等待
            // 如果没有:获取锁
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

公平锁和非公平锁的使用:

  • 公平锁保证了锁的获取按照FIFO原则,但是却付出了大量线程切换的代价,将一个被挂起的线程恢复执行需要消耗大量时间。其适合于业务线程占用时间较长的场景,可以增加可控性。

  • 非公平锁可能造成线程饥饿,即有一部分线程可能永远也无法获取到锁。但是其优势却很显著:只需要极少的线程切换,保证了更大的吞吐量。其适合于吞吐量要求比较高的场景(默认选择就是了)

4. 如何选择 synchronized 和 ReentrantLock

 ReentrantLock 在加锁的内存语义上与内置锁相同,此外还提供了一些其他的功能:定时的锁等待、可中断的锁等待、公平性以及非块结构的加锁等。既然ReentrantLock有如此多优越的特性,那我们是不是就要放弃使用 synchronized 呢?显然不是这样,自从JDK1.6对 synchronized 做了优化之后(偏向锁、轻量级锁等),内置锁已经不再那么的重量了。同时,内置锁有着很多优越的条件:隐式的重进入、简洁紧凑、易于使用和理解等等。

 所以关于两种锁的选择策略是:在一些内置锁无法满足需求的情况下,ReentrantLock 可以作为一种高级工具。当需要一些高级功能时才应该使用 ReentrantLock,如:可定时的、可轮询的与可中断的锁获取操作,公平队列以及非块结构的锁。否则,应该优先使用 synchronized。

读写锁——ReentrantReadWriteLock

 前面所说的锁都是独占锁(排他锁),即同一时刻只能有一个线程获取同一把锁。而读写锁是共享锁,即同一时刻可以允许多个读线程访问,在写线程访问时,其他所有的读线程和写线程阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大的性能提升。

在Java并发包中提供了ReentrantReadWriteLock来实现读写锁,其特性如下:

特性描述
公平性选择支持非公平(默认)和公平的锁获取方式,吞吐量非公平优于公平
重进入支持重进入,读线程获取了读锁之后能再次获取读锁;写线程获取了写锁之后能再次获取写锁,同时也可以获取读锁
锁降级遵循:获取写锁——获取读锁-释放写锁 的次序,写锁能够降级为读锁
1. 读锁示例

 下面的例子演示了使用读写锁实现缓存,缓存值存放于非线程安全的 HashMap 中,使用读锁来保证 get() 的安全性,使用写锁来保证 put() 的安全性。在执行 get() 操作时,所有的读线程都可以获得锁,但是所有的写线程均需阻塞等待;在执行 put() 操作时,只有一个写线程可以获得锁,其他的读线程和写线程均需阻塞等待,只有写锁被释放后,其他线程才可以获取锁。

public class TestReadWriteLock {
    private static Map<Integer, Object> map = new HashMap<>();
    private static ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
    // 读锁
    private static Lock readLock = rw.readLock();
    // 写锁
    private static Lock writeLock = rw.writeLock();

    // 获取一个key对应的value
    public static final Object get(Integer key) {
        readLock.lock();
        try {
            return map.get(key);
        } finally {
            readLock.unlock();
        }
    }

    // 设置key对应的value,并返回原value
    public static final Object put(Integer 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();
        }
    }

    public static void main(String[] args) {
        final TestReadWriteLock test = new TestReadWriteLock();

        String[] str1 = new String[5];
        String[] str2 = new String[5];
        Runnable runnable1 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                    test.put(i, "大米" + i);
                }
            }
        };
        Runnable runnable2 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                    str1[i] = (String) test.put(i, "蔬菜" + i);
                }
            }
        };

        Runnable runnable3 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                    str2[i] = (String) test.get(i);
                }
            }
        };

        Thread[] threadA = new Thread[5];
        Thread[] threadB = new Thread[5];
        Thread[] threadC = new Thread[5];

        for (int i = 0; i < 5; i++) {
            threadA[i] = new Thread(runnable1);
            threadB[i] = new Thread(runnable2);
            threadC[i] = new Thread(runnable3);
        }

        for (int i = 0; i < 5; i++) {
            threadA[i].start();
            threadB[i].start();
            threadC[i].start();
        }

        for (int i = 0; i < 5; i++) {
            System.out.println(str1[i]);
        }

        System.out.println("=========================");

        for (int i = 0; i < 5; i++) {
            System.out.println(str2[i]);
        }
    }
}

// 结果如下:
大米0
大米1
大米2
大米3
大米4
=========================
蔬菜0
蔬菜1
蔬菜2
蔬菜3
蔬菜4
2. 读写锁的实现
1. 读写状态的设计

 读写锁也依赖于同步器来实现同步功能,读写状态就是同步器的同步器状态,同步状态表示锁被一个线程功夫获取的次数。读写锁的同步状态的维护要比独占锁复杂一些,因为读锁可以被多个线程获取,那么自然会有多个读状态需要维护。

 如果在一个整型变量上维护多种状态,需要按位切割使用这个变量,读写锁将变量切分成两个部分:高16位表示读,低16位表示写

image

 上图中读状态为2,说明获取了两次读锁;写状态为3,表示获取了3次写锁。读写锁通过位运算来确定各自的状态,假设当前同步状态值为S:

  • 写状态等于 S & 0x0000FFFF,将高16位全部抹去。写状态增加1,等于 S+1

  • 读状态等于 S >>>,无符号右移16位,前面补0。读状态增加1,等于 S+(1 << 16),即 S+0x00010000

结论: S不等于0时,当写状态等于0,则读状态大于0,即读锁被获取

补充Java中的移位操作符:>>, <<, >>> 针对的是二进制位

  • >>: 带符号右移操作符,即按照指定位数整体右移,右移几位就在最左侧补上几位(注意:正数补0,负数补1)

  • <<: 左移操作符,即按照指定位数整体左移,左移几位就在最右侧补上几个0

  • >>>: Java独有的无符号右移操作符,无论正负,都在高位补上0

2. 写锁的获取与释放

 写锁是一个支持重进入的独占锁,其获取同步状态方法 tryAcquire(int acquires) 的源码如下:

protected final boolean tryAcquire(int acquires) {
        Thread current = Thread.currentThread();
        int c = getState();
        int w = exclusiveCount(c);
        // 同步状态不为0
        if (c != 0) {
            // (Note: if c != 0 and w == 0 then shared count != 0)
            // 写状态为0,没有线程获取到写锁
            // 但是同步状态不为0,说明读线程正在持有锁,写线程阻塞等待
            if (w == 0 || current != getExclusiveOwnerThread())
                return false;
            if (w + exclusiveCount(acquires) > MAX_COUNT)
                throw new Error("Maximum lock count exceeded");
            // Reentrant acquire
            // 设置同步状态
            setState(c + acquires);
            return true;
        }
        if (writerShouldBlock() ||
            !compareAndSetState(c, c + acquires))
            return false;
        setExclusiveOwnerThread(current);
        return true;
}
3. 读锁的获取与释放

 读锁是一个支持重进入的共享锁,它能被多个线程同时获取,其获取同步状态的方法 tryAcquireShared(int unused) 的源码如下:

protected final int tryAcquireShared(int unused) {
        Thread current = Thread.currentThread();
        int c = getState();
        // 如果写锁被另一线程占有,而不是当前线程占有,则获取失败
        if (exclusiveCount(c) != 0 &&
            getExclusiveOwnerThread() != current)
            return -1;
        int r = sharedCount(c);
        if (!readerShouldBlock() &&
            r < MAX_COUNT &&
            compareAndSetState(c, c + SHARED_UNIT)) {
            if (r == 0) {
                firstReader = current;
                firstReaderHoldCount = 1;
            } else if (firstReader == current) {
                firstReaderHoldCount++;
            // 重进入
            } else {
                HoldCounter rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    cachedHoldCounter = rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
            }
            return 1;
        }
        return fullTryAcquireShared(current);
}
4. 锁降级

 指写锁降级为读锁:把持住当前拥有的写锁,再次获取到读锁,随后释放写锁的过程(当前线程先拥有写锁,随后释放,再获取读锁,这种分段获取锁的过程不是锁降级)。锁降级示例如下:

public void process() {
    readLock.lock();
    if(!update) {
        // 先释放读锁
        readLock.unlock();
        // 锁降级从获取到写锁开始
        writeLock.lock();
        try {
            if(!update) {
                // 准备数据流程
                update = true;
            }
            // 获取读锁,还没有释放写锁
            readLock.lock();
        } finally {
            // 释放写锁
            writeLock.unlock();
        }
        // 锁降级完成
    }
    
    try {
        // 使用数据流程
    } finally {
        readLock.unlock();
    }
}

锁降级的作用: 保证数据的可见性。如果当前线程不获取读锁而直接释放写锁,假设此刻另一个线程T获取了写锁并修改了数据,当前线程则无法感知到T的数据更新。如果当前线程获取了读锁,遵循锁降级的步骤,线程T会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁、释放读锁),也是为了保证数据可见性,如果读锁已经被多个线程获取,其中任意线程获取了写锁并更新了数据,其更新操作对于其他线程是不可见的。

参考

《Java并发编程的艺术》

《Java并发编程实战》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值