JUC学习笔记(四):JMM、volatile和锁整理

JMM

  • JMM是Java内存模型,是一些内存使用上的约定,其中一些重要的规则:

  1. 线程解锁前,必须把共享变量立刻刷回主存。

  2. 线程枷锁前,必须读取主存中的最新值到工作内存中。

  3. 加锁和解锁是同一把锁

多线程下对共享变量的8种操作

  • 对于1个线程要读取主存中的1个变量时,有这8种操作。

  1. read读取:从主内存读取数据

  2. load载入:将主内存数据读取到的数据写入工作内存

  3. use使用:从工作内存读取出数据使用

  4. assign赋值:将计算好的值重新赋值到工作内存中

  5. store存储:将工作内存数据写入主内存

  6. write写入:将store过去的变量赋值给主内存中的变量

  7. lock锁定:将主内存变量加锁,表示为线程独占状态

  8. unlock解锁:将主内存变量解锁,解锁后其他线程可以锁定该变量

线程运行时从主内存将共享变量读出,在工作内存中存储一份共享变量副本,之后的操作都会使用这个副本的值。这会导致多线程修改主内存变量后造成其他线程工作内存数据与主内存中数据不一致问题。

JMM对这八种指令的使用,制定了如下规则:

  • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write

  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存

  • 不允许一个线程将没有assign的数据从工作内存同步回主内存

  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作

  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁

  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值

  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量

  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

Volatile

  • Volatile 是Java虚拟机提供的轻量级同步机制

  1. 保证可见性

  2. 不保证原子性

  3. 进制指令重排

可见性

package com.rzp.voliteilskf;
​
import java.util.concurrent.TimeUnit;
​
public class JMMDemo2 {
    private static int num = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (num == 0) {
                int a = num;
                int b= 0;
                b++;
//                System.out.println(Thread.currentThread().getName()+"num==");
            }
        }, "Thread A").start();
        TimeUnit.SECONDS.sleep(1);
        num = 1;
        System.out.println(num);
​
    }
}

  • 在这个例子中,直接运行,输出1后程序不会中断,说明了Thread A中没有读取到主线程对num的改变。

  • 这个时候只要把num加上Volatile关键字,那么输出1后程序就会中断,说明ThreadA读取到了num的改变。

    private volatile static int num = 0;

但是,其实并不是不加volatile就一定读取不到,测试到:

while (num == 0) {
    System.out.println();
    Thread.sleep(1000); 
    File file = new File("C:\work\test.txt"); 
}

以上三种情况都会停止,也就是说JVM会在这三种情况刷新线程工作内存:

  1. 输出的时候停止,甚至不需要输出num就会停止。

  2. 线程休眠、唤醒。

  3. IO操作。

  • 这并不意味着volatile没有意义了,volatile是官方给出的轻量级刷新的关键字,使用volatile是更有保证的做法。(其实用锁或者synchronized会更重量级)。

原子性

  • 代表最小颗粒度,要么同时成功,要么同时失败。

  • volatile不保证原子性

package com.rzp.voliteilskf;
​
public class JMMAtomic {
    private static volatile int num = 0;
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                for (int i1 = 0; i1 < 1000; i1++) {
                    add();
                }
            },"Thread Name").start();
        }
        //正在执行的线程数量
        while (Thread.activeCount()>2){
            Thread.yield();
        }
​
        System.out.println(Thread.currentThread().getName()+" "+num);
​
    }
​
    public static void add(){
        num++;
    }
}

  • 20条线程,每条1000,理论上num最后应该累加到20000,但是实际输出总是达不到。说明无法保证原子性。

  • num++本身不是一个原子性的操作,底层中,num++实际上有4步操作,也就是说:

    • 当线程a走到第一条代码,重新获取了值,这时候值是最新的。

    • 但是走到第二条代码,可能有线程b把这个值刷新了,但是a不会重新获取了,于是引起了加法不能成功。

    • 要解决这个问题就只能用锁来解决,可以是synchronized、lock,也可以是乐观锁。

    • 乐观锁的具体解决方案之一就是使用原子类(下文),原子类的底层代码就是CAS,原理就是乐观锁,见后文解释。

  • 注意一点就是,这里虽然反编译后变成了4行代码,但并不意味着这里1行代码就是原子性了,实际上1行代码仍然需要机器去编译解析,变成更多行代码。

