互斥锁
原子性问题到底该如何解决呢?
- “同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。
- 如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
锁模型
- 首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;
- 其次,我们要保护资源 R 就得为它创建一把锁 LR;
- 最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。
Java 语言提供的锁技术:synchronized
- 锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。
- synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。
class X { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 Object obj = new Object(); void baz() { synchronized (obj) { // 临界区 } } }
- Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()。
- 这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
- 当修饰非静态方法的时候,锁定的是当前实例对象 this。
锁和受保护资源的关系
- 受保护资源和锁之间的关联关系是 N:1 的关系。
保护没有关联关系的多个资源
- 例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
class Account { // 锁:保护账户余额 private final Object balLock = new Object(); // 账户余额 private Integer balance; // 锁:保护账户密码 private final Object pwLock = new Object(); // 账户密码 private String password; // 取款 void withdraw(Integer amt) { synchronized(balLock) { if (this.balance > amt){ this.balance -= amt; } } } // 查看余额 Integer getBalance() { synchronized(balLock) { return balance; } } // 更改密码 void updatePassword(String pw){ synchronized(pwLock) { this.password = pw; } } // 查看密码 String getPassword() { synchronized(pwLock) { return password; } } }
- 账户类 Account 有两个成员变量,分别是账户余额 balance 和账户密码 password。
- 取款 withdraw() 和查看余额 getBalance() 操作会访问账户余额 balance,我们创建一个 final 对象 balLock 作为锁(类比球赛门票);
- 更改密码 updatePassword() 和查看密码 getPassword() 操作会修改账户密码 password,我们创建一个 final 对象 pwLock 作为锁(类比电影票)。
- 不同的资源用不同的锁保护,各自管各自的,很简单。
- 用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
保护有关联关系的多个资源
- 例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。
class Account { private int balance; // 转账 void transfer( Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
- 我们声明了个账户类:Account,该类有一个成员变量余额:balance,还有一个转账的方法:transfer()。
- 用同一把锁来保护多个资源,也就是现实世界的“包场”,那在编程领域应该怎么“包场”呢?很简单,只要我们的锁能覆盖所有受保护资源就可以了。
- 用 Account.class 作为共享的锁。
- Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。
class Account { private int balance; // 转账 void transfer(Account target, int amt){ synchronized(Account.class) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }
- Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。
- 相对于用 Account.class 作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了:
class Account { private int balance; // 转账 void transfer(Account target, int amt){ // 锁定转出账户 synchronized(this) { // 锁定转⼊账户 synchronized(target) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } }
- 使用细粒度锁可以提高并行度,是性能优化的一个重要手段。使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
死锁
- 死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
如何预防死锁
- 并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
- 死锁的四个必要条件:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
- 也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
用“等待-通知”机制优化循环等待
- 一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
用 synchronized 实现等待-通知机制
- 在 Java 语言里,等待-通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。
- 左边有一个等待队列,同一时刻,只允许一个线程进入synchronized 保护的临界区,当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待。
- 这个等待队列和互斥锁是一对一的关系,每个互斥锁都有独立的等待队列。
- 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
- 那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify() 和 notifyAll() 方法。
- 当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
- 为什么说是曾经满足过呢?
- 因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。
- 除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。
尽量使用 notifyAll()
- notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。
- 实际上使用 notify() 也很有风险,它的风险在于可能导致某些线程永远不会被通知到。所以除非经过深思熟虑,否则尽量使用 notifyAll()。