Java并发编程(五)—ReetrantLock详解及应用

目录

一、ReetrantLock的特性

1、非阻塞获取锁

2、带超时的锁获取:

3、锁的公平性

4、锁的可中断性

5、Condition条件变量

6、锁的可重入性

可重入锁

不可重入锁

7、性能优化

二、ReentrantLock和Synchronized的区别

1、语法和使用方式

2、锁的获取和释放

3、高级特性

4、条件变量

5、性能

总结

三、ReentrantLock使用场景


之前的文章Java并发编程(四)—synchronized关键字的应用-CSDN博客讲述了sychronized的应用,那为什么还需要其他的锁呢?

在使用Synchronized,会存在以下几个问题:

  • 不可中断锁,需要线程执行完才会释放锁(synchronized的获取和释放锁由jvm实现)

  • 非公平锁

  • Synchronized引入了偏向锁,轻量级锁(自旋锁)后,性能有所提升

synchronized属于隐式锁,即锁的持有与释放都是隐式的,可能会导致死锁

为了可以灵活地控制锁,就需要使用到显式锁,即锁的持有和释放都必须手动编写

ReentrantLock是一把可重入锁互斥锁,它具有与 Synchronized 关键字相同的含有隐式监视器锁(monitor)的基本行为和语义,但是它比 Synchronized 具有更多的方法和功能

在Java 1.5中,官方在concurrent并发包中加入了Lock接口,ReentrantLock位于java.util.concurrent.locks包下,实现了Lock接口和Serializable接口,该接口中提供了lock()方法和unLock()方法对显式加锁和显式释放锁操作进行支持

 public class ReentrantLock implements Lock, java.io.Serializable {……}

Lock lock = new ReentrantLock();
 
public void save(){
    try{
        lock.lock();
        //业务代码……
    }finally{
        lock.unlock();
    }
}

从上述代码可以使用ReentrantLock来管理锁,确保在save方法执行期间对资源的独占访问。通过try-finally结构确保即使发生异常也能正确地使用lock.unlock()释放锁 

ReentrantLock实现了Lock接口,Lock接口是Java中对锁操作行为的统一规范

ReentrantLock结构:

Java并发编程(五)—ReetrantLock详解及应用_第1张图片

:多线程使用ReentrantLock获取资源

public class ReentrantLockTest {
     private static final Lock lock = new ReentrantLock();
 ​
 ​
     public static void test() {
         try {
             //获取锁
             lock.lock();
             System.out.println(Thread.currentThread().getName() + "获取到锁了");
             //业务代码,使用部分花费100毫秒
             Thread.sleep(100);
         } catch (InterruptedException e) {
             e.printStackTrace();
         } finally {
             //释放锁放在finally中。
             lock.unlock();
             System.out.println(Thread.currentThread().getName() + "释放了锁");
         }
     }
 ​
     public static void main(String[] args) {
         new Thread(() -> { test(); }, "线程1").start();
         new Thread(() -> { test(); }, "线程2").start();
     }
 }
 ​

 运行结果:

Java并发编程(五)—ReetrantLock详解及应用_第2张图片

效果和Synchronized的一样,线程1获取到锁了,线程2需要等待线程1释放锁后才可以获取锁

⚠️注意:为了防止锁不被释放,从而造成死锁,强烈建议把锁的释放lock.unlock()放在finally模块中

一、ReetrantLock的特性

不仅如此,ReetrantLock相对于synchronized解决了很多问题:

上述代码lock.lock();会阻塞当前线程直至获取到锁为止,那么为了避免这个问题就需要使用lock.tryLock()

1、非阻塞获取锁

ReentrantLock提供了tryLock()方法,可以尝试获取锁而不阻塞当前线程。

  • 如果锁当前未被任何线程持有,则tryLock()方法会立即获取锁并返回true。
  • 如果锁当前已被其他线程持有,则tryLock()方法不会阻塞当前线程,而是立即返回false。

