从锁的思想到Java主流锁分析

865 阅读6分钟

乐观锁和悲观锁

乐观锁和悲观锁是一种概念,他们的区别主要是在对待线程同步时的态度。

  • 悲观锁认为自己在使用数据时一定存在其他线程在修改数据,所以它在使用数据前会先加上锁,待到使用完毕释放锁资源。Java中,synchronized关键字和Lock的实现类都属于悲观锁。
  • 反之乐观锁则认为在使用数据时不会有线程修改数据,所以它不会添加锁,只是在更新数据时判断是否有线程修改了数据。如果数据没有被更新,则当前线程成功将数据写入。如果数据被更新了,则会根据实现方式不同执行不同的处理(报错 or 重试)。Java中,最常见的乐观锁实现就是CAS原子类。

正是因为乐观锁和悲观锁的不同,他们所适用的场景自然不一样,

  1. 乐观锁适合于读多写少的场景,无锁的设计能大幅提高并发效率。
  2. 悲观锁则适合写多读少场景,使用前先加锁能保证数据安全。

使用

//=============== 悲观锁 ===============
//synchronized
public synchronized void test() {
    //需要同步的资源
}

/**
 * ReentrantLock
 * 需要保证多线程操作的是同一个锁
 */
ReentrantLock lock = new ReentrantLock();
public void test1() {
    lock.lock();
    try {
        //需要同步的资源
    } finally {
        lock.unlock();
    }
}

//=============== 乐观锁 ===============
/**
 * 需要保证多线程操作的是同一个AtomicInteger
 */
AtomicInteger atomicInteger = new AtomicInteger(0);
public void test2() {
    atomicInteger.getAndIncrement();
}

通过上述使用方式我们可以总结出,悲观锁都是通过显式调用去获取锁从而同步数据,但是为什么乐观锁不需要显示的获取锁也同样能同步数据呢。这里就要谈谈什么是CAS。

CAS (compare and swap)

从字面意思上看,即比较和交换,是一种无锁的算法。即可以在不需要加锁的情况下,实现多线程变量同步。在Java中,atomic包下的原子类们是CAS的一系列实现

其中,我们就以最常见的AtomicInteger分析,源码如下,

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            //反射获取AtomicInteger类中value值的偏移量
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    //通过volatile关键字防止cpu指令重排序
    //使value对所有线程可见
    private volatile int value;
    
    ...
    
    public final int getAndIncrement() {
        //实际调用的是Unsafe.getAndAddInt
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
}

//Unsafe类
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    //通过循环重试比较新值与旧值,直到两者相等说明此时数据未被其他线程修改,之后更新内存中的变量值
    do {
        var5 = this.getIntVolatile(var1, var2);
    //compareAndSwapInt这个方法是native方法具体分析见底下
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

//native方法
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

可见实际的CAS操作的实现是在native层的compareAndSwapInt()中,JNI里是借助于CPU指令cmpxchg完成的,该指令是一个原子操作。显然,可以保证变量的可见性。

具体CPU的cmpxchg指令做的事情是,比较寄存器中的A和内存中的值V。

  1. 如果相等,把要写入的新值 B 存入内存中。
  2. 如果不相等,将内存值 V 赋值给寄存器中的值 A。

之后通过上述的do-while循环再次调用cmpxchg指令进行重试,一直到更新成功为止。

CAS带来的问题

CAS这种算法虽然非常高效,但也存在问题。

  1. ABA问题,因为CAS在更新变量前需要先检查变量是否可以更新,此时如果将变量A更新成B随后立马又更新成A。那么显然存在一种情况导致CAS认为变量没有变化,但实际是有变化的(线程安全策略变得不可靠)。解决办法可以将变量每次的更新记录一个版本号,即1A-2B-3A,这样CAS做compare的时候就不会出现变量已更新却被误判为未更新的情况了。
  2. 循环策略导致CPU开销高。

公平锁和非公平锁

  • 公平锁,线程按照申请锁的顺序来持有锁。优点是等待的线程不会饥饿,但缺点是吞吐效率比非公平锁下降。除了获取锁的线程,其余线程处于阻塞状态,而且CPU做线程唤醒的开销很大。
  • 非公平锁,线程获取锁是无序的,存在线程插队获取到锁的情况。优点是吞吐效率高,因为线程有几率不被阻塞就获取到了锁,但缺点可能会导致线程一直等待,处于饥饿状态。

ReentrantLock中的公平锁与非公平锁

public class ReentrantLock implements Lock, java.io.Serializable {
    ...
    public ReentrantLock() {
        //可见ReentrantLock默认使用的是非公平锁
        sync = new NonfairSync();
    }
    ...
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
    
    //非公平锁的实现
    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;
    
        final void lock() {
            ...
        }
    
        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }
    
    //公平锁的实现
    static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            ...
        }
        
        protected final boolean tryAcquire(int acquires) {
            ...
        }
    }
}

我们观察到实际获取锁的逻辑在tryAcquire方法中,我们对NonfairSyncFairSync中(左为FairSync)该方法做横向比较来看看他们的区别是什么,

除了增加了hasQueuedPredecessors以外没有什么不同,

public final boolean hasQueuedPredecessors() {
    ...
    Node t = tail;
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

该方法主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。

可重入锁和不可重入锁

  • 可重入锁,在外层方法获取了锁后,如果内部调用的方法也需要获取锁,那么会自动获取(必须为同一个锁)。不会因为外层方法获取到的锁没有被释放掉而被阻塞。可重入锁的特点就是可以在一定程度上避免产生死锁。
  • 同理不可重入锁则不允许出现上述情况,比如不能使用它做递归操作。

独享锁和共享锁

  • 独享锁又名互斥锁。该锁一次只能被一个线程所持有,获得锁的线程能同时进行读写操作。Java中synchronizedLock的实现类都属于互斥锁。
  • 共享锁,该锁可以被多个线程持有,如果一个变量A被线程加了共享锁,则之后的线程也只能加共享锁。并且获得共享锁的线程只能读,不能写。

Java中ReentrantReadWriteLock类实现了互斥锁与共享锁,如下

ReentrantReadWriteLock有两把锁,ReadLock读锁,是共享锁,WriteLock写锁,是互斥锁。