Skip to content

性能优化-Lock

About 2049 wordsAbout 7 min

性能新书

2026-09-04

Lock

锁是用来保护资源,避免多线程同时改写资源时候造成的数据不一致。线程在访问资源前必须先获得锁,只有获得锁的线程才能拿对资源进行改写。比如一个序列生成器,期望调用next方法,获取到依次递增得得序列

public class IDSeqHelper {
  int a = 0;
  public int next(){
    a++;
    return a;
  }
}

对于单线程来说,调用IDSeqHelper.next方法,依次返回1.2.3.4.5....但对于多线程调用next,可能线程得到重复的id。这是因为a++递增操作看上去是一个单个操作,实际上包含了三个独立操作,读取变量a,将a加1,写会计算结果到变量a。在并发编程下,会产生重复的ID:

时间线程A线程B
T1a=5
T2a=5
T3读取a,然后加1
T4读取a,然后加1
T5写回结果a=6
T6写回结果 a=6

在多线程下,线程A和线程B都有可能拿到变量值为5,并进行自增后赋值变量a,因此可能得到的序列值都是6,这不符合我们期望。解决办法可以使用synchronized关键字,保证串行执行

public int next(){
    synchronized{
        a++;
        return a;
    }
  }
时间线程A线程B
T1synchronized{
T2读取a为5,然后加1
T3写会结果6
T4}
T5synchronized{
T6读取a为6,然后加1
T7写回结果 a=7
T8}

Java除了提供synchronzied关键字外,还有java.util.concurrent.locks提供Lock接口实现了更强大的锁操作以及提供可观测能力,使得程序员很容易编写和调试并发程序。

当并发或者异步编程,使用锁保护资源时候,需要做如下考虑以提升并发性能

性能优化描述
线程挂起 VS 自旋当资源或者代码块被锁保护,其他线程必须挂起等待。如果触发了线程在操作系统上的挂起,其开销成本较大,导致性能问题。线程通过自旋尝试获取锁而不是挂起线程以提高性能
锁保护资源粒度使用锁保护的资源应该尽量精确,使用锁保护的代码块应该尽量较小以减少并发下的锁竞争,和锁时间长短。 一个典型的锁保护是数据库中的的行锁,只针对需要操作的行数据上锁。使用行锁有最高的并发度
共享锁 VS 排他锁在写少读多的高并发场景,共享锁允许并发操作读数据,而排他锁操作用于改写数据

挂起 VS. 自旋锁

当访问被锁保护受资源时候,线程可能被操作系统挂起,或者不挂起,进入入自旋状态

状态描述
挂起该线程被操作系统剥夺CPU使用权,从CPU运行队列中移到等待队列,线程暂停执行等待被唤醒,不会消耗CPU。但存在线程切换的较大开销。
自旋线程获取锁失败,会循环尝试再次获取锁,这里线程不会挂起,会消耗CPU。 自旋使用CAS操作,是一条CPU 硬件级原子指令, 使用CAS,该线程不会被挂起。

Java21 推出了虚拟线程,是JVM管理的线程,解决了不能创建过多内核线程以及内核线程切换开销大的情况。但在实际业务系统(包含数据库访问和RPC调用),不可能创建大量的虚拟线程,因为数据库连接池或者RPC底层连接池 只包含有限连接数目。大量虚拟线程无助于提高吞吐量反而可能导致系统线程可观测性降低

如上IDSeqHelper,关键字synchronized, 现代JVM并不会立即让未获得锁的线程立即挂起,而是进入自旋状态,JVM自旋一定次数后,才让该线程挂起。

自旋锁实现是通过操作系统提供的CAS(Compare‑And‑Swap,比较并交换)指令实现,CAS是本质上是操作系统提供的一种乐观锁实现方式,类似如下

// v为需改的变量,expected为修改前查询到的V值,value为新设置的值
boolean cas(V, expected, value){
    if (V == expected){
        V = value;
        return true; // 修改成功
    }else{
        return false; // 已经被别人改过,放弃更新
    }
}

Java可以调用Unsafe类

