锁优化

442 阅读4分钟

为了多线程之间更高效的共享数据,提出了多种锁优化的思路和方法。

锁优化.png

减少锁持有时间

public synchronized void method() {
    doSomething1(); // 1
    needLock(); // 2
    doSomething2(); // 3
}

如上,对整个method方法加锁,但是方法1和方法3都不需要加锁,那么可以将代码优化成如下所示:

public void method() {
    doSomething1();
    synchronized (LockTest.class) {
        needLock();
    }
    doSomething2();
}

减小锁粒度

在JDK 1.7中ConcurrentHashMap采用的segment分段锁来提高并发效率。看一下是怎么实现的。

JDK1.7 ConcurrentHashMap.png

ConcurrentHashMap按照concurrencyLevel参数分为N个segement数组,默认是16个。也就是说默认情况下可以支持16个线程并发写,只要它们在不同的segement上操作。concurrencyLevel这个值一旦初始化完成是不可变的。

具体到每个 Segment 内部,每个 Segment 很像HashMap,不过它要保证线程安全,所以处理起来要麻烦些。

今天就不分析源码了,它的主要做法就是根据key的hash值决定在哪个segment上进行操作,然后在往该 segment 写入前,需要先获取该 segment 的独占锁。由于有独占锁的保护,所以 segment 内部的操作并不复杂。

锁分离

最常见的锁分离技术就是读写锁。JDK提供了ReentrantReadWriteLock和LinkedBlockingQueue,以及在JDK1.8中对于读写锁的优化StampedLock。

ReentrantReadWriteLock

读写锁:允许多个线程同时读,但只要有一个线程在写。即允许多个线程同时读共享变量,只允许一个线程写共享变量,如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

允许不允许
不允许不允许

JAVA的并发包提供了读写锁的接口ReadWriteLock和实现类ReentrantReadWriteLock,从名字就可以看出,它是支持重入的。

例如可以用它来实现个简单的缓存:

static class Cache<K, V> {
    final Map<K, V> cacheMap = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    // 读锁
    final Lock readLock = rwl.readLock();
    // 写锁
    final Lock writeLock = rwl.writeLock();

    V get(K key) {
        readLock.lock();
        try {
           return cacheMap.get(key);
        } finally {
            readLock.unlock();
        }
    }

    V put(K key, V value) {
        writeLock.lock();
        try {
            return cacheMap.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }
}
LinkedBlockingQueue

这是读写分离思想的一种延伸,只要操作互不影响,锁就可以分离。LinkedBlockingQueue从头部取出数据,从尾部放入数据,使用了两个锁和两个condition来控制并发。

  1. 如果要获取(take)一个元素,需要获取 takeLock 锁,但是获取了锁还不够,如果队列此时为空,还需要队列不为空(notEmpty)这个条件(Condition)。

  2. 如果要插入(put)一个元素,需要获取 putLock 锁,但是获取了锁还不够,如果队列此时已满,还需要队列不是满的(notFull)这个条件(Condition)。

StampedLock

前面介绍的ReadWriteLock可以解决多线程同时读,但只有一个线程能写的问题。

如果我们深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。

要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock

StampedLockReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入。这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。

乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。

class Point {
    private int x, y;
    final StampedLock sl = new StampedLock();

    //计算到原点的距离
    double distanceFromOrigin() {
        // 乐观读
        long stamp = sl.tryOptimisticRead();
        // 读入局部变量,
        // 读的过程数据可能被修改
        int curX = x, curY = y;
        //判断执行读操作期间,
        //是否存在写操作,如果存在,
        //则sl.validate返回false
        if (!sl.validate(stamp)) {
            // 升级为悲观读锁
            stamp = sl.readLock();
            try {
                curX = x;
                curY = y;
            } finally {
                //释放悲观读锁
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(curX * curX + curY * curY);
    }
}

上面的代码是JDK官方给的示例,和ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。

可见,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。StampedLock还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。

锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短。但是事情总有一个度,如果对于同一个锁不断地进行加锁和释放锁,这本身就是资源的浪费。

public void method() {
    for (int i = 0; i < 100; i++) {
        synchronized (LockTest.class) {
            needLock();
        }
    }
}

// 优化后
public void method() {
    synchronized (LockTest.class) {
        for (int i = 0; i < 100; i++) {
            needLock();
        }
    }
}

上面是一个极端的例子,一般人不会这么干,如果你遇到了请打死他。

锁消除

锁消除是JVM编译期的优化,在进行上下文扫描时,如果发现被锁定的代码块中,共享资源不会存在竞争,则会进行锁消除。

-XX:+EliminateLocks 开启锁消除,默认是开启的,-XX:-EliminateLocks关闭锁消除。

public class LockEliminateTest {

    public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }

    public static void main(String[] args) {
        long tsStart = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            getString("test ", "test");

        }
        System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");
    }

}

StringBuffer的append的操作被synchronized修饰,所以如果不开启锁消除,则每次操作都需要申请锁资源。上面的代码中getString()操作的都是方法的局部变量,因此它就不可能被多个线程同时访问,也就没有资源的竞争。

公众号.jpg