java并发与锁机制

前言

当我们谈到多线程编程和高并发场景时,锁是一个不可或缺的主题。而今天要讲的内容是关于java中的锁机制和高并发场景中锁的使用 ,我会从最简单的synchronized到一些复杂的锁,逐步深入讲解。文章有点长,蹲坑必看!

一、synchronized(同步锁、互斥锁、悲观锁、独占锁、重量级锁,可重入锁)

sychronized是java的一个关键字,作用是给代码块加"锁",让代码块变成同步代码块。 我们先来看一段代码(省略getter和setter):

csharp 复制代码
public class A {
    long num = 0;

    public void increase(){
        num++;
    }
}    

这个类中有一个变量num和一个使num自增的方法increase(),然后我们在main方法中使用一个线程调用10000000次这个方法,然后用FutreTask拿到返回值并打印:

java 复制代码
public class Main {
    static A a = new A();
    private static class Test implements Callable<Long> {

        @Override
        public Long call() {
            for (int i = 0; i < 10000000; i++) {
                a.increase();
            }
            return a.getNum();
        }
    }

    public static void main(String[] args) throws Exception {
        Test test = new Test();
        FutureTask<Long> futureTask = new FutureTask<>(test);
        Thread t = new Thread(futureTask);
        t.start();
        Long result = futureTask.get();
        System.out.println(result);
    }
}

结果如下:

这个结果也是在意料之中,也是正常情况。那如果在主线程中调用同一个对象a的increase()方法会怎么样呢? 我们对main方法做如下更改,同样增加10000000次循环,预期结果应该为20000000:

ini 复制代码
public static void main(String[] args) throws Exception {
    Test test = new Test();
    FutureTask<Long> futureTask = new FutureTask<>(test);
    Thread t = new Thread(futureTask);
    t.start();
    for (int i = 0; i < 10000000; i++) {
        a.increase();
    }
    t.join();
    System.out.println(a.getNum());
}

输出的结果都是小于20000000的。原因也很简单,num++其实是做了两件事,先获取num的值,再执行num+1的操作。在这个过程中,我们可以把上述代码看做两个线程A和B,就有可能会出现线程A先拿到num的值,但是在执行num+1之前,线程B也拿到了num的值,然后去执行num+1,这导致线程A执行的num自增其实是无效的。解决这个问题只需要用synchronized关键字对能够被多个线程访问到的公共数据资源进行加锁操作:

arduino 复制代码
public synchronized void increase(){
    num++;
}

这里我们直接在方法上加锁,因为是非静态的,这个锁对象就是当前对象this,这样线程A和B就只可能有一个在执行这段代码,得到的结果也是预期的:

获取锁的情形如下图,只有拿到锁对象this的线程才可以继续执行同步代码块:

synchronized其实包含了很多种类的锁:同步锁互斥锁独占锁重量级锁悲观锁可重入锁,这些锁也包含在同步锁中,因为他们都会阻塞另一个线程,确保在同一时刻只有一个线程或进程能够访问共享资源,防止多个线程同时修改相同的数据,从而避免数据损坏或不一致的情况。

synchronized是通过对象内部的一个叫做对象监视器 monitor来实现的,这个monitor的作用简单来说就是控制前来获取这把锁的线程的wait(),notify()登阻塞唤醒的操作,这也涉及到一个操作系统级别的对象ObjectMonitor,底层是C++实现的,其次监视器锁本身依赖底层的操作系统的 Mutex Lock(互斥锁)来实现。操作系统实现线程的切换成本非常高。这种依赖于操作系统 Mutex Lock来实现的锁称为重量级锁。为了优化synchonized,引入了偏向锁、轻量级锁,这部分后面来讲。

我们可以自己创建一个新的空项目,然后编写以下方法:

csharp 复制代码
public class Main {
    public static void main(String[] args) {

    }
    public void test(){
        int a = 0;
        synchronized (Main.class){
            a++;
        }
        System.out.println(a);
    }
}

然后我们对这个main方法的字节码文件进行反编译,得到如下结果:

从上面我们可以看出,synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码出现异常的情况下也能被正确释放。

上下文切换:线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

小结

  1. synchronized包含了多种锁,通过对象监视器monitor来实现对没有持有锁但是尝试获取锁的线程的阻塞,从而实现了同步机制。被阻塞的线程被放在一个等待队列里,但是每次锁被释放这些线程都会被唤醒来争抢锁,但是刚刚释放锁的线程可能会更容易再次获取锁,因为它可能仍然在缓存中,而不必从主内存中重新加载。这种情况下,刚释放锁的线程被称为"锁的再次争用"。
  2. synchronized涉及到的锁
    • 同步锁:并发执行的多个线程,在同一时间内只允许一个线程访问 共享数据。
    • 悲观锁:总是假设线程每次进入都要进行"写"操作,与同步锁同义
    • 非公平锁:每个线程抢到锁的可能性与等待(阻塞)时间无关
    • 独占锁:与同步锁同义
    • 重量级锁:线程的挂起/唤醒需要CPU切换上下文,此过程代价比较大,这种锁被称为重量级锁。
    • 互斥锁:与同步锁同义
    • 可重入锁:任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。

二、CAS机制(无锁、自旋锁、乐观锁、轻量级锁)

  • 上述代码还存在一个问题:就是同步锁的性能差问题。那为什么同步锁的性能差呢?

其中最主要的原因就是,多线程抢夺cpu的执行权,当这些线程在竞争同一把锁的时候,没抢到锁的线程会被阻塞,这就导致整体性能下降(其他原因还有操作系统的上下文切换,线程调度等),上述代码的平均时长为618毫秒。

而CAS机制(Compare And Swap先比较再交换或Compare And Set先比较再赋值)性能好的原因就是占用操作系统资源少,且竞争锁的时候线程不会被阻塞,会一直通过循环来获取锁,首先我要介绍一个类:AtomicLong类 是Java中java.util.concurrent包下的一个原子类,它提供了一种线程安全的长整型(long)数据类型,支持原子性操作,可以用于多线程环境下共享数据的安全访问。改造后的代码如下:

csharp 复制代码
public class A {

    AtomicLong num = new AtomicLong(0);

    public void increase() {
        num.incrementAndGet();
    }

incrementAndGet()方法的底层如下:

  1. 读取当前变量的值。
  2. 检查读取的值是否等于预期值。
  3. 如果相等,就用新值更新变量。
  4. 如果不相等,操作失败,需要重试。

这整个操作流程是无锁 的,是基于单一的硬件指令中完成的原子性操作(其实这个原子性操作是底层的汇编语言加了锁,只是锁的粒度很小,可以忽略),底层运用了乐观锁的思想,结果准确的同时效率也显著提升,平均耗时140多毫秒

这里也采用了自旋锁轻量级锁 的思想,自旋锁就是在多个线程争夺锁的情况下,尝试自旋一段时间,等待其他线程释放锁,而不是进入阻塞状态。这有助于减少线程切换和上下文切换的开销。但是要注意的是,当想要改变这个变量的线程过多且一直更改失败的话,这个轻量级锁就不轻量了,反而会导致很多线程"阻塞"式卡在这个地方。

CAS机制的ABA问题

这个问题的描述是这样的:现在有两个线程,线程1和线程2,有一个变量A,线程1要执行的是将A改为B,这个过程也要先获取A,更改的时候再判断一次原来的A是否被修改了;但是线程2运行的速度比较快,它执行的操作是将A改为C再改为A,这个过程刚好在线程1获取到A之后,将A和原来的A比较之前,此时线程1并未察觉到A被修改过,所以可以成功将A修改为B。

总的来说,这就像去手机店买一个iPhone,用了两周再原模原样退回去,但是这个iPhone的本质已经变了(被使用过,不是新的)。

这个问题。。。也可以不做修改,因为它也不是问题。如果想要修改也很简单,就是加一个版本号version,就像mybatis plus那样实现就可以了。

小结

  1. cas在某种程度上也是优化同步锁的一种方法,不过cas更适用于读多写少的场景。

  2. AtomicLong其实就是把锁放到了更底层,所以当我们想要优化一个同步锁的时候,我们可以采用降低锁粒度的方法,尽量将不需要加锁的代码放到锁的外部。

  3. CAS机制涉及到的锁

    • 乐观锁:总是假设线程每次都不更改数据,允许线程重复执行,只会在提交修改的时候验证数据是否被修改了
    • 轻量级锁:轻量级锁是在无竞争的情况下使用CAS操 作去消除同步使用的互斥量。在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互 斥量产生的性能消耗。目前来说,也可以理解为不需要阻塞的"锁"
    • 自旋锁: 当前线程执行一个循环(自旋操作),不断在盯着持有锁的线程是否已经释放锁
  4. 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

三、乐观锁和悲观锁的对比

