Java 锁

211 阅读8分钟

**什么是线程安全?在多线程环境下,共享数据(也就是 JVM 堆内存的数据)可以被进程中的多个线程访问到,共享数据可能同时被多个线程修改而导致破坏,造成数据不一致或者出现脏数据,数据不一致常常会导致程序出现错误结果乃至异常崩溃。而线程安全就是避免这种情况的发生,在一个线程访问某个共享数据的时候,通过加锁的方式阻止其他线程修改这个共享数据,直到释放了锁。

例如:银行多个工作窗口(多个线程)的叫号,如果在线程不安全的情况下,会导致重复叫号、遗漏某个号码、号码超过最大值。

在秒杀场景下,如果扣库存时候没有加锁,1000 个线程都看到还有 库存 1 件,然后 1000 个线程同时进行减 1 个库存的操作,就会导致商品被超卖了。

什么是锁?通过锁可以实现线程安全,线程安全就是在多线程环境下,多个线程同时访问共享数据,共享数据的状态总是保存一致的,程序运行结果不会因为单线程或者多线程的不同而不同。(也就是在叫号的时候,单线程、多线程都不会出现重复叫号、遗漏某个号码或者号码超过最大值)。

1 Java 锁

1.1 什么是悲观锁、乐观锁?

悲观锁和乐观锁,其实是一种广义上的概念,在对共享数据并发操作的时候:

  • 悲观锁:悲观锁认为在使用共享数据的时候,其他线程会修改数据,所以直接上锁确保数据不会被其他线程修改,这样子其他线程读取这个共享数据就会阻塞直到获取到锁。每次只给一个线程使用,Java中,synchronized关键字和Lock的实现类都是悲观锁,以及 AQS 抽象的队列同步器定义的多线程访问共享数据的同步框架。
  • 乐观锁:和悲观锁相反的是,乐观锁认为在使用共享数据的时候,其他线程不会修改数据,所以不会上锁,在更新这个数据的时候会判断其他线程有没有更新这个数据,通过版本号机制和 CAS(campare - and - swap)实现。 例如 Java 的原子类java.util.concurrent.atomic

两种锁的适用场景:不能说某个更好,某个更差,有不同的应用场景,根据应用场景而取舍。

  • 悲观锁适用于多写的场景,可以确保数据不会被其他线程修改,缺点是会阻塞,线程上下文切换会消耗系统资源。线程会阻塞
  • 乐观锁适用于读多写少的场景有利于提高性能,提高系统吞吐量,缺点是如果在多写场景,也就是经常发生写冲突比较多的时候,线程一直在重试会占用过多的系统资源。线程不阻塞。

1.2 什么是自旋锁、适应性自旋锁?

**特点就是不阻塞当前线程,原地等一等(自旋),实现原理是 CAS **

例如去饮水机打水,有个排在你前面的老哥正在打水,你认为他很快搞定,你就在原地转两圈,然后你就获得饮水机的使用权(锁)开心的打水了。这就叫自旋。好处就是避免了你先回去工位,然后再去饮水机打水的一些消耗(这叫线程上下文切换)。

在很多场景中,共享数据的锁可能被其他线程持有的时间很短,如果等一等(自旋)就可以获得锁,这样子就避免了线程在内核态和用户态切换、挂起阻塞导致的时间和系统资源消耗。

自旋锁的优缺点:

  • 优点:减少 CPU 的上下文切换,适用于持有锁的时间非常短、锁竞争不太激烈的场景。因为线程上下文切换阻塞挂起、再唤醒的的时间大于程序等一等转几圈(自旋)的时间。
  • 缺点:如果持有锁的时间比较长,锁竞争比较激烈不适用,因为 CPU 一直在空转而导致 CPU 资源的浪费。(你去打水的时候,前面好几个人,每个打水用水都十多分钟,你一直在原地转圈圈(自旋)就要扣工资啦!)

适应性自旋锁就是针对自旋锁的缺点改进而来的:JDK 1.4 引用,JDK 1.6 默认开启。自适应意味着自旋锁的自旋时间(次数)不再是固定值,而是由上一次在同一个锁的自旋时间以及锁的拥有着的状态来决定的

  • 如果其他线程刚通过自旋获取锁,并且还在运行,那么 JVM 就会认为当前的自旋很可能再次成功,进而允许它自旋更长的时间。

  • 如果通过自旋很少成功获取到锁,JVM 就会认为自旋获取锁的概率很低,就自动阻塞线程避免 CPU 空转造成资源浪费。

下图自旋锁和非自旋锁的区别:

1.3 什么是 CAS ?

