Java锁详解

# Java锁详解

# 1、Java语言中有哪些锁的概念?

在Java语言中,锁是多线程编程中用来控制访问共享资源的机制。Java提供了多种锁机制,下面总结下这些锁机制。

# ①、内置锁(synchronized)

实例锁: 用在普通方法上,锁住的是当前对象实例(this)。
类锁: 用在静态方法上,锁住的是当前类的Class对象。
还可以用在代码块上,可以指定锁对象。(可以是实例锁也可以是类锁)

偏向锁、轻量级锁、重量级锁这几种锁是JVM对于synchronized关键字的优化。

自旋锁思想: 线程在短时间内等待锁时不会立即阻塞,而是自旋(即空转)等待锁释放。(自旋就是死循环)
自适应自旋思想: 自旋锁的改进版,根据上次自旋的时间和锁的拥有者的状态来决定自旋的次数。

关于详细的synchronized 可以: 参考 synchronized关键字详解(强烈建议先看这篇) (opens new window)

# ②、显式锁(Lock)

ReentrantLock: 可重入锁,类似于synchronized,但提供了更高级的功能。
ReentrantReadWriteLock: 读写锁,允许多个线程同时读,但在写时会独占锁。
StampedLock: 改进的读写锁,提供了三种模式(写锁、悲观读锁和乐观读锁),并且在某些操作上有更高的性能。

# ③、乐观锁和悲观锁的思想

乐观锁和悲观锁:
乐观锁和悲观锁是数据库和并发编程中的两个重要概念,用于解决多线程访问共享资源时的并发问题。
乐观锁和悲观锁并不是具体的锁实现,而是两种并发控制的思想或策略。
它们描述了如何处理多线程访问共享资源时的并发问题,具体的实现可以有多种方式。

# 乐观锁(Optimistic Locking)

乐观锁假设对共享资源的访问冲突很少。每次访问资源时,不加锁,先进行操作,并在提交时检查冲突。如果没有冲突,则操作成功;如果有冲突,则回滚操作并重试。

# 乐观锁实现方式

在Java中,乐观锁通常通过版本号或、时间戳或CAS(Compare-And-Swap)操作来实现。

例:
版本号实现
在对象中增加一个版本号,每次更新时,先检查版本号是否一致,再更新版本号,更新修改。 伪代码(需要考虑操作的原子性、可见性):

public class TestA {
    private int version;

    public void update() {
        int currentVersion = this.version;
        // 修改资源
        // ... 修改资源的操作
        if (this.version == currentVersion) {
            this.version++;
            // 提交修改
        } else {
            // 版本号不一致,说明有并发修改,重试或处理冲突
        }
    }
}

CAS操作实现:
通过原子操作来检测并发冲突。Java中的AtomicInteger、AtomicLong、AtomicReference等类都使用了这种技术。
原子类和CAS操作后续会另写博客分析。

# 悲观锁(Pessimistic Locking)

假设并发冲突很多,每次访问资源之前先加锁,确保只有一个线程能访问资源,其他线程必须等待锁释放。

# 悲观锁实现方式:

内置锁(synchronized):使用Java的synchronized关键字。 显式锁(Lock):使用ReentrantLock、ReentrantReadWriteLock、StampedLock。

# ④、共享锁和排它锁思想

共享锁(Shared Lock):
共享锁允许多个线程同时访问共享资源,但不允许任何线程进行写操作。共享锁通常用于读操作,因为多个读操作可以并行执行而不影响数据一致性。

在Java中,ReentrantReadWriteLock的读锁就是一种共享锁。

排它锁(Exclusive Lock):
排它锁(也叫互斥锁)只允许一个线程访问共享资源,任何其他线程在同一时刻都不能访问该资源。排它锁通常用于写操作,因为写操作需要独占访问资源以确保数据一致性。

在Java中,ReentrantLock、ReentrantReadWriteLock的写锁、synchronized的内部实现都是排它锁。

# ⑤、可重入锁和不可重入锁思想

可重入锁(Reentrant Lock):
可重入锁允许同一个线程多次获得同一个锁,而不会发生死锁。当一个线程持有一个可重入锁时,它可以再次进入该锁保护的代码块而不会被阻塞。

Java中的ReentrantLock和synchronized都是可重入锁。

不可重入锁(Non-reentrant Lock) 不可重入锁不允许同一个线程多次获得同一个锁。即使同一线程已经持有了该锁,再次尝试获取锁时也会被阻塞,直到锁被释放。