使用原子类解决问题

  • 上面的问题,如果加lock和synchronized肯定是可以的。

  • 不使用lock和synchronized可以使用原子类解决java.util.concurrent.atomic

  • 使用原子类甚至不加volatile都是正确的。

package com.rzp.voliteilskf;
​
import java.util.concurrent.atomic.AtomicInteger;
​
public class JMMAtomic {
    private static AtomicInteger num = new AtomicInteger(0);
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(()->{
                for (int i1 = 0; i1 < 1000; i1++) {
                    add();
                }
            },"Thread Name").start();
        }
        //正在执行的线程数量
        while (Thread.activeCount()>2){
            Thread.yield();
        }
​
        System.out.println(Thread.currentThread().getName()+" "+num);
​
    }
​
    public static void add(){
        num.getAndIncrement(); //+1操作 底层是CAS,见后文
    }
}

禁止指令重排

指令重排定义

  • 计算机并不是按照我们写的程序去执行的。

  • 这里经历了以下步骤:

    • 源代码

    • 编译器优化 ->重排

    • 指令并行 ->重排

    • 内存系统 ->重排

    • 执行

int x = 1; //1
int y = 2; //2
x = x + 5; //3
y = x + x; //4

我们期望的是1234,但是如果执行2134、1324结果仍然是对的。
   但是不可能是4123,因为/*处理器在进行指令重排的时候会考虑到数据之间的依赖性*/

指令重排可能影响的结果

//主线程中有两个值:
a = 0
b = 0
//假如有另外两条线程
线程A:
   x = a
   b = 1
线程B:
   y = b
   a = 2
正常顺序的结果是:
   x = 0
   y = 0
/*但是因为可能有指令重排,因为对于线程A来说
   x = a 和 b = 1 这两行代码的顺序是没有依赖关系的
因此指令重排最终结果可能会变成了:*/
   x = 2
   y = 1
对于程序来说识别不出来,但是实际因为有两条线程操作相互的变量,可能会导致诡异的结果。

volatile避免指令重排:

  • volatile实际上是调用了CPU的内存屏障。

  • 内存屏障是CPU的功能,能禁止指令重排。

单例模式

volatile一个典型的应用场景就是单例懒汉模式。

1.回顾单例模式

package com.rzp.singlemodel;
​
public class DemoHungry {
    //回顾单例模式
    //构造器私有化
    private DemoHungry() {
    }
​
    //静态加载一个final的对象
    private final static DemoHungry hungry = new DemoHungry();
​
    //通过方法来获得对象
    public static DemoHungry getInstance(){
        return hungry;
    }
}
​

2.懒汉式

package com.rzp.singlemodel;
​
public class Lazy {
​
    private Lazy() {
        System.out.println(Thread.currentThread().getName() + "ok");
    }
​
    private volatile static Lazy lazy;
​
    public static Lazy getInstance() {
        //懒汉式模式,要使用的时候才new出来
        //但是多线程下就会有并发问题,导致new了多个对象,没达到单例模式的效果
        if (lazy == null) {
            //这时候可以使用双重检测锁模式,就是对类对象加synchronized
            synchronized (Lazy.class) {
                if (lazy == null) {
                    //但是极端情况下还是可能会有问题
                    lazy = new Lazy();
                    /**
                     * 因为new 不是一个原子性操作
                     * 1.分配内存空间
                     * 2.执行构造方法,初始化对象
                     * 3.把这个对象执行这个空间
                     *
                     * 这3步操作是有可能指令重排的
                     * 因此在实例上还要加上一个volatile,禁止指令重排
                     */
​
                }
            }
        }
        return lazy;
    }
​
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                Lazy.getInstance();
            }, "Thread Name").start();
        }
    }
}
​

3.枚举类

  • 懒汉式可以被反射破坏,这时可以使用枚举类,因为反射的newInstance方法天然就对枚举类做出了控制

  
 @CallerSensitive
    public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        if (!override) {
            if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                Class<?> caller = Reflection.getCallerClass();
                checkAccess(caller, clazz, null, modifiers);
            }
        }
                   //这里,判断如果是枚举类,就会抛出异常
        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(initargs);
        return inst;
    }
  • 代码示例

