线程安全
目录
1、线程安全
1.1、案例引入
1.2、线程安全的概念
2、线程安全问题分析
2.1、线程安全问题的原因
2.2、解决线程安全问题
3、synchronized 关键字
3.1、修饰代码块
3.2、修饰普通方法
3.3、修饰静态方法
1、线程安全
1.1、案例引入
执行结果:
原因分析:
线程是并发执行的,调度是随机的。结果是 0,说明main线程先被执行打印了
我们希望先把 t1 和 t2 执行完,再执行 main 的打印,可以做出优化:
先让两个线程执行完,再执行main线程
这两个线程,谁先 join,谁后 join 都可以
分析:两种情况
1)t1 先结束,t2 后结束
main 先在 t1.join 阻塞等待
t1 结束
main 再在 t2.join 阻塞等待
t2 结束main继续执行后续打印
最终打印的值,就是 t1 和 t2 都执行完的值2)t2 先结束,t1 后结束
main 先在 t1.join 阻塞
t2 结束,t1.join 继续阻塞
t1 结束
main执行到 t2.join,由于 t2 已经结束了,此处的 t2.join 是不会阻塞的
main 继续执行后续打印
最终打印的值,还是 t1 和 t2 都执行完的值^
这两种方式总的阻塞时间都是一样的,都是 t1 和 t2 较长的时间。区别在于是分两个 join 各自阻塞一会还是在一个 join 全都阻塞完
优化后发现结果还是不对,并且每次执行出现的结果都不相同
但如果把两个线程变成串行执行(一个执行完了,再执行另一个),就没问题了
1.2、线程安全的概念
通过上述分析可以看出,当前 bug 是由于多线程的并发执行代码引起的 bug,这样的 bug 就称为 “线程安全问题” 或者叫做 “线程不安全”;反之,如果一个代码在多线程并发执行的环境下,不会出现类似于上述的 bug 的代码就叫 “线程安全”
2、线程安全问题分析
bug 分析:
count++; 看似是一行代码,实际上对应到 3 个 cpu 指令
1)load:把内存中 count 的值,加载到 cpu 的寄存器
2)add:把寄存器中的内容+1
3)save:把寄存器中的内容保存回内存上而操作系统,对于线程的调度,是随机的。执行 1 2 3 三个指令的时候,不一定是 “一口气执行完”,很可能是执行到其中的一部分,该线程就被调度走了
这种抢占式执行就是线程安全问题的罪魁祸首
^
例如:t1 线程 先执行了第一个指令,然后调度到 t2 线程并且把三个指令执行完了,再回过头来执行 t1 线程的后两个指令。
t1 执行第一个指令后,寄存器中的值是 0,执行完 t2 的三个指令后,内存的数字是 1,再执行 t1 线程的后两个指令,0+1 后把 1 写回到内存上。最终内存上的结果是 1。两次 ++,最终只相当于只 + 了一次
^
实际在循环5w次过程中,不知道有多少次是正确的,多少次是错误的,所以每次重新执行代码出现的结果都各不相同,但最终执行的结果,一定是小于等于 10w 的
如果把执行 5w 次改成 50 次,尝试多次运行,发现大部分情况都是正确的结果(100),只有偶尔才会出现错误的结果
public class Demo15 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50; i++) {count++;}System.out.println("t1 结束");});Thread t2 = new Thread(() -> {for (int i = 0; i < 50; i++) {count++;}System.out.println("t2 结束");});t1.start();t2.start();t1.join();t2.join();// 一个线程自增 5w 次, 两个线程, 总共自增 10w 次. 预期结果, count = 100000System.out.println(count);}
}
原因是,循环 50 次很可能在执行 t2.start 之前 t1 就已经执行完了,后续 t2 再执行,就变成纯串行了。但还是有可能出现 t1,t2 交替执行的情况,问题仍然存在,只是概率变低了
2.1、线程安全问题的原因
线程安全问题产生原因总结:
1. 根本原因:操作系统对线程的调度是随机的,抢占式执行
2. 多个线程同时修改同一个变量
例如上述的案例,t1 和 t2 都在修改同一个内存空间(count)
3. 修改操作不是原子的
如果修改操作只是对应到一 个cpu 指令,就可以认为是原子的(cpu不会出现 “一条指令执行一半” 这样的情况)如果对应到多个 cpu 指令就不是原子的
像 ++、--、+=、-= 等操作都不是原子的
赋值操作 “=” 在 Java 中是原子的,但在 C++ 就不一定了,因为C++涉及到 “运算符重载”
4. 内存可见性问题
5. 指令重排序
以下几种情况不会出现线程安全问题:
1. 一个线程只修改一个变量
2. 多个线程,不同时修改同一个变量
3. 多个线程修改不同变量
4. 多个线程读取(取值操作)同一个变量
2.2、解决线程安全问题
1. 抢占式执行的问题无法解决,因为是操作系统的底层设定
2. 多个线程同时修改同一个变量的解决方法 和代码的结构直接相关,只需要调整代码结构,规避一些线程不安全的代码的。但是这样的方案不够通用,有些情况下,需求上就是需要多线程修改同一个变量的,例如超买/超卖的问题
3. 修改操作不是原子的 解决方法:
在 Java 中解决线程安全问题,最主要的方案:加锁(互斥 / 排他)。通过加锁操作,让不是原子的操作,打包成一个原子的操作。
案例中的线程安全问题,就可以使用锁,把不是原子的 count++ 包裹起来。在 count++ 之前先加锁,然后再进行count++,计算完毕之后,再解锁。这样在执行 3 个指令的过程中,其他线程就没法插队了
加锁 / 解锁本身是操作系统提供的 api,很多编程语言都对这个 api 进行封装了
大多数的封装风格,都是采取两个函数:
Java中,是使用 synchronized 关键字,搭配代码块,来实现类似的效果的
3、synchronized 关键字
synchronized:同步的
在计算机中,同步这个术语有多种含义,这里同步指的是 “互斥”
synchronized 会起到互斥效果,某个线程执行到某个对象的 synchronized 中时,其他线程如果也执行到同一个对象的 synchronized 就会阻塞等待
3.1、修饰代码块
明确指定锁哪个对象
1. synchronized 用的锁是存在 Java 对象里的:
尝试给两个线程的 count++ 加锁:
1. synchronized 后的小括号填写的是 用来加锁的对象。要加锁,要解锁,前提是要先有一个锁。锁在 Java 中,任何一个对象,都可以用作 “锁”。这里使用 Object 对象
2. 这个对象的类型是什么,不重要。重要的是,是否有多个线程尝试针对同一个对象加锁(多个线程是否涉及到互斥)
3. 两个线程,针对同一个对象加锁,才会产生互斥效果。一个线程加上锁了,另一个线程就要阻塞等待,等到第一个线程释放锁,才有机会
4. 如果是不同的锁对象,不会有互斥效果,线程安全问题没有得到改变
5. 上一个线程解锁之后,下一个线程并不是立即就能获取到锁,而是要靠操作系统来 “唤醒”
6. 假设有 ABC 三个线程,线程 A 先获取到锁,然后 B 尝试获取锁,然后 C 再尝试获取锁,此时 B 和 C 都在阻塞队列中排队等待。当 A 释放锁之后,虽然 B 比 C 先来,但是 B 不一定就能获取到锁,而是和C重新竞争,并不遵守先来后到的规则
7. 把对象作为锁对象,不影响对象的其他使用。但一般来说,一个对象只有一个用途是比较好的。因此更推荐创建一个专门的对象只用于加锁操作
2. 解决线程安全问题,不是写了加了锁就可以,而是要正确的使用锁
1) synchronized { } 代码块位置要合适
2) synchronized ( ) 指定的锁对象也要合适
例如,把代码块加到循环外面:
这种写法意味着整个 for 循环,i<50000,i++,count++ 都是“互斥”的方式执行的,而 for 循环里的条件判断(i<50000)和 i++ 这两个操作不涉及到互斥
只有当第一个线程中的循环全部执行完,第二个线程才能拿到锁,相当于完全串行执行了。虽然最终结果是正确的,但效率低很多
而代码块加在 count++ 外面,只是每次 count++ 之间是串行的,for 中的 i<5w 和 i++ 是并发的,执行速度更快
3.2、修饰普通方法
针对 this 加锁
把案例的代码改一下,把 count++ 操作封装到一个类中:
class Counter {private int count = 0;public void add() {synchronized(this) {count++;}}public int get() {return count;}
}public class Demo18 {public static void main(String[] args) throws InterruptedException {Object locker = new Object();Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// synchronized (locker) {counter.add();// }}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// synchronized (locker) {counter.add();// }}});t1.start();t2.start();t1.join();t2.join();System.out.println("count=" + counter.get());}
}
此时对 counter.add(); 加锁的操作,可以直接在 add 方法内对 this 对象加锁:
进一步变成,可以写成这种形式:
像 StringBuffer、Vector 等对象,方法上就是带有 synchronized(针对 this 加锁)
3.3、修饰静态方法
针对类对象加锁
static 修饰的方法不存在 this。synchronized 修饰 static方法,相当于针对类对象加锁
等价与: