Featured image of post Java 并发 - 锁机制

Java 并发 - 锁机制

在并发情况下,多个线程会对同一个资源进行争抢,可能会导致数据不一致的问题。

为了解决这个问题,通过引入锁机制🔒,使用一种抽象的锁,对资源进行锁定,达到同步访问的目的。

实现

Java 采用了两种实现方式:

  • 阻塞同步(😢 悲观锁)

    📝 适合于写多读少的场景。先加锁,保证写操作时的数据正确。

    • 基于 Objectsynchronized 内部锁 🔒

    • 基于 API 类库的 java.util.concurrent.locks.Lock 🔒

  • 非阻塞同步(😁 乐观锁)

    📖 适合于读多写少的场景。不加锁,可以大幅提高读的性能。

    • 基于 CAS 的乐观锁

😢 悲观锁

pessimistic

在 Java 中,每个对象(Object),都拥有一把锁,存放在对象头中,记录了当前对象被哪个线程所占用。

🔐 内部锁 synchronized

synchronized 关键字可以用来同步线程,其被编译后,会生成 monitorentermoniterexit 两个字节码指令,用来进行线程的同步。

这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。

如果 Java 源码中的 synchronized 明确指定了对象参数,那就以这个对象的引用作为 reference

如果没有明确指定,就根据 synchronized 修饰的方法类型(实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁。

加锁方式

  • 对象锁

    • 修饰实例方法

      pubilc sychronized void method() { ... }

    • 代码块锁住当前对象

      synchronized(this)

  • 类锁

    • 修饰静态方法

      public static synchronized void method() { ... }

    • 代码块锁定 class 对象

      synchronized(MyClass.class) { ... }

synchronized 是非公平锁。一把锁只能被一个线程获取,没有获得锁的线程只能等待。线程对内部锁的申请和释放由 JVM 负责实施。

synchronized 是可重入锁。持有锁的对象,可以多次获得锁。

synchronized 修饰的方法,无论方法正常执行完还是抛出异常,都会释放锁。

🌸 JDK 1.6 对 synchronized 的优化

synchronized 依赖于 JVM,使用的是操作系统底层的 Mutex Lock 实现。

Java 中的线程都是与操作系统的原生线程一一对应的,如果要阻塞或唤醒一个线程,都需要依靠操作系统来实现。 操作系统线程之前的切换,都需要从用户态到内核太的转换,都是很耗时的操作,所以使用 synchronized 的成本较高。

JDK 1.6 对锁的实现引入了大量的优化,如 锁粗化锁消除轻量级锁偏向锁自旋锁适应性自旋等。 并且,对象锁也引入了四种状态,会随着竞争情况逐渐升级(不可以降级),提高获取锁和释放锁的效率。

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

  • 🔒 无锁状态

    不对资源进行锁定

    • 某些资源不会出现多线程竞争的情况,随意多个线程调用
  • 🔒 偏向锁

    • 只有一个线程访问同步代码块的场景

    在一些情况下,没有多线程的竞争,每次都是同一个线程多次获取锁,那么对象锁会“记住”这个线程,只要是这个线程过来,就直接把锁交出去

    如果对象发现目前不是只有一个线程,而是有多个线程在竞争锁,偏向锁就会升级为轻量级锁

  • 🔒 轻量级锁

    • 适合同步代码块的执行速度非常快的场景

    当锁升级为轻量级锁的时候,其他线程会通过 CAS 进行自旋等待来获取锁,不会阻塞,从而提高性能(并且会根据等待时间调整 CAS 的时间)

  • 🔒 重量级锁

    • 适合同步代码块执行速度较长的场景

    对象锁状态被标记为重量级锁,通过 Monitor 来对线程进行控制

😁 乐观锁 CAS

optimistic

在一些情况下,同步代码块执行的时间远远小于线程切换的耗时。所以希望能够在用户态中对线程的切换进行管理,这样效率更高。

我们让线程“乐观地”反复尝试获取共享资源,当发现空闲时便进行使用,否则继续“乐观地”进行重试。

基于以上想法,诞生了 CAS (Compare And Swap) 算法:比较并交换。

CAS算法涉及到三个操作数:

  • 需要读写的内存值 addr
  • 进行比较的值 oldValue
  • 要写入的新值 newValue
1
2
3
4
5
6
7
8
int cas(long *addr, long oldValue, long newValue)
{
    /* Executes atomically. */
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

这个 cas() 函数看起来没有任何同步措施,似乎还是存在线程不安全的问题。

当 A 线程比较了 oldValue 的值是想要的,但是这个瞬间,B 线程突然抢到了时间片,更改了值;但是 A 线程并不知道,将值改成了 newValue,这就出现了线程安全问题,A B 两个线程同时获得了资源。

所以,CAS 的操作必须是 原子性 的。这个原子操作,由计算机处理器指令集提供,直接由硬件保障。

Java 中的原子变量类

  • 基础数据类型

    • AtomicInteger
    • AtomicLong
    • AtomicBoolean
  • 数组类型

    • AtomicIntegerArray
    • AtomicLongArray
    • AtomicReferenceArray
  • 字段更新类型

    • AtomicIntegerFieldUpdater
    • AtomicLongFieldUpdater
    • AtomicReferenceFieldUpdater
  • 引用类型

    • AtomicReference
    • AtomicStampedReference
    • AtomicMarkableReference
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Demo {
  private static AtomicInteger n = new AtomicInteger(0);

  public static void main(String[] args) {
      for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                while (n.get() < 1000) {
                    System.out.println(
                            Thread.currentThread().getName() + " : " + 
                            n.incrementAndGet()
                    );
                }
            }, "Thread" + i).start();
      }
  }
}

AtomicInteger 主要由一个 Unsafe 类型的实例 unsafeLong 类型的 offset 实现。

Java 通过 Unsafe 的 CAS 操作来对 volatile int 值进行更新。根据 value 在对象中的偏移量,CAS 操作内存数据,执行一些底层,和平台相关的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclareFiele("value"));
    } catch (Exception e) { throw new Error(ex); }
}

private volatile int value;

例如 incrementAndGet()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// AtomicInteger.java
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));  // 自旋,可以通过启动参数配置,默认是 10
    return v;
}

public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);

缺点:

  • 循环开销大

  • 只能处理一个共享变量

    • 封装成对象 AtomicReference
  • ABA

ABA 问题

CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。

如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。

A -> B -> A

可以为共享变量引入一个修订号(时间戳),每次更新都会更新相应的修订号,判断变量的值是否被其他线程给修改过。AtomicStampedReference

[A, 0] -> [B, 1] -> [A, 2]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static void main(String[] args) {
    String a = "Hello";
    String b = "World";

    // initialRef  initialStamp
    AtomicStampedReference<String> reference = new AtomicStampedReference<>(a, 1);
    // expectedReference  newStamp
    reference.attemptStamp(a, 2);  // 对版本号进行修改
    // expectedReference  newReference  expectedStamp  newStamp
    reference.compareAndSet(a, b, 2, 3);  // CAS 操作需要同时提供 预期值 新值 预期版本号 新版本号
}

参考