注:本文不介绍 ReentrantLock 和线程安全的集合类。
1. 读写锁
1.1 ReentrantReadWriteLock
当读操作远远高于写操作时,这时候使用读写锁让读-读可以并发,提高性能。 类似于数据库中的 select ... from ... lock in share mode
提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法
class DataContainer {
private Object data;
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock r = rw.readLock();//获取读锁 保护读操作
private final ReentrantReadWriteLock.WriteLock w = rw.writeLock();//获取写锁 保护写操作
public Object read() {
log.debug("获取读锁...");
r.lock();
try {
log.debug("读取");
sleep(1);
return data;
} finally {
log.debug("释放读锁...");
r.unlock();//释放读锁
}
}
public void write() {
log.debug("获取写锁...");
w.lock();
try {
log.debug("写入");
sleep(1);
} finally {
log.debug("释放写锁...");
w.unlock();//释放写锁
}
}
}
测试 读锁-读锁 可以并发
public static void main(String[] args) throws InterruptedException {
DataContainer dataContainer = new DataContainer();
new Thread(() -> {
dataContainer.read();
}, "t1").start();
new Thread(() -> {
dataContainer.read();
}, "t2").start();
}
输出结果,从这里可以看到 t2 锁定期间,t1 的读操作不受影响
测试 读锁-写锁 相互阻塞
public static void main(String[] args) throws InterruptedException {
DataContainer dataContainer = new DataContainer();
new Thread(() -> {
dataContainer.read();
}, "t1").start();
Thread.sleep(100);
new Thread(() -> {
dataContainer.write();
}, "t2").start();
}
运行结果:
读锁获取到锁后,休眠1秒,写锁尝试获取锁,失败,等读锁释放锁后,写锁才获取到锁。
测试 写锁-写锁 相互阻塞
public static void main(String[] args) throws InterruptedException {
DataContainer dataContainer = new DataContainer();
new Thread(() -> {
dataContainer.write();
}, "t1").start();
Thread.sleep(100);
new Thread(() -> {
dataContainer.write();
}, "t2").start();
}
运行结果:
t1释放掉写锁后,t2才能获取锁。
注意事项
- 读锁不支持条件变量,写锁支持条件变量。
- 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待。
- 重入时降级支持:即持有写锁的情况下去获取读锁
以下从 ReentrantLock 中找的一个例子,来验证第三点注意事项:
class CachedData {
//需要缓存的数据
Object data;
// 是否有效,如果失效,需要重新计算 data
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
//加读锁
rwl.readLock().lock();
if (!cacheValid) {//缓存数据失效
// 尝试获取写锁重新计算
// 获取写锁前必须释放读锁
rwl.readLock().unlock();
// 获取写锁
rwl.writeLock().lock();
try {
// 判断是否有其它线程已经获取了写锁、更新了缓存, 避免重复更新
// 双重检查,外层的cahceVaild不受写锁保护,可能被别的线程读取到
if (!cacheValid) {
//确认失效,重新计算
data = ...
cacheValid = true;
}
// 降级为读锁, 释放写锁, 这样能够让其它线程读取缓存,且接下来读数据时,不被其他写线程干扰
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock();
}
}
// 缓存数据未失效
// 自己用完数据, 释放读锁
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}
1.2 StampedLock
该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合【戳】使用
加解读锁
long stamp = lock.readLock();//戳
lock.unlockRead(stamp);//根据该戳去解锁
加解写锁
long stamp = lock.writeLock();
lock.unlockWrite(stamp);
乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。
long stamp = lock.tryOptimisticRead();//返回一个戳,没有加锁
// 验戳 防止其他写线程干扰
if(!lock.validate(stamp)){
// 验戳失败,被其他线程修改 锁升级 升级为读锁
}
提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法
class DataContainerStamped {
private int data;// 共享的数据
private final StampedLock lock = new StampedLock();
public DataContainerStamped(int data) {
this.data = data;
}
public int read(int readTime) {
long stamp = lock.tryOptimisticRead();// 乐观读,返回戳
log.debug("optimistic read locking...{}", stamp);
sleep(readTime);// 模拟读的时间
if (lock.validate(stamp)) { // 戳的校验
// 验证通过,返回数据
log.debug("read finish...{}, data:{}", stamp, data);
return data;
}
// 验戳失败
// 锁升级 - 读锁
log.debug("updating to read lock... {}", stamp);
try {
// 加读锁
stamp = lock.readLock();
log.debug("read lock {}", stamp);
sleep(readTime);
log.debug("read finish...{}, data:{}", stamp, data);
return data;
} finally {
log.debug("read unlock {}", stamp);
lock.unlockRead(stamp);// 释放读锁,传入戳
}
}
public void write(int newData) {
long stamp = lock.writeLock();// 加写锁,返回戳
log.debug("write lock {}", stamp);
try {
sleep(2);
this.data = newData;
} finally {
log.debug("write unlock {}", stamp);
lock.unlockWrite(stamp);// 释放写锁,传入戳
}
}
}
测试 读-读 可以优化
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.read(0);
}, "t2").start();
}
输出结果,可以看到实际没有加读锁
测试 读-写 时优化读补加读锁
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.write(100);
}, "t2").start();
}
输出结果
t2写线程修改了戳,t1线程验戳失败,进行锁升级,加读锁,但是此时t2写线程还在工作,所以t1线程加读锁阻塞,等t2写线程工作结束(戳又被更新了)释放锁后,t1线程才加上读锁。
注意 :StampedLock 不支持条件变量; StampedLock 不支持可重入。所以 StampedLock 并不能取代 ReentrantReadWriteLock。
2. Semaphore[ˈsɛməˌfɔr]
信号量,用来限制能同时访问共享资源的线程上限。用在共享资源有多个,且允许多个线程访问这些共享资源,只是希望对访问的线程上限加以限制。
构造函数:
// permits 指共享资源的数量,或想限制的最大访问线程数量
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
// fair 表示是公平或非公平
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
测试案例:
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
log.debug("running...");
sleep(1);
log.debug("end...");
}).start();
}
}
运行结果:
此时没有线程限制,10个线程都运行起来了。
加上限制:
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
// 运行前获取许可
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
log.debug("running...");
sleep(1);
log.debug("end...");
} finally {
// 运行后释放许可
semaphore.release();
}
}).start();
}
}
运行结果:
3个线程获取锁开始运行,其他线程进入阻塞,只有当这三个线程运行结束后释放锁,才能又有3个线程获得锁运行,依次执行。
3. CountdownLatch
用来进行线程同步协作,等待所有线程完成倒计时。
其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一
看一下 CountdownLatch 的源码:
public class CountDownLatch {
// 内部维护了一个同步器,同步器继承了AQS
private static final class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 4982264981922014374L;
Sync(int count) {
setState(count);
}
int getCount() {
return getState();
}
// 判断线程获得锁的条件是getState() 是否等于0,等于0相当于获得了锁,大于0相当于获得不到锁会被阻塞住,使用的是共享锁
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
// 如果其线程调用了 release 方法,则它会间接调用该方法
protected boolean tryReleaseShared(int releases) {
// 让计算值每次减1,减到0便会唤醒阻塞的线程,没有减到0就return false,不会唤醒阻塞的线程
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
......
}
测试案例:
private static void test4() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3);
new Thread(() -> {
log.debug("begin...");
sleep(1);
latch.countDown();
log.debug("end...{}", latch.getCount());
}).start();
new Thread(() -> {
log.debug("begin...");
sleep(2);
latch.countDown();
log.debug("end...{}", latch.getCount());
}).start();
new Thread(() -> {
log.debug("begin...");
sleep(1.5);
latch.countDown();
log.debug("end...{}", latch.getCount());
}).start();
log.debug("waiting...");
latch.await();
log.debug("wait end...");
}
运行结果:
只有当线程Thread-2运行结束后,将计数减到0后,才能将主线程从AQS阻塞队列中唤醒,发现计数变成0,结束运行。
可以配合线程池使用,改进如下
private static void test5() {
CountDownLatch latch = new CountDownLatch(3);
// 线程池
ExecutorService service = Executors.newFixedThreadPool(4);
service.submit(() -> {
log.debug("begin...");
sleep(1);
latch.countDown();
log.debug("end...{}", latch.getCount());
});
service.submit(() -> {
log.debug("begin...");
sleep(1.5);
latch.countDown();
log.debug("end...{}", latch.getCount());
});
service.submit(() -> {
log.debug("begin...");
sleep(2);
latch.countDown();
log.debug("end...{}", latch.getCount());
});
// 汇总
service.submit(()->{
try {
log.debug("waiting...");
latch.await();
log.debug("wait end...");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
运行结果:
15秒时,4个任务都开始运行,包括等待汇总的任务也被提交到了线程池,直到线程thread-3运行结束将计数修改为0,等待汇总的任务才被唤醒,进行汇总工作。
下面来看一个 CountdownLatch 的应用小案例:
模拟打游戏时多个玩家进入游戏时加载情况,只有10个玩家都加载完成后,游戏才能开始:
private static void playGame() throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
ExecutorService service = Executors.newFixedThreadPool(10, (r) -> {
return new Thread(r, "t" + num.getAndIncrement());
});
CountDownLatch latch = new CountDownLatch(10);
String[] all = new String[10];
Random r = new Random();
for (int j = 0; j < 10; j++) {
// lambda 表达式只能引用局部常量
int x = j;
service.submit(() -> {
for (int i = 0; i <= 100; i++) {
try {
// 随机休眠一段时间,模拟不同的加载速度
Thread.sleep(r.nextInt(100));
} catch (InterruptedException e) {
}
all[x] = Thread.currentThread().getName() + "(" + (i + "%") + ")";
//不换行,用回车符回退(覆盖)之前的打印
System.out.print("\r" + Arrays.toString(all));
}
// 让计数减一
latch.countDown();
});
}
// 主线程等待
latch.await();
System.out.println("\n游戏开始...");
service.shutdown();
}
运行结果:
4. CyclicBarrier [ˈsaɪklɪk ˈbæriɚ]
循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置『计数个数』,每个线程执 行到某个需要“同步”的时刻调用 await() 方法进行等待,当等待的线程数满足『计数个数』时,继续执行
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(2);
CyclicBarrier barrier = new CyclicBarrier(2, ()-> {
// 第三个任务,当第1个和第2个任务执行完后才执行。
log.debug("task1, task2 finish...");
});
for (int i = 0; i < 3; i++) { // task1 task2 task1
service.submit(() -> {
log.debug("task1 begin...");
sleep(1);
try {
barrier.await(); // 2-1=1
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
service.submit(() -> {
log.debug("task2 begin...");
sleep(2);
try {
barrier.await(); // 1-1=0
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
});
}
service.shutdown();
}
注意:线程池线程数要和 CyclicBarrier 的计数一致,否则,由于任务1比任务2先执行完,假如线程池线程数为3 ,则会执行俩次任务1,1次任务2(task1 task2 task1),俩次任务1将计数减为了0,此时任务3便会执行,这样就不是任务1和任务2执行完(task1, task2 finish...)。
运行结果:
注意 CyclicBarrier 与 CountDownLatch 的主要区别在于 CyclicBarrier 是可以重用的