线程安全
线程安全的意思技术在多线程的各种随机调度顺序下,代码没有bug,都能够符合预期的方式来执行
线程为什么会不安全?就是在多线程随机调度下出代码出现bug。
有些代码在多线程环境下执行会出现bug,这样的问题就叫做线程不安全。
/**
* Created with IntelliJ IDEA.
* Description:
* User: admin
* Date: 2023-01-02
* Time: 17:04
*/
//演示线程安全问题
class Counter{
public int count = 0;
public void increase(){
count++;
}
}
public class L104 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
//两个线程对counter进行五万次自增,预期结果十万
Thread t1= new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2= new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("counter: " + counter.count);
}
}
而运行结果可能就不等于十万,原因是进行的count++操作,底层是三条指令在CPU上完成的
先从内存的数据读取到CPU寄存器中, load
如何把CPU的寄存器中的值进行+1, add
最后把寄存器中的值写回到内存中。 save
当前是两个线程修改一个变量,每次修改是三个步骤(也就是不是原子的),由于线程之间的调度书顺序不确定,所以两个线程在真正执行这些操作的时候,就有可能有多种执行的排列顺序。
例如
线程t1的load执行了,在执行接下来的add和save时,线程t2的load,add,save就有可能去抢占式执行。因此出现了多种排列组合方式导致不同结果的出现,原因是这些排列组合有些相加等于2有些就不等于。
不安全的原因有:
1.抢占式执行(内核实现,想要解决无能为力)
多个线程的调度执行过程,可以视为是”全随机的“
因此在多线程代码的时候,我们需要考虑到任意一种调度的情况下,都是能够运行出正确结果的。
2.多个线程修改同一个变量(有的时候可以调整代码,规避线程安全问题,但是普适度不高)
一个线程修改一个变量,多个线程读同一个变量,多个线程修不同变量,以上都没有问题,只有多个线程修改同一个变量时就会出问题。
3.修改操作不是原子的。(解决线程安全问题的主要入手途径——多个操作通过特殊手段打包成一个操作)
count++本质上是三个cpu指令,如果这个指令执行一半就被调度走了我们就说这个修改操作不是原子的。
4.内存可见性问题
JVM代码优化引入的bug,优化就是在同等的效果下去提升效率,单线程下没啥问题。多线程下经常出现误判问题。
5.指令重排序
要怎么让线程安全?
通过特殊手段来让count++变成原子的,这种手段就叫做加锁。
加锁需要在count++之前就进行加锁,在count++之后再解锁。
在加锁和解锁之间,进行修改,这个时候别的线程要进行修改是无法进行的。其他线程只能阻塞等待,也就是处于BLOCK状态)
锁具有独占特性,当前没有人来加锁,加锁操作就可以成功,已经有人加过锁了在前一个锁解锁前都会阻塞等待。
锁可以理解为把并行的两组load add save 变成了并行操作的,因此会一定程度减少执行效率
synchronized
//使用这个关键字来进行加锁操作
这样一来,两个线程自增之和就到了十万。
上图的两个increase会涉及到锁竞争,一方要等另外一方解锁才可加锁成功。但是for循环是在外面的,仍然是并发执行的。
加锁想要考虑好锁那端代码,锁的代码不一样,对代码执行效果会有很大影响,锁的代码越多,锁的力度越大/越粗,反之越小/越细。
上图的for循环放在了锁里面,这下代码完全串行执行
线程加锁,不是加了锁就安全,而是通过加锁来让并发修改同一个变量变成串行修改同一个变量才得以安全。
因此在上述案例,也就是count++,如果一个线程加锁,就不会涉及到锁竞争,也就不会阻塞等待,也就没有作用了。
Synchronized关键字
可以修饰方法和代码块和静态方法。
也可以用这种方法来进行加锁,可以对自己想加锁的代码块来进行选择。
synchronized后面的括号需要天的加锁要针对加锁的对象,也就是锁对象。括号中填成this就是针对当前对象加锁,谁调用就对谁加锁。所以当中国关键字修饰方法时就相当于括号里写了this一样。
Tip;在java中,任意的对象都可以作为锁对象,而在C++或者python,go中,只有特定的对象可以被用于加锁。
出现锁竞争的情况:
1.
这个时候counter1和counter2就没有锁竞争,因为是两个不同的对象,加锁是针对counter和counter2两个不同对象加锁。、
2.
上图是针对locker对象进行加锁,locker是counter的一个普通成员,也就是每个countert实例都有自己的locker实例,这个时候因为两个counter是同一个对象,所以两个locker是同一个对象,使用还是会产生锁竞争。
3.
counter和counter2有两个不同的locker对象,所以也没有锁竞争。
4.
此时的locker是一个静态成员(类属性) ,因为一个进程中类属性只有一个,所以虽然对象是counter和counter2,但是还是同一个locker对象,存在锁竞争
5.
increase这个线程是在针对静态locker对象进行加锁,而increase2则是在针对这个counter对象本身进行加锁,本身是不同的两个对象,所以也没有锁竞争。
6.
这个时候锁对象变成了类对象 (简单地说就是描述这个类有啥东西的一个东西),一个jvm进程也就只有一个类对象,所以会存在锁竞争。
死锁问题
上述两组代码都是连续加锁了两次,第一次可以加锁成功,但是第二次加锁就会失败,因为锁已经被占用。这样的锁就是死锁,也叫做不可重入锁。
我们要写一个可重入锁,需要注意:
1.让锁里持有线程而对象,记录是谁加了锁
2.维护一个计数器,用来衡量啥时候真加锁,啥时候真解锁,啥时候直接放行
内存可见性问题
package threading;
import java.util.Scanner;
/**
* Created with IntelliJ IDEA.
* Description:
* User: admin
* Date: 2023-01-04
* Time: 20:07
*/
public class L1043 {
static class Counter{
public int count = 0;
}
public static void main(String[] args){
Counter counter = new Counter();
Thread t = new Thread(()->{
while (counter.count == 0){
}
});
t.start();
Thread t2 = new Thread(()->{
System.out.println("请输入一个数字");
Scanner scanner = new Scanner(System.in);
counter.count=scanner.nextInt();
});
t2.start();
}
}
结果并没有结束进程。
t1重复读取且结果一样,编译器为了提升效率而自动优化成不在循环度内存,读一次就结束了。这个时候t2把内存改了,t1也就没有感知到,这就是内存可见性问题,本质为编译器优化在多线程环境下的误判。