作者:敲代码の流川枫
博客主页:流川枫的博客
专栏:和我一起学java
语录:Stay hungry stay foolish
给大家推荐一款好用的神器
Apifox = Postman + Swagger + Mock + JMeter。集接口文档工具、接口Mock工具、接口自动化测试工具、接口调试工具于一体,提升 10 倍研发效率戳我来体验~
目录
1.观察线程不安全问题
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
如果没有多线程,代码的执行顺序就是固定的,代码执行顺序固定,那么程序的结果也就是固定的,有了多线程,在抢占式执行,随即调度的机制下,代码的执行顺序就会发生改变,产生更多的变数!
代码执行顺序的可能性从一种情况变成无数种执行顺序,所以要保证无数种线程调度顺序的情况下,代码执行结果是正确的,如果不正确,线程就是不安全的!!
下面通过代码感受线程安全问题
我们定义两个线程,分别对count进行自增50000次,我们的预期结果是count为100000,在线程安全的前提下是这样的,如果线程不安全,结果肯定有差异,那如何让线程安全呢?
class Counter{
public static int count = 0;
public void add(){
count++;
}
}
public class ThreadDemo14 {
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) {
throw new RuntimeException(e);
}
System.out.println("counter = "+counter.count);
}
}
多次运行后的结果
我们预期的结果是十万,这里的结果不是并且每次结果都不一样,因此这是一个多线程带来的bug!!!
我们分析一下原因
自增一次操作分为三步,先把内存中的值读取到cpu的寄存器中(load),把cpu中的数值+1运算(add),最后把结果写到内存中(save)
由于线程是抢占式执行,两个线程中的这三个步骤中执行到任意一个指令时,线程都可能被被调度走,cpu让别的线程来执行
其中,这两个情况是没有问题的,线程安全
第一种情况
t1线程执行三个步骤,对count++后count为1,然后t2对count操作,count++后写入内存中,自增两次,count结果为2,正确.另一种情况和这种情况相反,t2先让count ++后写入内存,此时t1加载出的count是1,自增后count为2,写入内存
来看这种线程不安全的情况
t1先加载,并且自增,count等于1,但是此时没有写入内存,t2线程就开始加载,count为0,自增后为1,然后写入内存,count为1,然后t1线程写入内存,count还是1!!!就在这里出现了问题
这种情况也不安全,t1先load,此时count=0,然后t2load, 此时count=0.然后自增,count=1,写入内存中,然后t1自增,count=1,写入内存中,后一次的自增覆盖了前一次的自增,count还是1!经历了两次自增,count结果还是1,这就出bug了!
类似于之前提到事务”读未提交 read uncommitted”是相同的,相当于t1读到的是t2还没来得及提交的数据,并发事务和多线程都属于并发编程问题
这就是count结果不是十万的原因,但是也有可能结果恰好是正确的,如果线程每次调度的顺序都是上面提到的正确的顺序,结果就是正确的,但是可能性很小!
结果不是10W,那么结果一定都会大于5w吗,也不一定,如果全都出现了两个线程都自增一次,count+1这种情况,结果就不大于5w,或者,t1自增1次,t2自增了2次,count最终还是+1这种情况,就小于5w
这就属于,t2中的count自增无论多少次,还是会被t1最后给覆盖了,(t1load时count为0)count还是1
2.出现线程不安全问题原因
2.1 根本原因
抢占式执行,随即调度
2.2 代码结构
多个线程同时修改同一个变量(一个线程修改一个变量,安全.多个线程读取同一个变量,安全,像String对象,不可变对象,天然石线程安全的,无法修改,只能读取.多个线程修改多个不同的变量,安全)
因此可以通过调整代码结构来规避这个问题,这种调整不一定都能使用,不是一个普适性的方案
2.3 原子性
如果修改操作是原子的,那就线程安全,但是像上述案例中,非原子的,那就有很大概率出现线程安全问题!
那么如何让操作变成原子的呢?引入了重要的概念—-加锁
2.4 内存可见性问题
如果一个线程读,一个线程改,那么读到的结果可能不符合预期
2.5指令重排序
本质是编译器的优化出问题了 ,把代码调整了,保持逻辑不变的情况下,调整代码的执行顺序,可能会出问题
这几个问题是典型的线程不安全问题的原因,但是线程是否安全远不止这些原因,多线程运行代码 ,不出bug就是安全的!!
3.通过原子性解决线程安全问题
通过”加锁操作”把非原子的操作转化为原子的操作
我们对第一个案例进行改动
对方法加了synchronized之后,进入该方法,就会加锁,出了方法就会解锁
一个线程获取到🔒之后,除非他主动释放,否则不能强占
如果两个线程同时获取这个锁,只有一个线程能成功获取到,另一个没获取到的线程阻塞等待(BLOCKED)一直等待到上一个线程释放锁之后,当前线程才能获取到🔒
lock的阻塞就把刚才的t2的load推迟到t1的save之后了,也就避免了脏读问题!!(在t1执行提交数据后,t2再读数据)
再来看效果,多次执行后都是准确的结果,线程安全问题得到解决!!
加锁之后,代码的执行速度肯定会降低,但是为了主线任务,保证结果的正确性,是必须要加锁的
4.synchronized的使用方法
加锁要明确对哪个对象进行加锁,如果两个线程同时对一个对象加锁,就会产生阻塞等待(锁竞争/锁冲突),如果两个线程对不同对象加锁,不会产生锁竞争/冲突
4.1 修饰方法
修饰普通方法,进入方法加锁,出了方法解锁
锁的对象是this,哪个对象调用了方法,this就指向这个对象,即对这个对象加锁
这种情况就是把synchronized加到方法上了,相当于针对this加锁,当t1线程调用add()后,counter就加上锁了,另一个线程执行add()的时候,也尝试对counter 加锁,但是此时t1已经加过锁了,此时t2的加锁操作就会阻塞等待
修饰静态方法
锁的对象是类对象,和修饰普通方法同理
4.2 修饰代码块
需要显式指定锁对象,也就是手动指定🔒加到哪个对象上
进入代码块就加锁,出代码块解锁