JavaEE初阶第十三期:解锁多线程,从 “单车道” 到 “高速公路” 的编程升级(十一)

发布于:2025-08-04 ⋅ 阅读:(12) ⋅ 点赞:(0)

专栏:JavaEE初阶起飞计划

个人主页:手握风云

目录

一、常见的锁策略

1.1. 乐观锁与悲观锁

1.2. 重量级锁与轻量级锁

1.3. 挂起等待锁与自旋锁

1.4. 公平锁与非公平锁

二、synchronized的原理

2.1. 基本特点

2.2. 加锁工作过程

2.3. 其他的优化操作

三、CAS

3.1. 概念

3.2. 典型应用

3.3. CAS中的ABA问题


一、常见的锁策略

1.1. 乐观锁与悲观锁

        乐观锁假设在并发操作中,数据冲突发生的概率比较低。悲观锁假设在并发操作中,数据冲突发生的概率很高。冲突概率的高和低,在实际实现的过程中可能会出现不同的做法。

        对于悲观锁,更倾向于在锁中做一些“阻塞的操作”;对于乐观锁,认为锁冲突概率不高,就会尝试其他的方法代替阻塞,比如版本号或者忙等。

1.2. 重量级锁与轻量级锁

        重量级锁是指通过操作系统底层的互斥量来实现的锁。当一个线程尝试获取重量级锁而锁已被其他线程占用时,该线程会被阻塞,进入等待队列,并从用户态切换到内核态。当持有锁的线程释放锁时,操作系统会唤醒等待队列中的一个或多个线程,使其重新竞争锁。重量级锁的实现依赖于操作系统的调度器和内核态的转换,并且线程从用户态切换到内核态,再从内核态切换回用户态,以及线程的上下文切换,这些操作都会带来较大的性能开销。

        轻量级锁是JVM为了提高synchronized的性能而引入的一种优化策略。它假设在大多数情况下,同步块是没有竞争或者竞争不激烈的,只有一个线程进入同步块,或者在短时间内由少量线程交替进入。它不会直接请求操作系统互斥量,而是尝试在用户态通过自旋等方式来获取锁,避免了重量级锁带来的内核态切换开销。轻量级锁是JVM层面的一种优化,不依赖操作系统互斥量,避免了用户态与内核态的切换,以及线程阻塞和唤醒的开销,性能比重量级锁高。

1.3. 挂起等待锁与自旋锁

        挂起等待锁,当一个进程或线程尝试获取一个已被占用的锁时,它会挂起(或阻塞)。这意味着操作系统会将该进程或线程从CPU的运行队列中移除,并将其置于等待状态,直到锁被释放。

        自旋锁,当一个进程或线程尝试获取一个已被占用的自旋锁时,它不会挂起,而是会进入一个忙等待的状态,持续地检查锁是否已被释放。这个“忙等待”的过程就是“自旋”。

        对于synchronized而言,当锁竞争不激烈时,就会采取自旋锁的策略;当锁竞争激烈的时候,就会采取挂起等待锁的策略。

1.4. 公平锁与非公平锁

        公平锁是指线程按照请求锁的顺序来获取锁。如果一个线程请求了锁,而此时锁是空闲的,它就会获取锁,遵循一个先来后到的规则。

        非公平锁是指线程获取锁时不遵循严格的排队顺序。当一个线程请求锁时,如果锁是空闲的,它会尝试立即获取锁,而不管等待队列中是否有其他线程正在等待,相当于每个线程获取锁的概率是均等的。

二、synchronized的原理

2.1. 基本特点

  • 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁
  • 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁
  • 实现轻量级锁的时候大概率用到的自旋锁策略
  • 是一种不公平锁

2.2. 加锁工作过程

        锁升级会有一个自适应的状态,对于有些人不能正确地使用锁,Java的设计者为了能够降低锁的使用门槛,synchronized就引入了很多优化策略。当使用synchronized进行加锁之后,就会变成偏向锁。偏向锁的方案并不是真正加锁,只是做个标记(做个标记比加锁要轻量很多)。如果这个过程中,没有其他线程来竞争这个锁,偏向锁状态就始终保持,直到最终解锁。当遇到锁竞争,就会变成自选锁,如果竞争更激烈,又会变成重量级锁。

        上面这个过程就是锁升级,并且对于JVM来说,这个过程是不可逆的。

