Java并发编程(十三):显式锁之ReentrantLock与ReentrantReadWriteLock

大家好,我是欧阳方超,公众号同名。在这里插入图片描述

1 概述

在Java中,除了使用synchronized关键字实现同步之外,还可以使用显式锁(explicit lock)。显式锁主要通过Lock接口及其实现类类完成。相比synchronized,显式锁提供了更多的功能和更好的性能。本文主要介绍下显式锁中两个比较典型的类:ReentrantLock与ReentrantReadWriteLock。

2 主要特点和核心概念

2.1 什么是显式锁

在 Java 中,显式锁是通过java.util.concurrent.locks包中的Lock接口及其实现类来提供的一种更灵活的线程同步机制。与内置的synchronized关键字不同,显式锁需要开发者在代码中显式地获取和释放锁。

2.2 Lock接口的主要实现类

ReentrantLock:可重入锁,是Lock接口最常用的实现类。
ReentrantReadWriteLock:读写锁,允许多个线程同时读,但只允许一个线程写。
StampedLock:JDK 8引入的新锁,在读写锁的基础上优化了读操作。

2.3 Lock接口的核心方法

void lock();  // 获取锁
void unlock();  // 释放锁
boolean tryLock();  // 尝试获取锁,立即返回
boolean tryLock(long time, TimeUnit unit);  // 尝试在指定时间内获取锁
void lockInterruptibly();  // 获取可中断的锁
Condition newCondition();  // 获取等待通知组件

3 ReentrantLock使用示例

3.1 ReentrantLock的基本操作

获取锁(lock () 方法)
当一个线程调用lock()方法时,如果锁没有被其他线程占用,那么这个线程将获取到锁,然后可以执行被锁保护的代码块。如果锁已经被其他线程占用,那么这个线程将被阻塞,直到锁被释放。
释放锁(unlock () 方法)
当线程完成对共享资源的访问后,必须调用unlock()方法来释放锁,这样其他等待锁的线程才有机会获取锁。需要注意的是,unlock()方法应该放在finally块中,以确保即使在获取锁后的代码块中发生了异常,锁也能被正确释放。
Lock接口的主要实现类是ReentrantLock(可重入锁)。可重入锁意味着一个线程可以多次获取同一个锁,只要每次获取和释放的次数匹配即可。下面通过示例演示一下:

import java.util.concurrent.locks.ReentrantLock;

public class BankAccountDemo {
    private final ReentrantLock lock = new ReentrantLock();
    private double balance = 1000.0;

    //
    public double checkBalance() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 获取第一层锁");
            Thread.sleep(100);
            return getBalance();
        } catch (InterruptedException e) {
            e.printStackTrace();
            return -1;
        } finally {
            System.out.println(Thread.currentThread().getName() + " 获取第一层锁");
            lock.unlock();
        }
    }

    private double getBalance() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 获取第二层锁");
            System.out.println(Thread.currentThread().getName() + " 当前持有锁的次数:" + lock.getHoldCount());
            return balance;
        } finally {
            System.out.println(Thread.currentThread().getName() + " 释放第二层锁");
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        BankAccountDemo account = new BankAccountDemo();
        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " 开始查询余额");
            double balance = account.checkBalance();
            System.out.println(Thread.currentThread().getName() + " 查询到余额:" + balance);
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task, "Thread-" + i).start();
        }
    }

}

上面的代码演示了同一个线程可以多次获取同一个锁,在进入checkBalance()方法时,会获取第一层锁,同时在finally中释放第一层锁,期间调用了getBalance()方法,会获取第二层锁,同时在其finally中是否第二层锁,执行上面的程序可以看到如下的输出结果:

Thread-0 开始查询余额
Thread-1 开始查询余额
Thread-2 开始查询余额
Thread-2 获取第一层锁
Thread-2 获取第二层锁
Thread-2 当前持有锁的次数:2
Thread-2 释放第二层锁
Thread-2 获取第一层锁
Thread-0 获取第一层锁
Thread-2 查询到余额:1000.0
Thread-0 获取第二层锁
Thread-0 当前持有锁的次数:2
Thread-0 释放第二层锁
Thread-0 获取第一层锁
Thread-1 获取第一层锁
Thread-0 查询到余额:1000.0
Thread-1 获取第二层锁
Thread-1 当前持有锁的次数:2
Thread-1 释放第二层锁
Thread-1 获取第一层锁
Thread-1 查询到余额:1000.0

