Fork me on GitHub

注意:所有文章除特别说明外,转载请注明出处.

第五章 Java中的锁

[TOC]

5.1 Lock接口

Java SE 1.5 之后,在并发包中新增 Lock 接口用来实现锁功能。它提供了与synchronize关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。

提示:使用synchronize关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取后释放。这种方式简化了同步的管理,但是扩展性没有显式的锁获取和释放好。

注意:不要将获取锁的过程写在try块中,如果在获取锁(自定义锁的实现)时发生异常,异常抛出的同时,也会导致锁的释放。

Lock接口提供的 synchronize 关键字不具备的特性:

特性 描述
尝试非阻塞地获取锁 当前线程尝试获取锁,如果这一时刻锁没有被其它线程获取到,则成功获取并持有锁
能被中断地获取锁 与synchronize不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会抛出,同时锁会被释放
超时获取锁 在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回

Lock是一个接口,它定义了锁获取和释放的基本操作。

方法 描述
void lock() 获取锁
void lockInterruptibly() throws InterruptedException 可中断获取锁
boolean tryLock() 尝试非阻塞的获取锁
boolean tryLock(long time, TimeUtil unit) throws InterruptedException 超时的获取锁,当前线程在以下三种情况会返回:1.当前线程在超时时间内获得了锁。2.当前线程在超时时间内被终端。3.超时时间结束,返回false。
void unlock 释放锁
Condition newCondition 获取等待通知组件

5.2 队列同步器

队列同步器AbstractQueuedSynchronize(AQS),是用来构建锁或者其它同步组件的基础框架,其使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。

同步器的设计基于模板方法模式,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

重写同步器指定的方法时,需要使用同步器提供的三个方法访问或修改:

1.getState() 获取当前同步状态

2.setState() 设置当前同步状态

3.compareAndSetState(int expect, int update) 使用CAS设置当前状态,该方法能够保证状态设置的原子性

5.2.2 队列同步器的实现分析

1.同步队列

5.3 重入锁

重入锁 ReentrantLock,支持重进入的锁,表示该锁能够支持一个线程对资源的重复加锁。该锁还支持获取锁时的公平和非公平性选择。

提示:ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。

公平性问题:公平性的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是有顺序的。ReentrantLock 提供了一个构造函数,能够控制锁是否是公平。

提示:公平的锁机制往往没有非公平的效率高,但是并不是任何场景都是以TPS作为唯一的标准,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。

5.3.1 实现重进入

重进入表示任意线程在获取到锁之后能够再次获取该锁而不会被锁阻塞,该特性的实现需要解决的问题:

1.线程再次获取锁:锁需要去识别获取锁的线程是否是当前占据锁的线程。如果是则再次成功获取。

2.锁的最终释放:线程重复n次获取该锁,随后在第n次释放该锁后,其它线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,锁被释放时,计数自减。当计数为0时表示锁已经成功释放。

提示:

5.3.2 公平与非公平获取锁的区别

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,FIFO。

总结:公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

5.3.3 重入锁的方法

1. lock() 获得锁,如果锁已经被占用则等待

2. lockInterruptibly() 获得锁,但优先响应中断

3. tryLock() 尝试获得锁,如果成功则返回true,否则返回false,该方法不等待,立即返回

4. tryLock(long time, TimeUnit unit) 在给定时间内尝试获得锁

5. unlock() 释放锁

5.4 读写锁

之前的锁(Mutex | ReentrantLock)基本都是排它锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程进行访问,但是在写线程访问时,所有读线程和其它线程均被阻塞。

提示:读写锁维护了一对锁,一个读锁,一个写锁,通过分离读锁和写锁,使得并发相比一般的排它锁有了很高提升。

Java并发包中提供的读写锁实现是:ReentrantReadWriteLock,其提供特性为:

特性 | 说明
--- | ---
公平性选择 | 支持非公平和公平的锁获取方式,吞吐量还是非公平由于公平
重进入 | 该锁支持重进入
锁降级 | 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁

5.4.1 读写锁实例

5.6 Condition接口

任意一个Java对象都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait() | wait(long timeout) | notify() | notifyAll()方法,这些方法与synchronize同步关键字配合,可以实现等待/通知模式。

Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是两者再使用方式以及功能特性上是有差别的。

Condition的方法和描述:

方法名称 | 描述
--- | ---
await() | 当前线程进入等待状态直到被通知或中断
signal() | 唤醒一个等待在Condition上的线程,该线程从等待方法返回前必须获得与Condition相关联的锁

Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创建出来的,换句话说就是Condition是依赖Lock对象的。

提示:Condition在调用方法前需要获取锁。

//1. 在调用方法之前获取锁
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

public void conditionWait() throws InterruptedException {
    lock.lock();
    try {
        condition.await();
    }finally {
        lock.unlock();
    }
}

public void conditionSignal(){
    lock.lock();
    try {
        condition.signal();
    }finally {
        lock.unlock();
    }
}

一般都会将Condition对象作为成员变量,在调用成员变量之后,当前线程会释放锁并在此等待,而其它线程调用Condition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。

提示:获取一个Condition必须通过Lock的newCondition()方法。

提示:有界队列是一种特殊的队列,当队列为空时,队列的获取操作将会阻塞获取线程,直到队列中有新增元素,当队列已满时,队列的插入操作将会阻塞插入线程,直到队列出现空位。

5.6.1 Condition接口提供基本方法

1. void await(); 

2. void awaitUninterruptibly();

3. awaitNanos(long nanosTimeout);

4. boolean await(long time, TimeUnit unit);

5. boolean awaitUnit(Date deadline);

6. void signal();

7. void signalAll();

5.6.2 Condition实现分析

每个Condition对象都包含着一个队列(等待队列),该队列是Condition对象实现等待/通知功能的关键。

1.等待队列

等待队列是一个FIFO的队列,在队列中的每个节点都包含一个线程引用,该线程就是在Condition对象上等待的线程。如果一个线程调用了 Condition.await() 方法,那么该线程将会被释放锁、构造成节点加入等待队列并进入等待状态。

2.等待

调用Condition的await()方法(或者以await()方法开头的方法)会使当前线程进入等待队列并释放锁,同时线程状态变成等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。

3.通知

调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒结点之前,会将节点移到同步队列中。

5.6 锁

5.6.1 无锁

在并发控制层面,锁是一种悲观策略。无锁是一种乐观策略,所有线程都能够在不停顿的状态下持续执行,在遇到冲突的时候使用一种比较交换的策略(CAS)鉴别线程冲突。

1. CAS

CAS算法过程,包含三个参数 CAS(V,E,N),V表示要更新的变量,E表示预期值,N表示新值。仅仅当V==E时,才会将V设置为N,如果E!=E,说明其它线程已经做了更新,当前线程什么都不用做。最后CAS返回当前V的真实值。

CAS总是持乐观态度,在多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅仅是告知失败,并且允许再次尝试或放弃操作。

2. AtomicInteger

AtomicInteger直接使用CAS操作的线程安全的类型,可以将其看做一个整数。与Integer不一样的是它是可变的,并且线程安全。对其各种修改或其它操作都是CAS指令进行的。

3. Java中的指针 Unsafe类

Unsafe类是 sun.misc.Unsafe类型。该类封装了一些不安全的操作(指针是不安全的),所以该类封装了一些类似指针的操作。

Unsafe类提供的一些方法

1. public native int getInt(Object o, long offset);//获取给定对象偏移量上的int值

2. public native void putInt(Object o, long offset, int x);//设置给定对象偏移量上的int值

3. public native long objectFieldOffset(Field f);

...
4. 无锁对象的引用 AtomicReference

该类型是对普通的对象的引用。保证我们在修改对象引用时线程安全性,

5. 带有时间戳的对象引用 AtomicStampedReference

在其内部不仅维护了对象值,还维护了一个时间戳,当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须更新时间戳。

6. 数组无锁 AtomicIntegerArray

原子数组包括:AtomicIntegerArray | AtomicLongArray | AtomicReferenceArray。

AtomicIntegerArray本质上是对int[]类型的封装,使用Unsafe类通过CAS的方式控制int[]在多线程下的安全性。

7. 普通变量享受原子操作 AtomicIntegerFieldUpdater

AtomicIntegerFieldUpdater 在不改动或极少改动原有程序的基础上让普通的变量享受CAS操作带来的线程安全性。

根据数据类型不同,Updater有三种:AtomicIntegerFieldUpdater | AtomicLongFieldUpdater | AtomicReferenceFieldUpdater。

注意:

  1. Updater只能修改它可见范围内的变量,因为Updater使用反射得到这个变量。如果变量不可见,就会出错。如:score声明为private。
  1. 为了确保变量被正确的读写,其必须是volatile类型的。
  1. 由于CAS操作会通过对象实例中的偏移量直接进行复制,所以它不支持static字段。

本文标题:

文章作者:Bangjin-Hu

发布时间:2019年10月15日 - 09:22:26

最后更新:2020年03月30日 - 08:18:24

原始链接:http://bangjinhu.github.io/undefined/第5章 Java中的锁/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

Bangjin-Hu wechat
欢迎扫码关注微信公众号,订阅我的微信公众号.
坚持原创技术分享,您的支持是我创作的动力.