为了多线程之间更高效的共享数据,提出了多种锁优化的思路和方法。
减少锁持有时间
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分段锁来提高并发效率。看一下是怎么实现的。
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来控制并发。
-
如果要获取(take)一个元素,需要获取 takeLock 锁,但是获取了锁还不够,如果队列此时为空,还需要队列不为空(notEmpty)这个条件(Condition)。
-
如果要插入(put)一个元素,需要获取 putLock 锁,但是获取了锁还不够,如果队列此时已满,还需要队列不是满的(notFull)这个条件(Condition)。
StampedLock
前面介绍的ReadWriteLock可以解决多线程同时读,但只有一个线程能写的问题。
如果我们深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。
StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入。这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
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()操作的都是方法的局部变量,因此它就不可能被多个线程同时访问,也就没有资源的竞争。