Java中没有直接提供不可重入锁的实现,但可以通过自定义来模拟。
例:
使用synchronized配合waitnotify 和状态变量,来实现不可重入锁。

class NonReentrantLock{

    private volatile boolean isLocked = false;

    public synchronized void lock() throws InterruptedException {
        while (isLocked) {
            wait();
        }
        isLocked = true;
    }

    public synchronized void unlock() {
        isLocked = false;
        notify();
    }
}

# ⑥、公平锁和非公平锁思想

公平锁(Fair Lock):
公平锁按照请求锁的顺序(先来先服务)分配锁。公平锁确保线程获取锁的顺序与请求锁的顺序一致,从而避免线程饥饿现象。但可能性能和灵活性不如非公平锁。

Java中的ReentrantLock可以配置为公平锁。

ReentrantLock fairLock = new ReentrantLock(true);

非公平锁(Non-fair Lock):
非公平锁不保证线程获取锁的顺序,任何线程都有机会在锁释放时竞争锁。非公平锁可能导致线程饥饿,但通常性能较好,因为减少了线程调度的开销。

Java中的ReentrantLock默认是非公平锁,synchronized底层也是非公平锁。

ReentrantLock nonFairLock = new ReentrantLock();

# 2、java.util.concurrent.locks.Lock详解

建议先看前置知识点:
AQS详解https://deepjava.blog.csdn.net/article/details/140123293 (opens new window)

LockSupport详解https://deepjava.blog.csdn.net/article/details/140491126 (opens new window)

# Lock接口的继承结构

Lock接口非常简单,就是单一的接口,没有继承其他接口。

public interface Lock{
// ...
}

Lock接口是Java并发编程中提供的一种锁机制,它比synchronized关键字提供了更多的锁定操作,并且支持更复杂的线程同步功能。
Lock接口提供了对锁的显式控制,允许更加灵活和高效的同步策略。

# Lock接口的主要方法

①、void lock()
获取锁,如果锁已经被其他线程持有,则当前线程将被阻塞,直到锁被获取为止。

②、void lockInterruptibly()
获取锁,但允许中断。如果当前线程在等待锁的过程中被中断,则会抛出InterruptedException异常。

③、boolean tryLock()
尝试获取锁,如果锁可用,则立即返回true,如果锁不可用,则返回false。该方法不会阻塞当前线程。

④、boolean tryLock(long time, TimeUnit unit)
尝试在给定的时间内获取锁。如果在超时时间内获取到了锁,则返回true,如果超时仍未获取到锁,则返回false。该方法允许响应中断。

⑤、void unlock() 释放锁。通常在finally块中调用,以确保锁一定会被释放。

⑥、Condition newCondition() 返回与此锁绑定的一个新Condition实例。Condition实例提供了类似Objectwait、notify和notifyAll的方法(比wait、notify和notifyAll的方法更加灵活),但它们的使用必须与Lock对象配合。

# Lock有哪些实现类

基于JDK8。

mixureSecure

可以看到Lock的直接实现类虽然只有 ReentrantLock ,但是像ReentrantReadWriteLockStampedLock这两个类中都有内部类实现了Lock接口。
下面会详细介绍 ReentrantLockReentrantReadWriteLockStampedLock后面再单独分析。
至于ConcurrentHashMap 之前的文章已经有介绍过了,可以参考 ConcurrentHashMap详解 (opens new window)

# 3、Lock的实现类ReentrantLock

# ReentrantLock 的继承体系

public class ReentrantLock implements Lock, java.io.Serializable {
// ...
}
mixureSecure

可以看到ReentrantLock 的继承体系比较简单,实现了Lock接口规范说明具备Lock接口定义的一些功能,同时支持序列化操作。

# ReentrantLock 的构造方法和类属性

ReentrantLock 的构造方法:

①、空参构造(默认构造非公平锁)

public ReentrantLock() {
        sync = new NonfairSync();
    }

②、带参构造(可以指定锁的公平性)

public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

ReentrantLock 的类属性:

public class ReentrantLock implements Lock, java.io.Serializable {
	// Sync是一个抽象的内部类,继承自AbstractQueuedSynchronizer (AQS),用来实现锁的基本功能。
	//Sync有两个具体实现类,分别对应公平锁和非公平锁。
	// FairSync:实现公平锁,线程按照请求锁的顺序获取锁。
	// NonfairSync:实现非公平锁,任何线程都有机会竞争锁。
    private final Sync sync;
}

