极客时间 ——《Java并发编程实战》 14 | Lock 和 Condition

2020-10-28   9 次阅读


JDK 并发包内容丰富,但是核心还是对管程的实现,因为理论上使用管程可以实现并发包里所有的工具类。

在之前的

  • {% post_link 读书笔记/极客时间/Java并发编程实战/第一部分—基础/08|管程 08 | 管程:并发编程的万能钥匙 %}

中提到,并发编程中的两个核心问题

  1. 互斥同一时刻只允许一个线程访问共享资源
  2. 同步:线程之间如何进行通信协作

本章主要介绍的是 JDKLock 的使用,这里作者首先并没有介绍 Lock 是什么。

可以看到, Lock 是并发包中单独的 locks 包下的 顶层接口。 这个类的注释非常长,这是 JDK 中实现的一个锁接口。

Java 本身已经提供了内置的监视器锁 synchronized,其背后也是管程的一种实现,而 Java 已经从语言层面实现了管程,为什么还要单独提供 JDK 类库中对于管程的实现呢?

所以这两者之间肯定有着区别。

再造管程的理由

之前有种说法:在 Java1.5 之前,synchronized 性能不如 并发包中的 Lock, 但 1.6 之后 synchronized 做了很多优化,性能已经追赶上来了,所以肯定不是因为性能问题而重复的提供了 Lock 类。

之前说死锁问题的时候,导致死锁的原因之一就是 不可抢占:当试图获取内置锁的线程获取锁失败时,进入 BLOCKED 状态,线程被阻塞,并且线程持有的资源无法释放,最后导致死锁问题的发生。

而我们希望的是:占用部分资源线程进一步申请其他资源时,如果申请失败,则主动释放它已经占有的资源,这样不可抢占条件破坏死锁不会发生

所以如果我们需要重新设计一把互斥锁,那么该怎样解决这个不可抢占发生的问题呢?

有三种方案:

  1. 能够响应中断synchronized 存在的问题是:持有锁 A 后,如果尝试获取锁 B 失败,那么线程进入 BLCOKED 阻塞状态,一旦发生死锁,没有任何机会来唤醒阻塞线程。但如果阻塞状态能够响应 中断信号,也就是给阻塞线程发送中断请求时,能够唤醒 阻塞线程,并释放持有的锁 A,这样就破坏不可抢占条件
  2. 支持超时:如果线程在一段时间内没有获取到锁,不是进入阻塞状态,而是返回一个错误,并且这个线程有机会释放曾经已经持有的锁,也可以破坏不可抢占条件。
  3. 非阻塞地获取锁:如果尝试获取锁失败,不进入阻塞状态,而是直接返回,这个线程也有机会释放曾经持有的锁破坏不可抢占条件

这三种方案可以全面弥补 synchronized 的问题,而这三个特性正是 Lock 所具有的,体现在接口中的三个方法:

// 支持中断
void lockInterruptibly() throws InterruptedException;
// 非阻塞地获取锁
boolean tryLock();
// 支持超时
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

如何保证可见性

总结:通过 Happens-Before 规则。

Lock的使用有一个经典范例: try{}finanly{},下面是 tryLock 源码注释中官方给出的例子:

Lock lock = ...;
 if (lock.tryLock()) {
   try {
     // manipulate protected state
   } finally {
     lock.unlock();
   }
 } else {
   // perform alternative actions
 }

Java 中 可见性的保证是通过 JMM 内存模型中的 Happens-Before 规则保证的,其中有一条关于 synchronized 的相关规则: syncrhonized 的解锁 Happens-Before 于 后续对这个锁的加锁,所以后加锁的线程能看到上个获取锁的线程对临界区中共享变量的修改的值。

Lock 中的可见是怎样保证的呢? 如下面例中的 value += 1 ,就是对临界区中变量的修改。

如果线程 T1 修改了 value 的值, T2 是否能看到正确的 value 的结果呢?

下面是作者给出的例子:

public class X {
    private final Lock rtl = new ReentrantLock();
    int value;
    public void addOne() {
        // 获取锁
        rtl.lock();
        try {
            value += 1;
        }finally {
            // 保证锁释放
            rtl.unlock();
        }
    }
}


这里作者没有对 Lock 的实现进行详细的讲解,只介绍了其简单原理:利用 volatile 相关的 Happens-Before 原则,ReentrantLock 内部持有一个 volatile 变量 state,获取锁的时候会读写 state 的值,解锁的时候 也会读写 state 的值。

