目录
一.引入
二.介绍
1.概念
2.产生的原因
三.修改操作不是原子性
1.分析问题
2.解决问题(锁)
四.可重入与不可重入
五.死锁
1.引入
2.死锁的三种情况
3.构成死锁的必要条件
六.内存可见性
1.引入
2.产生原因
3.解决问题
七.指令重排序
一.引入
首先用一个经典了例子引出线程安全的问题:
我们创建两个线程和一个count,在每个线程内写一个循环,每一个线程内 count++ 一万次,最后输出count。
private static int count;
public static void main(String[] args) throws InterruptedException {Thread t1=new Thread(()->{for (int i = 0; i < 10000; i++) {count++;}});Thread t2=new Thread(()->{for (int i = 0; i < 10000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);
}
我们预期的输出结果应该是20000,但事实恰恰相反:
如果我们多运行几次,会发现每次输出的结果都不一样。
那么为什么会这样呢,这就引出了线程安全的问题。
二.介绍
1.概念
简单来说,像上述这种使用了多线程,预期结果和实际结果不同,出现bug,这就是线程不安全。如果一段代码预期结果与实际结果相同,没有bug,那就可以说线程安全。
2.产生的原因
1)操作系统对线程的调度是随机的,是抢占式执行的(根本原因);
2)多个线程同时修改同一个变量;
3)修改操作不是原子性的;
4)内存可见性问题;
5)指令重排序。
三.修改操作不是原子性
操作系统规定好的规则我们不方便修改,多个线程同时修改同一个变量与代码结构强相关,我们通过调整代码结构来解决问题是不通用的。
我们可以通过让修改的操作变成原子性的,这一个解决线程安全的主要方案。
1.分析问题
用的还是上面那个count的例子。
为什么这个例子中的修改操作不是原子性的?
因为如果修改操作只是对应一个cpu指令的话,就认为的原子性的;反之则不是原子性的。像什么:++、--、+=、-=等等都不是原子性的,像什么:基本数据类型的赋值操作(move指令)、单线程操作等都是原子性的。
可见count++这个操作不是原子性的。
count++这个操作对应了三个cpu指令:
1)load,把内存中的值读取到cpu寄存器中;
2)add,把寄存器中的值++;
3)save,把寄存器的值写回内存中。
由于操作系统对线程的调度是随机的,执行任何一个指令都可能会进行线程切换的操作,如下面这个例子:
解释:我们简化一下,假设每个线程就循环一次。t1先load从内存中获得了count值,count=0。这时操作系统突然把线程切换到了t2,t2也load从内存获得了count=0;紧接着t2进行了add操作,此时t2中的count变成了1;最后t2将count值写入了内存中,此时内存中的count的值是1。
这时线程切回t1,t1进行add操作,count变成1;再进行save操作,count值写入内存,内存中的count值是1。
进行完上述操作我们发现:哎?count的值是1。正确答案应该是2。
这里只是举了简单的一个例子,其实三个cpu指令可以随机搭配(因为线程可以随机切换)。如果进行更多的count++,像上述例子一样,count++循环10000次,那变数更多了,可以变成组合就太多太多了。
如果我们将修改操作变成了原子性,那么不就可以实现线程安全了。
补充:
为什么赋值操作不用从内存中读取到cpu,再cpu赋值,最后写回内存这三步?
1)寄存器优化—寄存器复用。一些场景下,源值可能已经在寄存器中,因此赋值操作直接在寄存器中执行即可。
2)局部性原理。最近访问过的数据很可能在不久的将来再次被访问,因此cpu会缓存这部分数据,如果被访问了直接用;
3)硬件特性—存储缓冲区。现代 CPU 通常具有存储缓冲区,允许在不立即写回内存的情况下进行多次赋值操作。CPU 可以将这些赋值操作暂存在存储缓冲区中,然后在合适的时候批量写回内存。这样可以提高 CPU 的执行效率,因为它不需要每次赋值都立即访问内存。同时,存储缓冲区也可以合并多个对同一内存地址的写操作,减少内存访问的次数;
4)编译器优化—常量折叠。如果赋值操作的源值是一个常量,编译器可能会在编译时直接将常量值赋给目标变量,而不需要在运行时进行从内存读取和写回的操作;
5)编译器优化—优化的指令选择。编译器会根据目标 CPU 的架构和指令集选择最有效的指令来实现赋值操作。
2.解决问题(锁)
解决这个问题就引出了锁这个东西。我们可以通过加锁操作将不是原子性的操作,打包变成一个原子性的操作。
加锁的基本思想是通过限制对共享资源的访问,使得在同一时间只有一个线程能够访问和修改该资源。当一个线程获取到锁后,其他线程如果想要访问被锁定的资源,就必须等待锁被释放。
Java中使用了 synchronized 来进行加锁。
首先先介绍一下 synchronized :
synchronized (锁对象){//要执行的操作
}
锁对象就是用来加锁的对象,这个对象可以是任何一种对象,什么都行。
private static int count;
public static void main(String[] args) throws InterruptedException {Object lock=new Object();Thread t1=new Thread(()->{synchronized (lock){for (int i = 0; i < 10000; i++) {count++;}}});Thread t2=new Thread(()->{synchronized (lock){for (int i = 0; i < 10000; i++) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);
}
上面是使用了 synchronized 进行修改,现在代码可以正常输出预期的答案了:
但是要注意了,这个锁对象一定要是同一个,如果不同的话,跟没加锁有什么区别。比如一个厕所隔间有两个门,你锁了一个,另一个没锁,那不就等于没锁嘛。
另外,一个对象用作了锁对象,但其还可以正常使用,但不鼓励这么做,让一个对象只当锁对象比较好。
上面是给代码块加锁,我们还可以给方法加锁:
class Counter{int count;public synchronized void add(){count++;}
}
补充:synchronized 的底层是使用操作系统的mutex lock实现的。
四.可重入与不可重入
首先介绍一下不可重入锁,如果再锁里再加一个锁,不可重入锁就是会阻塞等待。对于不可重入锁,在锁里再加锁会引起死锁的问题。
为了解决不可重入锁的问题,Java的 synchronized 是可重入锁。那什么是可重入锁,简单来说就是在锁里还可以再加一个锁,但是并不会阻塞等待。
举个例子:
Object lock=new Object();
Thread t=new Thread(()->{synchronized (lock){synchronized (lock){for (int i = 0; i < 10000; i++) {count++;}}}
});
按照其他的锁,上面就形成死锁了,但是 synchronized 加锁成功后再次要加锁时会直接跳过。
在可重入锁内部记录了“线程持有者”和“计数器”两个信息。
线程持有者的信息保证了如果线程获取锁时发现占用锁的是自己,会直接获取锁,并且让计数器+1。当计数器内的数为0时释放锁,并不是出一个 synchronized 就释放锁了。
五.死锁
1.引入
Object lock1=new Object();
Object lock2=new Object();
Thread t=new Thread(()->{synchronized (lock1){try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (lock2){for (int i = 0; i < 10000; i++) {count++;}}}
});
Thread t2=new Thread(()->{synchronized (lock2){try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (lock1){for (int i = 0; i < 10000; i++) {count++;}}}
});
t.start();
t2.start();
t.join();
t2.join();
System.out.println(count);
这就是一个典型的死锁场景,把上面的代码翻译成现实中的例子是:小明要玩会手机才写作业,而妈妈要小明写完作业后才能完手机,这就是死锁了。找工作要有实习经验,找实习要有工作经验,这也是死锁。
从上述例子可以看出死锁是一个很严重的事情,死锁会造成系统资源浪费、系统性能下降、系统可靠性下降等问题。
2.死锁的三种情况
1)一个线程,一把锁,连续锁两次;
2)两个线程,两把锁,每个线程获取一把锁后,尝试获取另一把锁,这就是上面举的那个例子;
3)n个线程,m把锁。
对于第三种情况有一个非常经典的模型方便大家理解,哲学家模型。
问题是这样的:一张圆桌上坐着五名哲学家,在每两名哲学家中间放着一根筷子,哲学家们的生活方式只做两件事:思考和进餐。饥饿时哲学家必须同时拿起两只筷子时才能进餐,进餐完毕后,放下筷子,进行思考。如果筷子被紧挨着的一名哲学家使用着,则不能争抢,必须等待,当这名哲学家就餐完毕后,放下筷子,才能使用。
当五个哲学家同时想进餐,他们都拿起了自己右手边的筷子,那么就是产生死锁问题,大家都无法吃饭->无法吃饭就无法放下->无法放下就无法获得两支筷子去吃饭,“闭环了”。
3.构成死锁的必要条件
1)锁是互斥的(锁的基本性质),如果一个线程拿到了锁,另外的线程想要拿到锁必须等待;
2)锁是不可抢占的(锁的基本特性),一个锁被线程A拿了,线程B也想要这个锁,线程B是不能强抢过来的;
3)请求和保持,当一个进程已经占有了一些资源,同时又去请求新的资源,但是在新资源尚未得到满足之前,该进程不会释放已经占有的资源;
4)循环等待,存在一组进程,其中每个进程都在等待另一个进程所占有的资源,形成一个循环等待的链条。
要想出现死锁,必须同时满足上面的四个条件,只要有一个不满足,死锁就不会出现了。
上述四个条件中,1和2是锁的基础的东西,我们无法左右。但是我们可以通过破坏3和4来避免死锁。
还是上面哲学家的例子,有一个非常明显的条件就是大家都在等对方把筷子放下。如果我们规定好了拿筷子的顺序,那么就可以破除这个循环等待的条件了。
我们给每个哲学家和筷子编号,哲学家从2到5到1开始获取筷子,每次都是先获取编号小的筷子,后获取编号大的筷子。哲学家2获取了1号筷子,哲学家3获取了2号筷子...到哲学家5时获得1号筷子,到哲学家1想获得1号筷子,但是1号筷子已经被哲学家2拿走了,所以哲学家1没有拿到筷子。
新一轮开始,大家开始拿大号的筷子,但是2号筷子被哲学家3拿走,因此哲学家2无法吃饭...哲学家5可以获得5号筷子,因为大家没有取5号筷子,因此哲学家5号吃上了饭。吃完饭后哲学家5退出。因此类推,直到大家都吃完饭了。
六.内存可见性
1.引入
还是通过一个例子来引出内存可见性问题:
private static int fg;
public static void main(String[] args){Thread t1=new Thread(()->{while(fg==0){}System.out.println("线程t1结束");});Thread t2=new Thread(()->{Scanner scan=new Scanner(System.in);System.out.println("输入fg的值:");fg=scan.nextInt();});t1.start();t2.start();
}
按照我们的期望,当我们输入fg(随便一个不等于0的数)时,线程t1会结束。但当我们输入一个不等于0的数的时候,t1并没有结束。
2.产生原因
要想解决这个问题,首先要明白这个问题是怎么产生的。
上述代码产生问题的罪魁祸首是编译器和JVM的优化。编译器会对我们写的代码进行优化,但是在多线程的环境,优化完的代码可能与实际的逻辑有偏差。
while(fg==0){}
while每次循环都会去内存中读取fg的值来判断是否退出循环,JVM发现,每次取的fg值都是0,每次去内存中取值都要消耗不少时间。因此JVM对此进行了优化,将从内存中读取值的操作换成从寄存器中读取值这样就可以提高效率了。其实这种优化在上面修改操作不是原子性那里有提到过。
此时,我们输入fg的值,我们输入的值是存储在内存中的,而while的fg值是从寄存器中读取的,所以无法改变while中的fg值。
3.解决问题
Java中引入了 volatile 关键词。
使用 volatile 修饰的变量在编译器对这个变量进行读取操作时,不会被优化到寄存器中。使用 volatile 关键字修饰的变量,当一个线程对其进行修改时,这个修改会立即被写入主内存,并且其他线程能够立即看到这个修改。
因此我们可以修改一下前面的代码了:
private static volatile int fg;
public static void main(String[] args){Thread t1=new Thread(()->{while(fg==0){}System.out.println("线程t1结束");});Thread t2=new Thread(()->{Scanner scan=new Scanner(System.in);System.out.println("输入fg的值:");fg=scan.nextInt();});t1.start();t2.start();
}
此时的结果就是我们想要的了:
七.指令重排序
属于编译器优化的一种。其会保证在原有的逻辑不变的情况下,调整原来代码的顺序,以达到提升效率。举一个例子:我想泡茶喝,我有一系列任务想要完成,洗茶具->烧水->泡茶。当我真正执行这些任务的时候发现我完全可以先烧水,在烧水的时候去洗茶具,这样可以节省时间,提升效率。不论是原来的顺序还是后来我们调整的顺序,我都能完成喝茶这个任务。
这就是指令重排序,编译器会根据情况对指令进行重排序。这个行为在单线程来看没有上面问题,但是在多线程中就会出现问题。
比如我们想要new一个对象,这个行为会经历一下几个指令:1.申请内存空间;2.在空间中构造对象;3.将内存空间的首地址赋值给引用变量。编译器如果对上面的三步进行重排序,让他们的顺序变成了132,如果先执行了13,这时线程切换了,另一个线程使用了还没有创建的对象(因为2没有执行),这时就出现错误了。
这个问题也可以使用 volatile 来解决。