线程安全是如何产生的?
想象一下你的代码是个夜店,单线程时一切都井然有序,但多线程就像突然来了群嗨过头的客人…
线程安全问题就像一群人在抢最后一杯奶茶:
//cnt初始=0
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
cnt+=1;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
cnt += 1;
}
});
当这两个线程同时运行的时候,我们最后是想让cnt最后等于100000的,但是这两个线程同时哄抢同一个变量的时候,会有人偷摸不付钱!
线程t1读取cnt(0)
线程t2也读取cnt(0)
线程t1加1后写入(1)
线程t2加1后也写入(1)
结果:两杯奶茶的钱,只喝到一杯 😭
多线程在运行的时候会遇见很多种情况,而cnt+=1在cpu执行的时候是分为三个步骤来执行的:
✨ 读取load(cnt)
✨ 增加add(cnt)
✨ 写入save(cnt)
而如果在执行的过程中出现如图类似的情况,那么你的两次+1操作会被吞掉一个。
CPU的线程调度是随机的,程序员无法控制它,所以在执行的时候会有无限的情况,因此cnt的值永远也不会为100000。
这是一个典型的线程安全问题。
线程安全产生的原因
根本原因
▌ 操作系统对于线程的调度是随机的(抢占式执行)
直接原因
├─ 🟢 共享数据:多个线程对同一个变量进行修改
├─ 🟠 非原子操作:修改操作不是原子的
└─ 🔴 执行顺序不可控:线程交替执行的顺序不确定
临时解决方案(局限性)
让t1完全执行完再执行t2(串行化)
t1.start();
t1.join();
t2.start();
✖ 缺点:失去了多线程的并发优势
而这时我们就需要掏出我们的大法宝,锁~
锁
锁的本质概念
锁:和生活中的锁一样的概念,互斥、排他,一旦把锁加上了,其他人想要加锁,就得阻塞等待。
锁如何保障线程安全
线程安全问题的根源
我们之前分析过线程安全产生的原因,其中关键一点是:
- 操作不是原子性的:指在CPU层面的操作不是单个命令,这会给其他线程可乘之机
锁的解决方案
Java的锁机制可以将多个操作封装在一起,使这一系列命令成为一个不可分割的整体,从而具备原子性。
我们依旧使用上述的案例来举例子,如果我们对其加上锁,它的执行过程会变成这样:
锁的使用
synchronized关键字
在java中,我们使用synchronized对要执行的任务进行加锁,synchronized在汉语中是同步的意思,但是在里面是互斥的含义。
synchronized(锁对象) {
// 临界区代码
// 任意Java对象都可作为锁
// 重要的是多个线程要竞争同一把锁
}
示例
之前的代码我们可以这样修改~
Object locker = new Object();
int cnt = 0;
Thread t1 = new Thread(()->{
synchronized (locker) { // 获取锁
for (int i = 0; i < 50000; i++) {
cnt += 1;
}
} // 释放锁
});
Thread t2 = new Thread(()->{
synchronized (locker) { // 获取同一把锁
for (int i = 0; i < 50000; i++) {
cnt += 1;
}
} // 释放锁
});
锁对象的选择要点
这里的要传入的对象可以理解为门牌号,当一个线程进入这个门的时候会把这个门锁上,执行完离开的时候才会解锁。如果这个时候其他线程也想进入这个门就得等里面的进程执行完。
但如果我们两个进入的压根不是同一个门,那当然也就不会产生这种烦恼了~
这里的锁对象没有固定的类型,而且将它作为锁也不会影响这个对象的正常使用,但是为了不影响业务逻辑我们一般还是会单独开一个变量来当做锁。
synchronized修饰方法
此外我们可以使用这个关键字来修饰方法,来简化我们的使用。
public synchronized void add() {
cnt++;
}
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
this.add();
}
});
这个方法本质上是使用了this当做关键字。
讲了这么多,锁也并不是越多越好的!一旦你的代码中使用了锁,意味着你的代码就可能会产生锁竞争导致阻塞,这会导致你程序的执行效率大大折扣。
所以不需要锁的时候不要乱加!
死锁
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,这些线程都无法继续执行下去。
死锁主要是存在三种类型
单线程,单锁
示例
Thread t1 = new Thread(()->{
synchronized (locker) {
synchronized (locker) {
//运行内容
}
}
});
分析一下这段代码
-
🔒 第二层同步块尝试获取已被当前线程持有的locker锁
-
⏳ 按照基础锁理论,这会导致线程阻塞等待自己释放锁
-
🚫 形成看似"死锁"的状态(线程等待自己)
可重入机制
但是在java中我们并不需要考虑这种情况,因为设计java的大佬考虑到了这种情况,为了解决上述问题,java的synchronized引入了可重入的概念,所以实际上并不会卡住。
当某个线程针对一把锁加锁成功后,后续再对这个锁进行加锁并不会触发阻塞,而是直接往下走。
它的实现原理是让锁对象内部保存当前线程持有那把锁,当后续线程针对这个锁加锁的时候进行对比,如果是同一个就计数器+1,否则正常运行。
加锁流程:
线程首次加锁成功 → 记录持有线程 + 计数器=1
↓
同一线程再次加锁 → 计数器+1(不阻塞)
解锁流程:
解锁流程
每次解锁 → 计数器-1
↓
计数器归零 → 真正释放锁
可重入的概念是针对同一个线程的同一把锁连续加锁多次的情况。最外层真正加锁,最外层真正解锁。
两个线程两把锁
当这两个线程去互相去拿对方的锁的时候可能会出现死锁的状况。
Thread t1 = new Thread(() -> {
synchronized (locker1) {
synchronized (locker2) {
System.out.println("666");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
synchronized (locker1) {
System.out.println("777");
}
}
});
他执行的时候有两种可能性
情况1
情况2
而这种情况是需要自己来考量的,java并没有相应机制来应对
n个线程,m把锁
这是最复杂的一种状况,发生概率低,但是在服务器庞大的基数下依旧风险很大
和上一中情况类似,也是当发生互相纠缠的时候可能产生死锁。
如何避免代码出现死锁?
死锁构成条件:
-
锁是互斥的,一个线程拿到锁后,另一个线程再尝试获取锁,必须阻塞等待;(锁的基本性质)
-
锁是不可抢占的,线程1拿到后其他线程要想获取必须等待,不可以直接抢过来;(锁的基本特性)
-
请求和保持,一个线程在拿到锁1后,不释放锁1的前提下,获取锁2;
-
循环等待,多个线程之间的等待关系构成了循环;
1和2是基本特征,所以破坏3和4是关键。
方法1:加锁过程不再去嵌套,通用性不强,有些情况难以避免;
方法2:约定加锁顺序,破除循环等待;
感谢各位的观看Thanks♪(・ω・)ノ,如果觉得满意的话留个关注再走吧。