package com.rzp.singlemodel;
​
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
​
//枚举单例模式
public enum  EnumSingle {
​
    INSTANCE;
    public EnumSingle getInstance(){
        return INSTANCE;
    }
​
}
​
//测试
class Test{
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        EnumSingle instance1 = EnumSingle.INSTANCE;
        EnumSingle instance2 = EnumSingle.INSTANCE;
​
        System.out.println(instance1==instance2);
​
        //如果通过反射获取单例,但是程序运行,会提示NoSuchMethodException,说明实际没有空参构造方法.
//        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
        //而通过反编译,可以发现实际上构造器是有2个参数的。
        //这次就能真正的获得枚举类抛出的异常Cannot reflectively create enum objects
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);
        declaredConstructor.setAccessible(true);
        EnumSingle instance3 = declaredConstructor.newInstance();
​
​
        System.out.println(instance1==instance3);
​
    }
}
​
  • 枚举类的最终反编译:

乐观锁和悲观锁

悲观锁(Pessimistic Lock)

  • 悲观锁就是要通过锁机制,保证一次只有1个线程对数据做修改。

    • 这个锁机制是泛指,比如java的lock、synchronized,还有数据库自带的锁,比如MySQL的for update。

    • 一般的实现就是我们上面学的读写锁(共享锁+独占锁)

  • 但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

乐观锁( Optimistic Locking )

  • 乐观锁就是不使用锁机制,而是通过对数据的校验来判断是否可以修改数据。

  • 比如要修改某一条数据,只有在真正提交的时候,再去校验数据是否没有被修改(常见的方式是增加version字段,每次修改就给version+1,修改提交前检查version是否和原来的一样),如果校验没被修改(version和拿到的一样),才去修改数据。

  • 乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

  • CAS就是一种乐观锁的实现。

CAS

  • 什么是cas

  • CAS其实就是比较并交换(乐观锁,先看下数据对不对,对才去改)

  • 比较当前工作内存中的值和主内存中的值,如果这个值是期望的,则执行操作,否则就一直循环下去,可以看这个例子:

package com.rzp.cas;
​
import java.util.concurrent.atomic.AtomicInteger;
​
public class CASDemo {
​
​
    public static void main(String[] args) {
        AtomicInteger atomicInteger =  new AtomicInteger(2020);
​
        //比较并交换
        /**    public final boolean compareAndSet(int expect, int update) {
         * expect 期望值
         * update 目标值
         * 意思是,如果达到了期望值,就会更新为目标值
         */
        System.out.println(atomicInteger.compareAndSet(2020, 2021));
        System.out.println(atomicInteger.get());
        //达不到期望值就不更新
        System.out.println(atomicInteger.compareAndSet(2020, 2021));
        System.out.println(atomicInteger.get());
​
    }
}

UnSafe类:

在调用Atomic类的操作的时候,看源码可以发现实际上调用的是Unsa类

而UnSafe类里面,调用的都是native方法,说明了:

  • Java的原子类实际上是用本地方法调用C++。

  • 而其实C++才可以操作CPU、内存。

  • 原子类的操作都是直接调用C++对内存中的值进行修改,效率很高。

通过UnSafe类,可以看到底层是一个自旋锁(定义见后文)。

  • 不断判断循环,CAS的循环判断就是这里来的。

缺点:

  1. 循环会一直耗时

  2. 一次性只能保证一个共享变量的原子性

  3. ABA问题

ABA问题

  • ABA问题就是指线程1对A变量操作,期望1,改为2.

  • 线程2也对A操作,但是操作以后又改回1了。

  • 线程1没有察觉到线程2实际对A已经作过改动了。

package com.rzp.cas;
​
import java.util.concurrent.atomic.AtomicInteger;
​
public class CASDemo {
​
​
    public static void main(String[] args) {
        AtomicInteger atomicInteger =  new AtomicInteger(2020);
​
        //比较并交换
        /**    public final boolean compareAndSet(int expect, int update) {
         * expect 期望值
         * update 目标值
         * 意思是,如果达到了期望值,就会更新为目标值
         *///ABA问题
        //捣乱的线程
        System.out.println(atomicInteger.compareAndSet(2020, 2021));
        System.out.println(atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(2021, 2020));
        System.out.println(atomicInteger.get());
        //达不到期望值就不更新
        System.out.println(atomicInteger.compareAndSet(2020, 2021));
        System.out.println(atomicInteger.get());
​
    }
}

