在前面多线程相关知识点的讲解中,我们简单介绍了一下锁的特性,在这里我们深入探讨一下锁相关的知识点,在不同的场景下我们应该怎么使用锁进行编写程序~~

1.悲观锁和乐观锁

1.1悲观锁

理论原理:

悲观锁顾名思义就是一直很悲观,它假设每次并发访问共享资源都会发生冲突,因此每次操作都采取加锁的方式来保证线程安全。即便说有一个线程已经获取到了锁,其他线程仍然会被阻塞,直到锁被释放。

悲观锁的核心思想是:假想数据会被修改,因此必须加锁。

在Java中,通过 synchronized 或 ReentrantLock 来实现悲观锁。每次进入代码块的时候线程会阻塞并尝试获取锁,只有持有锁的线程可以继续执行,其他的线程会被挂起,直到锁释放。

实现逻辑

synchronized 是Java中最常见的悲观锁实现。它通过JVM的monitor实现锁的管理,使用的是重量级锁(即内核层级的加锁机制),此外, ReentrantLock也可以实现悲观锁,通过显示调用Lock()和unlock() 来确保互斥性。

在高并发下环境下,悲观锁由于线程需要等待上一个锁的释放才能继续下一个步骤,会导致线程的阻塞和上下文切换,可能引起性能瓶颈。在轻负载的情况下悲观锁的开销才会小一点。

一般用于写操作频繁的场景之下,比如数据库事务中对同一个数据的频繁修改,保障数据的一致性;以及多线程访问同一个资源的情况,并且资源更新复杂的情况,比如文件的写操作,读取的时候可能会更改文件内容。

优缺点

优点::保证了数据的一致性。

缺点:性能较差,尤其在多线程资源竞争激烈的时候,会产生大量的上下文切换和·资源浪费。

代码示例:

这里使用一个两个线程自增1000次的场景:

public class demo20 {
    private static int count = 0;
    private static final ReentrantLock lock = new ReentrantLock();

    public static void increment(){
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable task = (() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("counter = "+count);
    }
}

这个程序使用的就是悲观锁,lock.lock()就能体现出来(调用lock对象的lock方法,也就是说刚进入这个方法就直接被加锁了)。线程在count++之前必须先获取锁,如果锁被其他线程持有,当前线程就会被阻塞(挂起),直到获取锁为止,这就是总是假设最坏情况的策略~

1.2 乐观锁

理论原理:

乐观锁认为并发访问资源不会冲突,因此在读取的时候不会进行加锁,而是在更新时通过检测冲突来确保数据一致性。

乐观锁的核心思想就是:假想数据不会被修改,因此无需加锁,但是当更改时会进行验证。

在Java中,通常会通过 CAS(Compare-And-Swap) 来实现乐观锁。

实现机制:

使用CAS操作来实现。CAS是一种无锁操作,它通过原子操作比较内存中的数据值是否与预期的值相同,如果相同就替换,否则就表示发生了冲突,需要重试。

AtomicInteager、AtomicReference和AtomicBoolean 等都是基于CAS实现的。

CAS操作通常比悲观锁更加高效,因为它避免了线程挂起和上下文切换的开销,但是如果发生大量冲突时,CAS也会导致较低的性能。对于大多数轻量级的读写操作,乐观锁可以通过减少所竞争来提高性能。

一般适用于读多写少的场景,例如股票价格、用户信息等频繁读取的资源更新;以及高并发场景没比如高效的并发计数器。

优缺点

优点:高并发时比悲观锁性能更好,避免了加锁带来的开销,并且能高效处理无锁操作,是和读多写少的场景。

缺点:如果发生频繁冲突,CAS可能重试多次,影响性能。

代码实例:

package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class demo21 {
    private static final AtomicInteger counter = new AtomicInteger(0);

    public static void increment() {
        counter.incrementAndGet();//原子性自增操作(i+=1的线程安全版)
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable task = (() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

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

        t1.join();
        t2.join();

        System.out.println("counter:"+counter.get());
    }
}

这个程序使用AtomicInteger来保证线程安全,这里面使用的是乐观锁加上CAS。

乐观锁策略:乐观锁假设并发重冲突的概率很低,他不需要先加锁,而是直接尝试更新数据,这个代码中看不到synchronized或者lock()这样的加锁代码,increment()方法执行的时候,线程是不阻塞的。

虽然说我们运用到了CAS,但是在代码中我们并没有看到CAS出现,其实CAS藏在了incrementAndGet方法的内部实现里面,虽然在写代码的时候看不到CAS,但是在调用counter.incrementAndGet()这个方法的时候,方法内部会自动调用CAS逻辑。

这里我们看一下简化版的CAS源码:

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!weakCompareAndSetInt(o, offset, v, v + delta));
        return v;
    }
