🎉🎉🎉点进来你就是我的人了
博主主页:🙈🙈🙈戳一戳,欢迎大佬指点!
人生格言:当你的才华撑不起你的野心的时候,你就应该静下心来学习!欢迎志同道合的朋友一起加油喔🦾🦾🦾
目标梦想:进大厂,立志成为一个牛掰的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)抢占式执行,调度过程随机(也是万恶之源,无法解决)
2)多个线程同时修改同一个变量(可以适当调整代码结构,避免这种情况)
3)针对变量的操作,不是原子的(加锁,synchronized)
4)内存可见性,一个线程频繁读,一个线程写(使用volatile)
5)指令重排序(使用synchronized加锁或者volatile禁止指令重排序)
即一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。
一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性的
多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
注意:其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。
由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
多个线程工作的时候都是在自己的工作内存中来执行操作的,线程之间是不可见的
1. 线程之间的共享变量存在主内存(实际内存)
2. 每一个线程都有自己的工作内存(CPU寄存器+缓存)
3. 线程读取共享变量时,先把变量从主存拷贝到工作内存,再从工作内存读取数据
4. 线程修改共享变量时,先修改工作内存中的变量值,再同步到主内存
注意:
(1)线程对共享变量的所有操作都必须在自己的工作内存中进行,不能绕过工作内存直接从主内存中读写变量
(2)不同线程之间无法直接访问其他线程工作内存中的变量,线程之间变量值的传递需要通过主内存来完成
线程1对共享变量的修改要想被线程2及时看到,必须要经过如下的2个步骤
(1)把工作内存1中更新过的共享变量刷新到主内存中
(2)将主内存中最新的共享变量的值更新到工作内存2中
变量传递顺序
JVM翻译字节码指令,CPU执行机器码指令,都可能发生重排序来优化执行效率
比如有这样三步操作:(1) 去前台取U盘 (2) 去教室写作业 (3) 去前台取快递
JVM会对指令优化,也就是重排序,新的顺序为(1)(3)(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,把寄存器中的值写回到内存中
修饰方法:修饰普通方法时,关键字在public前后都可,锁对象是 this,也就是谁调用谁上锁。修饰静态方法时,锁对象是类对象。
修饰代码块:修饰代码块时,显式(手动)指定锁对象。
对于构造方法来说,如果加锁,不能直接加在方法上,但是内部可以使用代码块的方法,来加锁。
代码演示
//修饰普通方法public synchronized void doSomething(){//...}//修饰代码块public void doSomething(){synchronized (this) {//...}}//修饰静态方法(与下面效果相同都是锁类对象)public static synchronized void doSomething(){//...}//修饰静态方法public static void doSomething(){synchronized (A.class) {//...}}
sychronized是基于对象头加锁的,特别注意:不是对代码加锁,所说的加锁操作就是给这个对象的对象头里设置了一个标志位
一个对象在同一时间只能有一个线程获取到该对象的锁
sychronized保证了原子性,可见性,有序性(这里的有序不是指指令重排序,而是具有相同锁的代码块按照获取锁的顺序执行)
(2.1) 互斥性
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
下面图加深理解:
阻塞等待:
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候,其他线程尝试进行加锁, 就加不上了,就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁!
(2.2) 刷新主存
synchronized的工作过程:
🐳获得互斥锁
🐳从主存拷贝最新的变量到工作内存
🐳对变量执行操作
🐳将修改后的共享变量的值刷新到主存
🐳释放互斥锁
(2.3) 可重入性
synchronized是可重入锁
同一个线程可以多次申请成功一个对象锁
可重入锁内部会记录当前的锁被哪个线程占用,同时也会记录一个加“锁次数”,对于第一次加锁,记录当前申请锁的线程并且次数加一,但是后续该线程继续申请加锁的时候,并不会真正加锁,而是将记录的“加锁次数加1”,后续释放锁的时候,次数减1,直到次数减为0才是真的释放锁
可重入锁的意义就是降低程序员负担(使用成本来提高开发效率),代价就是程序的开销增大(维护锁属于哪个线程,并且加减计数,降低了运行效率)
如下图:
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());}
}
引入一个线程不安全的场景:
当一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值不一定是修改后的值,这是编译器在多线程环境下优化时产生了误判,从而引起了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变量的副本
我们这里拿实例化一个对象举例
SomeObject s=new SomeObject(); //保证对象实例化正确
1.堆里申请内存空间,初始化为0x0
2.对象初始化工作:构造代码块,属性的定义时初始化,构造方法(这才算是一个正确对象)
3.赋值给s
volatile禁止重排序,只能1->2->3,如果1->3->2在3到2之间有线程(线程调度随机)使用对象,其对象是错的即出现问题。
能准确的表明其作用是单列模式:(这个我们后面会再讲)
单列模式分为饿汉模式(在类加载期间就进行对象实例化),懒汉模式(第一次用到时进行对象的实例化)
其懒汉模式实现如下:假如多个线程走先判断对象没有实例化,对类加锁(一个线程持有锁,但这是不知道是否实例化),所以要再判断是否实例化,没有实例化进行实例化,实例化了就返回对象,这里volatile就是要确保实例化正确。