解决方法:原子引用

  • 就是使用乐观锁的版本号方法,使用AtomicStampedReference类,就自带了版本号的API。

  • AtomicStampedReference<Integer> atomicInteger = new AtomicStampedReference<>(1,1);

  • 注意,这里判断版本号是否相等,底层是使用的是==,而不是Equal方法。

    • 对于包装类,如果(-128到127)之间,可以使用==判断。

    • 但是一旦超过这个范围,就会在堆上产生,而不会复用已有对象,也就是说总是产生一个新的对象,这时候==就永远为false了(即使实际数值相等)。

package com.rzp.cas;
​
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicStampedReference;
​
public class CASDemo {
​
​
    public static void main(String[] args) {
//        AtomicInteger atomicInteger =  new AtomicInteger(2020);
//比较并交换
        /**    public final boolean compareAndSet(int expect, int update) {
         * expect 期望值
         * update 目标值
         * 意思是,如果达到了期望值,就会更新为目标值
         */
​
        AtomicStampedReference<Integer> atomicInteger = new AtomicStampedReference<>(1,1);
​
​
        new Thread(()->{
​
            int stamp = atomicInteger.getStamp();//获取版本号
            System.out.println("a1 =>" +atomicInteger.getStamp());
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //参数3:当前正确的版本号
            //参数4:修改成功时对版本号的操作
            System.out.println("a2 =>" +atomicInteger.compareAndSet(1, 2, atomicInteger.getStamp(), atomicInteger.getStamp() + 1));
            System.out.println("a2 =>" +atomicInteger.getStamp());
            System.out.println("a3 =>" +atomicInteger.compareAndSet(2, 1, atomicInteger.getStamp(), atomicInteger.getStamp() + 1));
            System.out.println("a3 =>" +atomicInteger.getStamp());
​
​
        },"Thread a").start();
​
​
        new Thread(()->{
            int stamp = atomicInteger.getStamp();//获取版本号
            System.out.println("b1 =>" +atomicInteger.getStamp());
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //参数3:当前正确的版本号
            //参数4:修改成功时对版本号的操作
            System.out.println("b2 =>" +atomicInteger.compareAndSet(1, 5, atomicInteger.getStamp(), atomicInteger.getStamp() + 1));
            System.out.println("b2 =>" +atomicInteger.getStamp());
​
        },"Thread b").start();
​
​
    }
}
​

锁整理

1.公平锁、非公平锁

  • NonfairSync :非公平锁,默认构造方法,这个锁可以插队。

  • FairSync :公平锁,这个锁不能插队,必须先进先出。

    public ReentrantLock() {
        sync = new NonfairSync();
    }
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

2.可重入锁

  • 所有的锁其实都是可重入锁,又叫递归锁。

  • 拿到了外面的锁,自动获得了里面所有的锁。比如以下例子,sms和call两个方法都加锁了,sms调用了call,获得sms的时候会把call也拿到。

package com.rzp.cas;
​
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
​
public class Demo2locke {
​
    public static void main(String[] args) {
        Phone2 phone2 = new Phone2();
        new Thread(()->{
            phone2.sms();
        },"Thread a").start();
​
​
        new Thread(()->{
            phone2.sms();
        },"Thread b").start();
    }
}
​
​
class Phone2{
    Lock lock = new ReentrantLock();
​
    public void sms(){
        //第一把锁
        lock.lock();
         try {
             System.out.println(Thread.currentThread().getName()+"sms");
             call();//第二把锁
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
             lock.unlock();
        }
    }
​
    public void call(){
​
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"call");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

3.自旋锁

  • 自旋锁就是是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环

    • 如果是lock锁,线程拿不到会进入waiting

    • 如果是synchronized,线程拿不到锁会进入blocked。

