在多线程中, 线程是非常高效的操作,但是往往存在着一些安全隐患方面的问题.
举一个例子 如果对两个线程进行累加五万次操作, 然后将结果返回到终端 , 此时我们就会生出以下代码:
class Counter{
//这个变量就是两个线程要自增的变量
public int count ;
public void increase(){
count ++;
}
}
public class Main {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
//等待线程执行完毕 再打印count
t1.join();
t2.join();
//在main 中打印两个线程自增得到的count 结果
System.out.println(counter.count);
}
}
但是没有得到预期的打印结果10_0000
第一次执行:
第二次执行 :
第三次执行:
可以看出 并不是偶然而是本身确实达到不了预期的结果, 这就是多线程的安全问题
线程不安全的原因:
线程的特性 :
因为线程是抢占式执行 , 因此线程的调度是随机的 , 因此对一个变量进行操作的时候, 结果是充满未知的
对同一个变量进行读取操作 :
上述操作是多个线程修改同一个变量 ,在CPU内, 当线程修改一个变量的时候, 会分为三个指令
从内存中拿到 变量放到寄存器中 (load)
再将变量再寄存器内进行 ++ 操作 (add)
再将变量放回到内存中(save )
但是因为线程是抢占式执行, 因此存在如下可能:
当两个线程同时将变量进行了load操作
此时寄存器和内存的情况是这样的:
之后两个 线程都在寄存器内进行 ++ 操作
寄存器和内存中的情况:
再将寄存器储存的值放进去
此时内存中的情况是这样的:
此时就出现了问题, 当前两个线程同时对变量自增了一次, 但是实际内存中改变的结果却是1!
这就是多个线程同时修改同一个变量而存在的问题.
原子性:
针对该变量的操作不是原子性的, 所谓原子性 , 就是指CPU执行的三个指令 , 如果这三个指令 转为一个指令 , 就是原子性的.
内存可见性:
前三条证明了 对变量修改是存在线程安全问题的, 那么如果对线程不进行修改, 只进行读取操作就不会出现问题了吗? 当然不是, 其实如果只对变量进行读取操作, 会引发内存可见性的问题!
代码证明 :
让线程不判断读取操作, 如果isQuit 变为 0 以外的数字就结束循环.
public class Main {
private static int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(( ) -> {
//读取操作
while (isQuit == 0){
}
System.out.println( "循环结束 ,t 线程退出");
});
t.start();
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个 isQuit 的值: ");
//修改
isQuit = sc.nextInt();
System.out.println("main 线程执行完毕! ");
}
}
此时会看到以下结果:
可以看到 主线程已经结束了, isQuit 结果已经被修改了, 但是 线程t 并没有结束任务的执行 , 而是在不断的读取isQuit 变量.
出现上述情况的原因就是因为没有保证内存的可见性, 解释如下:
t线程在循环中读取这个变量, 但是, 读取内存的操作相比读取寄存器是一个非常低效的操作,
因JAVA对代码进行了优化: 此时不再从内存中读取数据 , 而是从寄存器中读取, (也就是不再执行load操作了)
此时一旦 t线程进行了这样的操作, 此时main 线程修改了 isQuit 的值, 此时 t 线程就不在感知到了.
(为什么需要进行优化? 因为如果编译器会对程序员写出的代码做出的一些调整, 保证原有的逻辑不变的前提之下, 程序的执行效率能够大大的提升)
指令重排序 :
指令重排序其实也是一种编译器优化的操作
首先构造对象需要三个步骤,
假如有一个对象 , 假设有两个线程 此时线程1 在进行 s 的初始化, 而线程2 到了判断s 是否为空的情况的时候,
但是此时 Java 编译器对指令进行了优化 , 如果将 2 3 顺序进行调换
此时 就会出现不可预期的问题! 因为此时 s2 线程中判定的是 true 就会进行调用对象的方法之类的操作... 但是此时该对象还没有被初始化数据! 那么后果是不堪设想的...
线程安全的解决方案:
synchronized 加锁操作
所谓 synchronized , 就是Java 的一种关键字, 当一个方法加上了该关键字, 就相当于对该方法进行了"加锁"操作, 什么是加锁?
就相当于当多个线程对同一把锁进行争夺的时候, 只有抢到锁的线程才可以进行锁内的操作
就类似于这样的图 , 三个滑稽老铁(也就是三个线程)去上厕所(调用构造方法) , 但是只有一扇门, 门上了锁, 如果没有锁进不去,
此时第一位第一位滑稽老铁拿到了锁,此时门上就上了锁, 其他的滑稽老铁都进不去, 只有当 第一个滑稽老铁 上完厕所, 出来了之后, 释放了锁, 此时 三个人才可以竞争锁, 看谁进去上厕所 (有可能是1 , 也有可能是2 , 也有可能是3).
应该此时大家就应该能懂 加锁的意义了: 让锁内的方法, 或者代码只能让拿到锁的线程进行执行, 不存在同时进入方法内的情况.
因此, 针对 线程不安全的前三条, 可以 通过 synchronized 关键字对需要加锁的方法进行上锁,
class Counter{
//这个变量就是两个线程要自增的变量
public int count ;
//最好的方法就是加锁 如果线程1 拿到这个锁的方法..
// 线程2在线程1枷锁成功的时候 线程2不断尝试枷锁,这个过程中 线程2 就一直处于阻塞状态BLOCKED
// 阻塞一直会持续到 线程1 释放锁
synchronized public void increase(){
count ++;
}
}
执行结果如下:
关于synchronized 不仅可以对方法进行加锁, 还可以对一部分代码进行加锁操作
直接修饰普通方法
使用synchronized 的时候本质上是在针对某个"对象" 进行加锁 (设置了一个对象的"对象头"上存在标志位)
如果修饰的是普通方法 那么也就相当于把锁对象 指定为this 了
class Counter{
public int count ;
//对普通方法直接加锁
synchronized public void increase(){
count ++;
}
}
修饰代码块
需要显式指定针对那个对象加锁(Java中的任意对象都可以成为锁对象);
此时线程是可以进入Counter类内部, 但是没有获得this 对象的锁 是不能进行count++ 操作的,
只有拿到了锁的线程才可以修改变量.
class Counter{
public int count ;
public void increase(){
//针对count++ 进行加锁, this 关键字就是该对象, 拿到该对象的线程可以进行操作
synchronized (this) {
count ++;
}
}
}
修饰一个静态方法(静态方法没有this)
所谓静态方法 -> "类方法"
相当于针对当前类的对象进行加锁
Counter.class (反射)
volatile关键字
volatile关键字可以保证内存可见性, 还可以禁止指令重排序 (禁止Java编译器对代码的优化)
public class Main {
volatile private static int isQuit = 0;
public static void main(String[] args) {
Thread t = new Thread(( ) -> {
//读取操作
while (isQuit == 0){
}
System.out.println( "循环结束 ,t 线程退出");
});
t.start();
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个 isQuit 的值: ");
//修改
isQuit = sc.nextInt();
System.out.println("main 线程执行完毕! ");
}
}
结果如下:
可以看出 , 当isQuit被修改之后, 线程确实已经结束了, 不再继续执行任务, 因此可以看出,
volatile 能够保证内存可见性.
但是请注意 , volatile并不能保证原子性, 因此要想保证线程安全 ,需要搭配synchronized 一起使用才可以保证线程的安全性!!!
觉得有用的话 麻烦点赞收藏支持一下 谢谢!!! 🌹🌹🌹