文章目录
JUC
一、AQS原理(重要)
1、概述
全称是AbstractQueueSynchronizer,是阻塞式锁和相关的同步器工具的框架。
特点:
用state属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
– getState - 获取state状态
– setState - 设置state状态
– compareAndSetState - cas机制设置state状态
– 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源提供来基于FIFO的等待队列,类似于 Monitor 的 EntryList
条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
子类主要实现这样的一些方法(默认抛出 UnsupportedOperationException)
- tryAcquire
- tryRelease
- tryAcquireShared
- tryReleaseShared
- isHeldExclusively
获取锁的姿势
// 如果获取锁失败
if (!tryAcquire(arg)) {
// 入队, 可以选择阻塞当前线程 park unpark
}
释放锁的姿势
// 如果释放锁成功
if (tryRelease(arg)) {
// 让阻塞线程恢复运行
}
2、实现不可重入锁
自定义同步器
final class MySync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int acquires) {
if (acquires == 1){
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
}
return false;
}
@Override
protected boolean tryRelease(int acquires) {
if(acquires == 1) {
if(getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
return false;
}
protected Condition newCondition() {
return new ConditionObject();
}
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
}
自定义锁
有了自定义同步器,很容易复用AQS,实现一个功能完备的自定义锁
class MyLock implements Lock {
static MySync sync = new MySync();
@Override
// 尝试,不成功,进入等待队列
public void lock() {
sync.acquire(1);
}
@Override
// 尝试,不成功,进入等待队列,可打断
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
@Override
// 尝试一次,不成功返回,不进入队列
public boolean tryLock() {
return sync.tryAcquire(1);
}
@Override
// 尝试,不成功,进入等待队列,有时限
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}
@Override
// 释放锁
public void unlock() {
sync.release(1);
}
@Override
// 生成条件变量
public Condition newCondition() {
return sync.newCondition();
}
}
测试一下
MyLock lock = new MyLock();
new Thread(() -> {
lock.lock();
try {
log.debug("locking...");
sleep(1);
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t1").start();
new Thread(() -> {
lock.lock();
try {
log.debug("locking...");
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t2").start();
输出:
不可重入测试
如果改为下面代码,会发现自己也会被挡住(只会打印一次locking)
lock.lock();
log.debug("locking...");
lock.lock();
log.debug("locking...");
3、心得
3.1 起源
早期程序员会自己通过一种同步器去实现另一种相近的同步器,例如用可重入锁去实现信号量,或反之。这显然不够优雅,于是在JSR166(java规范提案)中创建了AQS,提供了这种通用的同步器机制。
3.2 目标
AQS要实现的功能目标
- 阻塞版本获取锁acquire和非阻塞的版本尝试获取锁tryAcquire
- 获取锁超时机制
- 通过打断取消机制
- 独占机制及共享机制
- 条件不满足时的等待机制
要实现的性能目标
- Instead, the primary performance goal here is scalability: to predictably maintain efficiency even, or especially, when synchronizers are contended.
3.3 设计
AQS的基本思想其实很简单
1、获取锁的逻辑
while(state 状态不允许获取) {
if(队列中还没有此线程) {
入队并阻塞
}
}
当前线程出队
2、释放锁的逻辑
if(state 状态允许了) {
恢复阻塞的线程(s)
}
要点
- 原子维护state状态
- 阻塞及恢复线程
- 维护队列
1)state设计
- state使用了volatile配合cas保证其修改时的原子性
- state使用了32bit int来维护同步状态,因为当时使用log在很多平台下测试的结果并不理想
2)阻塞恢复设计
- 早期的控制线程暂停和恢复的api 有 suspend 和 resume,但它们是不可用的,因为如果先调用的resume,那么suspend 将感知不到。
- 解决方法是使用park & unpark 来实现线程的暂停和恢复,具体原理在之前讲过了,先unpark 再park 也没问题
- park & unpark 是针对线程的,而不是针对同步器的,因此控制粒度更为精细
- park 线程还可以通过 interrupt 打断
3)队列设计
- 使用了FIFO 先入先出队列,并不支持优先级队列
- 设计时借鉴了CLH队列,它是一种单向无锁队列
队列中有 head 和 tail 两个指针节点,都用 volatile 修饰配合cas使用,每个节点有 state 维护节点状态
- 入队伪代码,只需要考虑 tail 赋值的原子性
- 出队伪代码
CLH 好处:
- 无锁,使用自旋
- 快速,无阻塞
AQS 在一些方面改进了CLH
private Node enq(final Node node) {
for (;;) {
Node t = tail;
// 队列中还没有元素 tail 为 null
if (t == null) {
// 将 head 从 null -> dummy
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 将 node 的 prev 设置为原来的 tail
node.prev = t;
// 将 tail 从原来的 tail 设置为 node
if (compareAndSetTail(t, node)) {
// 原来 tail 的 next 设置为 node
t.next = node;
return t;
}
}
}
}
主要用到AQS的并发工具类
二、ReentrantLock原理(重要)
1、非公平锁锁实现原理
1.1 加锁解锁流程
先从构造器开始看,默认为非公平锁实现
public ReentrantLock() {
sync = new NonfairSync();
}
NofairSync 继承自 AQS
1、没有竞争时
2、第一个竞争出现时
Thread - 1 执行了
1、CAS尝试将state由0改为1,结果失败
2、进入tryAcquire逻辑,这时state已经是1,结果仍然失败
3、接下来进入addWaiter逻辑,构造Node队列
- 图中黄色三角表示该Node的waitStatus状态,其中0为默认正常状态
- Node的创建是懒惰的
- 其中第一个Node成为Dummy(哑元)或哨兵,用来占位,并不关联线程
当前线程进入acquireQueued逻辑
1、acquireQueued会在一个死循环中不断尝试获取锁,失败后进入park阻塞
2、如果自己是紧邻着head(排第二位),那么再次tryAcquire尝试获取锁,当然这时state仍为1,失败
3、进入shouldParkAfterFailedAcquire逻辑,将前驱 node ,即head的waitStatus改为 - 1,这次返回false
4、shouldParkAfterFailedAcquire执行完毕回到acquireQueued,再次tryAcquire尝试获取锁,当然这时state仍为1,失败
5、当再次进入shouldParkAfterFailedAcquire时,这时因为其前驱node的waitStatus已经是 - 1,这次返回true
6、进入parkAndCheckInterrupt,Thread - 1 park(灰色表示)
再次有多个线程经历上述过程竞争失败,变成这个样子
Thread - 0 释放锁,进入tryRelease流程,如果成功
- 设置exclusiveOwnerThread为null
- state = 0
当前队列不为null,并且head的waitStatus = - 1,进入unparkSuccessor流程
找到队列中离head最近的一个Node(没取消的),unpark恢复其运行,本例中即为Thread - 1
回到Thread - 1的acquireQueued流程
如果加锁成功(没有竞争),会设置
- exclusiveOwnerThread为Thread -1,state = 1
- head指向刚刚Thread - 1所在的Node,该Node清空Thread
- 原本的head因为从链表断开,而可被垃圾回收
如果这时候有其它线程来竞争(非公平的体现),例如这时有Thread - 4来了
如果不巧又被Thread - 4 占了先
- Thread - 4 被设置为 exclusiveOwnerThread,state = 1
- Thread - 1 再次进入acquireQueued流程,获取锁失败,重新进入park阻塞。
1.2 加锁源码
注意:
- 是否需要unpark是由当前节点的前驱节点的waitStatus == Node.SIGNAL来决定,而不是本节点的waitStatus决定
1.3 解锁源码
2、可重入原理
3、可打断原理
3.1、不可打断模式
在此模式下,即使它被打断,仍会驻留在AQS队列中,一直要等到获得锁后方能得知自己被打断了
3.2、可打断模式
4、公平锁实现原理
5、条件变量实现原理
每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject
5.1 await 流程
- 开始Thread - 0 持有锁,调用await,进入 ConditionObject的addConditionWaiter流程
- 创建新的Node状态为 -2 (Node.CONDITION),关联Thread-0,加入等待队列尾部
- 接下来进入AQS的fullyRelease流程,释放同步器上的锁
- unpark AQS队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么Thread - 1 竞争成功
- park阻塞Thread - 0
5.2 signal流程
- 假设Thread - 1要来唤醒Thread - 0
- 进入 ConditonObject的doSignal流程,取得等待队列中第一个Node,即Thread - 0 所在Node
- 执行transferForSignal流程,将该Node加入AQS队列尾部,将Thread- 0的waitStatus改为0,Thread - 3的waitStatus改为 -1
- Thread - 1释放锁,进入unlock流程,略
源码
三、读写锁
1、ReentrantReadWriteLock
- 当读操作远远高于写操作时,这时候使用
读写锁
让读-读
可以并发,提高性能。类似于数据库中的select ... from ... lock in share mode
- 提供一个
数据容器类
内部分别使用读锁保护数据的read()方法
,写锁保护数据的write()
方法
class DataContainer {
private Object data;
private ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); private ReentrantReadWriteLock.ReadLock r = rw.readLock(); private 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();
}
}
}
测试
读锁-读锁
可以并发
DataContainer dataContainer = new DataContainer();
new Thread(() -> {
dataContainer.read();
}, "t1").start();
new Thread(() -> {
dataContainer.read();
}, "t2").start();
输出结果,从这里可以看到Thread - 0锁定期间,Thread - 1的读操作不受影响:
测试
读锁-写锁
相互阻塞
DataContainer dataContainer = new DataContainer();
new Thread(() -> {
dataContainer.read();
}, "t1").start();
Thread.sleep(100); new Thread(() -> {
dataContainer.write();
}, "t2").start();
输出结果:
写锁-写锁
也是相互阻塞的,这里就不测试了。
注意事项:
- 读锁不支持条件变量
- 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待
- 重入时降级支持:即持有写锁的情况下去获取读锁
2、应用之缓存(重要)
2.1 缓存更新策略
更新时,是先清缓存还是先更新数据库
1、先清缓存
2、先更新数据库
3、补充一种情况,假设查询线程A查询数据时恰好缓存数据由于时间到期失效,或是第一次查询
这种情况的出现几率非常小,见facebook论文。
2.2 读写锁实现一致性缓存
使用读写锁实现一个简单的按需加载缓存
class GenericCachedDao<T> {
// HashMap 作为缓存非线程安全, 需要保护
HashMap<SqlPair, T> map = new HashMap<>();
ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); GenericDao genericDao = new GenericDao();
public int update(String sql, Object... params) {
SqlPair key = new SqlPair(sql, params);
// 加写锁, 防止其它线程对缓存读取和更改
lock.writeLock().lock();
try {
int rows = genericDao.update(sql, params);
map.clear();
return rows;
}
finally {
lock.writeLock().unlock();
}
}
public T queryOne(Class<T> beanClass, String sql, Object... params) {
SqlPair key = new SqlPair(sql, params);
// 加读锁, 防止其它线程对缓存更改
lock.readLock().lock();
try {
T value = map.get(key);
if (value != null) {
return value;
}
} finally {
lock.readLock().unlock();
}
// 加写锁, 防止其它线程对缓存读取和更改 lock.writeLock().lock();
try {
// get 方法上面部分是可能多个线程进来的, 可能已经向缓存填充了数据
// 为防止重复查询数据库, 再次验证
T value = map.get(key);
if (value == null) {
// 如果没有, 查询数据库
value = genericDao.queryOne(beanClass, sql, params);
map.put(key, value);
}
return value;
}
finally {
lock.writeLock().unlock();
}
}
// 作为 key 保证其是不可变的
class SqlPair {
private String sql;
private Object[] params;
public SqlPair(String sql, Object[] params) {
this.sql = sql;
this.params = params;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SqlPair sqlPair = (SqlPair) o;
return sql.equals(sqlPair.sql) && Arrays.equals(params, sqlPair.params);
}
@Override
public int hashCode() {
int result = Objects.hash(sql);
result = 31 * result + Arrays.hashCode(params);
return result;
}
}
}
注意:
以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑
– 适合读多写少,如果写操作比较频繁,以上实现性能低
– 没有考虑缓存容量
– 没有考虑缓存过期
– 只适合单机
– 并发性还是低,目前只会用一把锁
– 更新方法太过简单粗暴,清空了所有key(考虑按类型分区或重新设计key)乐观锁实现:用CAS去更新
3、读写锁原理(重要)
3.1 图解流程
读写锁用的是一个Sycn同步器,因此等待队列,state等也是同一个
t1 w.lock , t2 r.lock
1)t1成功上锁,流程与ReentrantLock加锁相比没有特殊之处,不同是写锁状态占了state的低16位,而读锁使用的是state的高16位
2)t2执行r.lock,这时进入读锁的sync.acquireShared(1)流程,首先会进入tryAcquireShared流程。如果有写锁占据,那么tryAcquireShared返回 -1表示失败
- tryAcquireShared返回值表示
– -1表示失败
– 0表示成功,但后继节点不会继续唤醒
– 正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回1
3) 这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为 Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态
4)t2 会看看自己的节点是不是老二,如果是,还会再次调用tryAcquireShared(1) 来尝试获取锁
5)如果没有成功,在 doAcquireShared 内 for (;😉 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;😉 循环一 次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park
t3 r.lock,t4 w.lock
这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子
t1 w.unlock
- 这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子
- 接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行
- 这回再来一次 for (;😉 执行 tryAcquireShared 成功则让读锁计数加一
- 这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点
- 事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用 doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行
- 这回再来一次 for (;😉 执行 tryAcquireShared 成功则让读锁计数加一
- 这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点
- 下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点
t2 r.unlock,t3 r.unlock
- t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零
- t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入 doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即
- 之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;😉 这次自己是老二,并且没有其他 竞争,tryAcquire(1) 成功,修改头结点,流程结束
3.2 源码分析
1、写锁上锁流程
2、写锁释放流程
3、读锁上锁流程
4、读锁释放流程
4、StampedLock
该类自JDK8加入,是为了进一步优化读性能,它的特点是在使用读锁、写锁时都必须配合【戳】使用
1、加锁读锁
long stamp = lock.readLock();
lock.unlockRead(stamp);
2、加解写锁
long stamp = lock.writeLock();
lock.unlockWrite(stamp);
3、乐观锁,StampedLock支持
tryOptimisticRead()
方法(乐观读),读取完毕后需要做一次戳校验
如果娇艳通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。
long stamp = lock.tryOptimisticRead();
// 验戳
if(!lock.validate(stamp)){
// 锁升级
}
4、提供一个数据容器类 内部分别使用读锁保护数据的
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();
}
输出结果
注意:
- StampedLock不支持条件变量
- StampedLock不支持可重入
四、Semaphore
1、基本使用
[ˈsɛməˌfɔr] 信号量,用来限制能同时访问共享资源的线程上限。
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
// 3. 获取许可
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
log.debug("running...");
sleep(1);
log.debug("end...");
} finally {
// 4. 释放许可
semaphore.release();
}
}).start();
}
}
输出:
2、Semaphore应用(重要)
semaphore实现
- 使用Semaphore限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比Tomcat LimitLatch的实现)
- 用 Semaphore 实现简单连接池,对比『享元模式』下的实现(用wait notify),性能和可读性显然更好, 注意下面的实现中线程数和数据库连接数是相等的
@Slf4j(topic = "c.Pool") class Pool {
// 1. 连接池大小
private final int poolSize;
// 2. 连接对象数组
private Connection[] connections;
// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;
private Semaphore semaphore;
// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
// 让许可数与资源数一致
this.semaphore = new Semaphore(poolSize);
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}
// 5. 借连接
public Connection borrow() {
// t1, t2, t3
// 获取许可
try {
semaphore.acquire();
// 没有许可的线程,在此等待
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 不会执行到这里
return null;
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
log.debug("free {}", conn);
semaphore.release();
break;
}
}
}
}
3、Semaphore原理(重要)
1、加锁解锁流程
- Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后 停车场显示空余车位减一
- 刚开始,permits(state)为 3,这时 5 个线程来获取资源
- 假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列 park 阻塞
- 这时 Thread-4 释放了 permits,状态如下
- 接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接 下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态
2、源码分析
五、CountdownLatch
- 用来进行线程同步协作,等待所有线程完成倒计时。
- 其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一
public static void main(String[] args) 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...");
}
输出:
可以配合线程池使用,改进如下
public static void main(String[] args) throws InterruptedException {
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();
}
});
}
输出:
1、应用之同步等待多线程准备完毕
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++) {
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();
中间输出:
最后输出:
2、应用之同步等待多个线程调用结束
@RestController
public class TestCountDownlatchController {
@GetMapping("/order/{id}")
public Map<String, Object> order(@PathVariable int id) {
HashMap<String, Object> map = new HashMap<>();
map.put("id", id);
map.put("total", "2300.00");
sleep(2000);
return map;
}
@GetMapping("/product/{id}")
public Map<String, Object> product(@PathVariable int id){
HashMap<String, Object> map = new HashMap<>();
if (id == 1) {
map.put("name", "小爱音箱");
map.put("price", 300);
} else if (id == 2) {
map.put("name", "小米手机");
map.put("price", 2000);
}
map.put("id", id); sleep(1000);
return map;
}
@GetMapping("/logistics/{id}")
public Map<String, Object> logistics(@PathVariable int id) {
HashMap<String, Object> map = new HashMap<>();
map.put("id", id);
map.put("name", "中通快递");
sleep(2500);
return map;
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
rest 远程调用
RestTemplate restTemplate = new RestTemplate();
log.debug("begin");
ExecutorService service = Executors.newCachedThreadPool();
CountDownLatch latch = new CountDownLatch(4);
Future<Map<String,Object>> f1 = service.submit(() -> {
Map<String, Object> r = restTemplate.getForObject("http://localhost:8080/order/{1}", Map.class, 1);
return r;
});
Future<Map<String, Object>> f2 = service.submit(() -> {
Map<String, Object> r = restTemplate.getForObject("http://localhost:8080/product/{1}", Map.class, 1);
return r;
});
Future<Map<String, Object>> f3 = service.submit(() -> {
Map<String, Object> r = restTemplate.getForObject("http://localhost:8080/product/{1}", Map.class, 2);
return r;
});
Future<Map<String, Object>> f4 = service.submit(() -> {
Map<String, Object> r = restTemplate.getForObject("http://localhost:8080/logistics/{1}", Map.class, 1);
return r;
});
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());
System.out.println(f4.get());
log.debug("执行完毕");
service.shutdown();
执行结果:
六、CyclicBarrier
[ˈsaɪklɪk ˈbæriɚ] 循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置『计数个数』,每个线程执 行到某个需要“同步”的时刻调用 await() 方法进行等待,当等待的线程数满足『计数个数』时,继续执行
CyclicBarrier cb = new CyclicBarrier(2); // 个数为2时才会继续执行
new Thread(()->{
System.out.println("线程1开始.."+new Date());
try {
cb.await(); // 当个数不足时,等待
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println("线程1继续向下运行..."+new Date());
}).start();
new Thread(()->{
System.out.println("线程2开始.."+new Date());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
try {
cb.await(); // 2 秒后,线程个数够2,继续运行
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println("线程2继续向下运行..."+new Date());
}).start();
注意: CyclicBarrier 与 CountDownLatch 的主要区别在于 CyclicBarrier 是可以重用的 CyclicBarrier 可以被比 喻为『人满发车』
七、线程安全集合类概述
线程安全集合类可以分为三大类:
- 遗留的线程安全集合如
Hashtable
,Vector
- 使用Collections装饰的线程安全集合,如:
–Collections.synchronizedCollection
–Collections.synchronizedList
–Collections.synchronizedMap
–Collections.synchronizedSet
–Collections.synchronizedNavigableMap
–Collections.synchronizedNavigableSet
–Collections.synchronizedSortedMap
–Collections.synchronizedSortedSet
java.util.concurrent.*
重点介绍
java.util.concurrent.*
下线程安全集合类,可以发现它们有规律,里面包含三类关键词:Blocking、CopyOnWrite、Concurrent
- Blocking大部门实现基于锁,并提供用来阻塞的方法
- CopyOnWrite之类容器修改开销相对较重
- Concurrent 类型的容器
– 内部很多操作使用cas优化,一般可以提供较高吞吐量
– 弱一致性
- 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
- 求大小弱一致性,size操作未必是100%准确
- 读取弱一致性
遍历时如果发生了修改,对于非安全容器来讲,使用 fail - fast 机制也就是让遍历立刻失败,抛出ConcurrentModificationException,不再继续遍历
八、ConcurrentHashMap
1、练习:单词计数
生成测试数据
static final String ALPHA = "abcedfghijklmnopqrstuvwxyz";
public static void main(String[] args) {
int length = ALPHA.length();
int count = 200;
List<String> list = new ArrayList<>(length * count);
for (int i = 0; i < length; i++) {
char ch = ALPHA.charAt(i);
for (int j = 0; j < count; j++) {
list.add(String.valueOf(ch));
}
}
Collections.shuffle(list);
for (int i = 0; i < 26; i++) {
try (PrintWriter out = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream("tmp/" + (i+1) + ".txt")))) {
String collect = list.subList(i * count, (i + 1) * count).stream()
.collect(Collectors.joining("\n"));
out.print(collect);
} catch (IOException e) {
}
}
}
模版代码,模版代码中封装了多线程读取文件的代码
private static <V> void demo(Supplier<Map<String,V>> supplier,
BiConsumer<Map<String,V>,List<String>> consumer) { //BiConsumer是操作接口
Map<String, V> counterMap = supplier.get();
List<Thread> ts = new ArrayList<>();
for (int i = 1; i <= 26; i++) {
int idx = i;
Thread thread = new Thread(() -> {
List<String> words = readFromFile(idx);
consumer.accept(counterMap, words);
});
ts.add(thread);
}
ts.forEach(t->t.start()); ts.forEach(t-> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counterMap);
}
public static List<String> readFromFile(int i) {
ArrayList<String> words = new ArrayList<>();
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("tmp/" + i +".txt")))) {
while(true) {
String word = in.readLine();
if(word == null) {
break;
}
words.add(word);
}
return words;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
你要做的是实现两个参数
- 一是提供一个map集合,用来存放每个单词的计数结果,key为单词,value为计数
- 二是提供一组操作,保证计数的安全性,会传递map集合以及单词List
正确结果输出应该是每个单词出现200次
下面的实现为:
demo(
// 创建 map 集合
// 创建 ConcurrentHashMap 对不对?
() -> new HashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter + 1;
map.put(word, newValue);
}
}
);
有没有问题?请改进
参考解答1
demo(
() -> new ConcurrentHashMap<String, LongAdder>(), (map, words) -> {
for (String word : words) {
// 注意不能使用 putIfAbsent,此方法返回的是上一次的 value,首次调用返回 null
map.computeIfAbsent(word, (key) -> new LongAdder()).increment();
}
}
);
参考解答2
demo(
() -> new ConcurrentHashMap<String, Integer>(),
(map, words) -> {
for (String word : words) {
// 函数式编程,无需原子变量
map.merge(word, 1, Integer::sum);
}
}
);
2、ConcurrentHashMap原理(重要)
2.1 JDK7 HashMap并发死链
1、测试代码
注意:
- 要在JDK 7 下运行,否则扩容机制和hash的计算方法都变了
- 以下测试代码是精心准备的,不要随便改动
public static void main(String[] args) {
// 测试 java 7 中哪些数字的 hash 结果相等
System.out.println("长度为16时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 16 == 1) {
System.out.println(i);
}
}
System.out.println("长度为32时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 32 == 1) {
System.out.println(i);
}
}
// 1, 35, 16, 50 当大小为16时,它们在一个桶内
final HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
// 放 12 个元素
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
map.put(6, null);
map.put(7, null);
map.put(8, null);
map.put(9, null);
map.put(10, null);
map.put(16, null);
map.put(35, null);
map.put(1, null);
System.out.println("扩容前大小[main]:"+map.size());
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null); System.out.println("扩容后大小[Thread-0]:"+map.size());
}
}.start();
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null); System.out.println("扩容后大小[Thread-1]:"+map.size());
}
}.start();
}
final static int hash(Object k) {
int h = 0;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
2、死链复现
- 测试工具使用idea
- 在HashMap源码590行加断点
int newCapacity = newTable.length;
断电的条件如下,目的是让HashMap在扩容为32时,并且线程为Thread - 0或Thread - 1时停下来
newTable.length==32 &&
(
Thread.currentThread().getName().equals("Thread-0")||
Thread.currentThread().getName().equals("Thread-1")
)
- 断点暂停方式选择Thread,否则在调试Thread - 0时,Thread - 1无法恢复运行
- 运行代码,程序在预料的断电位置停了下来,输出
- 接下来进入扩容流程调试
- 在HashMap源码594行加断电
Entry<K,V> next = e.next; // 593
if (rehash) // 594
// ...
- 这是为了观察e节点和next 节点的状态,Thread - 0单步执行到594行,再594处再添加一个断电(条件Thread.currentThread().getName().equals(“Thread-0”))
- 这时可以在Variables 面板观察到 e 和next 变量,使用
view as -> Object
查看节点状态
- 在Threads面板选中 Thread - 1恢复运行,可以看到控制台输出新的内容如下,Thread - 1扩容已完成:
- 这时Thread - 0还停在594处,Variables面板变量的状态已经变化为:
- 为什么呢,因为Thread - 1扩容时链表也是后加入的元素放入链表头,因此链表头就倒过来了,但Thread - 1虽然结果正确,但它结束后Thread - 0 还要继续运行
- 接下来就可以单步调试(F8)观察死链的产生了
- 下一轮循环到594,将e搬迁到newTable链表头
- 下一轮循环到594,将e搬迁到newTable链表头
- 再看看源码
3、源码分析
1、HashMap的并发死链发生在扩容时
2、假设map中初始元素是:
4、小结
- 究其原因,是因为在多线程环境下使用了非线程安全的map集合
- JDK8虽然讲扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)
2.2 JDK8 ConcurrentHashMap
1、重要属性和内部类
// 默认为 0
// 当初始化时, 为 -1
// 当扩容时, 为 -(1 + 扩容线程数)
// 当初始化或扩容完成后,为 下一次的扩容的阈值大小
private transient volatile int sizeCtl;
// 整个 ConcurrentHashMap 就是一个 Node[]
static class Node<K,V> implements Map.Entry<K,V> {}
// hash 表
transient volatile Node<K,V>[] table;
// 扩容时的 新 hash 表
private transient volatile Node<K,V>[] nextTable;
// 扩容时如果某个 bin 迁移完毕, 用 ForwardingNode 作为旧 table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}
// 用在 compute 以及 computeIfAbsent 时, 用来占位, 计算完成后替换为普通 Node
static final class ReservationNode<K,V> extends Node<K,V> {}
// 作为 treebin 的头节点, 存储 root 和 first
static final class TreeBin<K,V> extends Node<K,V> {}
// 作为 treebin 的节点, 存储 parent, left, right
static final class TreeNode<K,V> extends Node<K,V> {}
2、重要方法
// 获取 Node[] 中第 i 个 Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i)
// cas 修改 Node[] 中第 i 个 Node 的值, c 为旧值, v 为新值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v)
// 直接修改 Node[] 中第 i 个 Node 的值, v 为新值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)
3、构造器分析
可以看到实现了懒惰初始化,在构造方法中仅仅计算了table的大小,以后在第一次使用时才会真正创建:
4、get流程
5、put流程
以下数组简称(table),链表简称(bin)
6、size计算流程
size计算实际发生在put,remove改变集合元素的操作之中
- 没有竞争发生,向baseCount累加计数
- 有竞争发生,新建counterCells,向其中的一个cell累加计数
– counterCells初始有两个cell
– 如果计数竞争比较激烈,会创建新的cell来累加计数
Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)
- 初始化,使用 cas 来保证并发安全,懒惰初始化 table
- 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程 会用 synchronized 锁住链表头
- put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素 添加至 bin 的尾部
- get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新 table 进行搜索
- 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可 做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
- size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加 即可
源码分析: http://www.importnew.com/28263.html
其它实现: Cliff Click’s high scale lib
2.3 JDK7 ConcurrentHashMap
它维护了一个 segment 数组,每个 segment对应一把锁
- 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
- 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化
1、构造器分析
构造完成,如下图所示:
- 可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好
- 其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment
- 例如,根据某一 hash 值求 segment 位置,先将高位向低位移动 this.segmentShift 位
- 结果再与 this.segmentMask 做位于运算,最终得到 1010 即下标为 10 的 segment
2、put流程
segment 继承了可重入锁(ReentrantLock),它的 put 方法为
3、rehash流程
发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全
附,调试代码:
4、get流程
get 时并未加锁,用了 UNSAFE 方法保证了可见性,扩容过程中,get 先发生就从旧表取内容,get 后发生就从新表取内容
5、size计算流程
- 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
- 如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
九、LinkedBlockingQueue原理
1、基本的入队出队
- 初始化链表
last = head = new Node<E>(null);
Dummy 节点用来占位,item 为 null
- 当一个节点入队
last = last.next = node;
- 再来一个节点入队
last = last.next = node;
- 出队
h = head
first = h.next
h.next = h
head = first
2、加锁分析
高明之处在于用了两把锁和 dummy 节点
- 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
- 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
– 消费者与消费者线程仍然串行
– 生产者与生产者线程仍然串行线程安全分析
- 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
- 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
- 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
put操作
take操作
由 put 唤醒 put是为了避免信号不好
3、性能比较
主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较
- Linked 支持有界,Array 强制有界
- Linked 实现是链表,Array 实现是数组
- Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
- Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的 Linked 两把锁,Array 一把锁
十、ConcurrentLinkedQueue
ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是
- 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
- dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
- 只是这【锁】使用了 cas 来实现
- 事实上,ConcurrentLinkedQueue 应用还是非常广泛的
- 例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了 ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用
十一、CopyOnWriteArrayList
CopyOnWriteArraySet
是它的马甲 底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份,更 改操作在新数组上执行,这时不影响其它线程的并发读,读写分离。 以新增为例:
这里的源码版本是 Java 11,在 Java 1.8 中使用的是可重入锁而不是 synchronized
其它读操作并未加锁,例如:
适合『读多写少』的应用场景
1、get 弱一致性
不容易测试,但问题确实存在
2、迭代器弱一致性
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iter = list.iterator();
new Thread(() -> {
list.remove(0);
System.out.println(list);
}).start();
sleep1s();
while (iter.hasNext()) {
System.out.println(iter.next());
}
不要觉得弱一致性就不好
- 数据库的 MVCC 都是弱一致性的表现
- 并发高和一致性是矛盾的,需要权衡