1. 参数含义:
  • Object o:操作的对象(比如 AtomicInteger 实例)。
  • long offset:内存偏移量。Java 通过这个偏移量直接定位到对象在内存中的 value 字段地址。
  • int delta:需要增加的数值(比如 1)。
详细步骤:
  1. v = getIntVolatile(o, offset);

    • 动作:从主内存中读取 o 对象在 offset 偏移量处的整数值,赋给变量 v
    • 意义:这是获取“期望值”。此时 v 代表了线程认为的当前数值。
  2. weakCompareAndSetInt(o, offset, v, v + delta)

    • 动作:这是 CAS 操作的具体体现。它的意思是:
      • 拿着刚才读到的 v 去内存里比对一下。
      • 如果内存里的值等于 v(说明没人动过),就把内存里的值更新为 v + delta,并返回 true
      • 如果内存里的值不等于 v(说明被别的线程改过了),就什么都不做,并返回 false
    • 关于 weakweakCompareAndSet(弱比较并交换)在某些 JVM 实现和旧版本中,它可能不保证内存排序的 happens-before 原则(即不保证 volatile 读写的语义),但在现代 JDK(如 JDK 8+)的 AtomicInteger 实现中,它的效果通常等同于标准的 compareAndSwapInt,都是为了性能优化。
  3. while (!...) (自旋)

    • 动作:这是一个死循环(do-while)。
    • 逻辑
      • 如果 CAS 成功(返回 true),!true 为 false,循环结束,方法返回。
      • 如果 CAS 失败(返回 false),!false 为 true循环继续
    • 意义:这就是自旋锁的体现。一旦失败,线程不会阻塞,而是立刻重新执行 do 里面的代码:再次读取最新值 v,再次尝试 CAS。直到成功为止。
2. 举例

假设内存中 count 的值当前是 5,有两个线程 A 和 B 同时执行 getAndAddInt,想要加 1。

  1. 并发读取

    • 线程 A 读到 v = 5
    • 线程 B 也读到 v = 5
  2. CAS 竞争

    • 假设线程 A 的手速快一点,先执行了 weakCompareAndSetInt(..., 5, 6)
    • 内存值确实是 5,匹配成功。内存值更新为 6。线程 A 的 CAS 返回 true,循环结束,线程 A 完成。
  3. 重试

    • 轮到线程 B 执行 weakCompareAndSetInt(..., 5, 6)
    • 此时内存值已经是 6 了,而线程 B 手里的期望值还是 5
    • 匹配失败!CAS 返回 false
    • while 条件成立,线程 B 没有被挂起,而是立刻进入下一轮循环。
  4. 再次读取并尝试

    • 线程 B 再次执行 getIntVolatile,这次读到了最新的值 v = 6
    • 线程 B 再次执行 CAS:weakCompareAndSetInt(..., 6, 7)
    • 这次匹配成功了(内存确实是 6),更新为 7。线程 B 的循环结束。
3. 总结

这段代码展示了 CAS 算法的标准实现模式:

  1. 无锁:没有使用 synchronized,线程不会挂起。
  2. 自旋:通过 do-while 循环,在冲突发生时不断重试。
  3. 原子性:依赖底层的 weakCompareAndSetInt(CPU 指令 cmpxchg)保证比较和更新这两步是原子的,不可分割。

这就是为什么 AtomicInteger 代码不需要加锁也能保证线程安全的原因。

2. 重量级锁和轻量级锁