  1. 乐观锁:与其说是锁,不如说是一种思想(大部分锁都是这样)。
    • 优点:是一个轻量级锁,性能高
    • 缺点:并发量大的情况下,容易导致更改数据失败的可能性更大,使大量线程一直在重试,反而会降低程序的性能
  2. 悲观锁:只允许一个线程进入,锁住别的线程
    • 优点:并发量大的情况下,"锁"的效果好
    • 缺点:是一个重量级锁

总结:乐观锁适合读多写少 的场景,避免频繁失败和重试影响性能;悲观锁适合写多读少的场景,在高并发的情景中悲观锁可以有效避免重试带来的性能下降问题。

四、synchronized的优化

前言:在jdk1.6之前,synchronized关键字锁住的代码一旦被访问,就会涉及一系列底层的复杂操作(创建队列什么的等等)。这就浮现出一个问题,我们给代码块加锁是因为这段代码中的资源可能会有并发安全问题,但是真正并发访问这段代码的情况多吗?其实大部分时候被synchronized加锁的代码块都是单个线程在访问,偶尔会出现并发访问的情况,所以,如果每一次访问加锁的同步代码块都要做一些所谓的防范措施,大部分时候都是无用功且浪费性能,

让我们来看一下synchronized做了哪些优化

  1. 首先是无锁。当没有线程访问时处于无锁状态。

  2. 其次是偏向锁 。当一个线程来访问一段被synchronized锁住的代码块时,偏向锁机制会将当前线程的id存入synchronized的锁对象中,下一次释放锁后,再来加锁时,就会先判断锁对象中的id和当前id是否相同,如果相同,就可以直接执行同步代码块中的代码,不用执行其他逻辑。

  3. 引入偏向锁后,已经优化了很多只有一个线程执行synchronized中的代码的场景。那再来一个线程该怎么办呢?这个时候偏向锁就会升级为轻量级锁 ,多个线程就会执行CAS的机制(CAS自旋)。

  4. 当线程更多的时候,导致太多线程执行CAS自旋,就会出现大量线程空转现象,这会耗费大量cpu的性能。为了解决这个问题,轻量级锁会升级为重量级锁,与其浪费cpu还不如将这些线程放到一个队列中进行等待

补充:上面说到将当前线程存入锁对象中,很多人会疑惑:锁对象没有相关的成员变量,如何存线程id?

这里涉及到对象内部的组成结构。在 HotSpot 虚拟机中,一个完整的对象在内存中并不只有成员变量、方法,一个完整的对象包括对象头、实例数据和对齐填充位(java要求对象的大小能被8个字节整除,不够的部分java自动补充,目的是方便寻址,加快查找速度)

多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。

1、MarkWord

Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等。

占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位,64位JVM -> MarkWord是64位)。

2、类型指针

虚拟机通过这个指针确定该对象是哪个类的实例。

3、对象头的长度

长度 内容 说明
32/64bit MarkWord 存储对象的hashCode或锁信息等
32/64bit Class Metadada Address 存储对象类型数据的指针
32/64bit Array Length 数组的长度(如果当前对象是数组)

如果是数组对象的话,虚拟机用3个字宽(32/64bit + 32/64bit + 32/64bit)存储对象头,如果是普通对象的话,虚拟机用2字宽存储对象头(32/64bit + 32/64bit)。

偏向锁补充:在java中,刚开始默认是不开启偏向锁的,但是在程序运行4秒后之后任意new一个对象都会默认开启偏向锁,也可以通过设置来修改这个默认时长.

synchronized锁对象升级为重量级锁最少需要几个线程来竞争?这里直接说答案了,答案是3个。锁升级的过程如下:

一个锁对象默认开启了偏向锁,线程A获取synchronized锁对象后锁对象中记录了线程A的线程id(在此之前这个锁对象已经开启偏向锁了,注:如果jvm还没有开启偏向锁,线程去获取锁对象时锁对象会直接从无锁升级为轻量级锁),如果线程B也来获取锁对象,jvm发现不是 同一个线程就会升级为轻量级锁;当线程B拿着这把锁没有释放的时候,线程C来获取这个锁对象,没有拿到就会自旋,大概自旋几次如果还没有拿到锁对象,就会升级为重量级锁。

继续优化思路(了解思想)

当看到这里的时候也许会感到震惊,3个线程并发就升级为重量级锁了?!!不过这里是jdk8中的做法,还有优化空间,先通过一个代码示例来引入:

csharp 复制代码
public class A {

    LongAdder num = new LongAdder();

    public void increase() {
        num.increment();
    }
}    

这里的代码其实就是将上面的代码中的AtomicLong类改成了LongAdder类,LongAdder适用于高并发场景下做累加运算,默认值为volatile关键字修饰的0(volatile关键字:实现变量的可见性,如:线程A在synchronized代码块中修改了一个volatile变量,还没有释放锁的时候别的线程也能拿到这个已经被修改的变量)。再来看看使用LongAdder后的性能:

执行时间为44毫秒,可以说又提升了一个档次,为什么这么快呢?那是因为它的底层使用了分段CAS优化,这其中包含了一个非常好的能够支撑我们设计高并发的思想。

LongAdder的底层,当有一个线程来执行上述increase方法的时候,也是执行CAS机制;当线程数增加很多时,是不是就像上面说的线程空转,浪费性能。对于这个地方,我们刚开始的优化方案是直接升级为重量级锁,把这些线程放到队列里面去阻塞,但是还有没有更好的方案?

LongAdder的底层就帮我们实现了,当我们多个线程执行increase方法的时候,因为要操作的是同一个变量,只有一个线程操作,其余的自旋。为了不让其他线程自旋浪费性能,LongAdder底层创建了一个动态数组Cell数组(线程来了自动扩容,线程走了自动回收),每一个单元cell的初始值也为0,这些多出来的线程就来操作这些cell。新问题又出来了,怎么得到最终结果呢?解决方案也很简单,就是sum求和即可,其底层代码如下:

