一、线程安全的概念
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。
二、线程不安全的原因
首先我们来看一个线程不安全的实例:
两个线程对一个数进行自增5w次,假如这个数开始是0,那么两个线程自增过后,预期结果应该是10w
public class Demo1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
for (int i = 0; i < 5_0000; i++) {
count++;
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker){
for (int i = 0; i < 5_0000; i++) {
count++;
}
}
});
t1.start();
t2.start();
//打印count值的时候, 需要等待t1 和t2 执行完
t1.join();
t2.join();
System.out.println(count);
}
}
运行结果:
可以看到运行的结果与我们预期的结果不一致,并不是10w,这个现象就是“线程不安全”
不过这到底是是为什么呢?为什么和我们预期的结果不一致,是哪里出问题了呢?
其实我们从下面两个角度解析这个问题:
(1)从开发者角度来看
因为多个线程操作了同一个变量(也就是共享了数据)并且存在至少有一个线程修改了这个变量。
假如多个线程操作了不同的数据,那么就不会有线程安全的问题;假如多个线程操作了一个数据,但都是进行了只读操作,也不会有线程安全问题。
(2)从操作系统的角度来看
count ++ 其实就是 count = count + 1
这行代码,其实对应了三个指令:第一个,LOAD指令,从内存里读取count值;
第二个,ADD指令,对数据进行 + 1 操作;
第三个,SAVE指令,把修改后的数据写回到主存中。
而线程的调度是随机的,可能会发生在任意时刻(LOAD | ADD | SAVE) 可能会在LOAD指令的前面,也可能会在ADD指令的前面,也可能会在SAVE指令的前面或者其后面。
下面我们来说几种调度情况:
t2在t1执行完结束后开始执行。这种的一个顺序执行,那是没有问题的,两个线程对count进行了2次的(+1)操作,count=2 注意主内存中的count=0
t2在t1的LOAD(读数据)操作后,ADD(修改数据)操作前开始执行,由于t1和t2线程都有自己的工作内存,在从主内存读数据到自己的工作内存中进行修改的过程中,两个线程不会影响彼此,所以这种情况就是两个线程都是从count=0开始,分别对count进行了一次(+1)操作,所以最后往主内存中写入count的值是1. 也就是说两个线程加了两次,但只生效了一次,这就是bug产生的根源。
t2在t1的ADD(修改数据)操作之后,SAVE(将数据写入主内存)操作之前开始执行,由于t2读数据是从主内存中读取的,尽管是在t1线程修改了数据后,但未写数据到主内存中时,t2线程读的数据还是主内存中的count=0值,所以结果同上,最终只生效了一次,最终主内存中的count=1。
也就是说,只要两个线程不是串行执行的,就一定会有问题,就会产生“线程不安全”问题。
以上就是产生线程不安全的原因。
总结:产生线程不安全,我们归其原因是因为线程安全的特性
1.线程调度的随机性(这是根本原因,但是咱们解决不了,无可奈何)
2.多个线程对同一个变量进行修改操作(也不一定能解决,看需求)
3.原子性
count++ 就是一个非原子性的操作,针对共享变量的操作,是非原子性的。(如果对于共享变量的操作是原子性的,那么就不会有线程安全问题的产生。)
4.内存可见性
一个线程对于数据的操作,很可能其他线程是无感知的,甚至某些情况下,会被编译器优化成完全看不到(比如:线程更新完数据后,没有立马更新到主内存;线程立马更新到主内存,但是其他的线程没有及时从主内存里读数据)
5.指令重排序
编译器会对代码进行一定的优化。程序员是期望代码从上到下去执行的,但是实际上,编译器会进行一些优化,使得代码的顺序被打乱。当然,编译器也不是随意的调整代码的顺序,要遵循“happen-before”原则。简单来说,在单线程的情况,不改变程序的结果,但是多线程的场景比较复杂,所以,不能保证。
三、线程不安全问题的解决办法
如何解决线程不安全问题呢?我们从以下三个方面入手
1.原子性
什么是原子性? 我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性 的。那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进 不来了。这样就保证了这段代码的原子性了。 有时也把这个现象叫做同步互斥,表示操作是互相排斥的。一条 java 语句不一定是原子的,也不一定只是一条指令。比如刚才我们看到的 count++,其实是由三步操作组成的: 1. 从内存把数据读到 CPU 2. 进行数据更新 3. 把数据写回到 CPU
synchronized通过加锁的方式,实现了原子性,内存可见性,对于指令重排序,有一定的约束,但不是完全禁止。
对于上面的t1和t2线程就是将LOAD、ADD、SAVE三个指令操作打包在一起,通过加锁的方式,把两个线程对数据的操作,变成了串行操作,让其非原子性变为原子性,就解决了线程不安全问题。 取数据之前,先加锁,在取数据操作,最后解锁出来。
通过synchronized加锁,代码如何去写呢?
锁的对象可以是java的任何对象,通过synchronized,把for那块代码绑定在一起,变成了原子性。两个线程必须对同一个对象进行加锁,这两个线程才能构成竞争。
synchronized是加锁,放在不同的地方,锁的对象也不一样。放在代码块前,锁对象可以是任意对象;放在普通方法前,加锁对象就是该实例;放在静态方法前,加锁对象就是静态方法。
通过synchronized加锁,解决线程不安全的具体使用方法。
(1)修饰代码块
放在代码块前,锁对象可以是任意对象。通过synchronize,把这块代码,绑定在一起,变成原子性。
public class Demo1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
for (int i = 0; i < 5_0000; i++) {
count++;
}
}
});
}
}
(2)修饰普通方法
锁对象是该实例。同一个实例,同时执行fun1,fun2时,只能有一个方法执行。
public class Student {
static int count = 80;
public synchronized void fun1(){
//....具体代码
}
public synchronized void fun2(){
//... 具体代码
}
public void fun3(){
synchronized (this){
}
}
}
(3)修饰静态方法
静态方法不属于任意一个实例类方法。修饰静态方法时,锁对象是“类对象”,类对象!=类实例。
public static synchronized void fun4(){
}
public void fun5(){
synchronized (Student.class){
}
}
接下来让我们来看一下在不同的情况下,t1线程和t2线程是否会互斥(是否会争夺资源)。
synchronized与join的区别:join更多是控制线程结束的,把两个线程改成了串行执行,而synchronized加锁,线程还是并发执行的
2.内存可见性。
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
volatile不能保证原子性,但是可以保证内存可见性,禁止指令重排序。
3.指令重排序
什么是代码重排序 ?一段代码是这样的:
1. 去前台取下 U 盘
2. 去教室写 10 分钟作业
3. 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问 题,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价