代码如下:

     import java.util.concurrent.locks.ReentrantLock;

     public class DemoService {

         //将ReentrantLock实例声明为final可以确保锁对象的不变性,提高线程安全性
         private final ReentrantLock lock = new ReentrantLock();

         public void save() {
             try {
                 lock.lock();
                 // 业务代码……
             } finally {
                 lock.unlock();
             }
         }
     }

2、带超时的锁获取:

ReentrantLock提供了tryLock(long timeout, TimeUnit unit)方法,允许线程在指定时间内尝试获取锁

如果在指定时间内未能获取到锁,则线程不会被阻塞,而是返回false

:多线程获取超时锁的案例

     public class TryLockWithTimeoutExample {

         private final ReentrantLock lock = new ReentrantLock();

         public void save() {
             if (!lock.tryLock(5, TimeUnit.SECONDS)) {
                 // 如果未能在5秒内获取锁,可以记录日志或采取其他措施
                 return; // 或者抛出异常
             }

             try {
                 // 业务代码……
             } catch (Exception e) {
                 // 处理异常
                 throw e;
             } finally {
                 lock.unlock();
             }
         }

         public static void main(String[] args) {
             TryLockWithTimeoutExample example = new TryLockWithTimeoutExample();
             new Thread(example::save).start();
             new Thread(example::save).start();
         }
     }
     

3、锁的公平性

ReentrantLock允许创建公平锁非公平锁

  • 公平锁按照线程请求锁的顺序来分配锁,可以减少线程之间的饥饿现象。保证等待时间最长的线程优先获取锁,其实就是先入队的先得锁,即FIFO
  • 非公平锁不保证请求锁的顺序,可能会让后来的线程优先获取锁

公平锁常见的场景:多线程任务顺序处理、线程池

默认情况下ReentrantLock使用非公平锁

Java并发编程(五)—ReetrantLock详解及应用_第3张图片

/**
 * 默认创建非公平锁
 */
public ReentrantLock() {
    sync = new NonfairSync();
}

/**
 * fair为true表示是公平锁,fair为false表示是非公平锁
 */
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

:多线程获取公平锁的案例

public class FairReentrantLockExample {

    private final ReentrantLock lock = new ReentrantLock(true); // true 表示公平锁

    public void processTask(int taskId) {
        lock.lock();
        try {
            System.out.println("Processing task " + taskId + " by " + Thread.currentThread().getName());
            // 业务代码……
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        FairReentrantLockExample example = new FairReentrantLockExample();
        
        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            new Thread(() -> example.processTask(taskId)).start();
        }
    }
}

执行顺序:

  • 创建线程:主线程创建5个新线程,每个线程都会调用 processTask(),线程被创建后,它们就会被加入到线程调度器的就绪队列中,等待被调度执行
  • 请求锁
    • 每个线程开始执行时,都会尝试获取锁
    • 由于使用的是公平锁,线程将按照它们请求锁的顺序来获取锁
  • 执行过程
    • 线程1首先调度执行,如果获取到锁开始执行processTask()
    • 其他线程将被阻塞,直至锁可用的状态
    • 线程1执行完执行processTask(),释放锁
    • 线程2请求锁,获取锁开始执行processTask(),此过程将重复,直至所有线程都执行完毕

效果:

Processing task 1 by Thread-0
Processing task 2 by Thread-1
Processing task 3 by Thread-2
Processing task 4 by Thread-3
Processing task 5 by Thread-4

例如:可以使线程池中的线程公平地获取锁,以确保线程按照一定的顺序获取锁,从而避免某些线程长时间无法获取锁导致的饥饿问题

:线程池使用公平锁

     public class FairReentrantLockThreadPoolExample {

         private final ReentrantLock lock = new ReentrantLock(true); // true 表示公平锁
         private final ExecutorService executor = Executors.newFixedThreadPool(3);

         public void processTask(int taskId) {
             lock.lock();
             try {
                 System.out.println("Processing task " + taskId + " by " + Thread.currentThread().getName());
                 // 业务代码……
             } finally {
                 lock.unlock();
             }
         }

         public static void main(String[] args) {
             FairReentrantLockThreadPoolExample example = new FairReentrantLockThreadPoolExample();
             
             for (int i = 1; i <= 5; i++) {
                 int taskId = i;
                 executor.execute(() -> example.processTask(taskId));
             }
             
             // 关闭线程池
             executor.shutdown();
         }
     }
     