//Unsafe类,o和offset用于JVM定位了一个对象中的long变量
public final native boolean compareAndSetLong(Object o, long offset, 
                                              long expected,
                                              long x);

总的来说,Java提供的并发包和并发工具类,对资源的保护采用自旋方式,建议为了获得更好的并发性,优先使用这些并发包代替synchronzied关键字或者使用synchronzied类的Java集合类,如HashTable,Vector类

锁粒度

数据库行锁只锁定特定行而不是整个数据表,提高数据库的并发性,Java提供的高并发类库 减少锁粒度提高高并发,包括ConcurrentHashMapLongAdders 等类,以LongAdders为例子,它不像AtomicLong那样对其私有变量value在操作时候加自旋,如下AtomicLong代码,研究incrementAndGet方法

public class AtomicLong extends Number implements java.io.Serializable {
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE
    = U.objectFieldOffset(AtomicLong.class, "value");
    
    private volatile long value;
    public final long incrementAndGet() {
        return U.getAndAddLong(this, VALUE, 1L) + 1L;
    }
}

//Unsafe.java
public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset); //获取值value
    } while (!weakCompareAndSetLong(o, offset, v, v + delta)); //cas操作
    return v;
}

Unsafe.getAndAddLong内部使用自旋更新value,如果多个并发线程并发更新,则CAS操作会失败导致CPU开销增加

//Unsafe,这里的o和offset共同代表对象中的某一个属性
public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);
    } while (!weakCompareAndSetLong(o, offset, v, v + delta));
    return v;
}

LongAddrs类做了性能优化,同AotmicLong那样使用CAS更新数据,不同的是,LongAddrs最多能分出128个Cell,每个线程只更新属于自己的Cell的数据,Cell定义如下:

@jdk.internal.vm.annotation.Contended static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    final boolean cas(long cmp, long val) {
        //TODO
        return VALUE.weakCompareAndSetRelease(this, cmp, val);
    }
    final void reset() {
        VALUE.setVolatile(this, 0L);
    }
  }

当返回LongAddrs的值,需要遍历所有Cell

public long sum() {
    Cell[] cs = cells;
    long sum = base;
    if (cs != null) {
        for (Cell c : cs)
            if (c != null)
                sum += c.value;
    }
    return sum;
}

另外一个例子是LinkedBlockingQueue ,使用两把锁takeLockputLock,分别保护队首和队尾,如下take操作和put操作

//简化LinkedBlockingQueue的代码
public E take() throws InterruptedException {
  
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        x = dequeue(); //取出队首
    } finally {
        takeLock.unlock();
    }
   
    return x;
}

public void put(E e) throws InterruptedException {
   
    final Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    putLock.lockInterruptibly();
    try {
       
        enqueue(node);
      
    } finally {
        putLock.unlock();
    }
    
}

读****写锁

大多数业务场景面临的都是写少读多场景,比如缓存数据,保护这种资源可以采用读写锁,即一个共享锁(也成为读锁)和一个排他锁(称为写锁)。当写锁被持有的时候,其他任何读写操作就等待。读锁可以被多个线程持有,此时要获取写锁需要等待读锁被所有线程释放,可以简单理解为多个线程,读写是互斥的,读读是共享的。

如下是一个读写锁模版

ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 读操作
readLock.lock();
try {
    // 共享读取数据
}finally {
    readLock.unlock();
}

// 写操作
writeLock.lock();
try {
    // 修改数据,独占
}finally {
    writeLock.unlock();
}

需要注意,读写锁不仅仅保护数据,如果存在其他互斥的业务,比如dataOperation1调用是共享允许的,但dataOperation2排他,如上代码可以改成

// 读操作
readLock.lock();
try {
    dataOperation1();
}finally {
    readLock.unlock();
}

// 写操作
writeLock.lock();
try {
    dataOperation2()
}finally {
    writeLock.unlock();
}

无论如何使用锁保护资源,需要注意死锁情况,它出现将导致系统故障,不再响应请求。考虑到死锁不属于性能优化问题,因此关于多线程死锁,将在JVM可观测性章节中举例子说明

知行合一