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给优化掉了
3.3 处理方式
我们需要编译器在遇到这种情况的时候,不要优化
让循环速度降低,加入sleep(xxx),这样load的优化就几乎没用,所以编译器就不会优化~
使用volatile关键字~
用volatile修饰的变量,并不会被系统优化~
保证每次都是有load操作的,不会被省略~
volatile的用法很简单
volatile不保证“原子性”
volatile适用于一个线程写,一个线程读的情况~
防止代码都的时候被优化而已~
保证内存可见性~
synchronized则是适用于多个线程写,保证“原子性”
4. 指令重排序引起的线程不安全
这个也是编译器优化的策略~
即调整指令执行的顺序,让程序更加高效
同样的,这个行为只能保证此改动本线程内是无影响
对于其他线程的介入,不知道~
同样的,volatile关键字一样也能取消此优化~
如果外加一个线程,此线程判断a是否为null,如果不是,调用a的一个方法~
如果,此时先执行第三步,则会导致错误!因为构造方法未被调用,可想而知有多严重
将相当于去买房子,装修再住,住了再装修,最终结果都一样~
在这里,我不给代码演示了,因为不好演示,并且错误率不高,但是也不能说没有~
4.1 处理方法
volatile取消优化
只需要用volatile去修饰a,就可以在创建引用的时候,禁止指令重排序
指令重排序其实也就是因为原子性没被保证
所以可以用synchronized去环绕代码块~