目录
线程安全
线程安全问题的原因及解决办法
synchronized关键字
死锁
在单线程的情况下,程序代码执行顺序都是固定的,程序的运行结果就是固定的.而有了多线程,代码抢占式执行,代码的执行顺序,会出现多种情况,代码的执行顺序就从一种可能性变成了无数种情况,所以需要保证无数线程调度的顺序下,代码的执行结果都是正确的.只要有一种情况下,代码运行不正确,就会出现线程安全问题.
下面通过代码说明线程安全问题
class Counter {
public int count = 0;
public void add() {
count++;
}
}
public class ThreadDemo7 {
public static void main(String[] args) {
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();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count = " + counter.count);
}
}
运行结果
在上面代码中我们让线程t1和线程t2都循环50000次执行add()方法,预期的执行结果是count = 1000_00,而结果却不是,那是 因为上面的++ 操作分为三步,
1 load 先把内存中的值读取到CPU寄存器上
2 add 把CPU寄存器里的数值进行 +1运算
3 save 把得到的结果写到内存中
如果两个线程并发执行count++,此时就相当于两组 load,add,save进行执行,此时现成的调度顺序不一样就可能产生结果上的差异
画图演示线程的调度顺序
当执行正确的调度的时候,t1调度完成把值西写到内存中后t2才开始调度此时就是安全的线程,在第二种情况下,t1先load,t2 load的值是t1修改之前的值,导致t2后续保存数据的时候和t1保存的是同一份数据,就会出现线程不安全问题
1 根本原因: 线程之间抢占式执行,随机调度
2 代码结构: 多个线程同时修改同一个变量,当多个线程读取到同一个变量的时候是安全的
3. 原子性;如果修改操作时原子的,是安全的的,修改非原子的就是不全的
所谓原子性指的是 不可拆分的基本单位
解决办法
通过 synchronized关键字进行加锁
当前使用方法就是对代码块进行加锁,进入代码块就加锁,出了代码块就解锁,在这里可以指定任何想指定的对象,不一定是this.
3.可重入
一个线程针对同一个对象,连续加锁两次,是否会有问题,如果没有问题,就是可重入的,否则就是不可重入的.
针对上面代码,锁对象就是this,只要有线程调用add(),进入add()方法的时候,就会现加锁,紧接着执行到代码块再次尝试加锁,站在this的角度,他认为自己已经加锁了,已经被其他线程调用了,而此时相当于两个线程是同一个线程,如果允许上述操作,就是可重入的,如果不允许就是不可重入的,此时会导致死锁.
在java中,为了避免不小心死锁,java就把synchronized设定成了可重入的.
4. 内存的可见性问题
上面代码t1线程要循环快速读取,t2进行修改,预期结果是t2把flag改成非0 的值之后,t1随之循环结束,但结果是t1线程一直处于循环状态
1. load 把内存中flag的值读取到寄存器中,
2. cmp 把寄存器中的值和0进行比较,根据比较结果决定下一步往哪个地方执行
相比于tmp来说,load的执行速度太慢,在加上反复load的值都是一样的,jvm就不在真正重复load了,就只读取一次, 一个线程针对变量进行读取操作,另外一个线程针对这个变量进行修改操作,此时读取到的值不一定是修改后的值,向这样的问题就是内存可见性问题
可以使用volatile关键字来解决内存可见性的问题
使用volatile修饰的变量能够保证内存可见性.但volatile不保证原子性
5.指令重排序,(本质上是编译器优化出bug了)
在Java中,下面几种情况会出现死锁
1.一个线程,一把锁,连续加两次,如果锁是不可重入锁,就会死锁.但在Java里,synchronized和ReentrankLock都是可重入锁
2.两个线程.两把锁,t1和t2各自先针对锁A和锁B加锁,在尝试获取对方的锁.
public static void main(String[] args) {
Object lockerA = new Object();
Object lockerB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockerA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockerB){
System.out.println("t1获取到lockerA 和lockerB");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockerB) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockerA){
System.out.println("t2获取到lockerA 和lockerB");
}
}
});
t1.start();
t2.start();
}
执行结果
上面代码没有任何日志,说明没有线程拿到两把锁,此时两个线程都在等待对方先释放锁,就一直处在阻塞状态.
3. 多个线程多把锁
在学习这个之前,先听一个哲学家就餐的故事
一张圆桌上坐着5名哲学家,每两位哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考是,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根的拿起)。如果筷子已在他人手上,则需要等待。饥饿的哲学家只有同时拿起两根筷子才可以进餐,当进餐完毕后,放下筷子继续思考。
死锁的四个必要条件
1. 互斥使用. 线程1拿到了锁,线程2就要等待
2. 不可抢占. 线程1拿到锁之后,必须是线程1主动释放锁,而不是线程2就把锁强行获取到
3, 请求和保持. 线程1拿到锁A之后,在尝试获取锁B,线程1还是保持对A的加锁状态,不会因为锁Bj就把锁A释放
4. 循环等待 线程1尝试获取到锁A和B,线程尝试获取到锁B和锁A,线程1 在获取锁B的时候等待线程2释放B,同样,线程2在获取锁A的时候等待线程1释放A.
如何避免死锁
我们只需要给锁编号,然后指定一个固定的顺序来进行加锁.来解决循环等待的问题,
public static void main(String[] args) {
Object lockerA = new Object();
Object lockerB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockerA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockerB){
System.out.println("t1获取到lockerA 和lockerB");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lockerA) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockerB){
System.out.println("t2获取到lockerA 和lockerB");
}
}
});
t1.start();
t2.start();
}
运行结果