  • 以下例子是写一个自旋锁模拟锁的功能

package com.rzp.lock8q;
​
import java.util.concurrent.atomic.AtomicReference;
​
public class selfSpin {
    AtomicReference<Thread> atomicReference = new AtomicReference<>();
    //加锁
    public void myLock(){
        Thread thread = Thread.currentThread();
        //自旋锁
        while (!atomicReference.compareAndSet(null,thread)){
        }
        System.out.println(Thread.currentThread().getName()+"==> mylock");
    }
    //解锁
    public void myUnLock(){
        Thread thread = Thread.currentThread();
        //解锁
        atomicReference.compareAndSet(thread,null);
        System.out.println(Thread.currentThread().getName()+"==> myUnlock");
    }
}
​

测试类:

package com.rzp.lock8q;
​
import java.util.concurrent.TimeUnit;
​
public class TestLock {
    public static void main(String[] args) {
        selfSpin selfSpin = new selfSpin();
​
        new Thread(()->{
            selfSpin.myLock();
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                selfSpin.myUnLock();
            }
​
        },"Thread a").start();
​
​
        new Thread(()->{
            selfSpin.myLock();
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                selfSpin.myUnLock();
            }
​
​
        },"Thread b").start();
    }
}
​

代码执行流程分析:

1.new 锁对象
        selfSpin selfSpin = new selfSpin();
2.a线程进入,执行lock方法
            selfSpin.myLock();
3.lock 判断当前锁对象的原子引用为空,并把a线程放入原子引用中,即锁对象中存a线程a
        while (!atomicReference.compareAndSet(null,thread)){
        }
4.b线程进入,执行lock方法
5.lock判断当前锁对象有a线程,不为空,b无法获得锁。
6.b循环尝试获得锁
7.a线程执行unlock,锁对象把原子引用中的a对象改回null
        atomicReference.compareAndSet(thread,null);
8.b线程获得锁....

4.死锁

  • 两个线程各自都拿着一个锁了,然后尝试获取对方正在拿着的锁。

四个必要条件:   

1) 互斥条件:一个资源每次只能被一个进程使用。   

(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。   

(3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。   

(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

案例

package com.rzp.deadLock;
​
import com.sun.scenario.effect.impl.sw.sse.SSEBlend_SRC_OUTPeer;
​
import java.util.concurrent.TimeUnit;
​
public class DeadLockDemo {
​
    public static void main(String[] args) {
        String lockA = "lockA";
        String lockB = "lockB";
​
        new Thread(new MyThread(lockA,lockB),"Thread 1").start();
        new Thread(new MyThread(lockB,lockA),"Thread 2").start();
​
    }
}
​
​
class MyThread implements Runnable{
​
    private String lockA;
    private String lockB;
​
    public MyThread(String lockA, String lockB) {
        this.lockA = lockA;
        this.lockB = lockB;
    }
​
    @Override
    public void run() {
        //注意这里锁的看起来是这个对象的lockA,但是实际上锁的是传进来的值,也就是上面main方法中的String lockA。
        synchronized (lockA){
            System.out.println(this.hashCode());
            System.out.println(Thread.currentThread().getName()+" lock "+lockA+"=>get"+lockB);
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lockB){
                System.out.println(Thread.currentThread().getName()+" lock "+lockB+"=>get"+lockA);
​
            }
        }
​
    }
}

如何解决:

打开Terminal

D:\learnlibrary\java-juc>jps -l #jps -l 查看所有java进程
7920 org.jetbrains.idea.maven.server.RemoteMavenServer36
16036
38276 org.jetbrains.jps.cmdline.Launcher
33448 com.rzp.deadLock.DeadLockDemo #找到可能的进程
11132 org.jetbrains.kotlin.daemon.KotlinCompileDaemon
37292 sun.tools.jps.Jps
​
D:\learnlibrary\java-juc>jstack 33448 #jstack  查看进程的堆栈信息
....
===================================================
"Thread 2": #线程名称
        at com.rzp.deadLock.MyThread.run(DeadLockDemo.java:42)
        - waiting to lock <0x000000076b9b4020> (a java.lang.String) #尝试获得的锁
        - locked <0x000000076b9b4058> (a java.lang.String) #现在持有的锁
        at java.lang.Thread.run(Thread.java:748)
"Thread 1":
        at com.rzp.deadLock.MyThread.run(DeadLockDemo.java:42)
        - waiting to lock <0x000000076b9b4058> (a java.lang.String)
        - locked <0x000000076b9b4020> (a java.lang.String)
        at java.lang.Thread.run(Thread.java:748)
​
Found 1 deadlock.
​
​

 

猜你喜欢

转载自www.cnblogs.com/renzhongpei/p/12944686.html
今日推荐