多线程 (五) 线程安全及解决方案(看这一篇就够了)
创始人
2025-05-31 18:48:23

🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!

人生格言:当你的才华撑不起你的野心的时候,你就应该静下心来学习!

欢迎志同道合的朋友一起加油喔🦾🦾🦾
目标梦想:进大厂,立志成为一个牛掰的Java程序猿,虽然现在还是一个🐒嘿嘿
谢谢你这么帅气美丽还给我点赞!比个心


目录

前言

1. 造成线程不安全的原因有哪些呢?

1.1什么是原子性

1.2什么是内存可见性

1.3共享变量可见性实现的原理

 1.4 什么是指令重排序

2.解决线程安全问题

2.1 引入关键字synchronized解决线程不安全问题

(1) synchronized的使用方法(锁)

(2)synchronized的作用

 (3)优化后的代码(加锁后)

2.2 引入volatile解决线程安全问题

(1) volatile保证内存可见性

(2) volatile禁止指令重排序



前言

如果说在多线程环境下代码运行的结果是符合我们预期的,即该代码在单线程中运行得到的结果,那么就说这个程序是线程安全的,否则就是线程不安全的.


1. 造成线程不安全的原因有哪些呢?

1)抢占式执行,调度过程随机(也是万恶之源,无法解决)

2)多个线程同时修改同一个变量(可以适当调整代码结构,避免这种情况)

3)针对变量的操作,不是原子的(加锁,synchronized)

4)内存可见性,一个线程频繁读,一个线程写(使用volatile)

5)指令重排序(使用synchronized加锁或者volatile禁止指令重排序)

1.1什么是原子性

即一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行

一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性

多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:

x = 10; 	//语句1
y = x; 		//语句2
x++; 		//语句3
x = x + 1; 	//语句4

注意其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存,这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。

由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

1.2什么是内存可见性

多个线程工作的时候都是在自己的工作内存中来执行操作的,线程之间是不可见

1. 线程之间的共享变量存在主内存(实际内存)
2. 每一个线程都有自己的工作内存(CPU寄存器+缓存)
3. 线程读取共享变量时,先把变量从主存拷贝到工作内存,再从工作内存读取数据
4. 线程修改共享变量时,先修改工作内存中的变量值,再同步到主内存

注意:

(1)线程对共享变量的所有操作都必须在自己的工作内存中进行,不能绕过工作内存直接从主内存中读写变量

(2)不同线程之间无法直接访问其他线程工作内存中的变量,线程之间变量值的传递需要通过主内存来完成

1.3共享变量可见性实现的原理

线程1对共享变量的修改要想被线程2及时看到,必须要经过如下的2个步骤

(1)把工作内存1中更新过的共享变量刷新到主内存中

(2)将主内存中最新的共享变量的值更新到工作内存2中

变量传递顺序

 1.4 什么是指令重排序

JVM翻译字节码指令,CPU执行机器码指令,都可能发生重排序来优化执行效率

比如有这样三步操作:(1) 去前台取U盘 (2) 去教室写作业 (3) 去前台取快递
JVM会对指令优化,也就是重排序,新的顺序为(1)(3)(2),这样来提高效率

2.解决线程安全问题

引入count++问题

class Counter {private int count =0;public void add() {count++;}public int getCount() {return count;}
}
public class ThreadDemo1 {public static void main(String[] args) throws InterruptedException {Counter counter =new Counter();Thread t1 =new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});Thread t2 =new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.getCount());}
}

运行上述代码我们会发现每次都结果是小于100000的,因为上面两个线程在实际对count进行++操作的时候并不满足原子性,导致最终的结果一直不是我们想要的,这就是由于不满足原子性所导致的线程不安全问题!!!

count++操作,本质上是有三个CPU指令构成

1.load,把内存中的数据读到CPU寄存器中

2.add,就是把寄存器中的值进行+1运算

3.save,把寄存器中的值写回到内存中

2.1 引入关键字synchronized解决线程不安全问题

(1) synchronized的使用方法(锁)

修饰方法:修饰普通方法时,关键字在public前后都可,锁对象是 this,也就是谁调用谁上锁。修饰静态方法时,锁对象是类对象。

修饰代码块:修饰代码块时,显式(手动)指定锁对象。

对于构造方法来说,如果加锁,不能直接加在方法上,但是内部可以使用代码块的方法,来加锁。

代码演示

    //修饰普通方法public synchronized void doSomething(){//...}//修饰代码块public void doSomething(){synchronized (this) {//...}}//修饰静态方法(与下面效果相同都是锁类对象)public static synchronized void doSomething(){//...}//修饰静态方法public static void doSomething(){synchronized (A.class) {//...}}

(2)synchronized的作用

sychronized是基于对象头加锁的,特别注意:不是对代码加锁,所说的加锁操作就是给这个对象的对象头里设置了一个标志位

一个对象在同一时间只能有一个线程获取到该对象的锁
sychronized保证了原子性,可见性,有序性(这里的有序不是指指令重排序,而是具有相同锁的代码块按照获取锁的顺序执行)

(2.1) 互斥性

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待

进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

下面图加深理解:

阻塞等待:

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候,其他线程尝试进行加锁, 就加不上了,就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁!

(2.2) 刷新主存

synchronized的工作过程:

🐳获得互斥锁
🐳从主存拷贝最新的变量到工作内存
🐳对变量执行操作
🐳将修改后的共享变量的值刷新到主存
🐳释放互斥锁

(2.3) 可重入性

synchronized是可重入锁
同一个线程可以多次申请成功一个对象锁

可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个加“锁次数”,对于第一次加锁,记录当前申请锁的线程并且次数加一,但是后续该线程继续申请加锁的时候,并不会真正加锁,而是将记录的“加锁次数加1”,后续释放锁的时候,次数减1,直到次数减为0才是真的释放锁

可重入锁的意义就是降低程序员负担(使用成本来提高开发效率),代价就是程序的开销增大(维护锁属于哪个线程,并且加减计数,降低了运行效率) 

如下图:

 (3)优化后的代码(加锁后)

class Counter {private int count =0;synchronized public void add() {count++;}public int getCount() {return count;}
}
public class ThreadDemo1 {public static void main(String[] args) throws InterruptedException {Counter counter =new Counter();Thread t1 =new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});Thread t2 =new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(counter.getCount());}
}

2.2 引入volatile解决线程安全问题

(1) volatile保证内存可见性

引入一个线程不安全的场景:

当一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值不一定是修改后的值,这是编译器在多线程环境下优化时产生了误判,从而引起了bug

代码演示:

class Sign{public boolean flag = false;
}public class ThreadDemo4{public static void main(String[] args) {Sign sign = new Sign();Thread t1 = new Thread(()->{while(!sign.flag){}System.out.println("执行完毕");});Thread t2 = new Thread(()->{sign.flag = true;});t1.start();try {Thread.sleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}t2.start();}
}

运行上述代码我们会发现,程序会一直运行,while感知不到flag的变化。原因就是,执行到线程2的时候,while一直循环跑了好多遍,flag一直是false,所以编译器对代码进行优化,默认为程序不变,不再从内存中读取flag的值,而是读取寄存器中不变的flag的值,等到线程2执行到flag变量后,尽管修改掉了内存中flag的值,但是寄存器中的flag依旧为原来的值,所以while一直感知到的flag是没变的,一直循环跑。

那么如何解决该问题呢?

用volatile来修饰变量,通过保证内存可见性来解决上述问题,每次读取用volatile修饰的变量的值,都会从主内存中读取该变量。

通俗地讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样在任何时刻,不同的线程总能看到该变量的最新值。

那么,线程修改volatile变量的过程:

(1)改变线程工作内存中volatile变量副本的值

(2)将改变后的副本的值从工作内存刷新到主内存

线程读volatile变量的值的过程:

(1)从主内存中读取volatile变量的最新值到线程的工作内存中

(2)从工作内存中读取volatile变量的副本

(2) volatile禁止指令重排序

我们这里拿实例化一个对象举例

SomeObject s=new SomeObject();  //保证对象实例化正确

1.堆里申请内存空间,初始化为0x0

2.对象初始化工作:构造代码块,属性的定义时初始化,构造方法(这才算是一个正确对象)

3.赋值给s

volatile禁止重排序,只能1->2->3,如果1->3->2在3到2之间有线程(线程调度随机)使用对象,其对象是错的即出现问题。

能准确的表明其作用是单列模式:(这个我们后面会再讲)

单列模式分为饿汉模式(在类加载期间就进行对象实例化),懒汉模式(第一次用到时进行对象的实例化)

其懒汉模式实现如下:假如多个线程走先判断对象没有实例化,对类加锁(一个线程持有锁,但这是不知道是否实例化),所以要再判断是否实例化,没有实例化进行实例化,实例化了就返回对象,这里volatile就是要确保实例化正确。

相关内容

热门资讯

科普实测“微乐卡五星可以开挂吗... 您好:微乐卡五星这款游戏可以开挂,确实是有挂的,需要软件加微信【69174242】,很多玩家在微乐卡...
科技通报「丫丫诗词」辅助插件工... 您好:丫丫诗词这款游戏可以开挂,确实是有挂的,需要了解加客服微信【9307068】很多玩家在这款游戏...
玩家必看“打哈儿麻将到底是不是... 您好:打哈儿麻将这款游戏可以开挂,确实是有挂的,需要软件加微信【69174242】,很多玩家在打哈儿...
今日重大通报“广西八一字牌透视... 您好:广西八一字牌这款游戏可以开挂,确实是有挂的,需要软件加微信【6355786】,很多玩家在广西八...
最新消息“新西部究竟有挂吗”(... 您好:新西部这款游戏可以开挂,确实是有挂的,需要软件加微信【3671900】很多玩家在这款游戏中打牌...