所以通过ReentrantLock 源码可以知道,ReentrantLock类自身只是实现了Lock接口的方法,和两个简单的构造方法。
具体的功能实现,比如公平锁和非公平锁,重入锁等功能都是通过其内部类来实现的。

# ReentrantLock 的内部类

下面是JDK8 ReentrantLock 的内部类部分源码截图。

mixureSecure

看下这几个内部类的继承体系:

mixureSecure

梳理下这几个内部类的关系:

ReentrantLock的内部使用了一个抽象的同步类Sync来实现锁的功能。

Sync继承自AbstractQueuedSynchronizer(AQS),AQS是Java并发包中一个强大的同步框架,用于构建锁和同步器(具体可以参考 AQS详解 (opens new window))。
NonfairSyncSync的一个具体实现类,实现了非公平锁。非公平锁的特点是,锁的获取不保证线程按照请求的顺序来获取锁。
FairSyncSync的一个具体实现类,实现了公平锁。公平锁的特点是,锁的获取保证线程按照请求的顺序来获取锁,避免线程饥饿现象。
NonfairSyncFairSync实现的都是AQS中的tryAcquiretryRelease方法,表名利用的是AQS的独占锁模式,说明ReentrantLock是独占锁,即某时刻只有一个线程可以获取锁。

补充知识点:
线程饥饿现象:
线程饥饿(Thread Starvation)是指一个线程长时间无法获取所需的资源(如CPU时间片、锁等),从而无法执行其任务的现象。在多线程环境中,某些线程可能因为资源竞争或者调度策略等原因而得不到足够的执行机会,进而导致线程饥饿。

# ReentrantLocklock()方法详解

// 默认是实例化 NonfairSync 非公平锁实现
 public ReentrantLock() {
        sync = new NonfairSync();
    }

public void lock() {
	// 调用 Sync 子类  NonfairSync的lock方法
        sync.lock();
    }

# ReentrantLocklock()方法非公平锁实现

// NonfairSync的lock方法  
final void lock() {
    // 尝试将锁的状态从 0(未锁定)更新为 1(锁定)。这是一种原子操作。
    if (compareAndSetState(0, 1)) {
        // 如果状态更新成功,则将当前线程设置为锁的拥有者。
        setExclusiveOwnerThread(Thread.currentThread());
    } else {
        // 如果状态更新失败(说明锁已被其他线程持有),则调用 acquire(1) 方法尝试获取锁。
        acquire(1);
    }
}

public final void acquire(int arg) {
    // 尝试以非公平的方式获取锁。如果获取锁失败,则将当前线程加入等待队列。
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
        // 如果线程在等待队列中被中断,则进行中断处理。
        selfInterrupt();
    }
}

// NonfairSync的tryAcquire方法  
protected final boolean tryAcquire(int acquires) {
    // 以非公平的方式尝试获取锁,具体实现是 nonfairTryAcquire 方法。
    return nonfairTryAcquire(acquires);
}

// 尝试以非公平的方式获取锁的具体逻辑
// 这个方法是 Sync类的实现 
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread(); // 获取当前线程
    int c = getState(); // 获取当前锁的状态值
	// 非公平锁的具体体现,检查锁状态是0(释放状态) 直接让当前线程参与竞争锁。  
    if (c == 0) {
        // 如果锁状态为 0(未锁定),则尝试将状态更新为 acquires(通常为 1)。
        if (compareAndSetState(0, acquires)) {
            // 状态更新成功,将当前线程设置为锁的拥有者。
            setExclusiveOwnerThread(current);
            return true; // 锁获取成功
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 如果当前线程已经是锁的拥有者,则增加锁的持有计数。
        int nextc = c + acquires;
        if (nextc < 0) // 检查计数是否溢出
            throw new Error("Maximum lock count exceeded");
        setState(nextc); // 更新锁的持有计数
        return true; // 锁获取成功
    }
    return false; // 锁获取失败
}

最后看看非公平在代码里是怎么体现的:
首先非公平是说先尝试获取锁的线程并不一定比后尝试获取锁的线程优先获取锁。

非公平锁的核心特性主要体现在以下方面:
直接获取锁:
if (compareAndSetState(0, 1))获取锁失败后,直接调用acquire(1);,然后调用tryAcquire,最终会调用到nonfairTryAcquire
nonfairTryAcquire 中,尝试直接获取锁。在锁的状态为 0 时(即锁是释放状态),线程不检查等待队列中的线程,而是直接参与竞争锁。 即不保证在锁释放后,等待队列中的线程会被立即唤醒。