3.2 ReentrantLock的优势

更高的灵活性
可以在更精细的控制下进行加锁和解锁操作。比如,可以在一个方法的中间部分获取锁,而不是像synchronized关键字那样只能在方法开始处获取锁。
可以实现非阻塞式的获取锁尝试。通过tryLock()方法,可以尝试获取锁,如果锁不可用,则立即返回,不会像synchronized那样一直阻塞等待。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

public class TryLockExample {
    private static final Logger log = LoggerFactory.getLogger(TryLockExample.class);
    private final ReentrantLock lock = new ReentrantLock();

    public void performTask() {
        if (lock.tryLock()) {
            try {
                System.out.println(Thread.currentThread().getName() + ": Lock acquired, performing task.");
                //模拟任务执行
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + ": Lock released.");
            }
        } else {
            System.out.println(Thread.currentThread().getName() + ": Unable to acquire lock.");
        }
    }

    public void performTaskWithTimeout() {
        try {
            if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
                System.out.println(Thread.currentThread().getName() + ": Lock acquired with timeout, performing task.");
                //模拟任务执行
                Thread.sleep(1000);
            } else {
                System.out.println(Thread.currentThread().getName() + ": Unable to acquire lock with timeout.");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
            System.out.println(Thread.currentThread().getName() + ": Lock released.");
        }
    }

    public static void main(String[] args) {
        TryLockExample tryLockExample = new TryLockExample();
        Runnable performTask = tryLockExample::performTask;
        Runnable performTaskWithTimeout = tryLockExample::performTaskWithTimeout;
        new Thread(performTask, "Thread-1").start();
        new Thread(performTaskWithTimeout, "Thread-2").start();
    }

}

上面的代码,performTask()方法以非阻塞式的方式尝试获取锁,如果成功则执行任务,否则立即返回,performTaskWithTimeout()方法也以非阻塞式的方式尝试获取锁,唯一的不同在于它调用了带有两个参数的tryLock(long timeout, TimeUnit unit)方法,这意味着会在一定超时时间内获取锁,如果在超时时间内为获取到锁则返回flase。

支持公平锁和非公平锁
公平锁:在这种模式下,线程按照请求锁的顺序获得锁,即先到先得。这有效地避免了线程饥饿现象,因为所有线程都有机会按照它们请求的顺序获取锁。
非公平锁:在这种模式下,线程可以在任何时候尝试获取锁,而不考虑请求的顺序。这意味着后到的线程可能会抢占到锁,导致某些线程长时间无法获得锁。
ReentrantLock构造函数可以传入一个布尔值来指定是公平锁还是非公平锁。公平锁按照线程请求锁的顺序来分配锁,而非公平锁则允许插队的情况。非公平锁在性能上通常比公平锁要好,因为它减少了线程切换的开销。

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

public class FairAndUnfairLockExample {

    public static void main(String[] args) {
        // 创建一个公平锁
        Lock fairLock = new ReentrantLock(true);
        // 创建一个非公平锁
        Lock unfairLock = new ReentrantLock(false);



        System.out.println("Using Fair Lock:");
        testLock(fairLock);

        /*System.out.println("Using Unfair Lock:");
        testLock(unfairLock);*/
    }

    private static void testLock(Lock lock) {
        Worker worker = new Worker(lock);
        for (int i = 0; i < 5; i++) {
            new Thread(worker, "Thread-" + i).start();
        }
    }

    static class Worker implements Runnable {
        private final Lock lock;

        Worker(Lock lock) {
            this.lock = lock;
        }

        @Override
        public void run() {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " acquired the lock");
                // Simulate some work with the lock held
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + " released the lock");
            }
        }
    }
}

说明:
ReentrantLock(true): 创建一个公平锁。线程将按照请求锁的顺序依次获取锁。
ReentrantLock(false): 创建一个非公平锁。线程可能会插队获取锁,不保证顺序。
运行效果
公平锁,执行程序时会发现线程几乎按照它们启动的顺序获取锁。
非公平锁,线程获取锁的顺序可能会出现插队的情况,表现得更随机。
性能对比
公平锁,确保锁的分配顺序,但是可能会导致更高的线程切换开销,尤其是在竞争激烈的情况下。
非公平锁,通常在高并发环境下性能更好,因为减少了线程切换的开销。
饥饿问题:公平锁通过FIFO队列机制减少了线程饥饿的问题,但可能导致性能下降。
使用场景:根据具体需求选择合适的类型,如果对性能要求较高且可以接受一定程度的不公平,选择非公平锁;如果需要确保所有线程都有机会获取资源,则选择公平锁。

