目录
今日良言:一路惊喜 马声蹄蹄
一、线程安全问题
1.概念
2.代码
3.原因
4.解决方案
1.概念
如果多线程环境下代码运行的结果是符合我们预期的,即该代码在单线程中运行得到的结果,那么就说说这个程序是线程安全的,否则就是线程不安全的.
线程安全问题最根本的原因是:多线程的抢占式执行带来的随机性.
如果没有多线程,此时代码的执行顺序是固定的,因此程序的结果也就是固定的.
如果有了多线程,此时抢占式执行下,代码的执行顺序就会有很多种情况,所以为了执行结果正确,就需要保证在这多种执行顺序的情况下,代码运行得到的结果都是一样的.
在多线程中,只要有一种情况下,代码结果不正确,就认为是有bug的,线程不安全的
2.代码
通过下面代码,来理解线程安全问题
class MyCount {
public int count = 0;
public void add() {
count++;
}
}
public class ThreadDemo22 {
public static void main(String[] args) {
// 创建一个实例
MyCount myCount = new MyCount();
// 创建两个线程,调用5万次 add 方法
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
myCount.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
myCount.add();
}
});
t1.start();
t2.start();
// 等待两个线程结束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 打印最终的结果
System.out.println(myCount.count);
}
}
运行三次上述代码,观察结果
预期的效果是代码运行后,输出结果是100000 但是很遗憾,三次输出结果都不是
如果在单线程中运行结果是100000,此时在多线程中发生了线程安全问题.
为什么程序会出现这种情况呢?
这是因为:在 count++ 操作中, ++ 操作本质上要分为3步:
1.先把内存中的值,读取到CPU的寄存器上(该步骤称为load)
2.CPU寄存器中的值进行 +1操作 (该步骤称为add)
3.将得到的结果写回到内存中 (该步骤称为save)
这三个操作,就是CPU上执行的指令,指令可以视为是机器语言.
分析一下上述count++操作:
可以看到,在多线程中,count++ 操作有无数种情况,针对自增结果正确和不正确情况再进行分析:
结果正确
结果不正确
这里出现结果错误的情况,主要是因为t2读到了t1(还没提交)的数据.所以说,当运行代码后,最后的结果很大可能性是小于100000的.
3.原因
多线程出现线程安全的主要原因有以下几点:
1).抢占式执行,随机调度
这是多线程中线程安全问题的根本原因
2).多个线程同时修改同一个变量
一个线程修改一个变量,没问题
多个线程读取同一个变量,没问题
多个线程修改多个不同的变量,也没事
3).修改操作不是原子的
如果修改操作是原子的,不会出现问题
如果修改操作是非原子的,出现问题的概率非常高.
原子:不可拆分的基本单位.
上述的count++ 是非原子操作,可以拆分成load add save三个指令,而这三个指令无法再拆
分了,是原子的
4).内存可见性问题
一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,
此时读到的值,不一定是修改之后的值.这个读线程没有感知到变量的变化,
归根结底是编译器/jvm在多线程环境下优化时产生了误判.
为了解决内存可见性问题:需要为变量加上volatile关键字
当为变量加上volatile关键字时,告诉编译器,这个变量是’易变’的,需要每次都重新读取这个变量的内存内容
Volatile 不保证原子性 原子性是靠 synchronized 来保证的
Volatile 和 synchronized 都能保证线程安全
volatile关键字的作用主要有两个:
一个是解决内容可见性问题,一个是禁止指令重排序
从JMM(java Memory Model java内存模型)的角度表述该问题:
Java程序里,除了主内存,每个线程都有自己的工作内存(线程1和线程2的工作内存不是一个东西).
线程1进行读取的时候,只是读取了工作内存的值.
线程2修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中.
但是,由于编译器的优化,导致线程1没有重新从主内存同步数据到工作内存,读到的数据就是"修改之前"的结果.
5).指令重排序
本质上是编译器优化出bug了,可能是编译器觉得我们的代码有点差,在保持逻辑不变的情况
下,进行调整(调整了代码的执行顺序),从而加快程序的执行效率.
4.解决方案
主要是从原子性入手,来解决线程安全问题.
通过 '加锁' 操作,将非原子操作转换成原子.
加锁关键字:synchronized 这个关键字不仅要会写还要会读哦
对上面的add方法加锁:
此时再看执行结果,就是100000
加了synchronized 后,进入方法就会加锁,出了方法就会解锁.
如果两个线程同时尝试加锁,此时一个获取锁成功,另一个获取锁失败阻塞等待,只有当前面的线程释放锁之后,才可以获取到锁.
针对上面 count++ 结果不正确的操作,加锁后进行分析:
加锁的本质是把并发执行变成了串行执行
加锁后,线程安全问题就得到了改善,但是代码的执行速度是大打折扣的.
此时,就需要考虑我们的需求了,如果是要计算结果准确点,加锁无疑是正确的,虽然加锁会使多线程的速度慢了,但是还是比单线程要快.
synchronized 的使用方法
1.修饰方法
1)修饰普通方法
锁对象是this,谁调用这个普通方法,锁的对象就是谁
2).修饰类方法
锁对象是类对象
2.修饰代码块
显式/手动指定锁对象.
3.可重入
一个线程针对同一个对象加锁两次,是否会有问题,如果没有问题,就叫可重入,如果有问题,就叫不可重入.
以上面的add方法为例:
只要有线程调用add方法,进入add方法的时候,会先加锁(能够加锁成功),紧接着遇到代码块,再次尝试加锁.从this的角度看,它认为自己已经被另外的线程给占用了,这里的第二次是否要阻塞等待呢? 如果不阻塞等待就是可重入的,阻塞等待就是不可重入的.
所以,加锁是要明确对哪个对象加锁的
如果两个线程对同一个对象加锁,就会产生阻塞等待,锁竞争/锁冲突.
如果两个线程对不同对象加锁,不会产生阻塞等待(不会锁冲突/锁竞争)