【多线程安全】死锁 锁竞争总结

下面有两段代码:

public class test {
    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();
        t1.join();  //程序这样写,相当于线程顺序执行

        t2.start();
        t2.join();

        System.out.println(count);
    }
}

上面代码中每个线程先start(),再join(),这样就相当于线程顺序执行了。不会发生线程不安全。

public class test {
    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++这个操作在CPU上是怎样执行的?

count++在CPU上执行大致分为三步:

  1. load 把数据从内存读到cpu寄存器中
  2. add 把寄存器中的数据进行+1
  3. save 吧寄存器中的数据保存到内存中。

那么当多个线程执行count++操作时,由于线程的调度顺序是随机的,就会如下图所示,假设执行顺序从上到下。

上述组合情况只列了四个,还是在两个线程的情况下,如果线程更多,那排列组合的情况也会更多。那么这种线程安全问题如何解决呢?为此我们引入加锁这种方式来解决。

 解决办法:  加锁   synchronized

  • synchronized修饰的是一个代码块。同时会指定一个"锁对象"。在进入代码块的时候,对该对象进行加锁,在出代码块的时候,对该对象进行加锁。
  • 锁对象一般是临时创建出来的,Object型的,对象是谁不重要,关键是两个线程加锁的对象是否是同一个。
  • 这样的好处就是当多个线程针对同一个对象进行加锁时,就会出现"锁竞争/锁冲突",并且只有当线程拿到锁之后,才能继续执行代码;没拿到锁时,就只能阻塞等待。等待拿到锁的线程释放锁之后,它才有机会拿到锁,继续执行。
  • 这样的规则,本质上是把"并发执行"变成"串行执行"了。
  • 并且加锁后可以将不是原子的 count++ 变成原子的。

即如图两种情况所示:

// 加锁  针对同一个变量进行修改时存在的线程问题的 例如下面的 count++
public class Demo10 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        //创建一个对象
        Object locker = new Object();
        Thread t1 = new Thread( () -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (locker) {     //加锁方式 ()中需要表示一个用来加锁的对象   synchronized 还可以修饰方法
                    count++;
                }

            }
        });

        Thread t2 = new Thread( () -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (locker) {     //加锁方式 ()中需要表示一个用来加锁的对象
                    count++;
                }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

 可以看到,加锁之后,符合预期结果了。

产生线程安全问题的原因 (面试题)

  1. 操作系统中,线程的调度顺序是随机的(抢占式执行)。
  2. 当两个或多个线程针对同一个变量进行修改时,如count++。
  3. 修改操作不是原子的。count++就是非原子的。
  4. 内存可见性问题。
  5.  指令重排序问题。

synchronized修饰的两种方法

  1. 修饰静态方法  即static修饰的方法。
  2. 修饰非静态普通方法。

1.修饰静态方法,实则修饰类对象  synchronized (类名.class) {  }  类对象在一个java进程中是唯一的。

    public static int count = 0;
    

    //修饰静态方法简化
    synchronized public static void increase() {    //修饰静态方法 实则修饰类对象 加锁
        count++;
    }


    //修饰静态方法完整
    public static void increase() {
        synchronized (Counter.class) {  //类对象
            count++;
        }
    }

2.修饰非静态普通方法,实则修饰this  synchronized (this) {  }

    public static int count = 0;

    //修饰非静态方法简化 实则修饰this
    synchronized public void increase() {
        count++;
    }
    //修饰非静态方法完整
    public void increase() {
        synchronized (this) {   //this
            count++;
        }
    }

synchronized的特性

可重入锁  可重入锁是如何实现的?

  1. 使用一个专门的属性来记录当前是那个线程加的锁。
  2. 引入一个计数器count++来记录当前已经加了多少层锁,还需要减多少下count--才可以真正释放锁。
  3. 即从最外面一层锁出来后才释放锁,中间的 synchronized 不会释放锁。

死锁 

  • 所谓的死锁就是线程一拥有锁1,线程二拥有锁2,双方在拥有自身锁的同时尝试获取对方的锁,最终两个线程就会进入无线等待的状态,这就是死锁。相当于门上钥匙锁车里了,车钥匙锁家里了。
  • 注意在代码中的两个sychronized是嵌套关系,不是并列关系,嵌套关系是指在占用一把所得前提下,获取另一把锁(会可能出现死锁)。而并列关系则是先释放前面的锁,再获取下一把锁(不会出现死锁)。
  • 还要注意代码中的的sleep,很重要,目的是保证两个线程先拿到各自的锁。
public static void main(String[] args) {
        //创建两个锁对象
        Object locker1 = new Object();
        Object locker2 = new Object();
 
        //创建两个线程
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                            
                    //此处的sleep很重要,确保t1和t2都拿到一把锁之后,再往后执行
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName());
                synchronized (locker2) {
                    System.out.println(Thread.currentThread().getName());
                }
 
            }
        },"t1");
 
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName());
                synchronized (locker1) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        },"t2");
 
        t1.start();
        t2.start();
 
    }

运行代码,并用jconsole观察:

不难发现,两个线程都处于BLOCKED阻塞状态,想要获取的锁都被对方拥有。

死锁的成因及如何解决??

