文章目录
- 前言
- 一、可重入锁ReentrantLock
- 1.可重入函数,指的是多个线程可以同时调用该函数
- 2.公平锁与非公平锁
- 二、ReadWriteLock读写锁
- 三、StampedLock
- 四、CountDownLatch和CyclicBarrier
- 1.CountDownLatch实现线程等待示例:
- 2.CyclicBarrier 实现线程同步
- 对比
- 问题
前言
并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。
一、可重入锁ReentrantLock
可重入锁,顾名思义,指的是线程可以重复获取同一把锁。
1.可重入函数,指的是多个线程可以同时调用该函数
class X {private final Lock rtl =new ReentrantLock();int value;public int get() {// 获取锁rtl.lock(); ②try {return value;} finally {// 保证锁能释放rtl.unlock();}}public void addOne() {// 获取锁rtl.lock(); try {value = 1 + get(); ①} finally {// 保证锁能释放rtl.unlock();}}
}
2.公平锁与非公平锁
在使用 ReentrantLock 的时候,你会发现 ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
// 无参构造函数:默认非公平锁
public ReentrantLock() {sync = new NonfairSync();
}
// 根据公平策略参数创建锁
public ReentrantLock(boolean fair){sync = fair ? new FairSync() : new NonfairSync();
}
管程的入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
二、ReadWriteLock读写锁
所有的读写锁都遵守以下三条基本原则:
1.允许多个线程同时读共享变量;
2.只允许一个线程写共享变量;
3.如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁实现缓存的例子:
class Cache<K,V> {final Map<K, V> m =new HashMap<>();final ReadWriteLock rwl = new ReentrantReadWriteLock();final Lock r = rwl.readLock();final Lock w = rwl.writeLock();V get(K key) {V v = null;// 读缓存r.lock(); ①try {v = m.get(key); ②} finally{r.unlock(); ③}// 缓存中存在,返回if(v != null) { ④return v;} // 缓存中不存在,查询数据库w.lock(); ⑤try {// 再次验证// 其他线程可能已经查询过数据库v = m.get(key); ⑥if(v == null){ ⑦// 查询数据库v= 省略代码无数m.put(key, v);}} finally{w.unlock();}return v; }
}
高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
三、StampedLock
StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读。
ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而 StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。
final StampedLock sl = new StampedLock();// 获取 / 释放悲观读锁示意代码
long stamp = sl.readLock();
try {// 省略业务相关代码
} finally {sl.unlockRead(stamp);
}// 获取 / 释放写锁示意代码
long stamp = sl.writeLock();
try {// 省略业务相关代码
} finally {sl.unlockWrite(stamp);
}
StampedLock不支持重入。
使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。
四、CountDownLatch和CyclicBarrier
1.CountDownLatch实现线程等待示例:
// 创建 2 个线程的线程池
Executor executor = Executors.newFixedThreadPool(2);
while(存在未对账订单){// 计数器初始化为 2CountDownLatch latch = new CountDownLatch(2);// 查询未对账订单executor.execute(()-> {pos = getPOrders();latch.countDown();});// 查询派送单executor.execute(()-> {dos = getDOrders();latch.countDown();});// 等待两个查询操作结束latch.await();// 执行对账操作diff = check(pos, dos);// 差异写入差异库save(diff);
}
2.CyclicBarrier 实现线程同步
线程 T1 负责查询订单,当查出一条时,调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0;线程 T2 负责查询派送单,当查出一条时,也调用 barrier.await() 来将计数器减 1,同时等待计数器变成 0;当 T1 和 T2 都调用 barrier.await() 的时候,计数器会减到 0,此时 T1 和 T2 就可以执行下一条语句了,同时会调用 barrier 的回调函数来执行对账操作。
// 订单队列
Vector<P> pos;
// 派送单队列
Vector<D> dos;
// 执行回调的线程池
Executor executor = Executors.newFixedThreadPool(1);
final CyclicBarrier barrier =new CyclicBarrier(2, ()->{executor.execute(()->check());});void check(){P p = pos.remove(0);D d = dos.remove(0);// 执行对账操作diff = check(p, d);// 差异写入差异库save(diff);
}void checkAll(){// 循环查询订单库Thread T1 = new Thread(()->{while(存在未对账订单){// 查询订单库pos.add(getPOrders());// 等待barrier.await();}});T1.start(); // 循环查询运单库Thread T2 = new Thread(()->{while(存在未对账订单){// 查询运单库dos.add(getDOrders());// 等待barrier.await();}});T2.start();
}
对比
CountDownLatch 主要用来解决一个线程等待多个线程的场景(几组线程之间),可以类比旅游团团长要等待所有的游客到齐才能去下一个景点;而CyclicBarrier 是一组线程之间(一组线程内部)互相等待,更像是几个驴友之间不离不弃。除此之外 CountDownLatch 的计数器是不能循环利用的,也就是说一旦计数器减到 0,再有线程调用 await(),该线程会直接通过。但CyclicBarrier 的计数器是可以循环利用的,而且具备自动重置的功能,一旦计数器减到 0 会自动重置到你设置的初始值。除此之外,CyclicBarrier 还可以设置回调函数,可以说是功能丰富
问题
最后的示例代码中,CyclicBarrier 的回调函数我们使用了一个固定大小的线程池,你觉得是否有必要呢?
线程池大小为1是必要的,如果设置为多个,有可能会两个线程 A 和 B 同时查询,A 的订单先返回,B 的派送单先返回,造成队列中的数据不匹配;所以1个线程实现生产数据串行执行,保证数据安全
如果用Future 的话可以更方便一些
本文链接:https://my.lmcjl.com/post/6500.html
4 评论