csharp 复制代码
public long sum() {
    Cell[] as = cells; Cell a;
    long sum = base;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

其实synchronized后续的优化也可以参考这种分段CAS的思路,不过想要使用这种方案也是要看场合的,毕竟LongAdder是做自增。

小结

synchronized经历了多个阶段的优化,从无锁状态到偏向锁、轻量级锁、重量级锁,以提高性能和降低开销。在高并发场景下,还可以考虑使用更高级的优化技术,如LongAdder,以提高性能和并发能力。

五、可重入锁(ReentrantLock,递归调用)

在jdk1.6之前,由于synchronized每次加锁都要和操作系统内核打交道,所以jdk的作者写了Lock接口。但是synchronized在优化之后性能也提高了不少,但是synchronized还是不具备Lock接口的灵活性(如锁超时机制和可中断锁)。

而Lock的实现类使用最多的莫过于ReentrantLock。ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。Sync有公平锁 FairSync 和非公平锁 NonfairSync 两个子类,而ReentrantLock默认是非公平锁(也可以在构造函数中指定)。

可重入的意义

让我们先来看一下ReentrantLock和synchronized的比较,方便理解可重入锁:

可重入锁的"可重入"又是什么意思?我个人认为"可重入"应该理解为可重复进入更为恰当,可重入锁指一个线程在持有锁的情况下,可以再次获取同一个锁,而不会被自己所持有的锁阻塞。这种锁允许线程在已经拥有锁的情况下多次进入同步块或方法,而不会被自己持有的锁阻塞。说通俗点就是可以重复获取同一把锁,下面用代码来解释一下:

csharp 复制代码
private int count = 0;
private ReentrantLock lock = new ReentrantLock();

public void increment() {
    lock.lock();
    try {
        count++;
        System.out.println("Incremented count to: " + count);
        decrement(); // 调用另一个方法,锁可重入
    } finally {
        lock.unlock();
    }
}

public void decrement() {
    lock.lock();
    try {
        count--;
        System.out.println("Decremented count to: " + count);
    } finally {
        lock.unlock();
    }
}

这段代码中使用同一个ReentrantLock对象重复两个锁,所以释放的时候也必须调用两次unlock方法。

AQS(AbstractQueuedSynchronizer)

可重入锁是基于AQS实现的,而AQS是基于CLH锁实现或者说改进的。

CLH锁

CLH锁是对自旋锁的一种改进,解决了自旋锁的饥饿问题(竞争激烈的时候某些线程一直获取不到锁),并且性能上也有一定程度上的提升。

CLH锁的结构类似一个链表队列,并且有一个队尾指针(注意:这个队尾指针指向队尾节点)。这个队列中的每个节点可以简单看做一个线程和一个布尔类型变量state(或者是lock),用来标记线程是否持有锁:

当state为true时表示当前线程持有锁或正在等待获取锁,而这个变量也是被volatile修饰的,方便后驱节点能够读取到这个状态。当后一个节点读取到前一个节点的state为true时,发现前一个节点也在获取锁,就会将自己的state也设置为true,以此类推,每一个正在尝试获取锁的节点的state都是true

在这个队列中,每一个节点都会去轮询读取前驱节点的状态,当检测到前驱节点的状态由true变为false时(释放锁),当前节点就可以获取到锁而停止自旋(轮询)。我们不难发现,CLH锁其实是公平锁,虽然同自旋锁一样的是,每个线程都在不停自旋,但是每次锁释放时都可以保证是一个确定的线程获取到锁,这就是为什么CLH锁能够解决自旋锁的饥饿问题并提升性能,也避免了线程忙等待。从某个角度上来说,CLH锁也实现了同步。

什么是AQS

了解了CLH锁,我们就可以了解何为AQS。首先总结一下CLH锁的缺点:

  • 如果某个线程持有锁时间长也会导致后面线程不断自旋而带来较大的cpu的开销
  • 功能比较单一

而AQS机制针对CLH锁进行了改造:

  • 对于CLH锁的第一个缺点,AQS机制直接将自旋改为了阻塞
  • 对于第二个缺点,AQS提供了更多的线程状态的枚举

在AQS中有两个重要的成员变量,waitStatusstate。其中waitStatus表示线程在队列中的状态,有以下几种:

状态名 描述
SIGNAL 表示该节点正常等待
PROPAGATE 应将 releaseShared 传播到其他节点
CONDITION 该节点位于条件队列,不能用于同步队列节点
CANCELLED 由于超时、中断或其他原因,该节点被取消

其中图片中的SHARED和EXCLUSIVE分别表示共享锁和独占锁。

而state则是线程获取锁的关键所在。当state为0时,表示锁没有线程占有;当state为一个正整数时,表示某个线程的重入次数。这就是可重入的原理,线程重复获取锁时state++,释放锁时state--。

非公平锁:当有线程来获取锁时,这个线程会执行两次通过cas尝试将ReentrantLock中的state由0改为1,也就是说,如果某个持有锁的线程A把锁释放了(包括重入的锁),这个线程的后继节点的线程B就会通过cas尝试获取锁,如果这个时候线程C刚进来,也会通过cas尝试获取锁,如果线程C获取锁成功,也就代表插队成功,反之则进入等待队列。

公平锁:新来的线程会先去判断是否需要排队,如果需要就不会去争抢锁,只会进入等待队列。

进入等待队列

在前面我们说过,每一个Node节点都有一个prev前驱节点,在高并发环境下,多个线程同时进入等待队列,每一个队列的Node都想把队尾节点设置为自己的prev,那如果这个操作同时发生,一个节点的后继就可能会有多个节点,这样不就不是一个队列了吗?

为了解决这个问题,在AQS中,入队方式同样是通过我们的老朋友cas来实现的,具体步骤如下:

  1. 获取锁失败的线程将自己封装为一个Node对象
  2. 通过CAS尝试将自己的prev改为队尾节点,即加入队尾
  3. 修改成功则成功入队

释放锁

当持有锁的线程调用release()方法完全释放锁时(将state由1改为0),如果waitStatus不等于0,就会会唤醒正在阻塞后继节点的线程,通知后继线程来获取锁。

思考

为什么前面说AQS将CLH锁的自旋改为了阻塞,这里线程获取锁的时候还会使用cas?

原因是在等待队列中的线程是阻塞的,只有持有锁的线程的节点在释放锁后会通过unpark方法来唤醒后继节点的线程,然后被唤醒的线程会通过cas去获取锁,后面的线程都是阻塞的,无论是公平锁还是非公平锁。实现原理是,每一个新入队的节点不会立刻调用park方法去阻塞,而是会将自己的waitStatus设置为0,将前一个节点的waitStatus由0改为-1,-1表示后面有节点在排队,这样做方便该节点在释放锁后会去唤醒后继节点,也就是告诉前一个人后面有人在排队,释放锁的时候要唤醒一下后面排队的人。

小结

可重入锁允许线程多次获取同一锁,避免自身持有的锁阻塞,实现了灵活的同步。基于CLH锁的AQS机制为其提供了支持,通过状态标识和等待队列管理线程状态,保证锁的安全获取和释放。AQS的阻塞与唤醒机制确保线程等待队列中的阻塞,且新入队节点通过CAS保证获取锁的可靠性。而AQS本质上是一个单向队列,只是这个队列中的每个节点维护了前驱和后继节点,方便了加锁和解锁操作。

六、共享锁和独占锁

共享锁和独占锁分别是读锁和写锁,一般使用较少,主要也是在MySQL中使用。读锁(ReadLock)和写锁(WriteLock)都是ReentrantReadWriteLock的子类,他们有以下特性:读读不互斥,读写互斥,写写互斥,主要作用其实就是避免脏读。下面来看两段代码:

第一段代码是没加读锁和写锁的:

ini 复制代码
int num = 200;

public void main(String[] args) {
    Demo demo = new Demo();
    new Thread(() -> {
        for (int i = 0; i < 10; i++) {
            demo.readBalance();
        }
    }).start();

    new Thread(() -> {
        for (int i = 0; i < 10; i++) {
            int a = 10 * (i + 1);
            demo.updateBalance(a);
        }
    }).start();
}

public void readBalance() {
    System.out.println("读取: " + num);
}

public void updateBalance(int newBalance) {
    // 修改数据的操作
    num = newBalance;
    System.out.println("修改: " + newBalance);
}

从运行结果来看,出现了很多脏读

第二段代码是加了读锁和写锁的:

csharp 复制代码
int num = 200;

public static void main(String[] args) {
    Main main = new Main();
    new Thread(() -> {
        for (int i = 0; i < 5; i++) {
            main.readBalance();
        }
    }).start();

    new Thread(() -> {
        for (int i = 0; i < 5; i++) {
            int a = 10 * (i + 1);
            main.updateBalance(a);
        }
    }).start();

}

private final Lock readLock = new ReentrantReadWriteLock().readLock();//共享锁

public void readBalance() {
    readLock.lock(); // 获取共享锁
    try {
        System.out.println("读取: " + num);
    } finally {
        readLock.unlock(); // 释放共享锁
    }
}

private final Lock writeLock = new ReentrantReadWriteLock().writeLock();//独占锁

public void updateBalance(int newBalance) {
    writeLock.lock(); // 获取独占锁
    try {
        num = newBalance;
        //中间有线程安全问题
        System.out.println("修改: " + newBalance);
    } finally {
        writeLock.unlock(); // 释放独占锁
    }
}

尽管修改中的赋值和输出的中间可能出现线程安全问题,总体来看,加了读写锁和没加锁的前提下还是有差异的。

七、分段锁

说到分段锁,大部分人首先想到的应该是ConcurrentHashMap讲到过的LongAdder,那为什么要用分段锁呢?直接用同步锁不行吗?

来看一下三种数据结构:HashMap,Hashtable,ConcurrentHashMap

  • 共同点:都实现了Map接口,都是存储键值对
  • 区别:
    • a. 线程安全性和性能不同
      • HashMap:性能最好,但是线程安全性最差
      • Hashtable:性能最差,是线程安全的,采用同步锁
      • ConcurrentHashMap:性能较好,是线程安全的,采用分布锁
    • b. ConcurrentHashMap的键和值都不允许为空,而HashMap运行一个空的key,value也可以为空

为什么ConcurrentHashMap加了锁性能还能这么好,这就要说到分段锁的实现。在jdk1.8之前,ConcurrentHashMap的分段锁是通过内部的静态类Segment来实现的,但是到了jdk1.8,改为了用内部类Node来实现,下面简单介绍一下。

jdk1.8之前,ConcurrentHashMap通过Segment实现分段。Segment类实现了ReentrantLock,在操作ConcurrentHashMap时,只有写操作会加锁,读操作并不会加锁,并且就算写操作锁住了一个Segment,读操作也不会被阻塞,这样的设计允许多个线程同时对多个段进行写操作,大大提升了性能,这也是为什么要用分段锁的原因。但是在上面我们了解过,ReentrantLock本质上是同步锁,他会阻塞其他线程,这样的做法会增大操作系统的开销从而降低性能。

jdk1.8对ConcurrentHashMap做了很大的修改,不再是之前的 Segment 数组 + HashEntry 数组 + 链表 ,而是 Node 数组 + 链表 / 红黑树 。当冲突链表达到一定长度时,链表会转换成红黑树。ConcurrentHashMap的put()方法写的过程为:先计算出hashcode,再判断Node数组是否需要初始化,如果不用就定位到目标Node,如果目标Node为空就cas写入,如果这两个条件都不满足就使用synchronized写入数据(这里扩容就不说了,与主题无关)。先利用cas来获取锁,再写入数据,扩容的时候会利用synchronized关键字来确保线程安全。这样的操作保证了在获取锁的时候线程不会阻塞,而是自旋。与jdk1.8之前相同的是,读写互不干扰,并且写操作都用了CAS来实现。

小结

总的来说,无论是jdk1.8之前还是之后,我们要着重关注分段锁的思想,并利用这种思想来优化我们的业务逻辑。比如说,上面说到的商品超卖问题,我们可以利用分段锁来优化我们的分布式锁,将商品的库存分段,举个例子,某个商品库存有200,我们可以将这200个库存分成十段,每一段放20个库存,多个线程并发访问的时候就可以访问多个"段",理论上来说效率提高了十倍,实际上可能没这么夸张但是肯定能提高不少。

八、Redis的分布式锁

思考:redis其实并没有锁,只是用redis来模拟锁,那我们为什么要用redis来模拟锁呢?jdk内部给我们提供了这么多锁为什么不用呢?

以订单超卖问题为例:我们通常会在扣减库存的代码处加同步锁来处理这个问题,但是如果我们的服务端是分布式服务,假设有两个端口8080和8081,nginx将扣减库存的请求分别发送到这两个端口,显然同步锁就没用了。

要使用redis实现分布式锁,可以使用redissetnx命令,这个命令一旦key存在就不会执行成功,借此我们可以将key设置为商品id,就可以实现每次只有一个线程在操作商品库存。

Redis分布式锁的细节

细节一

Redis的分布式锁还有个小细节,就是过期时间的设置。举个例子,当线程A获取了一把互斥锁,设置过期时间为10秒。如果获取锁成功后的逻辑比较复杂,或者因为某些其他原因,要15秒才能执行完,这时锁自动释放了,另一个线程B进来了,就涉及到了线程安全问题。假设线程B又执行了两秒,剩下的逻辑还有很多没执行,但是这个时候线程A执行完了,就会执行finally中的释放锁的代码,意味着线程B的锁被释放了,线程C就进来了。如此往复,当并发量越大的时候,这类问题也会被持续放大。

解决方案:在获取锁之前,先生成一个uuid(默认uuid不会重复)来标识当前线程,并使用setnx命令将这个uuid作为value存进去,每次释放锁的时候,判断一下当前锁的uuid当前线程的uuid是否相同,相同才可以释放,以下面代码为参考:

vbnet 复制代码
String uuid = UUID.randomUUID().toString();//最好不要用Thread.currentThread().getId(),因为线程id在集群条件下可能重复,uuid有更严格的唯一性
Long productId = 1L;
String key = "lock:"+productId;
try {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, 30, TimeUnit.SECONDS);
    if(Boolean.FALSE.equals(flag)){
        //获取锁失败
        //重试或返回错误信息等
    }
    //执行业务逻辑
} finally {
    if(uuid.equals(stringRedisTemplate.opsForValue().get(key))){
        
        //思考:会不会有其他问题
        
        stringRedisTemplate.delete(key);
    }
}

