一、 案例引入
线程安全是多线程中最重要最复杂的部分。可能同一份代码在单线程的环境下执行是正确的,但在多线程环境中就不一定了。
示例:
public class Test4 {
private static int count = 0;
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: " + count);
}
}
在逻辑上,count应该自增了2w次,最终count的值应该为2w,然而结果却不是2w,而且几乎每次运行的结果都不相同。
解释:
这是因为count++ 操作并不是原子的,本质上是分成三步的:
1、load 把数据从内存中读到cpu寄存器中
2、add 把寄存器中的数据进行+1
3、save 把寄存器中的数据,保存到内存中。
如果是多个线程执行的话,由于线程之间的调度顺序是随机的,并不确定,就会导致出现问题。
如:当第一个线程正在进行第一个操作的load的时候,第二个线程已经完成了第二、三、四的操作,此时第一个线程再进行第一个操作的add的时候,从寄存器中读取到的数据是0,而非3,因此就会出现错误。
总结:
产生线程安全问题的原因:
1、操作系统中,线程的调度顺序是随机的(抢占式执行)
2、不同线程,最对同一个变量进行修改
3、修改操作,不是原子的,即某个操作必须一起全部完成。
4、内存可见性问题
5、指令重排序问题
那要如何保证代码一定准确呢?答案是加锁!
synchronized(对象名){
}
注意:
() 中需要表示一个用来加锁的对象,这个对象是啥不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁。如果两个线程是在针对同一个对象加锁,就会有锁竞争,如果不是针对同一对象加锁,就不会有锁竞争,而此时的并发程度最高,但是不能保证正确。
{}内的代码就是要执行的内容了。
当一个线程拿到了这把对象锁之后,另外一个线程就得阻塞,等待上一个线程释放锁,之后再进行竞争这把锁。
二、Synchronized的特性
2.1 修饰权限
synchronized不仅能修饰代码块,还可以修饰方法。
如下:
class Test{
synchronized void fun(){
}
}
//相当于
class Test{
void fun(){
//使用this,表示对当前对象加锁
synchronized(this){
}
}
}
//静态方法也是一样
class Test{
synchronized static void fun(){
}
}
//相当于
class Test{
static void fun(){
//这里Test.class为类对象
synchronized(Test.class){
}
}
}
2.2 刷新内存
由于网上众说纷纭.......
2.3 可重入
所谓的可重入锁指的是一个线程中连续对某一个对象进行加锁,但不会出现死锁的现象,如果满足就是“可重入”。
举个例子:
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker){
synchronized (locker){
}
}
});
}
}
分析:如果没有可重入特性的话......
假设当在最外面的时候对locker对象加锁成功了,此时locker对象应该是"被锁定的状态",然后进行内层的加锁操作,发现此时locker已经是锁定的状态了,原则上,需要阻塞等待locker对象的锁被释放,才能进行第二次加锁,这样就形成了“死锁”,即第二次加锁操作需要等待第一次加锁操作释放锁,第一次加锁操作需要等待第二次加完锁后执行代码才能释放锁.......
但在Java中并不会出现这种情况,这是因为synchronized的可重入特性。当进行加锁操作的时候,会先记录一下是哪个线程获得了这个对象锁,后续这个线程再进行加锁的话,会检查是否已经持有了这个对象锁,如果有直接加锁成功。同理释放锁是在最外层的synchronized结束后,才释放锁(底层使用了计数器来管理,每当加锁一次,计数器+1,出了这个锁,计数器-1,如果为0了,则真正释放锁)。
三、 死锁
死锁可大致分为两类:一个线程一把锁,N个线程M把锁。
3.1 一个线程一把锁
这种情况也就是上面所说的情况,但在Java中synchronized是可重入锁,并不会产生,但在c++中,std::mutex可并不是可重入锁,就会出现死锁。
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
//并不会死锁~~
synchronized (locker){
synchronized (locker){
synchronized (locker){
synchronized (locker){
synchronized (locker){
}
}
}
}
}
});
}
}
3.2 N个线程M把锁
这个情境下,最经典的就是哲学家就餐问题。
描述如下:
有5个哲学家在一张桌子前吃饭,在每个哲学家左手边放置一根筷子,哲学家拿起两根筷子才能吃饭,吃完饭才能把筷子放下。
如果某一时刻,某几个哲学家手快,拿起了两个筷子,那么这些哲学家就可以吃饭,吃完后放回筷子,然后竞争下一次。如果非常不巧,每个哲学家都抢到一根筷子,此时,所有人都没有两根筷子且此时所有人都持有一根筷子,每个哲学家都在等待别人将筷子放下,但是只有拿到两根筷子后才能放下筷子,这就陷入了死局。
死锁是比较严重的bug,会导致线程卡住,无法执行后续的代码。
如何避免死锁的产生呢?首先考虑产生的原因(4点)。
1、互斥使用(锁的基本特性,无法改变)。即两个线程不能同时获得同一把对象锁,当一个线程获得这个对象锁的时候,另一个线程需要阻塞等待。
2、不可抢占(锁的基本特性,无法改变)。当一个线程获得这把对象锁后,另一个线程不能抢过来,只能等待释放这把锁才能去竞争。
3、请求保持(可通过调整代码结构避免)。一个线程可以拿到多把对象锁。即当一个线程获取到了锁1,再获取到了锁2,锁1不会立即释放。(吃着碗里的,看着锅里的)
4、循环等待(可通过调整代码结构避免)。如上述哲学家就餐问题,等待的依赖关系成环了。
要想出现死锁的情况,需要把上面的4个条件都占了,但其中的1和2是锁的基本特性不可避免,因此我们只需要针对3和4的情况。
对于条件3,避免编写“锁嵌套”,但这个有时候也无法避免。因此我们着重对条件4着手。
对于条件4,可以约定加锁的顺序,这样就可以避免循环等待。如:针对锁进行编号,加多把锁的时候,先加编号小的锁,再加编号大的锁。
哲学家就餐问题解决方案:
我们规定,每个哲学都要遵守如下规定:选择左手和右手中编号较小的一根筷子,如果较小的那根筷子没了,那就等待出现编号小的筷子再进行竞争。这样优化以后,就不会出现僵持的现象了。
四、 volatile
4.1 案例引入
public class Demo01 {
private static int Quit = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(Quit == 0){
}
System.out.println("退出成功");
});
Thread t2 = new Thread(() -> {
System.out.println("请输入Quit的值:");
Scanner sc = new Scanner(System.in);
Quit = sc.nextInt();
});
t1.start();
t2.start();
}
}
在这段代码中,当我们通过线程2来修改Quit的值变为1时,此时线程1并没有退出输出“退出成功”,而是依然还在运行。
此处的问题就是”内存可见性“引起的,其实是编译器优化错了。
4.2 内存可见性
为什么会有内存可见性?
这是因为在计算机运算代码的时候,要经常访问数据,而这些数据存储在内存中,cpu使用这个变量的时候,就会把到内存中取出这个数,放到寄存器上,然后进行计算,但是读内存的速度相较于读寄存器慢了几千倍,如果要频繁的读内存的话会大大降低效率,因此编译器为了解决频繁读内存的问题,就对代码进行了优化,把一些本来要读取内存的操作优化成读取寄存器,从而使整体效率提升了。
对于上述案例,因为线程1的循环体内没有做任何事情,因此循环的速度非常快,但每一次循环的时候,都需要读取内存中Quit的值到寄存器中,编译器发现你老是读取这个值,然后这个值还一直没有修改,而每一次读都非常浪费时间,于是编译器就做了一个大胆的决定,不再从内存中读取了,而是直接从寄存器中拿值比较,于是后面的修改只是修改了内存中的值,实际比较的时候并没有改变。
这种情况下就得使用volatile来修饰Quit。在多线程环境下,编译器对于优化的判定不一定准确,此时就需要程序猿通过volatile关键字,告诉编译器不要进行优化。
五、 wait和notify
在多线程编程中,我们往往会涉及到多个线程间的配合调用。前面所提到join方法可以使线程阻塞,但得等到某个线程执行完后,才能解除阻塞,继续执行,而通过使用wait方法,可以手动阻塞某个线程,然后通过notify方法手动再让线程继续执行。
5.1 wait
wait方法的作用是:让当前调用的线程进入等待状态,直到其他线程调用notify方法。
wait方法是Object的方法,因此任何对象都有wait方法。
在执行wait方法的时候,会做3件事情。
-
1、释放当前锁(如果当前线程没有进行加锁操作会报错)
-
2、让当前线程进入阻塞状态
-
3、当线程被唤醒的时候,尝试重新获取这把锁
5.2 notify
notify方法是用来唤醒等待的线程。有以下3点需要值得注意:
-
1、notify方法需要在synchronized代码块中调用
-
2、notify方法调用完后,当前线程不会立马释放对象锁,而是等到执行notify方法的线程执行完所有代码后才会去释放对象锁
-
3、如果有对个线程等待,会随机挑选一个等待的线程唤醒。因此还提供了notifyAll方法,可以唤醒所有等待的线程。
5.3 线程饿死
假设现在有多个线程来竞争一把锁,第一次线程1抢到了这把锁,执行完代码后就释放了锁,然后进行下一次的锁竞争,恰巧第二、第三、第四...........第N次又抢到了这把锁(因为线程1已经在cpu上执行,没有调度的过程,更容易拿到锁),但是线程1每一次拿到锁又不干嘛,就光竞争,最后就有可能导致某些关键的线程一直拿不到锁。我们称这种情况为“线程饿死”。
针对这种情况,我们可以使用wait和notify来解决。让线程在某个条件下调用wait,把资源让出来,不参与后续竞争。