说明:在这个例子中,我们创建了一个固定大小的线程池,其中包含3个线程

线程池将处理5个任务,每个任务都需要获取公平锁。由于线程池的大小为3,最多只有3个线程可以同时执行

当一个线程获取锁后,其他线程将被阻塞,直到锁被释放。由于使用了公平锁,线程将按照它们请求锁的顺序来获取锁

4、锁的可中断性

使用ReentrantLock时,线程可以通过中断机制来取消等待锁的操作,避免线程阻塞

lock.lockInterruptibly()获取一个可以被中断的重入锁,允许线程在等待锁的过程中响应中断信号,使用thread.interrupt()可以打断线程

:多线程执行业务时被中断

     public class InterruptibleLockExample {

         private final ReentrantLock lock = new ReentrantLock();
         private final Condition condition = lock.newCondition();

         public void processTask()  {
            try {
                lock.lockInterruptibly();
                System.out.println("Processing task by " + Thread.currentThread().getName());
                // 业务代码……
                // 假设需要等待一段时间
                condition.await(5, TimeUnit.SECONDS);
                System.out.println("Task completed by " + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " interrupted, task cancelled");
            } finally {
                lock.unlock();
                System.out.println(Thread.currentThread().getName() + " released the lock");
            }
        }

         public static void main(String[] args) throws InterruptedException {
             InterruptibleLockExample example = new InterruptibleLockExample();

             Thread thread = new Thread(example::processTask);
             thread.start();

             // 假设我们需要在一段时间后中断线程
             TimeUnit.SECONDS.sleep(2);
             thread.interrupt();
         }
     }
     

线程调用 condition.await(5, TimeUnit.SECONDS) 开始等待 5 秒。在等待过程中,主线程在 2 秒后中断了 Thread-0。Thread-0 在等待过程中被中断,因此将抛出 Interrup如果线程被中断,将抛出 InterruptedException,线程中断退出

效果:

Processing task by Thread-0
Thread-0 interrupted, task cancelled
Thread-0 released the lock

5、Condition条件变量

ReentrantLock支持条件变量,允许线程等待特定条件满足后再继续执行

Condition 接口是 Java 1.5 中引入的 java.util.concurrent 包的一部分,旨在提供更高级别的并发控制,设计目的是为了提供比 wait 和 notify 更加灵活和强大的线程同步机制

Condition 接口允许更细粒度的控制,比如可以有多个条件变量,每个条件变量可以独立使用,从而支持更复杂的同步模式

提供了 awaitsignalsignalAll 等方法。

  • await 方法释放锁并等待,直到被 signal 或 signalAll 方法唤醒。
  • signal 方法唤醒一个等待线程,而 signalAll 方法唤醒所有等待线程。
  • Condition 对象可以与多个锁关联,因此可以实现多个条件变量。

Condition接口可以与ReentrantLock一起使用,提供了更灵活的线程同步机制。

private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();

在上述线程中断的案例中,也使用了Condition的await()

// 假设需要等待5s时间
condition.await(5, TimeUnit.SECONDS);

通过Condition接口定义的方法我们发现跟之前Objectwaitnotify功能几乎差不多,所以使用Condition对象的方法也可以完成线程间的通信

waitnotify 方法:

  • 必须在一个已经同步的对象上调用,即必须在 synchronized 块或方法中使用。
  • wait 方法释放锁并等待,直到被 notify 或 notifyAll 方法唤醒。
  • notify 方法随机唤醒一个等待线程,而 notifyAll 方法唤醒所有等待线程。 只能使用一个同步对象来控制多个线程之间的同步

6、锁的可重入性

锁的可重入性和不可重入性主要描述了一个线程在获取锁之后是否能够再次获取同一把锁而不引起死锁的能力

可重入锁

可重入锁允许一个已经获取了锁的线程再次获取同一把锁,而不会导致其他等待该锁的线程被阻塞