细节二

让我们继续思考一下,这段代码判断uuid是否等于锁的value,还有没有其他问题?

如果当前线程A判断成功这个锁是他自己的,也就是if的逻辑为true,准备释放锁的时候,程序因为某些原因卡了,在其释放锁之前刚好这把锁到了过期时间被Redis自动剔除了,线程B就可以获取到这把锁了,这个时候线程B刚获取到的锁就被线程A释放了,就只能GG了。

解决方案有两种:

  1. 一是将锁的过期时间设长一点
  2. 二是保证判断和释放锁部分代码的原子性

锁续命

这里只说一下第一种方案,其实把这个过期时间设置的再长,都不能彻底解决这个问题,这只不过是思想,真正的解决方案是锁续命,这也是一个很重要的概念。

实现思路就是:在线程A获取锁成功后,开启一个分线程去执行一个定时任务,这个定时任务的工作就是检查当前锁有没有过期,没过期就延长过期时间,但是注意这个定时任务的频率要小于锁的过期时间,如:锁定过期时间为30秒,定时任务频率就设置为10秒,每次检查如果锁没有过期就重新设置过期时间为30秒。

如果是我们自己编写代码的话,可能还会有很多潜在的问题,所以我们可以用Redisson框架来实现,只需要很少的代码就能实现