cmpxchg 指令,是一个原子的操作。

CAS全称 Compare And Swap(比较与交换),是一种无锁算法,其实现的流程如下

  • 读取当前内存的值 V
  • 需要比较的值 A
  • 需要替换的新值 B

只有当 A 等于 V 才会把新的值 B 替换 V,比较并交换整体上是一个原子操作。但 A 不等于 B 的时候,会循环重试。java.util.concurrent.atomic 包的是基于 CAS 实现的乐观锁。

public class AtomicInteger extends Number implements java.io.Serializable {
    // setup to use Unsafe.compareAndSwapInt for updates
  	// Unsafe 的类
    private static final Unsafe unsafe = Unsafe.getUnsafe();
  	// value 在 AtomicInteger 便宜量
    private static final long valueOffset;
    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
  	// 使用 volatile 保证线程的可见性
    private volatile int value;
}

AtomicInteger 是如何实现 i++ 的原子自增操作的:

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe 类的 
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
      // 循环
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

1.4 什么是 ABA 问题?

CAS 在(线程 1)更新数据值的时候,会比较内存中的值有没有发生变化,但如果另一个线程 (线程 2)将 A 更新为 B,又更新为 A,那么(线程 1)更新数据发现数据值看起来没有变化。

假设当前月初钱包余额为 Value = 0 元,内存中的值。

  • **(线程 2)**月中你领了 10000 的工资,此时 Value = 10000 元,然后你又花了 10000 元,此时 Value = 0 元。

  • 这时候税务**(线程 1)** 发现,你月初 Value = 0 元,月末 Value = 0 元,你的 vlaue 看起来没有发生变化。好家伙,你这个月收入 0 元,好像不用纳税了。线程 1:小朋友的问号好像不对经,我该怎么确定期间内存的 Value 值到底有没有发生改变呢?

image-20200821221929921

怎么解决 ABA 问题呢?很简单的,给你的钱包余额加个版本号 version,每次入账、出账的时候更新一下 version(自增或者时间戳),税务**(线程 1)** 就会发现,虽然你月初月末都是 Value = 0 元,但版本号变了,嘻嘻,该纳税还是得纳税!

保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

Java NIO 的 SelectionKey.java,AtomicReferenceFieldUpdater 对象中的某个变量的 CAS:

// SelectionKey 中,这个是用来存储 channel 的,在 Selector 的时候根据 key 找到对应的 channel
private volatile Object attachment = null;
private static final AtomicReferenceFieldUpdater<SelectionKey,Object>
    attachmentUpdater = AtomicReferenceFieldUpdater.newUpdater(
        SelectionKey.class, Object.class, "attachment"
    );
// 将 key 和 channel 关联
public final Object attach(Object ob) {
    return attachmentUpdater.getAndSet(this, ob);
}
// 根据 key 获取对应的 channel
public final Object attachment() {
    return attachment;
}

小总结

  • 自旋锁是非阻塞的,如果锁被其他线程持有,则会在原地转圈圈(自旋),直到获取到锁,时候读多写少、当前锁的线程竞争不太激烈的场景。
  • 自旋锁在自旋的时候会消耗 CPU 资源,因此不适合自旋时间长的场景
  • 自旋锁本身不具备公平性和重入性
  • CAS 会带来的一个问题是 ABA 问题,这个通过版本号机制解决
  • CAS 的共享的变量 Value 需要加 volatile 保证线程可见性

以下内容了解一下,主要针对是锁的公平性:

  • TicketLock:类似银行排队叫号,只有线程的排队号和 serviceNum 服务号一致才能获取服务。比较好的实现了公平性。缺点是经常要读取 serviceNum ,在多核心 CPU 中还涉及到缓存同步(缓存一致性)的问题,性能比较低
  • CLHLock 则是定义一组链表,一个节点对应一个等待线程。是“隐式”的链表,使用 AtomicReferenceFieldUpdater ,看上面的 key 关联 channel 的方式。不适合有 cache 的 NUMA 体系的机器。
  • MCSLock 也是类似 CLHLock 使用链表,不过会把节点缓存一份在线程的本地 ThreadLocal。
  • CLH 轮询其前驱节点的锁状态,也就是多次监听远端内存监听+ 一次本地内存更新,MCS 轮询当前节点的锁状态,也就是多次本地内存监听 + 一次远端内存更新更新

参考和部分图片来源:

Java 高并发编程详解

java 锁事:tech.meituan.com/2018/11/15/…

Java Guide:snailclimb.gitee.io/javaguide/#…

深入理解自旋锁:blog.csdn.net/qq_34337272…