目录
1.什么是线程安全
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
2.线程不安全的原因
大家可以先猜测这个代码的结果,可能有的人会觉得是20000,但结果真的是这样吗?
public class Demo15 {
public static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for (int i=0;i<10000;i++){
count++;
}
});
Thread t2=new Thread(()->{
for (int i=0;i<10000;i++){
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
实际上这个代码的结果是不确定的, 这里运行了三次,但每次结果都不一样。其实再多运行多少次这个代码的结果每次也都还是不一样的,这就是典型的线程不安全的情况。
为什么会出现这样线程不安全的情况呢?
count++ 这个操作,站在 cpu 指令的角度上看,其实是3个指令:
- load: 把内存中的数据加载到寄存器中
- add: 把寄存器中的值 +1
- save: 把寄存器中的值写回到内存
由于线程是随机调度,抢占式执行,那么在某个线程执行指令过程中,当它执行到任何一个指令的时候,其他线程都可能会抢占它的 cpu 。
上面这两种情况t1和t2都是可以做到互不干扰的让count+1。
不难看出上图中 其中一个线程的load 总在 另一个线程的save 之后,这样的就是线程安全的
一旦 其中一个线程的load 不在 另一个线程的save 之后 都是会导致线程不安全的问题出现。
比如下面这样的情况,到最后只有t1这个线程成功让count+1了,t2的被t1覆盖掉了
像这样的情况其实有无数种,这里就不一一列举了
……
3.解决线程不安全的方法
其实线程不安全最主要的原因就是以下这几点 :
1.线程在系统中是随机调度,抢占式执行的(根本原因)
2.多个线程同时修改同一个变量
3.线程针对变量的修改操作不是"原子"的
4.内存可见性引起的线程不安全问题
5.指令重排序引起的线程不安全问题
解决方案:
1.线程在系统中是随机调度,抢占式执行的(根本原因)
对于这个原因不是我们能改变的
2. 多个线程同时修改同一个变量
那可能有人会说,不如让多个线程改多个变量,各改各的,不会争起来。但是,对于前面的代码,t1和t2就是要修改同一个变量,那么这种情况下就不行了。
3.线程针对变量的修改操作不是"原子"的
可以通过一些操作,把上述一系列"非原子"的操作,打包成一个"原子"操作,也就是把load,add,save这三个动作变成一个整体,要么一起做,要么都不做。为此,Java引入了一个关键字 synchronized,这个关键字的作用是给线程加锁。
通过加锁可以起到互斥的效果,通俗一点的意思就是我办事的时候你打扰不了。
public class Demo15 {
public static int count=0;
public static void main(String[] args) throws InterruptedException {
// 先创建出一个对象, 使用这个对象作为锁
Object locker=new Object();
Thread t1=new Thread(()->{
for (int i=0;i<10000;i++){
synchronized (locker){
count++;
}
}
});
Thread t2=new Thread(()->{
for (int i=0;i<10000;i++){
synchronized (locker){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
}
对于上面这个代码,t1和t2仍然是随机调度抢占式执行,但是现在无论是谁争到了那把锁,它都可以做到在count++的时候不被另一个线程打扰(能够完整的执行一套load,add,save操作),t1争到了锁,那么t2就得阻塞等待,反之亦然,这时就能得到我们想要的结果了。
对于上述代码,t1和t2都加锁,线程是安全的,那么一个加锁一个不加锁线程还安全吗?
答案是否定的,t1和t2一个加锁一个不加锁,线程是不安全的,每次的执行结果又开始变得不可预料。
因为此时对于t1来说它加了锁就得守规矩,但是t2没加锁,它没有锁限制,那么这时候,t1拿到锁正干活呢,以为不会有人打扰,但t2却不讲武德,不阻塞等待了,不管三七二十一,撬了你的锁就一顿操作猛如虎,欺负守规矩的t1,那么这样就可能会把t1的结果覆盖掉。
关于死锁问题的探讨:
场景一:锁是不可重入锁,并且一个线程针对一个锁对象连续加了两次锁
class Counter {
private int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
public int get() {
return count;
}
}
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (counter) {
counter.add();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + counter.get());
}
}
上述代码,t2被加了两把锁,假设 t2 先启动(t1 先不考虑)。
t2 第一次加锁,肯定能加成功,当 t2 尝试第二次加锁的时候,此时counter2 变量,属于已经被锁定的状态了。按照之前的理解,尝试针对一个已经被锁定的对象加锁就会出现阻塞等待,阻塞到对象被解锁为止。要想获取到第二把锁,就需要执行完第一层大括号,要想执行完第一层大括号,就需要先获取到第二层的锁,那么这就矛盾了,就会出现死锁问题。
解决方法:通过引入可重入锁来解决问题。
也就是java的锁不是普通的锁,经过处理,在这样的情况下即使嵌套使用加锁也不会出现死锁问题。
场景二:两个线程两把锁
有线程1和线程2,以及有锁A和锁B
现在,线程1 和 2 都需要获取到 锁A 和 锁B
现在让两个线程分别拿到一把锁,然后再去尝试获取对方的锁
这样的代码结果会怎样呢?
public class Demo21 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
// 为了更好的控制线程的执行顺序, 引入 sleep, 否则死锁可能重现不出来.
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) {
System.out.println("t1 获取了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) {
System.out.println("t2 获取到两把锁");
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
在这个代码中,
t1 尝试针对 locker2 加锁就会阻塞等待,等待 t2 释放 locker2
t2 尝试针对 locker1 加锁也会阻塞等待,等待 t1 释放 locker1
两个线程互不相让,僵持不下,导致程序既没有结束,也没有打印线程中的内容。
这样的结果等于是告诉我们程序出bug了,我们回到代码中把代码的加锁顺序改一改,就不会出现上述结果了。
场景三: N个线程, M 把锁
让每个哲学家都坐在两个筷子之间.
每个哲学家要做两件事:1.思考人生.(放下筷子)2.吃面条.(拿起左右两根筷子)
每个哲学家什么时候吃面条,什么时候思考人生都是不确定的(抢占式执行)
如果出现极端情况,就会出现问题:同一时刻,所有的哲学家都拿起左边的筷子,那么所有的哲学家都无法拿起右手的筷子。而且,每个哲学家都是比较固执的人,每个哲学家只要吃不到面条,就绝对不会放下手里的筷子 。
这种情况也是比较典型的死锁情况。
那么如何解决呢?
约定每个哲学家必须先获取编号小的筷子,后获取编号大的筷子。
放到代码的情况下就是:如果当代码中确实需要用到多个线程获取多把锁,那就约定好加锁的顺序,就可以有效避免死锁了 。
总结:产生死锁的必要条件(缺一不可)
1.互斥.(锁基本特性)
2.不可抢占/不可剥夺. (锁的基本特性)
3.请求和保持.(解决方法:避免锁嵌套使用)
4.循环等待.(解决方法:如果一定要嵌套使用锁,一定要约定好加锁的顺序 )
4.内存可见性引起的线程不安全问题
5.指令重排序引起的线程不安全问题
public class Demo22 {
private static int count = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (count == 0) {
;
}
System.out.println("t1 执行结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数: ");
count = scanner.nextInt();
});
t2.start();
t1.start();
}
}
上述代码我们预期的结果是:如果输入一个非零的结果那么线程t1就会退出循环,线程结束。
但是结果却并非如此
可以看到无论我们输入什么都不结束,那么为什么会出现这样的问题呢?
上述问题产生的原因,就是"内存可见性"引起的问题。
如何解决内存可见性问题?
通过使用 volatile关键字 来修饰变量,给变量修饰上这个关键字之后,编译器就知道了这个变量是"反复无常的",那么编译器就不会按照上述策略进行优化了。
volatile关键字的作用主要有如下两个:
- 保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
- 保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
现在代码结果就符合我们的预期了。
注意:volatile 不能保证原子性
这个代码没加锁,但用volatile来修饰count,可结果依然不稳定