ini 复制代码
Long productId = 1L;
String key = "lock:"+productId;
RLock rLock = redisson.getLock(key);
rLock.lock();//尝试获取锁,成功就执行后序逻辑,失败则阻塞
try {
    //执行业务逻辑
} finally {
    //释放锁
    rLock.unlock();
}

可以看到代码变得很简洁,我们也不用自己生成uuid,这些redisson的底层都帮我们做了,只不过redisson实现加锁用的不是string类型,而是hash类型。而之所以redisson能够做到把这么多坑给填上,最主要的还是底层运用了大量Lua脚本来保证redis命令的原子性。

如果说还要找问题,那也只能是主从环境下的redis,如上图,在线程获取到锁后,主节点还未将这个"锁"写入从节点时,主节点宕机了,从节点代替主节点后新的主节点中没有这把"锁",同样也会造成所丢失造成的线程安全的问题,这里就不多说了。

小结

redis分布式锁通过redis的setnx命令模拟锁,解决了分布式环境下的资源竞争问题。关键细节包括设置合适的锁过期时间和引入锁续命机制,确保锁在关键操作未完成时不会被自动释放。Redisson框架简化了锁的实现,提供了高效的获取和释放锁的方式,可以自行了解。

死锁和活锁

死锁

什么是死锁?大家应该都不陌生了,这里就不浪费时间了,我们直接进入主题------如何预防和避免死锁?