因为可重入锁内部维护了一个计数器,每当同一个线程再次获取锁时,计数器加一;当线程释放锁时,计数器减一,直到计数器为零时,锁才真正被释放

ReentrantLock支持可重入性,即允许已经持有锁的线程再次获取锁。

这种特性在synchronized中也是支持的,但在ReentrantLock中更为明显和可控

:假设一个银行账户需要有存款和取款的操作

先使用synchronized实现重入锁

public class Account {
    private int balance = 0;

    public synchronized void deposit(int amount) {
        balance += amount;
        // 假设我们想在存款后打印余额
        printBalance();
    }

    public synchronized void withdraw(int amount) {
        if (amount <= balance) {
            balance -= amount;
        }
        // 假设我们想在取款后打印余额
        printBalance();
    }

    public synchronized void printBalance() {
        System.out.println("当前余额: " + balance);
    }
}

在这个例子中,deposit 和 withdraw 方法都是同步的,这意味着它们只能由一个线程执行。同样,printBalance 方法也是同步的,这样当一个线程正在执行 deposit 或 withdraw 方法时,它可以安全地调用 printBalance 方法而不会导致死锁

使用ReentrantLock实现重入锁

public class Account {
    private int balance = 0;
    private final ReentrantLock lock = new ReentrantLock();

    public void deposit(int amount) {
        lock.lock();
        try {
            balance += amount;
            // 假设我们想在存款后打印余额
            printBalance();
        } finally {
            lock.unlock();
        }
    }

    public void withdraw(int amount) {
        lock.lock();
        try {
            if (amount <= balance) {
                balance -= amount;
            }
            // 假设我们想在取款后打印余额
            printBalance();
        } finally {
            lock.unlock();
        }
    }

    public void printBalance() {
        lock.lock();
        try {
            System.out.println("当前余额: " + balance);
        } finally {
            lock.unlock();
        }
    }
}

假设初始余额为 0,并且线程 T1 先执行 deposit(100),接着线程 T2 执行 withdraw(50),最后线程 T3 执行 printBalance(),那么输出可能是:

当前余额: 100
当前余额: 50

不可重入锁

不可重入锁不允许一个已经获取了锁的线程再次获取同一把锁,除非它首先释放了锁。如果一个线程试图再次获取锁,它将会被阻塞,直到锁被释放。这种类型的锁通常用于那些不需要支持递归调用的场景

由于 Java 标准库中没有直接提供的不可重入锁实现,我们可以使用 java.util.concurrent.locks.Lock 接口的实现类来模拟一个不可重入锁的行为。这里我们使用 ReentrantLock 并手动管理锁的可重入性

import java.util.concurrent.locks.ReentrantLock;

public class NonReentrantAccount {
    private int balance = 0;
    private ReentrantLock lock = new ReentrantLock();

    public void deposit(int amount) {
        lock.lock();
        try {
            balance += amount;
            // 假设我们想在存款后打印余额
            printBalance();
        } finally {
            lock.unlock();
        }
    }

    public void withdraw(int amount) {
        lock.lock();
        try {
            if (amount <= balance) {
                balance -= amount;
            }
            // 假设我们想在取款后打印余额
            printBalance();
        } finally {
            lock.unlock();
        }
    }

    public void printBalance() {
        // 模拟不可重入锁行为,检查当前线程是否持有锁
        if (!lock.isHeldByCurrentThread()) {
            lock.lock();
            try {
                System.out.println("当前余额: " + balance);
            } finally {
                lock.unlock();
            }
        } else {
            // 如果当前线程已经持有锁,则不打印余额
            // 这里我们简单地跳过打印操作
        }
    }
}

使用 ReentrantLock 来实现锁的功能,但是在 printBalance 方法中,我们检查当前线程是否已经持有锁。如果是,则不执行打印操作,以此来模拟不可重入锁的行为

假设初始余额为 0,并且线程 T1 先执行 deposit(100),接着线程 T2 执行 withdraw(50),最后线程 T3 执行 printBalance(),那么输出可能是:

当前余额: 50