也就是在执行 value += 1 这个操作之前,先读写了一次 volatile 变量 state,执行 value +=1 之后,又读写了一次 volatile 变量 state,根据相关 Happens-Before 规则:

  1. 顺序性规则:对于线程T1value+=1 Happens-Before 释放锁的操作 unlock()
  2. volatile 变量规则:由于 state =1 ,会先读取 state,所以线程T1unlock() Happens-Before 线程T2 的lock()
  3. 传递性规则线程T1value+=1 Happens-Before 线程T2 的 lock 操作
public class SampleLock {
    volatile int state;
    //加锁
     lock() {
        // 省略其他操作
        state = 1;
    }
    //解锁
     unlock() {
         // 省略其他操作
        state = 0;
    }
}

个人注解:在 JDK8 的源码中:这里的 state 变量并不是在 ReentrantLock 类中直接定义的,而是通过 ReentrantLock 的内部类 Sync 获取到的,Sync 继承了 AbstractQueuedSynchronizerstate 定义在 这个类中。

image-20200730155945984

这里的 getState 获取到的是上面作者说的 state 字段,代表锁的状态。

AbstractQueuedSynchronizer.class 
    /**
     * The synchronization state.
     */
    private volatile int state;

可重入锁

ReentrantLockLock接口的一个具体实现,其名称的含义就是可重入锁,顾名思义,指线程可以重复获取同一把锁

例如下面的代码,当线程T1 执行到 处时,已经获取到了锁 rtl,当在 处调用 get() 方法时,会在 再次对锁 rtl 进行加锁操作。

此时如果 rtl 是可重入锁,那么 线程T1 可以再次加锁成功,而如果锁 rtl 不可重入, 则 线程T1 会被阻塞。

除了可重入锁还有可重入函数,指多个线程可以同时调用该函数并且每个线程都得到正确的结果。同时在一个线程内支持切换,无论被切换到少次,结果都是正确的。可重入函数是线程安全的

public class X {
    private final Lock rtl = new ReentrantLock();
    int value;


    public int get() {
        // 获取锁
        rtl.lock(); // ② 在这里发生了锁重入
        try {
            return value;
        }finally {
            rtl.unlock();
        }
    }

    public void addOne() {
        // 获取锁
        rtl.lock();
        try {
            value += 1+get(); //① 第一次获得锁
        }finally {
            // 保证锁释放
            rtl.unlock();
        }
    }
}

公平锁与非公平锁

ReentrantLock 的构造函数有一个 参数 fair

    /**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

这里的 fair 指的就是锁的 公平策略,如果传入 true 表示要构造一个**公平锁,反之构造一个非公平锁**。

这里的公平指的是等待获取锁的等待队列中的唤醒策略

  • 公平锁:谁等待时间长优先唤醒谁。
  • 非公平锁:不提供这个保证,可能等待时间短的线程反而先被唤醒。

锁的最佳实践

Doug Lea 在《Java 并发编程:设计原则与模式》中推荐了三个用锁的最佳实践:

  1. 永远只在更新对象的成员变量加锁
  2. 永远只在访问可变成员变量时加锁。
  3. 永远不在调用其他对象的方法时加锁。

第三条可能你会觉得苛刻,但是调用其他对象方法存在着许多未知:也许这个方法中存在 sleep 调用,也许这个方法的 I/O 很慢,这些如果加锁之后都会严重影响性能,更可怕的是这个方法内可能也存在锁,双重加锁可能导致死锁的发生。

并发问题本身就难以诊断,所以要让代码尽量安全、简单,哪怕有一点可能存在问题,都要尽力去避免。

总结

JDK 并发包中 Lock 接口的每个方法都是经过深思熟虑的,除了支持类似 synchronized 隐式加锁的 lock 方法外,还支持 超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全,健壮的并发程序提供了很大便利。

个人总结:

从这一章开始涉及到 JDK 并发包中的锁,作者这里只是简单提及了一下,实际上看了下源码和注释,很长,从个人感觉上还是要比 synrchonized 内置锁复杂的。

JDK 中提供的锁的类库很多:可重入锁,读写锁,主要还是应对了不同的场景,这里学习的重点一是学其原理,二就是学合适的应用场景了。

课后思考:

下面这段转账代码是否存在死锁问题:

public class LockAccount {
    private int balance;
    private final Lock lock = new ReentrantLock();

    // 转账
    void transfer(LockAccount tar,int amt) {
        while (true) {
            if (this.lock.tryLock()) {
                try {
                    if (tar.lock.tryLock()) {
                        try {
                            this.balance -= amt;
                            tar.balance += amt;
                        }finally {
                            tar.lock.unlock();
                        }
                    }
                }finally {
                    this.lock.unlock();
                }
            }
        }
    }
}

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。