如何预防死锁?

对于这个问题大致可以总结两个解决方案:

  1. 要么一次性获取全部锁,要么让每一个线程都按照某个固定的顺序获取锁,并反序释放(先获取的后释放)
  2. 如果一个线程要获取多个锁,当这个线程在获取其中某一个锁的时候一直获取不到,可以释放已经获取到的锁,不再执行后序逻辑,可以设置一个超时时间来实现。

如何避免死锁

  1. 做好死锁的预防工作
  2. 使用jvm检测工具等

活锁(Livelock)

什么是死锁?死锁的反义词就是活锁吗?其实不是,反而活锁可以理解为是死锁的近义词。活锁的定义为:活锁是类似于死锁的一种情况,不同之处在于线程(或进程)不断地改变其状态,但是仍然无法继续执行。

说白了活锁可能并不存在锁,只是很多线程同时"卡"在一个地方不断执行相同操作,没有下一步进展。活锁一般发生在竞争激烈的情况下,比如说大量线程通过cas去写一个公共资源,有可能导致不断修改失败,线程不断自旋。但是我们也不难发现,活锁是有可能自己解开的,如果某个线程突然修改成功,后面卡住的线程修改成功的概率会增大,慢慢的就自己解开了。

那如何降低活锁出现的可能性呢?还是以上述场景为例,我们可以:

  1. 修改数据失败后让线程休眠一段时间。如100毫秒,让其他线程修改成功的可能性更大。
  2. 在竞争激烈的情况下让线程按照一定的顺序获取锁。

总结

本文到这里就结束了,让我们来回顾一下。

  1. synchronized通过底层的对象监视器monitor来控制获取锁对象的线程的阻塞或唤醒这些线程,实现了同步加锁机制,同时只有一个线程可以占有这个锁对象,也就是占有monitor的控制权。这种同步阻塞的机制适用于读少写多的场景,可以避免读操作加锁导致操作系统的开销大。
  2. cas是一种无锁算法,同时也是一种乐观锁机制,cas的过程是原子性的,中间不会被打断,但是有可能失败,其中cas的ABA问题可以使用版本号version这类方法来解决。这个机制适用于读多写少的场景,可以避免大量线程被卡在某个步骤。
  3. synchronized在jdk1.6的时候迎来了优化,不再是之前的每次加锁都是重量级锁了,改为了由偏向锁升级为轻量级锁再升级为重量级锁的过程,锁信息记录在锁对象的对象头中的MarkWord中。
  4. AQS机制是一个重要的部分,是基于CLH锁进一步改造实现的,也是实现可重入锁的关键。AQS底层是一个FIFO队列,队列中的每个节点都维护了前驱和后继节点,其本质上是通过阻塞和唤醒机制来实现的同步机制。
  5. 分段锁是一个重要的思想,在实现某些业务逻辑的时候可以作为优化性能的手段。
  6. redis实现的分布式锁的应用场景也很多,但是在使用过程中我们可以在最基础的分布式锁上继续使用锁续命来确保不会因为超时释放错误的锁,同样也可以使用Lua脚本来保证获取锁和释放锁的原子性。
相关推荐
sszmvb123411 分钟前
测试开发 | 电商业务性能测试: Jmeter 参数化功能实现注册登录的数据驱动
jmeter·面试·职场和发展
码农派大星。14 分钟前
Spring Boot 配置文件
java·spring boot·后端
测试杂货铺17 分钟前
外包干了2年,快要废了。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
王佑辉17 分钟前
【redis】redis缓存和数据库保证一致性的方案
redis·面试
真忒修斯之船23 分钟前
大模型分布式训练并行技术(三)流水线并行
面试·llm·aigc
ZL不懂前端1 小时前
Content Security Policy (CSP)
前端·javascript·面试
杜杜的man1 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu1 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s1 小时前
Golang--协程和管道
开发语言·后端·golang