不加锁
如果不加锁,任何得到的读写操作是无序的,甚至可能读出来 null 数据。
class Cache {
Map<String, String> map = new HashMap<>();
public void put(String key, String value) {
System.out.println(Thread.currentThread().getName() + " 开始写");
map.put(key, value);
System.out.println(Thread.currentThread().getName() + " 写数据: " + value);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public String get(String key) {
System.out.println(Thread.currentThread().getName() + " 开始读");
String value = map.get(key);
System.out.println(Thread.currentThread().getName() + " 读数据: " + value);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return value;
}
}
public class RWDemo {
public static void main(String[] args) {
Cache cache = new Cache();
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.put(num, num);
}).start();
}
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.get(num);
}).start();
}
}
}
普通 lock 锁
使用普通加锁lock
,读操作和写操作都是串行执行,而且写操作全部完成,才会进行读。这是因为 main
方法中,先定义了写线程。
写串行保证数据安全,读操作怎么可以串行呢?
class Cache {
private static final ReentrantLock lock = new ReentrantLock();
Map<String, String> map = new HashMap<>();
public void put(String key, String value) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始写");
map.put(key, value);
System.out.println(Thread.currentThread().getName() + " 写数据: " + value);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
public void get(String key) {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始读");
String value = map.get(key);
System.out.println(Thread.currentThread().getName() + " 读数据: " + value);
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
public class RWDemo {
public static void main(String[] args) {
Cache cache = new Cache();
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.put(num, num);
}).start();
}
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.get(num);
}).start();
}
}
}
读写锁
使用读写锁,写锁串行,但是读并发,瞬间完成了读效果非常好,如下图。
结论:读写锁保证了写操作的原子性,并且可以进行并发读。
class Cache {
private static final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
Map<String, String> map = new HashMap<>();
public void put(String key, String value) {
readWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始写");
map.put(key, value);
System.out.println(Thread.currentThread().getName() + " 写数据: " + value);
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
readWriteLock.writeLock().unlock();
}
}
public void get(String key) {
readWriteLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始读");
String value = map.get(key);
System.out.println(Thread.currentThread().getName() + " 读数据: " + value);
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
readWriteLock.readLock().unlock();
}
}
}
public class RWDemo {
public static void main(String[] args) {
Cache cache = new Cache();
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.put(num, num);
}).start();
}
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.get(num);
}).start();
}
}
}
锁饥饿
锁饥饿(Lock Starvation)是指在多线程编程中的一种情况,其中某些线程可能无法获得所需的锁,而一直等待下去,从而无法继续执行,即线程被"饿死"在等待锁的过程中。这可能会导致应用程序的性能问题和不稳定性。
读写锁用不好就会出现锁饥饿。锁饥饿,写操作执行完才进行读,那么读操作就一直在等待,全部等待。
那可不可以,读写交叉并行呢?不是说 写开始 -> 读开始 -> 写结束 -> 读结束
这样穿插。
是读写操作独立,但是可以交叉执行两个操作,如图。
只需要修改执行线程的这个步骤,for 循环中允许读线程,也允许写线程。
for (int i = 0; i < 5; i++) {
String num = String.valueOf(i);
new Thread(() -> {
cache.put(num, num);
}).start();
// }
//
// for (int i = 0; i < 5; i++) {
// String num = String.valueOf(i);
new Thread(() -> {
cache.get(num);
}).start();
}
锁降级
- 什么是锁降级呢?
加写锁
写入数据 a = 1
释放写锁
...
加读锁
读取数据 a = 2
释放读锁
问题在于该在释放写锁、获取读锁
中间能够有其他的写线程插入,从而影响数据的一致性。影响了我们想要读取到的数据a = 1
,如果避免呢?锁降级。
即,先加读锁再释放写锁,就是锁降级。
加写锁
写入数据 a = 1
加读锁
释放写锁
... 这地方无论怎么操作,不能影响 a = 1
读取数据 a = 1
释放读锁
锁降级是一种多线程编程中的一种锁策略,它指的是在持有某个锁的同时,降低该锁的粒度,也就是大粒度锁 -> 小粒度锁
。这通常是为了减少锁的竞争,提高程序的并发性能。
锁降级的典型场景是在持有某个写锁时,释放写锁并获取读锁。这样做的好处是在执行读操作期间,其他线程可以同时执行读操作,提高了并发性能。
看到结果就是,读线程读取的数据和写线程写入的数据是完全一样的。
因为我们没法模拟其他线程在中间修改data
的一个情形,但是这样一定是对的!
public class RWDemo {
private static final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static Integer data = 0;
public static int update(Integer newData) {
readWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始写");
data = newData;
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + " 写数据: " + data);
// 写操作结束释放锁之前,锁降级开始
readWriteLock.readLock().lock();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
readWriteLock.writeLock().unlock();
}
try {
return get();
} finally {
readWriteLock.readLock().unlock();
}
}
public static int get() {
readWriteLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始读");
Thread.sleep(200);
System.out.println(Thread.currentThread().getName() + " 读数据: " + data);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
readWriteLock.readLock().unlock();
}
return data;
}
public static void main(String[] args) {
new Thread(() -> {
update(999);
},"写线程-1").start();
new Thread(() -> {
update(666);
},"写线程-2").start();
new Thread(() -> {
get();
},"读线程-1").start();
new Thread(() -> {
get();
},"读线程-2").start();
}
}
小结
- 支持公平/非公平策略(可以去看源码,支持参数
true/false
) - 支持可重入
可以理解为就是粒度的转换呗。
是否可重入 |
再去读锁 |
再去写锁 |
获取了读锁 |
✅ |
❌ |
获取了写锁 |
✅ |
✅ |
3. 支持锁降级,不支持锁升级
粒度大 -> 粒度小 √
粒度小 -> 粒度大 ×
4. 读写锁如果使用不当,很容易产生“饥饿”问题:
在读线程非常多,写线程很少的情况下,很容易导致写线程“饥饿”,虽然使用“公平”策略可以一定程度上缓解这个问题,但是“公平”策略是以牺牲系统吞吐量为代价的。