锁的核心特性“原子性”,这样的机制追溯源是CPU这样的硬件设备提供的。

  • CPU提供了“原子操作指令”。
  • 操作系统基于CPU的原子指令,实现了mutex互斥锁、、
  • JVM基于操作系统提供的互斥锁,实现了synchronized 和 ReentrantLock等关键字和类。

synchronized不仅仅是对mutex进行封装,在synchronized内部还做了很多其他的工作。

2.1 重量级锁

理论原理:

重量级锁依赖于操作系统的底层机制,比如操作系统的互斥锁。当线程获取到锁失败时,操作系统会将线程阻塞,并进行上下文切换。当线程被唤醒时,它会重新尝试获取锁。

实现机制:

  • 在Java中,synchronized是重量级锁的典型实现,它会锁通过操作系统的内核进行管理。在争用激烈时,JVM会将锁升级为重量级锁。
  • 在高并发情况下,重量级锁导致线程阻塞,频繁的上下文切换和CPU切换回引发性能瓶颈。

2.2 轻量级锁:

轻量级锁是JVM提供的一种优化策略,它在没有竞争的情况下,通过自旋锁或CAS等机制避免了线程的阻塞。只有在竞争发生时,他才会将锁升级为重量级锁。

实现机制:

  • 自旋锁:通过循环(自旋)不断尝试获取锁,避免线程挂起。
  • CAS操作:轻量级锁通过原子操作判断锁状态,如果锁未被占用,则获取锁。、

轻量级锁的开销较小,特别是在锁竞争较少时,它能有效提升性能~

代码案例(自旋锁):

import java.util.concurrent.atomic.AtomicBoolean;

public class tty {
    private static final AtomicBoolean lock = new AtomicBoolean(false);

    public static void acquireLock() {
        while(!lock.compareAndSet(false,true)){
            //自旋等待
        }
    }

    public static void releaseLock() {
        lock.set(false);
    }

/**
 * 主方法,创建并启动两个线程来执行任务
 * @param args 命令行参数,本程序中未使用
 */
    public static void main(String[] args) {
    // 使用Lambda表达式创建一个Runnable任务
        Runnable task = () -> {
        // 获取锁
            acquireLock();
        // 打印当前线程名和获取锁的信息
            System.out.println(Thread.currentThread().getName()+"  acquired the lock");
        // 释放锁
            releaseLock();
        };

    // 创建第一个线程,执行上述任务
        Thread t1 = new Thread(task);
        
    // 创建第二个线程,执行相同的任务
        Thread t2 = new Thread(task);

    // 启动第一个线程
        t1.start();
    // 启动第二个线程
        t2.start();

    }
}

1. 核心组件:AtomicBoolean

private static final AtomicBoolean lock = new AtomicBoolean(false);
  • 含义:这里声明了一个静态的、不可变的 AtomicBoolean 对象,命名为 lock,初始值为 false
  • 作用AtomicBoolean 是一个提供原子操作的布尔值。在多线程环境下,普通的 boolean 变量在读写时可能会出现线程安全问题(如内存可见性问题),而 AtomicBoolean 内部使用了 CAS (Compare-And-Swap) 算法,保证了操作的原子性可见性
  • 状态定义
    • false:表示锁处于空闲状态(没有线程持有)。
    • true:表示锁处于占用状态(有一个线程正在持有)。

2. 获取锁:acquireLock()

public static void acquireLock() {
    while(!lock.compareAndSet(false, true)){
        //自旋等待
    }
}

这是自旋锁的核心逻辑。

  • compareAndSet(false, true) 方法

    • 这是 CAS 操作的体现。它的含义是:"原子性地检查当前的值是不是 false,如果是,就把它设置为 true,并返回 true;如果当前值不是 false(即已经被别的线程改成了 true),就什么都不做,并返回 false。"
    • 原子性保证了这一步是不可打断的,不会出现两个线程同时看到 false 并同时将其改为 true 的情况。
  • while 循环(自旋)

    • 如果 compareAndSet 返回 true,说明抢锁成功,!true 为 false,循环结束,线程继续往下执行。
    • 如果 compareAndSet 返回 false,说明锁已经被其他线程占用了(当前值是 true)。此时 !false 为 true,线程进入 while 循环体。
    • 循环体是空的(或者只有注释),线程会在这里死循环,不断地再次尝试 compareAndSet。这就是所谓的**"自旋"**(Spinning)。线程不会进入阻塞状态(不会让出 CPU),而是一直占用 CPU 等待锁释放。

