一、常见的锁策略 锁策略,和普通程序猿基本没啥关系,和 "实现锁” 的人才有关系的
这里所提到的锁策略,和 Java 本身没关系,适用于所有和 “锁” 相关的情况
1、乐观锁 vs 悲观锁 处理锁冲突的态度(原因)
悲观锁:预期锁冲突的概率很高乐观的态度:认为,下一波疫情即使来了,但是菜应该是能买到 (根据前两波疫情的经验),就不必专门做特殊的准备
乐观锁:预期锁冲突的概率很低
悲观的态度:认为,下一波疫情来了之后,可能就买不到菜,于是换一个大的冰箱,去超市定期屯一些米面油肉+方便面+矿泉水+常用药…… [要做的事情更多,付出更多的成本和待见]
悲观锁,做的工作更多,付出的成本更多,更低效
乐观锁,做的工作更少,付出的成本更低,更高效
2、读写锁 vs 普通的互斥锁 对于普通的互斥锁,只有两个操作,加锁和解锁
只要两个线程针对同一个对象加锁,就会产生互斥
对于读写锁来说,分成了三个操作:针对读锁和读锁之间,是不存在互斥关系的。读锁和写锁之间,写锁和写锁之间,才需要互斥
- 加读锁 – 如果代码只是进行读操作,就加读锁
- 加写锁 – 如果代码中进行了修改操作,就加写锁
- 解锁
多线程同时读同一个变量,不会有线程安全问题!
而且在很多场景中,都是读操作多,写操作少 (数据库索引)
3、重量级锁 vs 轻量级锁 处理锁冲突的结果
和上面的悲观乐观有一定重叠
重量级锁,就是做了更多的事情,开销更大
轻量级锁,做的事情更少,开销更小
也可以认为,通常情况下,悲观锁一般都是重量级锁,乐观锁一般都是轻量级锁 (不绝对)
在使用的锁中,如果锁是基于内核的一些功能来实现的 (比如调用了操作系统提供的 mutex接口),此时一般认为这是重量级锁 (操作系统的锁会在内核中做很多的事情,比如让线程阻塞等待…)
如果锁是纯用户态实现的,此时一般认为这是轻量级锁 (用户态的代码更可控,也更高效)
4、挂起等待锁 vs 自旋锁
挂起等待锁,往往就是通过内核的一些机制来实现的,往往较重,[重量级锁的一种典型实现]5、公平锁 vs 非公平锁
自旋锁,往往就是通过用户态代码来实现的,往往较轻, [轻量级锁的一种典型实现]
**公平锁:**多个线程在等待一把锁的时候,谁是先来的,谁就能先获取到这个锁,(遵守先来后到)注意:此处约定的是,遵守先来后到,才是公平
**非公平锁:**多个线程在等待—把锁的时候,不遵守先来后到,(每个等待的线程获取到锁的概率都是均等的)
对于操作系统来说,本身线程之间的调度就是随机的 (机会均等的),操作系统提供的 mutex这个锁,就是属于非公平锁
–> 考虑到相同优先级的情况,实际开发中很少会手动修改线程的优先级,(改了之后在宏观上的体会并不明显)
要想实现公平锁,反而要付出更多的代价,(得整个队列,来把这些参与竞争的线程给排一排先来后到
6、可重入锁 vs 不可重入锁 一个线程,针对一把锁,咔咔连续加锁两次,如果会死锁,就是不可重入锁;如果不会死锁,就是可重入锁
7、synchronized 特性
1.既是—个乐观锁,也是一个悲观锁 (根据锁竞争的激烈程度,自适应)二、CAS 1、什么是 CAS CAS:全称 Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
2.不是读写锁只是一个普通互斥锁
3.既是一个轻量级锁,也是一个重量级锁 (根据锁竞争的激烈程度,自适应)
4.轻量级锁的部分基于自旋锁来实现,重量级的部分基于挂起等待锁来实现
5.非公平锁
6.可重入锁
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。CAS 伪代码:
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解
CAS 的工作流程.
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/170de014fb8f4a88a075c0f690d95ad9.jpg)
文章图片
此处所谓的 CAS 指的是,CPU 提供了一个单独的 CAS 指令,通过这一条指令,就完成上述伪代码描述的过程
如果上述过程都是这 “—条指令" 就干完了,
就相当于这是原子的了 (CPU上面执行的指令就是—条—条执行的…指令已经是不可分割的最小单位)
此时线程就安全了!
【JavaEE|常见的锁策略、synchronized中的锁优化机制】CAS最大的意义,就是让我们写这种多线程安全的代码,提供了一个新的思路和方向 (就和锁不一样了)
很多功能,既可以是硬件实现,也可以是软件实现
就像刚才这段比较交换逻辑,这就相当于硬件直接实现出来了,通过这一条指令,封装好,直接让咱们用了
2、CAS 有哪些应用 2.1、基于CAS能够实现"原子类"
Java 标准库中提供了一组原子类,针对所常用多一些 int, long, int array… 进行了封装,可以基于 CAS 的方式进行修改,并且线程安全
java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
public class TestDemo {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
// 创建一个整数 值是 0
// 此方法相当于 num++
Thread t1 = new Thread(() -> {
for (int i = 0;
i < 50000;
i++) {
num.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0;
i < 50000;
i++) {
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 通过 get 方法得到原子类 内部的数值
System.out.println(num.get());
}
}
运行结果:100000
这个代码里面不存在线程安全问题,基于 CAS 实现的 ++ 操作
这里面就可以保证既能够线程安全,又能够比
synchronized
高效,``synchronized会涉及到锁的竞争,两个线程要相互等待
CAS` 不涉及到线程阻塞等待方法:
num.incrementAndGet();
// ++num
num.decrementAndGet();
// --num
num.getAndIncrement();
// num++;
num.getAndDecrement();
// num--;
num.getAndAdd(10);
// += 10
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = https://www.it610.com/article/value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/b57ec89c3e7b41299b9f0a15adaa59d2.jpg)
文章图片
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/cc532c0e5c394fcfa062703337125506.jpg)
文章图片
假设两个线程同时调用 getAndIncrement通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作
- 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
- 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值
- 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
在循环里重新读取 value 的值赋给 oldValue- 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作
- 线程1 和 线程2 返回各自的 oldValue 的值即可
本来 check and set 这样的操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这个操作, 也就变成原子的了
2.2、基于CAS能够实现"自旋锁"
基于 CAS 实现更灵活的锁,获取到更多的控制权
自旋锁伪代码 :
public class SpinLock {
private Thread owner = null;
// 记录下当前锁被哪个线程持有了,为 null 表示当前未加锁public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}public void unlock (){
this.owner = null;
}
}
和刚才的原子类类似,也是通过—个循环来实现的,循环里面调用CAS
CAS 会比较当前的 owner 值是否是 null
如果是 null 就改成当前线程,意思就是当前线程拿到了锁;如果不是 null,就返回false,进入下次循环
下次循环仍然是进行CAS操作
如果当前这个锁**一直被别人持有,**当前尝试加锁的线程就会在这个 while 的地方快速反复的进行循环 => 自旋 (忙等)
自旋锁是一个轻量级锁,也可以视为是一个乐观锁
当前这把锁虽然没能立即拿到,预期很快就能拿到 (假设锁冲突不激烈)
短暂的自旋几次,浪费点CPU,问题都不大,好处就是只要这边锁─释放,就能立即的拿到锁
例如:滑稽老铁追女神,女神说滚,滑稽滚了,过一会儿又问,这样的锲而不舍的过程,就相当于"自旋”的过程,也就是"忙等"的过程
好处:一旦女神分手了,心里处在空虚的情况下,就容易趁虚而入
坏处:浪费大量的时间精力 (不如多看看书学习学习)
如果滑稽老哥比较乐观,已经洞察到了女生的感情即将发生危机,短时间付出这些成本也是值得的
如果要是情况比较悲观的话,显然自旋锁就不合适
3、CAS 的 ABA 问题 3.1、ABA 问题
CAS 中的关键,是先比较,再交换
比较其实是在比较当前值和旧值是不是相同,把这两个值相同视为是中间没有发生过改变
但是这里的结论存在漏洞
当前值和旧值相同可能是中间确实没改变过,也有可能变了,但是又变回来了
假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A这样的漏洞,在大多数情况下,其实没啥影响,但是,极端情况下也会引起 bug
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
- 先读取 num 的值,记录到 oldNum 变量中
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z
线程 t1 的 CAS 是期望 num 不变就修改。但是 num 的值已经被 t2 给改了,只不过又改成 A 了,这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
这就好比,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手
机
举—个典型的例子,ABA问题产生的bug:
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
异常的过程:
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
int oldValue = https://www.it610.com/article/value;
//读取旧值
CAS(&value, oldValue, oldValue - 50)
当按下取款的操作的时候,机器卡了一下,滑稽多按了—下取款~
这就相当于,一次取钱操作,执行了两遍,(两个线程,并发的去执行这个取钱操作),咱们的预期效果应该是只能取成功一次!(希望取走50,账户还剩50)
假设在取款的一瞬间,滑稽的朋友给他转了50此时就会触发ABA问题
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/cb4e0c0b731d4f91a8f39185fc8a7f02.jpg)
文章图片
卡了和转了50,这两次巧合导致了一个存在 BUG 的 ABA 问题 (极端场景的问题)
哪怕这样的 bug 出现概率是 0.01%,咱们也需要处理!!!
一个互联网产品每天接收的请求,处理的用户量可能是非常大的!!
3.2、解决方案
给要修改的值, 引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期
这个版本号只能变大,不能变小,修改变量的时候,比较就不是比较变量本身了,而是比较版本号了
这里不一定非得用“版本号",也可以用“时间戳"
- CAS 操作在读取旧值的同时,也要读取版本号
- 真正修改的时候
- 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1.
- 如果当前版本号高于读到的版本号,就操作失败 (认为数据已经被修改过了
这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的对比理解上面的转账例子:
手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意
这是翻新机, 就买. 如果买家在意, 就可以直接略过
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,
版本号为 1, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读
到的版本号为 1, 版本小于当前版本, 认为操作失败
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/11e00b89ba504d7f8f75de161b1fe349.jpg)
文章图片
此处就要求,每次针对余额进行修改,都让版本+1
每次修改之前,也要先对比版本看看旧值和当前值是否一致
当引入版本号之后,t2再尝试进行这里的比较版本操作,就发现版本的旧值和当前值并不匹配.
因此就放弃进行修改
如果直接拿变量本身进行判定,因为变量的值有加有减, 就容易出现 ABA 的情况,现在是拿版本号来进行判定,要求版本号只能增加, 这个时候就不会有ABA问题了
这种基于版本号的方式来进行多线程数据的控制,也是一种乐观锁的典型实现在 Java 标准库中提供了
- 数据库里
- 版本管理工具 (SVN) 通过版本号来进行多人开发的协同
AtomicStampedReference
类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能.
相关面试题:三、synchronized 中的锁优化机制 1、加锁工作过程 Java 的版本非常多,在这些版本变迁的过程中,很多地方都有了不少的变化,我们只考虑 JDK 1.8
- 讲解下你自己理解的 CAS 机
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.
- ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
前版本号比之前读到的版本号大, 就认为操作失败
JVM 将
synchronized
锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级 。1.1、偏向锁
偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程),如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销,一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/5b84939c00c143f0af51ad8df40c839c.jpg)
文章图片
举个栗子理解偏向锁 :
有一天我看上了一个小哥哥,长的又帅又有钱文里的偏向锁,和懒汉模式也有点像,思路都是一致的,只是在必要的时候,才进行操作,如果不必要,则能省就省
万一后面有一天,我腻歪了,然后想把他甩了,但是他要是对我纠缠不休,这还麻烦
偏向锁 并不是真的加锁,只是做了一个标记
- 我就只是和这个小哥哥搞暧昧。同时,又不明确我们彼此的关系。
- 这样做的目的就是为了有朝一日,我想换男朋友了,就直接甩了就行
- 但是如果再这个过程中,有另外一个妹子,也在对这个小哥哥频频示好
我就需要提高警惕了,对于这种情况,就要立即和小哥哥确认关系 (男女朋友的关系),立即对另外的妹子进行回击:他是我男朋友。你离他远点
带来的好处就是,后续如果没人竞争的时候,就避免了加锁解锁的开销
偏向锁,升级到轻量级锁的过程
如果没有其他的妹子和我竞争,就一直不去确立关系,(节省了确立关系 / 分手的开销)
如果没有其他的线程来竞争这个锁,就不必真的加锁,(节省了加锁解锁的开销)
1.2、轻量级锁
着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态 (自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.1.3、重量级锁
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 “自适应”
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
- 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁
体现了
synchronized
能够"自适应"这样的能力2.2、锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化
此处的粗细指的是“锁的粒度",锁的粒度: 粗和细
加锁代码涉及到的范围,加锁代码的范围越大,认为锁的粒度越粗范围越小,则认为粒度越细
举个栗子理解锁粗化 :![]()
文章图片
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁
滑稽老哥当了领导, 给下属交代工作任务:到底锁粒度是粗好还是细好?各有各的好
方式一:
方式二:
- 打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.
显然, 方式二是更高效的方案.
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
如果锁粒度比较细,多个线程之间的并发性就更高
如果锁粒度比较粗,加锁解锁的开销就更小
编译器就会有一个优化,就会自动判定,如果某个地方的代码锁的粒度太细了就会进行粗化
如果两次加锁之间的间隔较大 (中间隔的代码多),一般不会进行这种优化;如果加锁之间间隔比较小 (中间隔的代码少),就很可能触发这个优化
2.3、锁消除
有些代码,明明不用加锁,结果你给加上锁了
编译器就会判断锁没有什么必要,就直接把锁给去掉了
有的时候加锁操作并不是很明显,稍不留神就做出了这种错误的决定
StringBuffer,Vector…在标准库中进行了加锁操作
在单个线程中用到了上述的类,就是单线程进行了加锁解锁
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁,但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销
四、Callable 接口 Java 中的 JUC:``java.util.concurrent`
并发 (多线程相关的操作)
Callable 是一个 interface ,也是一种创建线程的方式,相当于把线程封装了一个 “返回值”,方便程序猿借助多线程的方式计算结果.
Runnable
不太适合于让线程计算出一个结果这样的代码例如,像创建一个线程,让这个线程计算 1+2+ 3 + …+ 1000。如果基于 Runnable 来实现,就会比较麻烦,Callable 就是要解决 Runnable 不方面返回结果这个问题的
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本
- 创建一个类 Result , 包含一个 sum 表示最终结果,lock 表示线程同步使用的锁对象.
- main 方法中先创建 Result 实例,然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
- 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了).
- 当线程 t 计算完毕后,通过 notify 唤醒主线程,主线程再打印结果
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class Test {
public static void main(String[] args) {
//通过callable来描述一个这样的任务~~
Callable callable = new Callable() {
@Override
public Integer call() throws Exception {
int sum = 6;
for (int i = 1;
i <= 1000;
i++){
sum += i;
}
return sum;
}
};
//为了让线程执行 callable中的任务,光使用构造方法还不够,还需要一个辅助的类.
FutureTask task = new FutureTask<>(callable);
//创建线程,来完成这里的大算工作
Thread t = new Thread(task);
// 凭小票去获取自己地麻辣烫
// 如果线程的任务没有完成,get 就会阻塞,一直到任务完成了,结果算出来了
try {
System.out.println(task.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
运行结果:500500
可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/54f3c7550daf489fb102e407e1f4547d.jpg)
文章图片
理解 Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,理解 FutureTask
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是五、JUC(java.util.concurrent) 的常见类 1、ReentrantLock 可重入互斥锁,和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全
FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没
ReentrantLock 也是可重入锁,“Reentrant” 这个单词的原意就是 “可重入”
ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等
trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
unlock():解锁
把加锁和解锁两个操作分开了
ReentrantLock lock = new ReentrantLock();
// -----------------------------------------lock.lock();
try {
// working
} finally {
lock.unlock() // 保证不管是否异常都能执行到 unlock, 这么写比较麻烦
}
这种分开的做法不太好,很容易遗漏 unlock (容易出现死锁),当多个线程竞争同一个锁的时候就会阻塞…
ReentrantLock 和 synchronized 的区别:
- synchronized 是一个关键字,是 JVM 内部实现的(大概率是基于 C++ 实现)。 ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
- synchronized 使用时不需要手动释放锁,出了代码块,锁自然释放。 ReentrantLock 使用时需要手动释放. 使用起来更灵活,但是也容易遗漏 unlock,要谨防忘记释放。
- synchronized 在竞争锁锁失败时,会阻塞等待,死等。ReentrantLock 除了阻塞等待这一手之外,还有一手
trylock
,给了我们更多的回旋余地,等待一段时间就放弃,直接返回。
- synchronized 是非公平锁,ReentrantLock 默认是非公平锁,提供了非公平和公平锁两个版本,可以通过构造方法传入一个
true
开启公平锁模式
- 更强大的唤醒机制,基于 synchronized 衍生出来的等待机制是通过
Object
的wait / notify
实现等待–唤醒,每次唤醒的是一
个随机等待的线程,功能是相对有限的。基于 ReentrantLock 衍生出来的等待机制,是Condition
类 (条件变量) 实现等待–唤醒,功能要更丰富一些,可以更精确控制唤醒某个指定的线程
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
如何选择使用哪个锁?
锁竞争不激烈的时候,使用 synchronized,效率更高,自动释放更方便
锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock 更灵活控制加锁的行为,而不是死等
如果需要使用公平锁,使用 ReentrantLock
日常开发中,绝大部分情况下,synchronized就够用了!
2、原子类 原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean以 AtomicInteger 举例,常见方法有
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
num.incrementAndGet();
// ++num
num.decrementAndGet();
// --num
num.getAndIncrement();
// num++;
num.getAndDecrement();
// num--;
num.getAndAdd(10);
// += 10
3、线程池 虽然创建销毁线程比创建销毁进程更轻量,但是在频繁创建销毁线程的时候还是会比较低效
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了
1)ExecutorService 和 Executors:
代码示例:
- ExecutorService 表示一个线程池实例.
- Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
- ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors 创建线程池的几种方式 :
newFixedThreadPool: 创建固定线程数的线程池
newCachedThreadPool: 创建线程数目动态增长的线程池.
newSingleThreadExecutor: 创建只包含单个线程的线程池.
newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装
2)ThreadPoolExecutor:
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定
ThreadPoolExecutor 的构造方法
理解 ThreadPoolExecutor 构造方法的参数代码示例:
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
- keepAliveTime: 临时工允许的空闲时间.
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务
ExecutorService pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS,
new SynchronousQueue(),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
for(int i=0;
i<3;
i++) {
pool.submit(new Runnable() {
@Override
void run() {
System.out.println("hello");
}
});
}
4、信号量 Semaphore 是一个更广义的锁
锁是信号量里第一种特殊情况,叫做 “二元信号量"
理解信号量P 和 V 没有对应的英文单词,提出信号量的人,叫做"迪杰斯特拉” (数学家) ,数据结构中的图=>迪杰斯特拉算法,能够计算两点之间的最短路径
开车经常会遇到一个情况,停车,停车场入口一般会有个牌子,上面写着 “当前空闲xx个车位”,
每次有个车开出来,车位数+1
这个牌子就是信号量,描述了可用资源 (车位)的个数,每次申请一个可用资源,计数器就 -1 (称为Р操作)
如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源
- 当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 (这个称为信号量的 P 操作)
- 当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作)
可以看成英文: P acquire 申请,V release释放
锁就可以视为 “二元信号量”,可用资源就一个,计数器的取值非 0 即 1
信号量就把锁推广到了一般情况,可用资源更多的时候,如何处理的
实际开发中,并不会经常用到信号量
信号量,用来表示 “可用资源的个数”,本质上就是一个计数器Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用
使用信号量可以实现 “共享锁”,比如某个资源允许 3 个线程同时使用,那么就可以使用 P 操作作为加锁,V 操作作为解锁,前三个线程的 P 操作都能顺利返回,后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作.
代码示例:
- 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
- acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
- 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0;
i < 20;
i++) {
Thread t = new Thread(runnable);
t.start();
}
5、CountDownLatch 同时等待 N 个任务执行结束
假设有一场跑步比赛,当所有的选手都冲过终点,此时认为是比赛结束
这样的场景在开发中,也是存在的
例如,多线程下载
迅雷…下载一个比较大的资源 (电影),通过多线程下载就可以提高下载速度
把一个文件拆成多个部分,每个线程负责下载其中的一个部分,得是所有的线程都完成自己的下载,才算整个下载完
countDown
给每个线程里面去调用,就表示到达终点了await
是给等待线程去调用,当所有的任务都到达终点了,await 就从阻塞中返回,就表示任务完成好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩
- 构造 CountDownLatch 实例,初始化 10 表示有 10 个任务需要完成
- 每个任务执行完毕,都调用 latch.countDown() ,在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕,相当于计数器为 0 了
import java.util.concurrent.CountDownLatch;
public class Test2 {
public static void main(String[] args) throws InterruptedException {
// 构造方法的参数表示有几个选手
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0;
i < 10;
i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName() + " 到达终点");
latch.countDown();
// 调用 countDown 的次数和个数一致,此时就会await返回的情况
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}// 裁判要等所有线程到达
// 当这些线程没有执行完的时候,wait 就会阻塞,所有线程执行完了,await 才返回
latch.await();
System.out.println("比赛结束");
}
}
运行结果:
(等待几秒后)
Thread-7 到达终点
Thread-8 到达终点
Thread-6 到达终点
Thread-9 到达终点
Thread-1 到达终点
Thread-0 到达终点
Thread-3 到达终点
Thread-2 到达终点
Thread-4 到达终点
Thread-5 到达终点
比赛结束
Process finished with exit code 0
六、线程安全的集合类 1)、自己使用同步机制 (``synchronized
或者
ReentrantLock`)2)、
Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List全部加锁,不如第一种自己加锁灵活
synchronizedList 的关键操作上都带有 synchronized
3)、使用
CopyOnWriteArrayLis
写时拷贝,在修改的时候,会创建一份副本出来
CopyOnWrite容器即写时复制的容器。举例:
优点:
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
- 添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。
缺点:
- 在读多写少的场景下, 性能很高, 不需要加锁竞争.
- 占用内存较多.
- 新写的数据不能被第一时间读取到
有一个 ArrayList
如果咱们是多线程去读这个 ArrayList,此时没有线程安全问题,完全不需要加锁,也不需要其他方面的控制。如果有多线程去写,就是把这个ArrayList 给**复制了一份,先修改副本*
{1,2,3,4} 把 1 改成 100这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本
- {100,2,3,4} [副本]
- 当修改完毕,再让副本转正
不会说出现读到一个 “修改了一半” 的中间状态
也叫做 “双缓冲区" 策略
操作系统,创建进程的时候,也是通过写时拷贝。显卡在渲染画面的时候,也是通过类似的机制。
也是适合于读多写少的情况,也是适合于数据小的情况
更新配置数据,经常会用到这种类似的操作
2、多线程环境使用队列
1). ArrayBlockingQueue 基于数组实现的阻塞队列
2). LinkedBlockingQueue 基于链表实现的阻塞队列
3). PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
4). TransferQueue最多只包含一个元素的阻塞队列
3、多线程下使用哈希表 [最常考的问题之一]
HashMap 本身不是线程安全的3.1、HashTable
在多线程环境下使用哈希表可以使用:
- Hashtable [不推荐]
- ConcurrentHashMap [推荐]
HashTable 如何保证线程安全的?就是给关键方法加锁:
public synchronized V put(K key, V value) {public synchronized V get(Object key) {
针对 this 来加锁
当有多个线程来访问这个 HashTable 的时候,无论是啥样的操作,无论是啥样的数据,都会出现锁竞争
这样的设计就会导致锁竞争的概率非常大,效率就比较低!
如果多线程访问同一个
Hashtable
就会直接造成锁冲突size 属性也是通过
synchronized
来控制同步,也是比较慢的一旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会非常低
举例:
有个公司,老板。若干部门,每个部门有领导,也有基层员工
设老板规定:
员工要想请假,都得找他当面来申请,他好签字
由于公司里的人很多,很多人都要请假
这个时候大家都在老板门口排队,非常不方便!
- 每个 HashTable 对象只有一把锁
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/4387b3d76267488694b88632b5797354.jpg)
文章图片
如果元素多了,链表就会长,就影响 hash 表的效率,就需要扩容 (增加数组的长度)3.2、ConcurrentHashMap
扩容就需要创建一个更大的数组,然后把之前旧的元素都给搬运过去 (非常耗时)
- 解决方案:
老板自己也不耐烦,直接放权,让批示请假的权限,下放到部门领导
ConcurrentHashMap
里面的情况就是:
![JavaEE|常见的锁策略、synchronized中的锁优化机制](https://img.it610.com/image/info8/4d713655930d493295330339cf54a9b1.jpg)
文章图片
操作元素的时候,是针对这个 元素所在的链表的头结点 来加锁的
如果你两个线程操作是针 对两个不同的链表上的元素, 没有线程安全问题,其实不必加锁
由于 hash 表中,链表的数目非常多,每个链表的长度是相对短的,因此就可以保证锁冲突的概率就非常小了
4、相关面试题 1)、ConcurrentHashMap的读是否要加锁,为什么?
- ConcurrentHashMap 减少了锁冲突,就让锁加到每个链表的头结点上 (锁桶)
- ConcurrentHashMap 只是针对写操作加锁了,读操作没加锁,而只是使用
- ConcurrentHashMap 中更广泛的使用 CAS,进一步提高效率 (比如维护 size 操作)
- ConcurrentHashMap 针对扩容,进行了巧妙的化整为零
- 对于 HashTable 来说,只要你这次 put 触发了扩容,就一口气搬运完,会导致这次 put 非常卡顿
- 对于 ConcurrentHashMap,每次操作只搬运一点点,通过多次操作完成整个搬运的过程
- 同时维护一个新的 HashMap 和一个旧的,查找的时候,既需要查旧的也要查新的,插入的时候**只插入新的,直到搬运完毕再销毁旧的
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了volatile 关键字2)、介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁.3)、ConcurrentHashMap在jdk1.8做了哪些优化?
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).4)、Hashtable和HashMap、ConcurrentHashMap 之间的区别?
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树
HashMap: 线程不安全. key 允许为 null其他面试题:
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用CAS 机制. 优化了扩容方式. key 不允许为 null
1)、谈谈 volatile关键字的用法?
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰2)、Java多线程是如何实现数据共享的?
的变量, 可以第一时间读取到最新的值
JVM 把内存分成了这几个区域:3)、Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
方法区, 堆区, 栈区, 程序计数器.
其中堆区这个内存区域是多个线程之间共享的.
只要把某个数据放到堆内存中, 就可以让多个线程都能访问到
创建线程池主要有两种方式:4)、Java线程共有几种状态?状态之间怎么切换的?
- 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
- 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.
LinkedBlockingQueue
表示线程池的任务队列. 用户通过submit / execute
向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务.
5)、在多线程下,如果对一个数进行叠加,该怎么做?
- NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
- RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在
CPU 上运行/在即将准备运行 的状态.- BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状
态.- WAITING: 调用 wait 方法会进入该状态.
- TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
- TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.
- 使用 synchronized / ReentrantLock 加锁
- 使用 AtomInteger 原子操作
- Servlet是否是线程安全的?
Servlet 本身是工作在多线程环境下.7)、Thread和Runnable的区别和联系?
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行
操作, 是可能出现线程不安全的情况的
Thread 类描述了一个线程.8)、多次start一个线程会怎么样
Runnable 描述了一个任务.
在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用
Runnable 来描述这个任务
第一次调用 start 可以成功调用.9)、有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常
synchronized 加在非静态方法上, 相当于针对当前对象加锁.10)、进程和线程的区别?
如果这两个方法属于同一个实例:
如果这两个方法属于不同实例:
- 线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到
锁之后才能执行方法内容.
- 两者能并发执行, 互不干扰
- 进程是包含线程的,每个进程至少有一个线程存在,即主线程。
- 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
- 进程是系统分配资源的最小单位,线程是系统调度的最小单位
推荐阅读
- java|java 将fileoutputstream 文件流保存为文件_Java输出流FileOutputStream使用详解
- JavaEE|【Java 多线程】多线程带来的的风险-线程安全、多线程五个经典案例
- 连接MySQL 数据库
- 深讨CS246 Straights技术
- Java毕业设计项目实战篇|Java项目:YY酒店管理系统(java+JSP+Easyui+Echars+ssm+mysql)
- 蓝桥杯|2021年 第12届 蓝桥杯 Java B组 省赛真题详解及小结【第1场省赛 2021.04.18】
- 蓝桥杯|2021年 第12届 蓝桥杯 Java B组 省赛真题详解及小结【第2场省赛 2021.05.09】
- Java IO 流
- ELEC 279 面向对象详细教程