再举个例子帮助理解:
这里假设线程 A 调用 lock()方法时执行到 nonfairTryAcquire 的代码,发现当前状态值不为 0(假设此时锁被其他线程持有),所以继续执行代码,发现当前线程不是线程持有者,则返回 false,然后当前线程A被放入 AQS 阻塞队列。

这时候线程 B 也调用了 lock() 方法执行到 nonfairTryAcquire 的代码,发现当前状态值为 0 了(假设占有锁的其他线程释放了锁),然后线程B继续执行代码,会直接通过 CAS 设置获取锁,并且B线程此时抢到了锁。

再看整个过程是线程 A 先请求获取锁,但是后来请求获取锁的线程B却抢到了锁,这就是非公平的体现。本质上是因为线程 B 在获取锁之前并没有查看当前 AQS 队列里面是否有比自己更早请求锁的线程,而是当发现锁被释放后直接调用 compareAndSetState去抢占锁资源。

# ReentrantLocklock()方法公平锁实现

// 创建公平锁
ReentrantLock reentrantLock = new ReentrantLock(true);
reentrantLock.lock(); // 调用公平锁的 lock 方法

public ReentrantLock(boolean fair) {
    // 根据参数决定使用 FairSync(公平锁)还是 NonfairSync(非公平锁)
    sync = fair ? new FairSync() : new NonfairSync();
}

public void lock() {
    // 调用 Sync 子类 FairSync 的 lock 方法
    sync.lock();
}

// FairSync 的 lock 方法
final void lock() {
    acquire(1); // 调用 acquire 方法尝试获取锁
}

public final void acquire(int arg) {
    // 尝试获取锁,如果失败则将线程加入等待队列
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
        // 如果线程在等待队列中被中断,则进行中断处理
        selfInterrupt();
    }
}

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread(); // 获取当前线程
    int c = getState(); // 获取当前锁的状态值
    if (c == 0) {
        // 如果锁状态为 0(未锁定),则进一步检查是否是公平的
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            // 如果没有其他线程在等待队列中,并且状态更新成功,则设置当前线程为锁的拥有者
            setExclusiveOwnerThread(current);
            return true; // 锁获取成功
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 如果当前线程已经是锁的拥有者,则增加锁的持有计数
        int nextc = c + acquires;
        if (nextc < 0) // 检查持有计数是否溢出
            throw new Error("Maximum lock count exceeded");
        setState(nextc); // 更新持有计数
        return true; // 锁获取成功
    }
    return false; // 锁获取失败
}

public final boolean hasQueuedPredecessors() {
    Node t = tail; // 读取尾节点
    Node h = head; // 读取头节点
    Node s;
    // 如果头节点不等于尾节点且 (头节点的下一个节点不为空或 头结点的下一个节点的等待线程不等于当前线程)
    // 表示
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

实现公平性的关键在于 hasQueuedPredecessors() 方法:

hasQueuedPredecessors() 方法通过检查 headtail 节点,以及 head.next 节点的线程,确定当前线程是否在等待队列的最前面。如果 head 不等于 tail 且当前线程是队列中的第一个线程,则返回 false(表示当前线程可以抢占锁),否则返回 true(表示当前线程不可以抢占锁)。这个方法确保公平锁的公平性,即保证队列中前面有其他线程的情况下,当前线程不会直接获取锁而是加入AQS的同步队列。

# ReentrantLockunlock()方法

public void unlock() {
	// 调用 Sync 子类的 release 方法,实际上最终调用的是AQS提供的release方法
    sync.release(1); 
}

// AQS的release方法
public final boolean release(int arg) {
    // 尝试释放锁
    if (tryRelease(arg)) {
        // 如果成功释放锁,则通知队列中的后继线程
        Node h = head; // 读取队列的头部节点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h); // 唤醒队列中的后继线程
        return true; // 释放锁成功
    }
    return false; // 释放锁失败
}

// Sync子类的tryRelease 实现
protected final boolean tryRelease(int releases) {
    // 计算释放锁后的状态值
    int c = getState() - releases;
    
    // 检查当前线程是否是锁的持有者
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException(); // 如果当前线程不是持有锁的线程,则抛出异常
    
    boolean free = false; // 标记锁是否完全释放
    
    // 如果释放后的状态值为 0,说明锁已经完全释放
    if (c == 0) {
        free = true; // 设置标记为 true
        setExclusiveOwnerThread(null); // 解除锁的持有者
    }
    
    // 更新锁的状态值
    setState(c);
    
    // 返回是否完全释放锁
    return free;
}


 // 唤醒同步队列中的线程
 private void unparkSuccessor(Node node) {
    /*
     * 如果节点的状态为负(可能需要信号),尝试将其状态置为 0 以准备信号。
     * 如果状态设置失败或者等待线程修改了状态,也没有关系。
     */
    int ws = node.waitStatus; // 读取节点的状态
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0); // 尝试将状态从负值设置为 0

    /*
     * 唤醒的线程通常是后继节点(即下一个节点)。但如果节点被取消或者为空,
     * 则从尾节点向后遍历找到实际的非取消的后继节点。
     */
    Node s = node.next; // 获取下一个节点
    if (s == null || s.waitStatus > 0) {
        s = null; // 如果下一个节点为空或状态为正,说明节点不可用
        // 从尾节点开始向前遍历,找到第一个状态为非正值的节点
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t; // 找到第一个非取消节点
    }
    if (s != null)
        LockSupport.unpark(s.thread); // 唤醒线程
}

