文章目录
JavaEE & 线程安全问题
-
重点重点重点~
-
线程安全问题,就是某个代码在多线程环境下执行,会出bug,即线程不安全,即不符合预期~
-
本质的本质,是因为线程之间的调度顺序是不确定的~
1. 线程安全的一个经典例子
- 现在我们想要完成一个操作,就是将一个计数器count进行【++】
- 而这个操作,我们不仅仅在一个线程中去执行
- 而是在多个线程里去执行
- 这样子做会发生什么效果呢?
1.1 初步代码设计
首先我们可以将计数器做成对象,或者用“全局性质”的静态变量~
- 两个自己创建的线程和静态变量count
public class Test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
thread1.start(); //启动
thread2.start();
thread1.join(); //等线程结束~
thread2.join();
System.out.println(count);
}
}
- 效果是这样的~
- 多次运行的结果好像不尽人意呀,如果5000-> 50000 ,现象会更明显~
- 对,没错,答案大概率不是100000,总共加了100000次了,并且还是静态变量,为什么不对呢~
- 一个自己创造的线程和main线程 + 计数器对象
class Counter {
private int count = 0;
public void add() {
count++;
}
public int get() {
return count;
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
for (int i = 0; i < 5000; i++) {
counter.add();
}
thread1.start();
thread1.join();
System.out.println(counter.get());
}
}
- 奇怪的是,为什么这两个线程反而结果是对的~
-
thread1.start(); System.out.println(counter.get()); thread1.join(); System.out.println(counter.get());
-
-
没错,因为只加5000次,main线程一下子就跑完了
-
你只要加多点,肯定会出错~
-
不过你较真的话,有百分之0.00000001的可能成功~
-
其实什么方式,只要次数低点,都很有可能结果正确
1.2 原因
-
count++语句的非原子性
- “原子”在那个时期,是无法分割的意思
- 所以“原子性”代表【一条语句/一段语句】是不可分割的整体
-
对于一条语句
- 它可能分为多条汇编代码
-
对于一段语句
- 它本身就是可分割的
- 而在MySQL里的事务,是具有”原子性“的
1.2.1 count++ 的“非原子性”
- count++在汇编中分为三个原子步骤
- load,加载,即寄存器记录count值
- add,增加值,即寄存器中的值进行增加操作
- save,赋值,即将寄存器中值赋给内存中的count
1.2.2 线程的调度是无序的
- count++分为3个原子步骤
- 而CPU的核心在执行的时候是按照原子步骤走的~
- 那么线程调度的时候,CPU的核心并不会一次性将count++这条语句执行完,而是并发或者并行的分次执行完~
- 并发
- 并行
- 无论是哪一种,其根本原因,都是,在寄存器未把值赋给count的时候,此时的count就被其他寄存器记录了,导致,两次count++,实际只加了一次
- 要知道,别的线程再load的时候,是读内存的count值,如果此时count并没被赋值(save),就会浪费一次count++
- 当然,还是可能会出现极端情况的,并不代表最近结果是在【50000,100000】
- 而是【1,100000】(1就很极端极端极端了~)
- 因为可能多条count++,进行影响
2. synchronized锁
- 在上面的经典案例中我们可以得知,线程的不安全的原因
- 抢占式执行(罪魁祸首)
- 多个线程修改同一变量
- 如果多个线程修改不同变量,是安全的
- 如果多个线程读取同一/不同变量,是安全的
- 一个线程修改同一变量,是安全的
- 而多个线程修改同一变量,是不安全的
那么怎么解决呢?
- Java中有一个关键字synchronized,
- 这个关键字,可以让一个对象,在被一个线程修改(任何方面的变化)的时候,多个线程谁先“开始”修改,即抢到了“锁”,那么其他线程,就会进入阻塞等待的状态,“BOLOCKED”
- 即该修改语句以及其后的语句,均被设置为“原子”整体~
- 而该线程执行完后,释放锁,并且跟其他线程参与**“抢锁环节”**
2.1 代码演示 + 解析
class Counter {
private int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
public int get() {
return count;
}
}
public class Test1 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
for (int i = 0; i < 50000; i++) {
counter.add();
}
thread1.start();
thread1.join();
System.out.println(counter.get());
}
}
- 解决问题~
- 解释
- 这只是其中一种写法,其实还有以下几种写法~
- 传对象
- 修改此对象时候,需要抢到锁才行
- 用synchronized修饰方法
- 这个方法与锁(this)是一样的
- 整个方法为锁范围
- 传类对象(.class)
- 这种方式适用于静态方法和普通方法
- 想修改此类的实例,就要抢到锁才行
- 用synchronized修饰静态方法
- 在整个方法为锁范围
- 跟传类对象是一样的
- 在对静态变量count进行count++操作的时候,count为基本数据类型,无法成为锁对象,就可以这么做~
-
Object类作为锁对象时,则代表,进入这个代码块,从始至终,必然需要抢到锁才行,没抢到锁的线程,阻塞等待~
-
不同于其他方式,这个并没有明确的锁对象
* 是个同步锁- 其他方式,则是此对象仅仅可以被抢到锁的线程修改,执行完花括号的语句,才释放锁。
- 此过程中,此对象是绝对不能在其他线程被修改的,要修改必须阻塞等待,争取抢锁
- 其他方式,则是此对象仅仅可以被抢到锁的线程修改,执行完花括号的语句,才释放锁。
注意:不同锁对象的话,就相当于修改不同变量,并不会引起阻塞,不会锁竞争,那就跟没加锁一样,反而增加了开销
补充说明:
-
类对象的含义:
- 类名
- 类的属性,属性名,类型,权限…
- 类的方法,方法名,参数列表(签名),返回类型,权限…
- 类继承哪些类
- 类实现哪些接口
- …
-
类对象相当于“对象的图纸”
- 这个图纸反映了对象的“轮廓”
- 这样就可以用Java反射的API去做一些事情了~
- 当然,反射是非常规的语法
- 能不用,就不用~
- 忘了也无所谓~
-
类对象还能直接调用静态方法/静态属性
- 因为静态属性/静态方法,不依托于对象存在,类被加载即存在
- 所以,类对象就可以调用这些静态的东西了~
3. 内存可见性引发的线程不安全
- 什么是内存可见性问题呢?
- 先看一个bug
3.1 内存可见性bug例子
public class Test {
public static int flag;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while(flag == 0) {
}
System.out.println("thread线程over");
});
Thread thread1 = new Thread(() -> {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = 1;
});
thread.start();
thread1.start();
}
}
- 我们想的是:flag在2毫秒后,被置为1,即非0,线程thread的循环停止,程序进而结束~
但是实际情况是:
- 2ms是可能让结果正确的,只是我这里没展示
- 而这是因为flag在线程thread循环判断前就被置为1了,那么就没法进入循环,紧接着程序就结束了~
- 1ms太短了,导致结果是正确的
3.2 线程不安全原因
- 内存可见性
- 本质上是编译器对代码的优化
- 为了增加程序运行效率
- 因为读内存比cmp操作慢太多了~
- 所以编译器做出了一个大胆的操作
- 既然每次循环都要用load
- load的结果又都是一样的
- 那么我咋不把这个load省略掉
- 更详细点来说,就是不读主内存
- main memory
- 只读工作内存:CPU寄存器
-
work memory
-
读寄存器/缓存 >> 读内存 >> 读硬件盘
-
cpu缓存:
-
cpu查数据:
- 看看寄存器有没有
- 看看L1有没有
- 看看L2有没有
- 看看L3有没有
- 看看内存有没有…
-
这样子安排,是为了提高速度~
-
- 这一理论来自,Java官方的JMM(Java Memory Model)
- 更详细点来说,就是不读主内存
- 所以此线程的flag的内存可见性,第一次读应用于以后~
- 因为此线程只知道,该线程内,并没有任何对flag有修改的意图
- 单线程,这个判断,很准确~
- 所以,编译器就误认为flag不会被更改-
- 并且把load给优化掉了
- 因为此线程只知道,该线程内,并没有任何对flag有修改的意图
3.3 处理方式
- 我们需要编译器在遇到这种情况的时候,不要优化
-
让循环速度降低,加入sleep(xxx),这样load的优化就几乎没用,所以编译器就不会优化~
-
使用volatile关键字~
- 用volatile修饰的变量,并不会被系统优化~
- 保证每次都是有load操作的,不会被省略~
- volatile的用法很简单
- volatile不保证“原子性”
- volatile适用于一个线程写,一个线程读的情况~
- 防止代码都的时候被优化而已~
- 保证内存可见性~
- synchronized则是适用于多个线程写,保证“原子性”
4. 指令重排序引起的线程不安全
-
这个也是编译器优化的策略~
- 即调整指令执行的顺序,让程序更加高效
- 同样的,这个行为只能保证此改动本线程内是无影响
- 对于其他线程的介入,不知道~
-
同样的,volatile关键字一样也能取消此优化~
-
如果外加一个线程,此线程判断a是否为null,如果不是,调用a的一个方法~
- 如果,此时先执行第三步,则会导致错误!因为构造方法未被调用,可想而知有多严重
- 将相当于去买房子,装修再住,住了再装修,最终结果都一样~
在这里,我不给代码演示了,因为不好演示,并且错误率不高,但是也不能说没有~
4.1 处理方法
- volatile取消优化
- 只需要用volatile去修饰a,就可以在创建引用的时候,禁止指令重排序
- 指令重排序其实也就是因为原子性没被保证
- 所以可以用synchronized去环绕代码块~
文章到此结束!谢谢观看
可以叫我 小马,我可能写的不好或者有错误,但是一起加油鸭🦆!