Java中的锁

概述

Lock接口

锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。在Lock接口出现之前,Java程序时靠synchronized关键字实现锁功能的,而Java SE5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显示地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提供的的)隐式获取释放锁的便捷性,但是却拥有了获取锁与释放锁的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取在释放。当然,这种方式简化了同步的管理,可是扩展性没有显示的锁获取和释放来的好。
Lock是一个接口,它定义了锁获取和释放的基本操作,Lock的API如下表:
在这里插入图片描述
这里先简单介绍一个Lock接口的API,随后的章节会详细介绍同步器AbstractQueuedSynchronizer以及常用Lock接口的实现ReentrantLock。Lock接口的实现基本都是通过聚合一个同步器的子类来完成线程访问控制的。

队列同步器

队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础架构,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,并发包的作者(Doug lea)期望它能够成为实现大部分同步需求的基础。
同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这是就需要使用同步器提供的3个方法(getState()、setState(int newState)和compareAndSetState(int expect, int update))来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器即可以支持独占式地获取同步状态,也可以支持共享式的获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock和CountDownLatch)等。
同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者锁需关注的领域。

队列同步器的接口与示例

重入锁

重入锁ReentrantLock,顾名思义,就是支持重新进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁的公平和非公平性选择。
比如我们拥有一个不可重入锁Mutex(互斥锁),同时考虑如下场景:当一个线程调用Mutex的lock()方法获取锁后,如果再次调用lock()方法,则该线程将会被自己所阻塞,原因是Mutex在实现tryAcquire(int acquires)方法时没有考虑占用锁的线程再次获取锁的场景,而在调用tryAcquire(int acquires)方法时返回了false,导致该线程被阻塞。简单的说,Mutex是一个不支持重进入的锁。而synchronized关键字隐式的支持重入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获取该锁,而不像Mutex由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。
ReentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不是阻塞。
这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。
事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以TPS作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。
下面讲着重分析ReentrantLock是如何实现重进入和公平性获取锁的特性。

实现重进入

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

  1. 线程再次获取锁
    锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  2. 锁的最终释放
    线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁释放时,计数自减,当计数等于0时表示锁已经成功释放。
    ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,默认是非公平性获取锁。

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

公平性与否是针对获取锁而言,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
为什么会出现线程连续获取锁的情况呢?回顾nofairTryAcquire(int acquires)方法,当一个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放的线程再次获取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。
非公平性锁可能是线程“饥饿”,为什么它又被设定成默认的实现呢?通过测试发现,如果把每次不同线程获取锁定义为1次线程切换,公平性锁在测试中进行了10次切换,而非公平性锁只有5次切换,这说明非公平性锁的开销更小。
在测试中公平性锁与非公平性锁相比,总耗时是其94.3倍,总切换次数是其133倍。可以看出,公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少线程切换,保证了其更大的吞吐量。

读写锁

之前提到锁(Mutex和ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义了一个共享的用作缓存的数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占用的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。
在没有读写锁支持的(Java5之前)时候,如果需要完成上述工作就要使用Java的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized关键字进行同步),这样做的目的是使读取到的数据,不会出现脏读。改用读写锁实现上述功能,只需要读操作时获取读锁,写操作时获取写锁即可。当写锁被获取时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单多了。
一般情况下,读写锁的性能都会比排他锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现时ReentrantReadWriteLock,它提供的特性如下:

  • 公平性选择:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
  • 重进入:该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次读取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁。
  • 锁降级:遵循获取写锁、然后获取读锁在释放写锁的次序,写锁能够降级称为读锁。
    接下来,通过一个缓存示例说明读写锁的使用方式,示例代码如下:
package com.study.practice;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * @Description :
 * @Version : V1.0.0
 * @Date : 2022/4/4 19:37
 */
public class Cache {
    static Map<String, Object> map = new HashMap<>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock w = rwl.writeLock();
    static Lock r = rwl.readLock();

    // 获取一个Key对应的value
    public static final Object get(String key) {
        r.lock();
        try {
            return map.get(key);
        } finally {
            r.unlock();
        }
    }

    // 设置key对应的value, 并返回旧的value
    public static final Object put(String key, Object value) {
        w.lock();
        try {
            return map.put(key, value);
        } finally {
            w.unlock();
        }
    }

    // 清空所有内容
    public static final void clear() {
        w.lock();
        try {
            map.clear();
        } finally {
            w.unlock();
        }
    }
}

上述示例中,Cache组合一个非安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。在读操作get(String key)方法中,需要获取读锁,这 使得并发访问该方法时不会被阻塞。写操作put(String key, Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续。Cache使用读写锁提升读性能的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。

读写锁的实现分析

读写状态的设计

如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,地16位表示写。

写锁的获取与释放

写锁是一个支持重进入的排他锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
源码中除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果运行读锁在已经被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续写线程可见。

读锁的获取与释放

在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。

锁降级

锁降级指的是写锁降级成功为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,在获取读锁,随后释放(先前拥有的)写锁的过程。
下面是一个示例:

public void processData() {
	readLock.lock();
	if(!update) {
		// 必须先释放读锁
		readLock.unlock();
		// 锁降级从写锁获取开始
		writeLock.lock();
		try {
			if(!update) {
				// 准备数据的流程
				update = true;
			}
			readLock.lock();
		} finally {
			writeLock.unlock();
		}
		// 锁降级完成,写锁降级为读锁
	}
	try {
		// 使用数据的流程
	} finally {
		readLock.unlock();
	}
}

锁降级中读锁的获取是否有必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会别阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据的可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。

LockSupport工具

当需要阻塞或者唤醒一个线程的时候,都会使用LockSupport工具类来完成相应的工作。LockSupport定义了一组公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。
LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程。Park有停车的意思,假设线程为车辆,那么park方法代表着停车,而unpark方法则是指车辆启动离开。

Condition接口

任意一个Java对象,都拥有一一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两种在使用方式以及功能特性上还是有差别的。
通过对比Object的监视器方法和Condition接口,可以更相信地了解Condition的特性。
在这里插入图片描述

Condition接口与示例

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

	Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();

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

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

如示例所示,一般都会将Condition对象作为成员变量。当调用await()方法后,当前线程会释放锁并在此等待,进入等待队列,而其他线程调用Condition对象的signal()方法,通知当前线程后,当前线程从等待队列转移到阻塞队列,当调用signal的线程释放锁后,当前线程获取到锁后从阻塞队列删除,进入runnable状态,从await()方法返回继续执行之后的操作。
简单的说就是当调用await()方法后,当前线程会释放锁并在此等待,而其他线程调用Codition对象的signal()方法,通知当前线程后,当前线程才从await()方法返回,并且在返回前已经获取了锁。
状态变迁可以参考Java并发编程基础
Condition定义的常用方法以及描述如下:
在这里插入图片描述

小结

本章介绍了Java并发包中与锁相关的API和组件,通过示例讲述了这些API和组件的使用方式以及需要注意的地方,并在此基础上详细剖析了队列同步器、重入锁、读写锁已经Condition等API和组件的实现细节,只有理解这些API和组件的实现细节才能够更加准确的运用它们。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

融极

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值