总结:
unlock() 方法: 释放锁,调用 sync.release(1) 方法,其中 sync 是 FairSync 或 NonfairSync 的实例。 release(int arg) 方法: 尝试释放锁。如果成功释放锁,则通知队列中的后继线程。 unparkSuccessor(Node node) 方法: 负责唤醒等待队列中的下一个线程。如果后继线程被取消或为空,则向后遍历找到第一个可用的非取消线程并唤醒它。

# ReentrantLock 公平\非公平模式使用示例

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TestA {

    // 公平锁
    static ReentrantLock lock = new ReentrantLock(true);

    // 非公平锁
//    static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws Exception {

        Thread t1 = new Thread(() -> {
            print(Thread.currentThread().getName());
        }, "t1");

        Thread t2 = new Thread(() -> {
            print(Thread.currentThread().getName());
        }, "t2");

        Thread t3 = new Thread(() -> {
            print(Thread.currentThread().getName());
        }, "t3");

        // 主线程先获取锁 后面几个线程 非公平抢占锁
        print(Thread.currentThread().getName());

        t1.start();
        t2.start();
        t3.start();

    }

    public static void print(String str) {

        try {
            lock.lock();
            if (Thread.currentThread().getName().equals("main")) {
                TimeUnit.SECONDS.sleep(1);
            }

            System.out.println(str);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

需要注意的是:并不能按照 主线程执行start方法的顺序来反映锁的公平性。 尽管使用的锁是公平的,但如果线程 t1、t2、t3 启动的时机非常接近,操作系统可能会调度它们几乎同时尝试获取锁。这种情况下,未获取到锁的线程加入等待队列的时机可能不是按照主线程执行start方法的顺序,就会导致,获取锁的时候也不是按照代码执行顺序。 可能会出现 即使使用公平锁的情况下,也会打印出 非代码期望顺序的结果 比如使用公平锁仍然可能打印出下面的结果(只是概率不是太大):

main
t1
t3
t2

# ReentrantLock和synchronized的区别

特性 ReentrantLock synchronized
实现 显式锁(显示地创建和释放锁,由JDK提供支持) 内置监视器锁(隐式管理,由JVM提供支持)
公平性 可以选择公平性(ReentrantLock(true) 不支持公平性(总是非公平)
锁的粒度 细粒度(支持尝试锁定和定时锁定) 粗粒度(不支持尝试锁定和定时锁定)
可中断性 可以响应中断(lockInterruptibly() 不支持中断(不能响应中断)
条件变量 支持多个条件变量(Condition 不支持条件变量 (可以通过wait\notify方法实现线程调度)
锁的重入 支持锁重入 支持锁重入
锁的状态查询 可以查询锁的状态(isLocked() 不支持直接查询锁的状态
性能 可能更高(灵活控制锁的获取和释放) 使用更简单,由JVM优化通常性能足够

总结:

  • ReentrantLock 是一个显式的锁实现,它提供了比 synchronized 更为灵活的锁控制特性,比如公平锁、可中断锁和条件变量。
  • synchronized 是 Java 中的内置锁机制,使用更简单,但提供的功能相对有限,不支持条件变量和锁的公平性等高级特性。