3. 释放锁:releaseLock()

public static void releaseLock() {
    lock.set(false);
  • 含义:当线程完成临界区代码(需要互斥执行的代码)后,调用此方法释放锁。
  • lock.set(false):将 AtomicBoolean 的值重新设置为 false
  • 效果:这一步利用了 AtomicBoolean 的 volatile 语义(set 方法具有写内存屏障),保证这个修改对其他线程立即可见。其他正在 while 循环中自旋的线程会立刻发现 lock 变成了 false,于是它们中的一个会通过 CAS 成功抢到锁。

4. 测试代码:main()

public static void main(String[] args) {
    Runnable task = () -> {
        acquireLock();
        System.out.println(Thread.currentThread().getName()+"  acquired the lock");
        releaseLock();
    };

    Thread t1 = new Thread(task);
    Thread t2 = new Thread(task);

    t1.start();
    t2.start();
}
  • 任务定义:定义了一个 Lambda 表达式 task,它的逻辑是:先获取锁 -> 打印当前线程名 -> 释放锁。
  • 线程启动:创建了两个线程 t1 和 t2,几乎同时启动,去执行同一个任务。
  • 执行流程推演
    1. t1 和 t2 同时运行。
    2. 假设 t1 稍微快一点,执行 acquireLock()。此时 lock 为 falset1 的 CAS 操作成功,lock 变为 truet1 进入打印语句。
    3. t2 紧接着执行 acquireLock()。此时 lock 已经是 true 了,t2 的 CAS 操作失败,t2 进入 while 死循环(自旋),等待 t1 释放锁。
    4. t1 打印完语句,执行 releaseLock(),将 lock 设为 false
    5. t2 在下一次循环中检测到 lock 为 false,CAS 成功,抢到锁,跳出循环,执行打印语句。

总结与优缺点

功能总结
这段代码实现了一个非阻塞的互斥锁。它确保了同一时刻只有一个线程能进入临界区(即 System.out.println 那一行),保证了线程安全。

优点

  1. 无阻塞:线程没有挂起(没有调用 wait 或 LockSupport.park),避免了操作系统内核态和用户态切换的开销。
  2. 响应快:如果锁被持有的时间非常短,自旋等待能立刻获取锁,执行效率高。

缺点(重要)

  1. CPU 浪费:如果锁被持有的时间较长,或者竞争非常激烈,其他线程就会一直占用 CPU 进行空转(自旋),导致 CPU 负载飙升,极其浪费资源。
  2. 不可重入:如果一个线程已经持有锁,再次调用 acquireLock(),会因为 lock 已经是 true 而进入死循环,导致自己把自己锁死(死锁)。

适用场景
锁持有的时间非常短,且并发竞争不激烈的场景。在实际开发中(如 Java JDK),通常会在自旋锁的基础上增加自适应策略(比如自旋一定次数后放弃),或者直接使用更高级的 ReentrantLock 或 synchronized,它们在内部已经优化了自旋与阻塞的切换逻辑。

理解自旋锁vs挂起等待锁

想象下,去追求⼀个女神.当男升向女神表白后,女神说:你是个好人,但是我有男朋友了~~
挂起等待锁:陷入沉不能自拔....过了很久很久之后,突然女神发来消息,"咱俩要不试试?"(注意,这 个很长的时间间隔里,女神可能已经换了好几个男票了).
自旋锁:死皮赖脸坚韧不拔.仍然每天持续的和女神说早安晚安.一旦女神和上一任分手,那么就能立刻 抓住机会上位.

3. 自旋锁

3.1 原理

自旋锁通过让线程忙碌等待的方式避免了线程挂起。当线程发现锁被占用时,它会反复检查锁的状态,直到成功获得锁。

3.2 使用场景

  • 锁竞争不激烈,并且锁占用时间较短的场景。
  • 自旋锁的典型应用是在CPU密集型的任务中,避免了阻塞带来的上下文切换。

3.3 优缺点

  • 优点:避免了上下文切换的开销,在短时间内能够提升性能。
  • 缺点:如果锁被长时间占用,会造成CPU资源的浪费。

4. 公平锁和非公平锁

顾名思义,公平锁和非公平锁就是看是否公平。

举个例子:假如说三个线程ABC,A现获取锁(成功),然后B获取锁(失败,阻塞等待)再然后C也尝试获取锁(失败,阻塞等待)。但是如果此时线程A释放锁了会怎么样呢?

  • 公平锁情况下,遵守‘先来后到’,B比C先尝试获取,当A释放锁之后,B顺理成章拿到锁,等待B释放后C才能拿到锁。
  • 非公平锁情况下,不遵守‘先来后到’,B和C都有可能获得锁。

这就好比一群男生追同一个女神.当女神和前任分手之后,先来追女神的男生上位,这就是公平锁;如果 是女神不按先后顺序挑一个自己看的顺眼的,就是非公平锁.

4.1 公平锁

公平锁保证请求锁的线程按顺序获取锁,避免线程饥饿问题

代码实现:

import java.util.concurrent.locks.ReentrantLock;

public class f1 {
    private static final ReentrantLock lock = new ReentrantLock(true); // 公平锁

/**
 * 主方法,创建两个线程并启动它们,每个线程都会尝试获取锁
 * @param args 命令行参数,本程序中未使用
 */
    public static void main(String[] args) {
    // 创建一个Lambda表达式实现的Runnable任务,该任务会尝试获取锁
        Runnable task = () -> {
        // 加锁操作
            lock.lock();
            try {
            // 打印当前线程名称和获取锁的信息
                System.out.println(Thread.currentThread().getName() + " acquired the lock");
            } finally {
            // 确保锁被释放,无论是否发生异常
                lock.unlock();
            }
        };

    // 创建第一个线程,执行上述任务
        Thread t1 = new Thread(task);
    // 创建第二个线程,执行相同的任务
        Thread t2 = new Thread(task);

    // 启动第一个线程
        t1.start();
    // 启动第二个线程
        t2.start();
    }
}

1. 核心组件:ReentrantLock 与公平锁

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

    • 位于 java.util.concurrent.locks 包下,是 JDK 提供的基于 AQS (AbstractQueuedSynchronizer) 实现的锁。
    • 它比 synchronized 关键字更灵活,提供了如可中断锁尝试非阻塞获取锁公平锁等高级功能。
  • new ReentrantLock(true)

    • 构造函数中的参数 true 表示创建一个公平锁
    • 公平锁的含义:锁的分配是严格按照线程请求锁的时间顺序(先进先出,FIFO)来决定的。如果有多个线程在等待锁,排队时间最长的线程将优先获得锁。
    • 对比:如果是 new ReentrantLock(false) 或者无参构造,则创建的是非公平锁。非公平锁允许“插队”,性能通常比公平锁高,但可能导致某些线程长时间等待(“饥饿”现象)。

2. 标准加锁范式:lock() 与 try-finally

Runnable task = () -> {
    // 加锁操作
    lock.lock();
    try {
        // 临界区代码
        System.out.println(Thread.currentThread().getName() + " acquired the lock");
    } finally {
        // 确保锁被释放
        lock.unlock();
    }
};

这是使用 ReentrantLock 最重要的一点,也是与 synchronized 最大的区别之一。

  • lock.lock()

    • 线程主动获取锁。如果锁已经被其他线程持有,当前线程会进入休眠(阻塞)状态,直到锁被获取为止。
    • 注意:与 tryLock() 不同,lock() 不会响应中断(除非使用线程中断机制配合),且如果不小心导致 unlock() 没执行,就会死锁。
  • try { ... } finally { ... } 结构

    • 为什么必须这样写? ReentrantLock 不会像 synchronized 那样在代码块结束时自动释放锁。必须手动调用 unlock()
    • 风险控制:将 unlock() 放在 finally 块中,是为了保证无论临界区代码是否发生异常(抛出 Error 或 Exception),锁都一定会被释放。如果忘记放在 finally 中,一旦业务代码报错跳出,锁就会永远被占用,导致系统死锁。

3. 线程执行逻辑

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
  • 创建了两个线程 t1 和 t2,它们共享同一个 lock 实例。
  • 两个线程几乎同时启动,竞争同一个锁。
  • 由于这是一个公平锁,如果 t1 先启动并尝试获取锁(或者先进入等待队列),t2 将乖乖排队,直到 t1 执行完毕并释放锁,t2 才能获取锁。

4. 与上一段自旋锁代码的对比

为了让你更好地理解,我们将这段代码与之前的 AtomicBoolean 自旋锁做一个对比:

特性 上一段代码 (AtomicBoolean 自旋锁) 这段代码
实现原理 基于 CAS 算法,通过 while 循环不断检测状态 基于 AQS 队列,线程获取锁失败后会阻塞(挂起)
CPU 消耗 。线程在等待时会一直占用 CPU 进行空转。 。线程等待时会释放 CPU 资源,由操作系统调度唤醒。
响应速度 如果锁持有时间极短,响应很快(无需上下文切换)。 如果锁持有时间短,可能因为上下文切换而有轻微延迟。
功能特性 仅提供基本的互斥访问。 提供公平性选择可重入条件变量等丰富功能。
代码安全性 简单场景下可用,复杂场景容易出错(如死循环)。 标准的 try-finally 范式,异常安全性高。

总结

这段代码演示了 Java 并发编程中显式锁的正确使用方法。

  1. 功能:它创建了一个公平的互斥锁,确保两个线程串行执行打印操作,不会发生交错。
  2. 关键点
    • 使用了 ReentrantLock(true) 启用公平机制,保证先来后到。
    • 使用了 try-finally 块,这是防止死锁、保证资源释放的黄金法则。
  3. 适用场景:适用于需要高并发控制、需要公平锁机制、或者需要利用 tryLock 等高级特性的复杂业务场景。在简单的同步需求下,synchronized 可能更简洁,但在需要精细化控制锁时,ReentrantLock 是首选。

4.2 非公平锁:

非公平锁不保证按照请求顺序获得锁,可能会导致部分线程长时间等待

代码示例:

package fff;

import java.util.concurrent.locks.ReentrantLock;

public class f2 {
    private static final ReentrantLock lock = new ReentrantLock(); // 非公平锁

/**
 * 主方法,演示多线程环境下的锁使用
 * 创建了两个线程,它们都会尝试获取同一个锁
 */
    public static void main(String[] args) {
    // 使用Lambda表达式创建一个Runnable任务
        Runnable task = () -> {
        // 获取锁
            lock.lock();
            try {
            // 打印当前线程名称和获取锁的信息
                System.out.println(Thread.currentThread().getName() + " acquired the lock");
            } finally {
            // 确保锁一定会被释放,避免死锁
                lock.unlock();
            }
        };

    // 创建两个线程,使用相同的任务
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

    // 启动两个线程
        t1.start();
        t2.start();
    }
}

这段代码与上一段(f1 类)非常相似,它们都使用了 ReentrantLock 来保证线程安全,并且都遵循了标准的 lock() / try...finally / unlock() 编程范式。

唯一的关键区别在于锁的“公平性”策略。

下面我将重点讲解这个区别,以及它对程序运行的影响。

1. 核心区别:非公平锁

private static final ReentrantLock lock = new ReentrantLock(); // 非公平锁
  • 构造方法:这里调用的是无参构造 new ReentrantLock()。在 Java 源码中,这等同于 new ReentrantLock(false)
  • 非公平锁的含义
    • 不保证先来后到:当锁被释放时,正在等待的线程和新来的线程都有机会获取锁。
    • 允许“插队”:如果有一个新线程在锁刚好释放的瞬间请求锁,它可以直接抢到锁,而不管队列中是否有等待了更久的线程。
    • 默认行为ReentrantLock 和 synchronized 关键字默认都是非公平的。

2. 公平锁 vs 非公平锁:深入对比

为了更好地理解,我们可以对比一下 f1(公平锁)和 f2(非公平锁)的行为:

特性 f1: 公平锁 (new ReentrantLock(true)) f2: 非公平锁 (new ReentrantLock())
获取锁策略 严格遵循 FIFO(先进先出)队列。 允许“插队”。
线程饥饿 不会发生。所有等待的线程最终都能拿到锁。 可能发生。在高并发下,如果不断有新线程插队,排在队列后面的线程可能长时间拿不到锁。
吞吐量 较低。除了线程挂起/唤醒的开销,还维护了严格的队列顺序,增加了系统开销。 较高。减少了线程挂起的几率,不需要频繁切换线程状态,CPU 利用率更高。
适用场景 严格要求按顺序执行任务,或者为了防止某个线程长期等待而“饿死”。 绝大多数通用场景。追求更高的执行效率,对执行顺序要求不严。

3. 代码执行流程推演

让我们看看在这段 f2 代码中,非公平性是如何体现的:

  1. 启动阶段t1 和 t2 启动。
  2. 竞争阶段
    • 假设 t1 抢到了锁,开始执行 System.out.println
    • t2 尝试获取锁失败,进入阻塞队列(Waiting 队列)等待。
  3. 释放阶段
    • t1 执行完毕,调用 unlock() 释放锁。
    • 关键点来了:此时,t2 还在被操作系统唤醒的过程中(从阻塞态转为就绪态需要时间)。
    • 如果此时恰好有一个新线程 t3(假设有的话)发起了 lock() 请求,非公平锁策略允许 t3 直接检查锁的状态,发现空闲,直接抢走锁!
    • 等到 t2 终于被唤醒准备去拿锁时,发现锁又被 t3 拿走了,t2 只能无奈地继续回去排队等待。

4. 为什么默认是非公平锁?

你可能会问,非公平锁会导致线程“饿死”,为什么 Java 还把它设为默认?

  • 性能考量:恢复一个被挂起的线程(操作系统上下文切换)是非常昂贵的操作。
  • 实际情况:在极短的时间内,让刚释放锁的线程或者新来的线程直接再次获取锁(重入或插队),比先去唤醒队列头部的线程要快得多。
  • 结论:为了追求更高的系统吞吐量,牺牲了绝对的公平性。在实际应用中,线程“饿死”的概率相对较低,而性能提升带来的收益是巨大的。

总结

这段代码演示了 ReentrantLock 的默认用法(非公平锁)

  1. 代码结构:与 f1 完全一致,是标准的并发安全写法。
  2. 核心区别:去掉了构造函数中的 true 参数,使用了默认的非公平策略。
  3. 实际影响
    • 程序运行速度通常会更快。
    • 线程获取锁的顺序是不确定的,不保证先启动的线程先拿到锁。
    • 在只有两个线程(t1t2)的简单示例中,你可能看不出区别;但在高并发场景下,这种策略能显著提升系统性能

加餐

ReentrantLock 的构造函数括号中的参数(boolean fair)决定了它是公平锁还是非公平锁。

具体规则如下:

  1. new ReentrantLock(true)

    • 参数为 true
    • 创建的是公平锁
    • 线程获取锁的顺序严格按照请求锁的时间顺序(先来后到)。
  2. new ReentrantLock() 或 new ReentrantLock(false)

    • 不传参数,或者参数为 false
    • 创建的是非公平锁
    • 线程获取锁的顺序是不确定的,允许“插队”。

源码层面的解释

如果你查看 ReentrantLock 的 Java 源码,会看到如下两个构造函数:

// 1. 无参构造函数
public ReentrantLock() {
    // 它内部调用了带参构造,并且默认传入了 false
    sync = new NonfairSync();
}

// 2. 带参构造函数
public ReentrantLock(boolean fair) {
    // 如果 fair 为 true,创建公平锁内部类对象
    // 如果 fair 为 false,创建非公平锁内部类对象
    sync = fair ? new FairSync() : new NonfairSync();
}

总结:

  • 写 new ReentrantLock() 等同于 new ReentrantLock(false),这就是非公平锁
  • 只有显式地写成 new ReentrantLock(true),才是公平锁

以上就是本篇的全部内容啦~~咱们下篇再见~~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