线程安全问题(2) --- synchronized,volatile关键字

目录

一,synchronized的特性

1.1 互斥性

1.2 可重入性

二, 死锁

2.1 死锁产生的原因

三,volatile 关键字

3.1 能保证内存可见性

3.2 无原子性 


一,synchronized的特性

1.1 互斥性

当两个线程对同一个对象加锁时,后加锁的线程要 "阻塞等待" ,直到第一个线程的锁释放。

1.2 可重入性

我们都知道当两个不同的线程正对同一个对象进行 "加锁" 时,会出现锁竞争,那么如果当一个线程对同一个对象连续 "加锁" 时,会怎么样呢?,比如下面的代码:

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker){
                synchronized (locker){
                    System.out.println("t1执行");
                }
            }
        });
        t1.start();
        t1.join();
    }
}

按照之前说的,当 t1 线程第一次 "加锁" 成功后,此时的 locker 就属于是 "被锁定" 状态,那么当 t1 线程进行第二次 "加锁" 时,原则上是要 "阻塞等待" ,等到锁(locker)被释放了之后,才能再次 "加锁" ,但是在上述的代码中,只要第二次(locker)不加锁,第一个锁(locker)就不会被释放,而第一个锁(locker)不释放,第二次(locker)又不能加锁,这样的话,在逻辑上,上述代码中的线程 t1 就会产生死锁从而卡死,很明显,这是一个BUG。

但是在日常开发中,这个BUG又很难避免,这时候有人会说,你上面的代码不是一眼就看出来了,这还不好避免?我在这里再举一个例子:

class Test1{
    Object locker = new Object();
    public void fun1(){
        synchronized (locker){
            fun2();
        }
    }
    public void fun2(){
        fun3();
    }
    public void fun3(){
        fun4();
    }
    public void fun4(){
        synchronized (locker){
            System.out.println("4444");
        }
    }
}
//当出现上述代码时,当我们调用 fun1 方法,根本看不出来有什么问题!!!

所以为了解决上述的BUG,设计Java的那群人就把 synchronized 设计成 "可重入锁" ,就是说当一个线程对同一个对象连续加锁时,这个锁会自己记录是哪个线程给它 "加锁" ,这样后续再次加锁时,如果加锁线程就是当前持有锁的线程,就直接加锁成功。

这里我要提出一个问题:synchronized 虽然是可重入锁,避免了死锁,但是当一个线程对同一个对象加 N 层锁时,我们的锁什么时候释放?以及计算机如何判断锁释放的时机?第一个问题很简单,肯定是要第一次加锁的{}执行结束锁才能释放,第二个问题:锁对象不光会统计是谁拿到了锁,还会记录锁被加了几次,每一次加锁,计数器+1;每一次解锁,计数器-1。当出了最后一个{},计数器恰好为0,释放锁。

二, 死锁

产生死锁的情况:

1. 如果 synchronized 没有可重入性,对同一个对象连续加锁

2. 两个线程,两把锁,synchronized嵌套使用(可能产生!!!)。例如:

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker1){
                try {
                    Thread.sleep(1);//为了让t1拿到locker1,t2拿到locker2
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2){
                    System.out.println("t1结束");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2){
                synchronized (locker1){
                    System.out.println("t2结束");
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

3.  M个线程,N把锁(相当于2的推论)

2.1 死锁产生的原因

四个必要条件(只要下面4个条件中有一个不成立,那么就不会形成死锁)

  1. 互斥使用(锁的基本特性)即当两个线程对同一个对象加锁时,后加锁的线程要 "阻塞等待" ,直到第一个线程的锁释放。
  2. 不可抢占(锁的基本特性)与第一点相同
  3. 请求保持,即锁和锁之间可以嵌套使用
  4. 循环等待/环路等待,即等待的依赖关系形成了环,比如:车钥匙锁家里了,家钥匙锁车里了。与上述代码一个情况

那么我们如何解决死锁? 

因为上述的4个条件中,前两个条件是锁自带的属性,无法干预,因此我们只能从后两个条件入手。对于条件3:只要我们避免编写锁嵌套的逻辑就行。(但是有的情况下,这是无法避免的)对于条件4:给锁编号,约定加锁的顺序,比如:约定先加编号大的锁,后加编号小的锁,所有的线程都要遵守。

三,volatile 关键字

3.1 能保证内存可见性

什么是内存可见性?

在计算机运行代码时,要经常访问数据,这些数据往往存储在内存中(定义一个变量,这个变量就存储在内存中)。而cpu读取内存的这个操作,比cpu读取寄存器慢了几万倍,这时就会出现,cpu在解决大部分的情况时,速度很快,一旦读取内存,速度瞬间就慢下来了的情况。

为了解决上述问题,提高运行效率,此时编译器就会对代码做出优化,把一些原本读取内存的操作,优化成读取寄存器,这样就减少了读内存的次数,从而提高了整体的效率。举一个例子:

public class Demo3 {
    static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while(flag){

            }
            System.out.println("循环结束!");
        });
        t1.start();
        Thread.sleep(10);
        flag = false;
        t1.join();
    }
}

 很明显,即使我们将flag改成false,线程也没有停止循环,这就是 "内存可见性" 引起的,由于该循环没有任何其他操作,循环速度非常快,就会进行大量的 load(将flag读取到内存) ,cmp 操作。此时编译器发现,虽然进行了多次 load 操作,但是 flag 的值没有改变,而 load 操作又很浪费时间,所以编译器直接就在第一次循环的时候,读内存,将flag存到寄存器中,后续就不读内存了,直接从寄存器中取出 flag,这就是导致 "内存可见性" 问题。

而 volatile 关键字可以解决这一问题,只要被 volatile 修饰的变量,编译器就不可以对其进行优化,也就是说,该变量必须从内存中读取。例如:

public class Demo3 {
    volatile static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while(flag){

            }
            System.out.println("循环结束!");
        });
        t1.start();
        Thread.sleep(10);
        flag = false;
        t1.join();
    }
}

 还有一点需要注意的是,编译器优化的触发是不确定的,我们不知道它什么时候触发,什么时候不触发。所以最好使用 volatile 关键字!!!

3.2 无原子性 

volatile 关键字不能像 synchronized 关键字那样让 count++ 这类操作变成原子操作!!例如:

public class Test {
    static volatile 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);
    }
}

 3.3 禁止指令重排序

比如说我们常常使用的 new 操作,它会大概分成三步 : 1. 向内存申请一块空间   2. 实例化变量   3. 返回该空间的引用,它可以是 1 -> 2 -> 3 ,也可以是 1 -> 3 -> 2,在有些情况中,我们必须要保持 1 -> 2 -> 3 的顺序,这时候我们就可以使用 volatile 关键字。

猜你喜欢

转载自blog.csdn.net/m0_74859835/article/details/132779308
今日推荐