成因有四个必要条件:

  1. 互斥使用。虽然是锁的基本特性,但也不可避免的会造成死锁,即当一个线程已经拥有一把锁之后,另一个线程也想获取到此锁,就要阻塞等待。
  2. 不可抢占。也是锁的基本特性。即当一个锁已经被线程1得到之后,线程2要想再获取此锁,就只能等待线程1主动释放锁,不能强行去抢。
  3. 请求保持。代码结构,程序员写的逻辑。即一个线程尝试去获取多把锁(先拿锁1,再尝试获取锁2,获取锁2的时候,锁1并不会释放),嵌套synchronized。
  4. 循环等待/环路等待。等待的依赖关系,形成环了。

解决死锁的方法

只要破坏上述必要条件中的任意一条就行,但前两条本身就是锁的特性,破坏不了,故破坏后两条任意一条就行。

对于第三条:调整代码逻辑,避免"锁嵌套"逻辑。也得看实际需求,可能实际需求就是会有"锁嵌套"这种逻辑。所以第四条的破坏就尤为重要。

public class bbbbbbbb {
    public static void main(String[] args) {
        //创建两个锁对象
        Object locker1 = new Object();
        Object locker2 = new Object();

        //创建两个线程
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {

                    //此处的sleep很重要,确保t1和t2都拿到一把锁之后,再往后执行
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }

            //挪出来
            synchronized (locker2) {
                System.out.println("t1 加锁成功!");
            }

        },"t1");

        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
            }

            synchronized (locker1) {
                System.out.println("t2 加锁成功!");
            }

        },"t2");

        t1.start();
        t2.start();

    }
}

对于第四条;可以约定加锁的顺序,就可以避免循环等待。即针对锁进行编号。比如约定在再多把锁的时候,先加编号小的锁,再加编号大的锁。规定所有线程都要遵守这个规则。

面试题: 你是否了解死锁,谈谈对死锁的理解???

volatile 关键字作用及使用

  1. 保证内存可见性
  2. 禁止指令重排序

1).保证内存可见性

由于计算机运行的程序/代码,经常要访问数据,这些数据被存储在内存中。CPU在使用这个变量的时候,就会把内存中的数据先读出来,再放到CPU的寄存器中,再参与运算。(load 在上文count++的三步时讲过)。

寄存器我们通常都知道,读内存的速度远远大于读外存的速度,而读寄存器的速度却又远远快于内存。CPU在进行大部分操作时都很快,但一旦操作到读/写内存时速度就变慢了。

为了解决这个变慢问题,提高效率,此时编译器就可能会对代码做出优化,即把一些本来要度内存的操作,优化成读取寄存器了,这样就会减少读内存的操作,也就会提高整体程序的效率了。但这种优化操作并不一定是我们想要的。于是也要去解决防止编译器自动优化。方法如下。

示例多线程代码:

预期目标是改变isQuit的值,然后打印 t1 线程执行结束!

import java.util.Scanner;

public class test {
    //两个线程
    //预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
    public static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {

            }
            System.out.println("t1 线程执行结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.print("请输入 isQuit=");
            isQuit = sc.nextInt();
        });

        t1.start();
        t2.start();
    }
}

结果是程序并没有停止运行。此时就是发生内存可见性问题,即编译器通过优化读取操作,把读内存优化成都寄存器了,而改变后的值在却内存中,虽然是读速度变快了,但是有可能也就不准了,这样的优化操作也无疑会使预期结果出错。

如何解决?即对该变量加上修饰符 volatile。就会禁止编译器做这种优化操作。

import java.util.Scanner;

public class test {
    //两个线程
    //预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
    public  volatile static int isQuit = 0;  //加 volatile
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {

            }
            System.out.println("t1 线程执行结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.print("请输入 isQuit=");
            isQuit = sc.nextInt();
        });

        t1.start();
        t2.start();
    }
}

或者,不加 volatile ,给循环里加个 sleep 也行,原因是加了sleep之后,while循环的执行速度变慢了,这样load操作的开销就不大了。因此优化也就没必要进行了。故没有触发load的优化,也就没内存可见性问题了。但总的来说,编译器优不优化,拿捏不准,还是最好使用volatile更靠谱

不过这种优化前提是在一个线程中使用,另一个线程中修改isQuit的值,编译器以为没人修改isQuit,就做出了优化。如果都是在一个线程中使用并修改,就不会有BUG了。

import java.util.Scanner;

public class aaaaaaaa {
    //两个线程
    //预期目标是改变isQuit的值,然后打印 t1 线程执行结束!
    public  volatile static int isQuit = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(isQuit == 0) {
                try {
                    Thread.sleep(1000);   //加sleep
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
            System.out.println("t1 线程执行结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.print("请输入 isQuit=");
            isQuit = sc.nextInt();
        });

        t1.start();
        t2.start();
    }
}

"内存可见性"问题

  • CPU先load操作读取内存中isQuit的值到寄存器中。
  • 再通过汇编语言cmp指令比较寄存器中的值是否为0,并决定是否要进行while循环。
  • 由于这个while循环速度非常快,短时间内会进行大量的循环,也就是进行大量的load和cmp操作。此时,编译器/JVM就会发现虽然进行了折磨多次的load,但是load书来的结果都是一样的,而且load操作有非常浪费时间,可以说一次load顶得上上万次cmp操作了。于是编译器就做了一个决定。只是在第一次循环的时候,去读内存即进行load操作,后续就不去再读内存了,而是直接从寄存器中读isQuit的值,即进行只cmp操作了,这样就即使是isQuit的值改变了,也感知不到。

若对于什么是原子操作,内存可见性,指令重排序,类对象不懂的可看我的下一篇博客。

  • 34
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 23
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

去北极避暑~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值