努力努力,月薪过亿!!!
格局打开~~~
文章目录
- 前言
- 1. 线程安全问题的概念
- 2. 线程安全问题的原因
- 3. 线程安全问题解决--加锁
- 3. synchronized
- 4. 死锁
- 4.1 产生死锁的情况
- 4.3 产生死锁的必要条件
- 4.4 避免死锁的方法
前言
线程安全这里可能会出道面试题,在日常工作中也是很重要的内容.下面,我们来具体探讨一下吧~~
1. 线程安全问题的概念
首先,什么是不安全的线程呢?
线程是抢占式执行,随机调度,所以,线程调度的顺序不可预知.所以,必须在所有可能的调度顺序下,都能保证正确的结果,这样的线程为安全线程,否则为不安全的线程.
如下代码
class Counter{
public int count = 0;
public void add(){
count++;
}
}
public class ThreadAdd {
public static void main(String[] args) {
Counter c = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10_0000; i++) {
c.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10_0000; i++) {
c.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count的值为" + c.count);
}
}
执行结果如下,两个线程分别对count执行+100000操作,结果却比200000小,这是为什么呢?
由于i++操作需要三个操作才能执行完.
1.先把内存中的值,读取到CPU寄存器中,load.
2.进行++操作,add.
3.把结果写回到内存中.save.
由于两个线程进行++操作,线程调度顺序有多少种呢?大家猜一猜
答案:有无数种.
为啥呢?
我们看下图,以此类推,有无数种排列方式.那他们的结果如何呢?
上图第一种,t1从内存中拿到count值0,执行+1操作,count = 1,再放回内存,t2从内存中拿到count值1,执行+1操作,count = 2,再放回内存,这个顺序是没问题的.
上图第二种,t1先拿到count值0,t2也取出count值0,t2执行+1操作,count = 1,t1执行+1操作,count = 1,t2把count值1放回寄存中,之后t1再把count值1覆盖到寄存器中,此时,两个线程执行完++操作,最终count值为1.
之后的操作类似.
我们发现,只有像上图第一种第三种这样把操作中的load,add,save集中操作的才是结果正确的.其余全都不对.
2. 线程安全问题的原因
1.产生安全问题的根本原因是线程是抢占式执行,随即调度
2.与代码结构有关,出现多个代码同时修改同一个变量的情况,导致最终结果不可预控.上图就是这种情况.
3.操作不是原子性,就是类似上图的++操作,load,add,save如果必须是全都一次性执行完才能执行下次的++操作,就能避免线程安全的问题.
4.内存可见性问题,如果我们在读数据的时候,这个数据正在被另一个修改,那么,这个读到的数据就不是正确的.
5.指令重排序,这是编译器优化产生的bug,有时,编译器觉得你的代码复杂度太高了,自作主张给你的代码优化了,产生了结果的不可预知.
以上五个问题不是全部原因,具体问题具体分析,不可一概而论.
3. 线程安全问题解决–加锁
我们从原子性方面解决问题,我们将++操作原子化,完整执行完一次++操作后,才能执行下次的++操作.
synchronized public void add(){
count++;
}
用synchronized修饰add()方法,对调用add的对象c加锁,只有执行完add()方法之后,出去了synchronized修饰的范围,程序会自动给对象c解锁.
c加锁过程中,别的对象若想调用对象c,就会造成线程阻塞,必须等待c执行完add()函数,才有机会使用对象c.实现了c调用add()函数时,进行++操作的原子性.
3. synchronized
1.synchronized修饰普通方法
调用方法时,对调用的对象加锁,进方法自动加锁,出方法自动解锁.
synchronized public void add(){
count++;
}
2.synchronized修饰静态方法
调用方法时,对这个类进行加锁,线程调用这个静态方法时,别的线程无法使用该类
synchronized public static void fun(){
System.out.println("这里是synchronized修饰静态方法");
}
3.synchronized修饰代码块
如下面代码,缩小了锁的范围,进代码块对调用方法的对象加锁,出代码块,自动对对象解锁.但要注意的是,这里需要手动指定加锁的对象,可根据需要自行指定.
public void add(){
synchronized (this) {
count++;
}
}
4. 死锁
死锁是一个很重要很麻烦的事情,一旦出现死锁,线程就无法继续执行.但死锁很隐蔽,开发时不经意间会写出来,测试时,又不容易测出来,比较麻烦~
4.1 产生死锁的情况
1.一个线程已经对一个对象加锁了,又尝试对这个对象再加一把锁.如下代码所示.这时如果锁是可重入锁,线程正常执行,否则,就会导致死锁.很幸运,Java中的synchronized是可重入锁,但C++,Python中的锁是不可重入锁,同一个线程对一个对象加两把锁,就会导致死锁.
synchronized public void add(){
synchronized (this) {
count++;
}
}
2.两个线程互相等待对方的锁,造成循环等待,产生死锁.
如下代码所示,线程t1对对象d1加锁,同时线程t2对对象d2加锁,之后,t1再尝试对d2加锁,同时t2再尝试对对象d1加锁.两个线程都在等待对方释放资源,互不相让,造成循环等待,产生死锁.
public class ThreadLockProblem {
public static void main(String[] args) {
Object d1 = new Object();
Object d2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (d1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (d2) {
System.out.println("汤老湿把酱油和醋都拿到了");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (d2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (d1) {
System.out.println("师娘把酱油和醋都拿到了");
}
}
});
t1.start();
t2.start();
}
}
结果如下,产生死锁,不打印任何内容.
3.多个线程多把锁,哲学家问题,如下图,每个哲学家左手右手都有一根筷子,只有拿到两只筷子的人才能吃到好吃的.如果所有人都同时拿起左手边的筷子,那么所有人都要等另一只筷子,导致了死锁.
4.3 产生死锁的必要条件
1.互斥使用,这是线程的基本特性.线程1拿到资源后,其他线程也想使用就只能等待.
2.不可抢占,线程1对对象A加锁后,其他线程若想对对象A加锁,便只能等待线程1释放锁才行
3.请求和保持,线程1已经对对象A加锁时,再尝试对对象B加锁,此时,线程仍可以保持对对象A的锁.
4…循环等待,线程t1对对象d1加锁,同时线程t2对对象d2加锁,之后,t1再尝试对d2加锁,同时t2再尝试对对象d1加锁.两个线程都在等待对方释放资源,互不相让,造成循环等待.
以上前三种是线程的特性,不可强制改变,唯一可控的是第四点.
避免出现循环等待.
4.4 避免死锁的方法
给对象加锁时,给对象锁编号,线程都按照固定的顺序进行加锁.
线程1先对对象A加锁,再对B加锁,线程2也先对对象A加锁,再对对象B加锁.如下图所示