本文内容仅供对线程安全问题、锁的认识和使用等,进行一个介绍。适合小白的文章!
目录
一、线程安全问题
1.什么是线程安全问题
2.解释上述安全问题
3.线程安全的五大原因
二、使用锁解决线程安全问题
1.介绍锁
2.加锁操作
一、线程安全问题
在多线程代码实现中,最重要最核心的部分也就是线程安全问题,非常值得我们去认识和理解。线程安全问题及其影响程序运行的结果,也就是出现的bug。
1.什么是线程安全问题
1.1.啥样的称为安全问题
(1)在程序运行的结果中,只要结果有一点点和预期不一样,那就是该程序有bug,不算合格程序。
(2)在多线程代码中产生的bug,我们就称为“线程安全问题”。
1.2.为啥产生线程安全问题
(1)多线程在执行的时候,是“抢占式”的,“随机调度”的,进而很容易产生一系列的线程安全问题。
(2)如果不对代码进行限制,很容易产生和预期不一样的结果。
1.3.一个线程不安全的多线程例子
代码描述:分别两个线程t1、t2,同时对count++五万次,最后输出count的值
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
程序预期结果:count=100000
实际运行结果:
我去?这么离谱,跟100000差了这么多,到底是道德的沦丧,还是人格的扭曲?不不不,都不是,这是多线程产生的安全问题。
下面我们通过执行指令的三个过程来解析上述代码所出现的线程安全问题。
2.解释上述安全问题
解释上面代码出现的线程安全问题,我们需要使用到前面介绍到的,cpu是如何执行指令的。像上述出现的问题,可以直接定位到count++这个指令上面。下面解析:
2.1.count++代码背后的指令
(1)count++这一句代码,在cpu上其实是三条指令,相当于分成三步去执行。
(2)执行count++代码需要三步
1)把内存count中的数值,读取到cpu寄存器中(这一步,我们暂且使用load这样的名字来代替)
2)把寄存器中的值+1(暂且称为add)
3)最后把寄存器上述计算后的值,写回到内存中的count中,也就是更新结果(暂且称为save)
以上就是执行count++这一句代码,在cpu上面发生的大致过程,现在把他抽象出来。
2.2.指令执行过程
(1)按理来说,正确执行顺序应该是这样,或者 t1:4 - 5 - 6 、t2:1 - 2 - 3
但是由于多线程是随机调度、抢占式执行的,这样的原因就会导致在cpu上执行t1线程的某一条指令时,t1线程随时会被从cpu上调离而走,进而执行t2线程。所以,在多线程代码中,指令的执行顺序是不确定的,随机的。
(2)不正常的执行顺序
像不正常的指令执行顺序有无数种,但是正确的执行顺序只有两种。
为什么不正常的指令执行顺序就会产生线程安全问题呢?请听下文解析。
2.3.指令所产生的问题
有请我们的凹凸曼同志
那么我们现在是两个线程同时执行+1操作,比如:t1将count+1后变成2并更新到内存中,此时t2线程也操作后了,随机也将内存中的count更新成2,这样的操作就会产生问题了。也就是所谓的执行覆盖问题,在多线程中很常见。
当然,上述的指令执行也不一定是这样;总之,是因为指令的执行顺序不一样,导致产生的结果会被覆盖,进而导致结果和预期不一样。
像上面的指令执行顺序,只要是一个线程的load指令执行顺序在另一个线程的save指令执行顺序后面,就是线程安全的。
下面罗列五大线程不安全的原因
3.线程安全的五大原因
3.1.线程在系统中随机调度,是抢占式执行的
这是出现线程安全问题最本质的原因,但是这种原因,我们是无法修改和干预的
3.2.在代码中,存在多个线程同时修改同一个变量
(1)一个线程修改同一个变量,不存在线程安全问题
(2)多个线程读取同一个变量,不存在线程安全问题
(3)多个线程修改不同的变量,不存在线程安全问题
(4)多个线程修改同一个变量,存在线程安全问题(*)
3.3.线程针对变量的操作,不是“原子性”的
(1)像上述的指令执行过程,就不是一个原子性的
(2)要想将上述三个指令打包成一个原子,则需要进行加锁操作,也就是本节课所需要介绍的内容。
(3)不一定所有的代码语句都不是原子性的,例如赋值操作,就是一个原子性的操作。
3.4.内存可见性问题
此类问题是由于jvm的优化而产生的问题,后续文章介绍
3.5.指令重排序
此类问题也是由于编译器而产生的问题,后续文章介绍
在这里,我们介绍加锁操作,也就是针对第三条原因而进行的措施。
二、使用锁解决线程安全问题
这里的操作适用于第三个原因,其他的线程安全问题,不一定会适用。
1.介绍锁
1.1.锁的介绍内容
(1)在这里,会介绍锁操作的两个方面----加锁和解锁
(2)这里是利用锁的一个性质,当一个线程对一个箱子加锁之后,另一个线程再想对其加锁,就会产生阻塞,也就是排斥的效果。
(3)例如:当t1线程对A加锁之后,t2线程再对A加锁,t2线程就会阻塞等待(也就不会干扰到t1线程执行指令);当t1线程对A解锁之后,t2线程才有机会拿到加锁的机会,也就是加锁成功。
1.2.对锁的举例
(1)两个线程,我们比如成两个人。对象,我们比如成厕所。
(2)当两个人争取一个厕所时,一个人先进入了厕所并进行加锁操作,此时另一个就需要阻塞等待。
(3)如果两个人争取不同的厕所,则不会产生任何的阻塞等待。
2.加锁操作
注意:两个线程对同一个对象加锁,才能起到作用,否则和没有一样
2.1.锁的语法
(1)锁的关键字:synchronized
(2)加锁的前提是:有对象。对象就相当于是一个物品,任何对象都可以被加锁
(3)基本加锁结构
出了锁范围自动解锁
2.2.关于锁和对象的注意实现(重点)
(1)锁的用途是让多个线程之间产生阻塞
(2)要想某几个线程有序的执行,那么,这几个线程必须对同一个对象进行加锁操作,否则就是加了一个寂寞
(3)当两个线程对同一个对象加锁时,就会产生锁竞争/锁冲突/互斥的效果,进而会引起线程阻塞,也就是前面所提到的线程BLOCKED状态
(4)至于加锁的对象,对象是什么类型、和该对象有什么变量和方法,没有任何关系;对一个对象加锁,不会对该对象产生任何的效果和影响。
2.3.对上面代码进行加锁操作
(1)代码:
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
String s = "锁对象的外貌没有任何影响";
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (s) {
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (s) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count="+count);
}
运行结果:
此时:
这样的操作之后,count++操作就会被打包一个操作,也就是原子性,此时三条执行的执行顺序是一体的,也就是不会再产生上述的线程不安全问题了。
(2)加锁后的注意点
1)两个线程对同一个对象加锁之后,当t1先拿到锁,也就是先执行代码,即使t1线程执行到一半,被cpu调度走;此时t2线程也无法进行拿到锁。
2)加锁后,count++语句是串行执行的,而for循环语句是并行执行。
3)当t1释放锁之后,t1线程和t2线程还是会同时争夺这把锁,也就是说他们的拿到锁的顺序也是不确定的。
2.4.锁的几处场景
上面的是直接对一个对象引用进行加锁,也就是对普通语句加锁。下面总结几种
(1)对普通语句加锁
(2)对方法加锁
普通方法加锁
或者:
如果,锁的声明周期需要跟方法的声明周期一样,那么锁就加在外面。对普通方法加锁时,只有当两个线程同时调用到该方法时,才会产生阻塞,也就是起到作用。也就是只对this加锁,调用方法才会加锁
对静态方法加锁:
或者:A.class是拿到了A这个类的对象,也就是类对象
对静态方法加锁后,针对的是这个类对象,也就是说,多个线程同时实例化类对象,就会产生阻塞。
2.5.关于锁的小结
(1)锁的两个操作:加锁和解锁
(2)锁的特点:互斥
(3)两个线程对同一个对象加锁,就可能产生:阻塞/锁竞争/锁冲突;如果不是,就不会产生阻塞
(4)对普通方法加锁,相当于对this加锁;对静态方法加锁,相当于对 类对象 加锁
(5)一张个人对锁理解的草稿图
本文结束,线程安全问题的解决方式完全不止以上锁描述的,对锁的描述也不止上述所介绍的。上面的内容仅供入门。