2.3. 其他的优化操作

  • 锁消除

        如果有些地方我们写了加锁,但JVM在执行的时候发现没必要加锁,就会自动把锁去掉。比如StringBuilder是不带synchronized,StringBuffer是带有synchronized,如果有人在单线程环境下使用了StringBuffer,JVM就会使用锁消除进行优化。

  • 锁粗化

        粗化指的是锁的粒度,可以理解为加锁或解锁范围内代码越多,粒度就越大,反之,粒度就越小。一段代码中如果出现多次加锁和解锁,就会进行锁粗化。

        这就好比,领导给我们安排了3个任务,如果我们完成一个任务就去打电话汇报,每一次汇报就是对领导加锁,但这样很明显会招领导烦,此时就可以优化为3个任务完成之后一起汇报给领导。

        synchronized是Java的关键字,底层是在JVM内部实现的。

三、CAS

3.1. 概念

        CAS就是Compare And Swap(比较并交换)的缩写。一个CAS涉及到以下:3个参数,内存位置 (V)、旧的预期值 (A)、新的值 (B)。先比较内存位置V中的值是否和旧的预期值A相等,如果相等将B写入V,最后返回操作是否成功

        伪代码:

boolean CAS(address, exceptValue, swapValue) {
    if (&address == exceptValue) {
        &address = swapValue;
        return true;
    }
    return false;
}

        上述逻辑是通过一个CPU指令完成的。一条指令就意味着就是原子的。这条指令的第一个操作数是内存地址,另外另个都是CPU寄存器。利用内存中的值和寄存器1中的值进行比较,如果相等,就把内存和寄存器2的值进行交换,可以近似理解为赋值。

        CPU的特殊指令,完成了上述操作,操作系统又封装了这个指令,形成了一个系统API,Java又封装操作系统中的API。

3.2. 典型应用

  • 实现原子类

        前面提到过,一个整数自增的操作不是原子性的,但在Java中封装了一套基于CAS实现的类,将自增操作转化为原子性的。比如下图中的AtomicInteger。

import java.util.concurrent.atomic.AtomicInteger;

public class Demo1 {
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_000; i++) {
                // Java不支持运算符重载,这里不能写作count++
                count.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_000; i++) {
                count.getAndIncrement();
            }
        });

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

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

        System.out.println(count);
    }
}

        这样的操作不仅线程安全,而且效率更高,并且不涉及到阻塞。

        伪代码实现:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while (CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
        }
        return oldValue;
    }
}

        注意这里的oldValue不是一个变量,而是一个寄存器。CAS如果一样,就说明在两者之前,没有其他线程修改value内存,此时就可以安全地对value进行修改了。

  • 实现自旋锁

        伪代码实现:

public class SpinLock {
    private Thread owner = null;
    
    public void lock() {
        //通过 CAS 看当前锁是否被某个线程持有
        //如果这个锁已经被别的线程持有, 那么就⾃旋等待
        //如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程
        while (!CAS(this.owner, null, Thread.currentThread()) {
        }
    }

    public void unLock() {
        this.owner = null;
    }
}

        如果owner为空,表明是解锁状态,否则就会保存持有锁的线程的引用。如果这个锁已经被占用了,while循环就会快速反复地执行,并且循环里面没有任何sleep休眠逻辑,这样就可以消耗CPU资源,尽快加锁。当然这个逻辑只适合锁竞争不激烈的情况,如果锁竞争,大量的线程自旋,消耗大量的资源会负担不起,锁释放之后还是要竞争的。

3.3. CAS中的ABA问题

        通过CAS来判定,当前load到寄存器的内容是否和内存中的内容一致,如果一致,就认为没有其他线程修改过内存,接下来本线程的修改就是安全的。但这里还有一个漏洞,就是另一个线程把内容A修改成了B,再从B改成了A,此时CAS是无法感知到的。

        ABA问题,通常情况下都是没事的,即使其他线程真的修改了,又改回原来的值,所以ABA问题不一定会引起bug。但还是有一部分极端问题引起bug。

        比如,我们账户上有1000,我们去ATM上取500,碰巧机器卡住了,我们再执行一次取500操作,同时又有人给转了500,由于ABA问题,就会扣款2次。

        解决方案:给要修改的值,引入版本号,CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期。


网站公告

今日签到

点亮在社区的每一天
去签到