这是因为:

  • 线程 T1 执行 deposit(100),此时T1持有锁,因此 printBalance 不会被执行
  • 线程 T2 执行 withdraw(50),T1释放锁,T2持有锁,因此 printBalance 不会被执行
  • 线程 T3 执行 printBalance(),此时T3不持有锁,因此会先获取锁并打印当前余额 50

⚠️注意:实际应用中很少会使用这样的不可重入锁实现,因为这通常会导致代码难以理解和维护。通常情况下,更倾向于使用可重入锁来避免死锁问题

7、性能优化

ReentrantLock使用了AbstractQueuedSynchronizer(AQS)框架,可以利用现代处理器的特性(如CAS操作)来优化锁的性能

二、ReentrantLock和Synchronized的区别

通过ReentrantLock 上述的特性,就可以了解与synchronized区别了

最好是理解记忆,切记死记硬背!

1、语法和使用方式

  • synchronized:关键字,可直接作用于方法或代码块,不需要显式地调用获取和释放锁的方法
  • ReentrantLock:类,需要通过 lock() 和 unlock() 明确地获取和释放锁。

2、锁的获取和释放

  • synchronized:自动释放锁,当线程退出作用域或发生异常时,锁会被自动释放
  • ReentrantLock:需要显式释放锁,如果在 unlock() 方法之前发生异常,锁可能不会被释放,需要使用 try-finally 块或 try-with-resources 语句来确保锁被释放。

3、高级特性

  • ReentrantLock:提供了更多的高级特性,如公平锁、非公平锁、超时等待、可中断等待等。
  • synchronized:只支持非公平锁,不支持超时等待或可中断等待

4、条件变量

  • synchronized中可以使用wait(),notify(),notifyAll()进行线程间的
  • ReentrantLock配合Condition对象提供了强大的线程间协调能力,可以有多个Condition,每个Condition管理自己的等待集

5、性能

  • synchronized:基于 JVM 实现,使用了多种锁优化技术,如偏向锁、轻量级锁和重量级锁。这些技术使得 synchronized 在许多情况下性能接近或优于 ReentrantLock
  • ReentrantLock:基于 JDK 实现,使用了 CAS(Compare and Swap)原子操作进行锁的获取和释放。ReentrantLock 提供了更多高级功能,如公平锁、非公平锁、可中断的锁等待等。

总结

  • synchronized:适用于大多数基本的同步需求,提供了简洁的语法,自动释放锁,适合于简单的同步场景。
  • ReentrantLock:适用于需要更高级特性的场景,如公平锁、超时等待等,需要显式管理锁的获取和释放。
  • 如果竞争比较激烈,推荐ReentrantLock去实现,不存在锁升级概念。而synchronized是存在锁升级概念的,如果升级到重量级锁,是不存在锁降级的。

三、ReentrantLock使用场景

JDK 在并发包中, 使用 ReetrantLock 的地方有:

  1. CyclicBarrier

  2. DelayQueue

  3. LinkedBlockingDeque

  4. ThreadPoolExecutor

  5. ReentrantReadWriteLock

  6. StampedLock

1. 生产者消费者模式
应用场景:在消息队列或缓存系统中,生产者负责产生数据,消费者负责消费数据。为了确保数据的一致性和线程安全,可以使用 ReentrantLock

2. 文件上传下载系统
应用场景:在一个文件上传下载系统中,多个用户可能同时访问同一文件。为了保证文件的一致性和安全性,可以使用 ReentrantLock 来同步文件的读写操作

3. 线程池中的任务调度
应用场景:在线程池中,多个线程可能需要调度任务执行。使用 ReentrantLock 可以确保任务的正确调度和执行顺序。

4. 数据库连接池
应用场景:数据库连接池中需要管理多个数据库连接。使用 ReentrantLock 可以确保线程安全地获取和释放数据库连接

5. 限流器
应用场景:在高并发系统中,为了防止服务器过载,可以使用限流器来限制请求的速率。使用 ReentrantLock 可以确保限流逻辑的线程安全性


下一篇:Java并发编程(六)—线程池的使用-CSDN博客

你可能感兴趣的:(Java并发编程,java,开发语言)