一、线程不安全的原因
线程在执行的过程中出现错误的主要原因有以下几种:
1、根本原因
导致线程不安全的所有原因中,最根本的原因是——抢占式执行。因为CPU字在进行线程调度的时候,是随机调度的,而且这是无法避免的一种原因。
2、代码结构
当多个线程同时修改同一个变量的时候,很容易产生线程的不安全。所以不可变对象是天然线程安全的,比如String。可以通过调整代码结构来避免这个问题,但是这种调整不是一定都能够适用的,这虽然是一个方案,但不具备普适性。
3、原子性
修改操作如果是原子的,出现问题的概率小,但如果试非原子的,出现问题的概率就非常大了。原子性就是不可拆分的基本单位。
比如代码在执行“++”操作时,会分为三个阶段,load、add、save三个原子操作,所以多线程对同一变量进行“++”操作时,会出现不安全的状态。
针对线程安全问题,最主要的解决手段就是从这个原子性入手,把非原子的操作,变成原子的。
4、内存可见性问题
上述的多个线程对同一个变量进行修改时,会出现不安全的问题,同样的,一个线程读,一个线程改的操作也存在安全问题,可能就造成脏读,读的结果不符合预期。
5、指令重排序
编译器在执行代码时,会检测代码的,会存在编译器自作主张在保证相同的逻辑情况下对代码进行优化和修改,从而加快程序执行的效率,这是发生在单个线程里面的。这就有可能出现安全问题。
上述分析的五个原因,只是比较典型的,并不是全部原因。一个代码究竟是不是线程安全的,需要具体问题具体分析。即使某个代码踩中了上面的某个原因或几个原因,但仍然有可能是线程安全的,反过来说,即便某个代码一个都没踩中,也有可能是不安全的。
二、避免线程出现问题
解决线程安全问题也有几种主要的方法,首先介绍一个从原子性入手来解决安全问题的操作——加锁。
1、synchronized
(1)案例简介
多个线程在进行同一变量修改时:
class Counter1{
public int count;
synchronized public void add(){
count++;
}
}
public class prastice {
public static void main(String[] args) {
Counter1 counter1 = new Counter1();
Thread thread1 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counter1.add();
}
});
Thread thread2 = new Thread(()->{
for (int i = 0; i < 10000; i++) {
counter1.add();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(counter1.count);
}
}
如果不加synchronized的话,最终打印的结果基本每次都是小于20000的,因为两个线程在实际对count进行++操作的时候,流程如下图:
![](https://i-blog.csdnimg.cn/blog_migrate/2ddd96a2de11c8b8509f2ee251bda49b.png)
这只是无数多种可能中的一种情况,两个线程分别进行一次++操作后,count的值只增加了1,产生的原因就是++操作在这里不是原子性的,线程1、2在执行时,很有可能相互交叉,进而导致结果错误。但是一旦加上synchronized锁后:
![](https://i-blog.csdnimg.cn/blog_migrate/32a640ace820668de6247920e8a7d476.png)
一旦线程进入锁中,其他线程就无法再进入,直到上锁的线程结束执行,解锁后,方可进入。
(2)synchronized的使用方法
修饰方法:修饰普通方法时,关键字在public前后都可,锁对象是 this,也就是谁调用谁上锁。修饰静态方法时,锁对象是类对象。
修饰代码块:修饰代码块时,显式/手动指定锁对象。
对于构造方法来说,如果加锁,不能直接加在方法上,但是内部可以使用代码块的方法,来加锁。
(3)死锁
死锁就是表面意思线程卡住无法继续执行,出现死锁大概有以下三种情况:
1)一个线程一把锁,连续加锁两次,如果锁是不可重入的,就会造成死锁。而synchronized是可重入锁,所以不会出现这种情况的死锁。
2)两个线程两把锁,t1 和 t2 各自先针对A和B进行加锁操作,两者分别加锁完成之后,再尝试获取对方的锁,就会造成死锁。
public class prastic2 {
public static void main(String[] args) {
Object t1 = new Object();
Object t2 = new Object();
Thread t3 = new Thread(()->{
synchronized (t1){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("拿到t1,尝试拿t2");
synchronized (t2){
};
};
});
Thread t4 = new Thread(()->{
synchronized (t1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("拿到了t2,尝试去拿t1");
synchronized (t2){
};
};
});
t3.start();
t4.start();
}
}
3)多个线程多把锁,相当于上一条的一般情况。
解决死锁的核心思想就是让线程统一一个顺序,按照类似从大到小这种有序的状态来进行执行,比如2中,两个线程同时先去拿A,然后再去拿B就能够很好的解决死锁问题。
2、volatile
这个关键字和内存可见性有着密切的联系。内存可见性问题实际是一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值不一定是修改后的值,有可能读线程没有感知到变量的变化。归根结底就是编译器在多线程环境下优化时产生了误判。此时,volatile这个关键字就可以发挥作用了。用volatile来修饰变量,来告诉编译器,这是一个易变的变量,不可以随意进行优化。
class Sign{
volatile public boolean flag = false;
}
public class prastic3 {
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();
}
}
此处,如果flag变量不被volatile修饰,程序就会一直运行,while感知不到flag的变化。原因就是,执行到线程2的时候,while空跑了好多遍,flag一直是false,所以被默认为不变,不再从内存中读取flag的值,而是读取寄存器中不变的flag的值,等到线程2执行到修改flag变量后,修改掉了内存中flag的值,但是寄存器中的flag依旧为原来的值,所以while后感知的flag是没变的,一直循环跑。
3、wait和notify
某个线程调用wait方法,就会进入阻塞,(无论是通过哪个对象调用的wait的)此时就处于WAITING状态。如果不加任何参数,就一直等待,直到被他的搭档notify唤醒。
wait的三个操作:先释放内存,然后进行阻塞等待,最后收到通知后,重新尝试获取锁,并在获取锁后,继续往下执行。wait的操作需要搭配synchronized使用。
Object object = new Object();
synchronized (object){
object.wait();
}
synchronized(object){
object.notify();
}
object.wait();这里虽然wait阻塞在synchronized代码块里面,但实际上,这里的阻塞是释放了锁,此时其他的线程是可以获得到object这个对象的锁的,此时阻塞处于WAITING状态。负责通知wait的notify要和wait配对,而且notify只能唤醒在同一对象上的线程。同时要保证notify要在wait之后执行。