写点什么

Java 多线程系列 7:JDK 包中的管程实现

作者:BigBang!
  • 2023-12-20
    广东
  • 本文字数:4205 字

    阅读完需:约 14 分钟

Java多线程系列7:JDK包中的管程实现

前面讲到在并发编程中有两个核心问题:线程间的互斥与协同。所谓互斥就是一次只能允许一个线程访问共享变量,而协同就是线程间的等待与通知机制。管程提供了一种多线程互斥与协同的机制,Java 语言内置的 sychronized/wait/notify/notifyAll 提供了一个简化版的管程实现。今天我们介绍 JDK 并发包中的另外一个管程实现:Lock/Condition。


首先看一个简单的例子:

package demo;
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
public class LockDemo { private final Lock lock = new ReentrantLock(); private int value = 0;
public int getValue() { lock.lock(); try { return value; } finally { lock.unlock(); } }
public void addOne() { lock.lock(); try { value = getValue() + 1; } finally { lock.unlock(); } }
public static void main(String[] args) throws InterruptedException { LockDemo demo = new LockDemo(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100; i++) { demo.addOne(); try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } } });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100; i++) { demo.addOne(); try { Thread.sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("value=" + demo.getValue()); }}
复制代码

在这个例子中,我们使用了 Lock,在读取或者更新 value 变量前,调用 Lock.lock()方法。这段代码和我们在 getValue()和 addOne()方法上加 synchronized 的效果是一样的。既然这样,那我们为什么 Java 还需要在 JDK 中实现 Lock 呢?


答案是:为了灵活性和可控性。

为什么还需要重新实现一个管程?

sychronized 关键字实现了管程,但因为是编译器生成了加锁和解锁的代码,因此锁的工作方式是固定的:当线程获取不到锁时,直接接入阻塞状态,直到锁被释放才能被唤醒继续执行。这就产生了一个问题:如果在复杂情况下出现了死锁,程序没有任何办法可以解决死锁的情况,只能重启程序。或者当程序设计为如果获取不到锁则主动退出竞争,或者等待 20 毫秒获取不到则退出竞争,或者在加锁过程中发现条件不满足了抛出异常,这些都是 synchronized 无法实现的。


而 JDK 并发包中提供的 Lock 接口,提供了 tryLock()、lockInterupptibly()等接口,让线程在获取锁后可以被中断或者获取不到锁则退出等待。虽然理论上编译器可以做到足够灵活以提供类似的功能,但无疑会给编译器的设计带来复杂度,语法也会变得复杂。与其在编译器上折腾,还不如利用 java 语言的特性来提供这些丰富的控制功能。

Lock 接口的加锁方法和实现类

三种加锁方式

Java 并发包中的 java.util.concurrent.Lock 接口,定义了以下几个加锁的接口:

//如果线程获取不到锁则阻塞,效果等同于synchronized关键字void lock();
//如果线程获取不到锁则阻塞,获得锁后可以被中断释放锁void lockInterruptibly() throws InterruptedException;
//尝试获取锁,如果获取不到则退出,返回falseboolean tryLock();
//尝试获取锁,如果在规定等待时间内获取不到则退出,在等待期间可以被中断boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
复制代码


让我们看一个关于 tryLock 的示例代码:

package demo;
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo { private final Lock lock = new ReentrantLock(); private int value = 0;
public int getValue() { lock.lock(); try { return value; } finally { lock.unlock(); } }
public boolean addOne() { if (lock.tryLock()) { try { value = getValue() + 1; return true; } finally { lock.unlock(); } } else { System.out.println("尝试获取锁失败"); return false; } }
public static void main(String[] args) throws InterruptedException { TryLockDemo demo = new TryLockDemo(); Thread t1 = new Thread(() -> { for (int i = 0; i < 100; i++) { while(!demo.addOne()); try { Thread.sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } } });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100; i++) { while (!demo.addOne()); try { Thread.sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("value=" + demo.getValue()); }}
复制代码

在 addOne 方法中,我们把加锁改为如果获取不到锁,那么则返回 false。为了简化,调用线程使用了 while 不断重试调用 addOne 方法(实际业务中可以 sleep 等待一段时间或者返回失败的结果)。执行程序你会发现大量尝试获取锁失败的打印输出。


也可以使用等待超时的 tryLock 方法:

    public boolean addOne() {        boolean success = false;        try {            success = lock.tryLock(5, TimeUnit.MILLISECONDS);        } catch (InterruptedException e) {            System.out.println("interrupted while acquiring lock");        }        if (success) {            try {                value = getValue() + 1;                return true;            } finally {                lock.unlock();            }        } else {            System.out.println("尝试获取锁失败");            return false;        }    }
复制代码

这时你会发现获取锁失败的打印输出大大减少,因为等待 5 毫秒几乎肯定可以获取锁,毕竟执行 addOne 的操作非常快。


使用 tryInteruptibly 加锁的代码示例:

package demo;
import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
public class LockInteruptiblyDemo { private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> { try { lock.lockInterruptibly(); System.out.println("线程1获得锁,睡眠10秒"); Thread.sleep(10*1000); //模拟线程要执行很久 System.out.println("线程1执行完成"); } catch (InterruptedException e) { System.out.println("线程1被中断了"); } finally { lock.unlock(); System.out.println("线程1释放锁"); } });
Thread t2 = new Thread(() -> { try { lock.lockInterruptibly(); System.out.println("线程2获得锁"); Thread.sleep(100); System.out.println("线程2执行完成"); } catch (InterruptedException e) { System.out.println("线程2被中断了"); } finally { lock.unlock(); System.out.println("线程2释放锁"); } }); t1.start(); t2.start(); Thread.sleep(1000); //在主线程中 中断线程1的执行 t1.interrupt(); t1.join(); t2.join(); }}
复制代码


Lock 的实现类之一:可重入锁


可重入锁,顾名思义就是可以被一个线程重复获取的锁。比如一个线程要调用多个方法,多个方法都使用了同一把锁,那么线程只要在一开始获得了这个锁,以后所有的方法调用都可以继续获得这把锁。回忆一下,其实 synchronized 实现的锁就是一把可重入锁。


JDK 中可重入锁的实现类是:java.util.concurrent.locks.ReentrantLock; 这是我们最常用的锁。这个类支持两种模式:公平锁和非公平锁。通过在构造函数中指定参数设置,默认是非公平锁。所谓公平锁,就是被锁阻塞的线程等待时间越长,就越先被唤醒,而非公平锁就没有这个保障。


一个问题:Java 中有不可重入锁吗?如果有,用在什么场景?

使用 Lock 的注意事项

使用 synchronized 加锁虽然没那么灵活,但我们也不用操心加锁和释放锁导致的问题。而使用 Lock 后,在获得灵活性的同时,我们需要手动进行加锁和释放锁,因此需要格外小心,以避免出现忘记释放锁、死锁、以及锁定的代码范围过大等问题。


以下是使用 Lock 的一些注意事项:

  1. 在 try ... finally 中进行加锁和解锁操作。在 try 模块中进行加锁,在 finally 模块中进行解锁,确保锁一定会被释放。

  2. 尽可能缩小被锁定的代码块。这样可以最大化并行度。一个原则就是只针对共享变量的读取和更新代码块进行加锁。

  3. 在调用外部代码库时,不加锁。因为不确定别的代码库是否会有性能问题,是否有 sleep 等会阻塞线程但不释放锁的操作。除非你非常确定外部代码库不存在上述问题。


发布于: 刚刚阅读数: 4
用户头像

BigBang!

关注

宁静致远,快乐随行,知行合一,得大自在! 2008-10-08 加入

一个程序员,一名架构师,一位技术管理人......

评论

发布
暂无评论
Java多线程系列7:JDK包中的管程实现_Java多线程_BigBang!_InfoQ写作社区