目录
前言
线程安全是多线程里面最核心的话题,也是和实际工作最相关的话题,不管是工作还是学习,都应该重点掌握。
什么是线程安全?【经典面试题】
为了更好的理解多线程安全问题,我们先看一个例子
public class ThreadDemo15 {
static class Counter{
public int count=0;
public void increase(){
count++;
}
}
static Counter counter=new Counter();
public static void main(String[] args) throws InterruptedException {
//此处创建两个线程,分别对count自增5w次
Thread t1=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t1.start();
Thread t2=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
运行结果每次都不一样
而我们真正想要的结果其实是10w
那么哪里出了问题呢?
我们可以判断这大概率和多线程并发执行相关,而且通过验证,确实是这样
验证过程
public class ThreadDemo15 {
static class Counter{
public int count=0;
public void increase(){
count++;
}
}
static Counter counter=new Counter();
public static void main(String[] args) throws InterruptedException {
//此处创建两个线程,分别对count自增5w次
Thread t1=new Thread(){
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
counter.increase();
}
}
};
t1.start();
/*Thread t2=new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};*/
//t2.start();
t1.join();
//t2.join();
System.out.println(counter.count);
}
}
这种情况,我们称之为“线程不安全”
为了解决这个问题,我们需要分析程序执行的过程
首先我们需要知道count++的详细过程
count++的详细过程
共分为三个步骤:
1.把内存中的值读取到CPU中(load)
2.执行++操作(add)
3.把CPU的值写回到内存中(save)
刚刚是一个线程执行count++操作,但如果是两个线程并发执行count++操作,那么就容易出问题
我们需要知道的是操作系统调度线程的时候是“抢占式执行”的方式,也就是竞争的关系,某个线程什么时候上CPU执行,什么时候切换出CPU,是完全不确定的。而且另一方面,两个线程在两个不同的CPU可以并发执行。因此,两个线程的执行顺序是完全不可预测的。
我们先假设两个线程针对一个变量进行++操作,我们需要知道count++的操作并不是完全三步都要执行完,也可能是线程1只执行完load,线程2把三步都执行完,线程1再执行后面的两步,此时,算出的结果就会出问题,假设内存中初始值为0,线程2执行完count++后内存中的值变为了1,线程1把后面两步都执行完之后,内存中的值还是1,这是出现了问题,因为我们的两个线程执行了两次count++操作,内存中的结果按理来说应该是2。而且这只是出现问题的其中一种情况。
只有串行执行的时候才不会出现这样的问题。
线程不安全的原因【经典面试题】
1.线程之间是抢占式执行的(根本原因,万恶之源)
因为是抢占式执行,所以导致两个线程里面操作的先后顺序不确定,这样的随机性,导致了线程的不安全。
这是操作系统内核实现的,我们无力改变。
2.多个线程修改同一个变量
一个线程修改一个变量,没有线程安全问题,结果确定;
多个线程读取同一变量,也没有线程安全问题,读只是单纯的把内存中的数据放到CPU中;
多个线程修改不同的变量,举例来说就是十个线程修改十个变量,一对一的关系,也没有线程安全问题,这个类似于第一种情况,所以为了规避线程安全问题,可以变换代码的组织形式,达到一个线程只修改一个变量的条件,但是有些场景下可以这么变换,有些却不可以。
3.原子性
像++这样的操作,本质上是三个步骤,是一个“非原子”的操作
像=这样的操作,本质上是一个步骤,是一个“原子”操作
++操作本身不是原子操作,可以通过加锁的方式,把这个操作变成原子操作。
4.内存可见性
具体就是一个线程修改,一个线程读取,由于编译器的优化,可能把一些中间环节的LOAD和SAVE省略掉了,此时读的线程可能读到的是未修改过的结果。
这里我们需要知道:++操作每次执行都要LOAD和SAVE,由于ADD比LOAD和SAVE要快一万倍,因此在执行很多次++操作的时候,很多LOAD和SAVE操作就被省略掉了,这样做是为了提高程序的整体效率。这个省略操作是编译器和JVM综合配合达成的效果。
这种优化在单线程下具有很高的效率,但是在多线程的时候,另一个线程也尝试读取/修改这个数据,这时候就会出问题。
volatile关键字可以解决内存可见性的问题。(原理是加上这个关键字就把编译器的优化给关闭了)
5.指令重排序(也是和编译器的优化有关)
编译器会自动调整代码执行的顺序,起到提高执行效率的效果。
调整的前提是不改变最终的结果,如果在单线程的情况下,编译器判断顺序是否影响结果很容易,可是在多线程的情况下编译器判定顺序是否影响结果,就容易出错。