4 ReentrantReadWriteLock使用示例

4.1 ReentrentLock概念与使用场景

ReentrentLock是Java中的一种读写锁,旨在提高多线程环境中对共享资源的访问效率,读写锁是一种更高级的锁机制,它允许同时有多个线程对共享资源进行读取操作,但在有线程进行写入操作时,其他线程(包括读线程和写线程)都需要等待。java.util.concurrent.locks包中的ReentrantReadWriteLock是其实现类。
应用场景:适用于数据被读取的频率远高于被写入的情况,像一些配置文件的读取、缓存数据的读取等场景。

4.2 读写锁机制

4.2.1 锁的类型

读锁(共享锁):多个线程可以同时持有读锁,只要没有线程持有写锁,适用于并发读取数据的场景。
写锁(独占锁):只有一个线程可以持有写锁,且在写锁被持有时,其他任何线程(包括读锁)都无法获取该锁,即被阻塞。

4.2.2 进入条件

读锁:
没有其他线程持有写锁。
可以有多个线程同时获取读锁。
写锁:
没有其他线程持有读锁或写锁。
只有一个线程可以获取写锁。

4.2.3 锁的降级与升级

降级:一个线程可以在持有写锁的情况下,先释放写锁再获取读锁。这种情况被称为“降级”,允许从独占状态转变为共享状态。
升级:从读锁升级到写锁是不被允许的,这样做可能会导致死锁。

4.3 使用示例

例如,一个缓存系统,多个线程可以同时读取缓存中的数据,但当有一个线程要更新缓存数据时,其他线程必须等待更新完成。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cacher {

    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock readLock = rwLock.readLock();
    private final Lock writeLock = rwLock.writeLock();

    private int cacheData = 0;

    public void readData() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " reading data: " + cacheData);
            Thread.sleep(200);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            readLock.unlock();
        }
    }

    public void writeData(int value) {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " writing data: " + value);
            cacheData += value;
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        Cacher cacher = new Cacher();

        Runnable readData = cacher::readData;
        Runnable writeData = () -> cacher.writeData((int)(Math.random() * 100));



        for (int i = 0; i < 2; i++) {
            new Thread(writeData, "Writer-" + i).start();
        }
        for (int i = 0; i < 10; i++) {
            new Thread(readData, "Reader-" + i).start();
        }
        Thread writer1 = new Thread(writeData, "Writer-1");
        Thread reader1 = new Thread(readData, "Reader-1");
        Thread reader2 = new Thread(readData, "Reader-2");

        writer1.start();
        reader1.start();
        reader2.start();
    }
}

以下是几组可能的运行结果:

Reader-0 reading data: 0
Reader-3 reading data: 0
Reader-6 reading data: 0
Reader-2 reading data: 0
Reader-5 reading data: 0
Reader-1 reading data: 0
Reader-4 reading data: 0
Writer-0 writing data: 4
Writer-1 writing data: 58
Reader-7 reading data: 62
Reader-9 reading data: 62
Reader-8 reading data: 62
Writer-1 writing data: 50
Reader-1 reading data: 112
Reader-2 reading data: 112

读锁是共享锁,允许多个线程同时持有,写锁是独占锁,只允许一个线程持有。这句还要在程序运行过程中才能体会到,如果只是静态观看上面的运行结果的话,是体会不到读写锁的含义的。在程序运行过程中会看到,读线程会并发地执行,虽然每个读线程执行逻辑前都获取了锁,但由于读锁是共享锁,因此多个读线程可以并发执行,当有写线程运行时,在其sleep的超时时间未到时,其他读、写(如果有的话)都会被阻塞,因为sleep并不会导致线程释放锁,这也是“写锁是独占锁”的体现。

5 总结

本文介绍了 Java 中的显式锁,包括 ReentrantLock 与 ReentrantReadWriteLock。对比了显式锁和 synchronized 的不同,阐述了 ReentrantLock 的特点、使用示例及优势,还介绍了公平锁和非公平锁的区别,最后详细讲解了 ReentrantReadWriteLock 的读写锁机制、使用场景及示例,对多线程同步机制进行了全面阐释。
我是欧阳方超,把事情做好了自然就有兴趣了,如果你喜欢我的文章,欢迎点赞、转发、评论加关注。我们下次见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值