美文网首页
java并发编程

java并发编程

作者: 定金喜 | 来源:发表于2020-10-19 00:03 被阅读0次

    1.何为线程安全

    多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。用一个简单的例子来说明:

    package com.renlijia.multithread;
    
    /**
     * @Author: ding
     * @Date: 2020-07-28 10:41
     */
    public class 线程不安全计数器 {
    
        private static int count = 0;
    
        public static void main(String[] args) throws Exception{
            Thread[] threads = new Thread[10];
            for (int i=0; i<10; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void  run() {
    
                        for (int i=0; i <1000; i++) {
                                count++;
                        }
                    }
                });
                threads[i].start();
            }
    
            for (int i=0; i<10; i++) {
                threads[i].join();
            }
    
            System.out.println(count);
        }
    }
    
                                     例子1
    

    如果不存在线程同步的问题,这个程序每次执行的结果应该都是10000,但是因为存在线程安全问题,所以这段代码执行结果是不确定的,我们计算5次的输出结果分别为:
    9875
    9924
    8746
    9105
    9241
    如果想要得到线程安全的代码,就需要进行加锁:

    package com.renlijia.multithread;
    
    /**
     * @Author: ding
     * @Date: 2020-07-28 10:41
     */
    public class 线程安全计数器 {
        private static int count = 0;
        private static Object obj = new Object();
    
        public static void main(String[] args) throws Exception{
            Thread[] threads = new Thread[10];
            for (int i=0; i<10; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void  run() {
                        synchronized(obj){
                            for (int i=0; i <1000; i++) {
                                count++;
                            }
                        }
                    }
                });
                threads[i].start();
            }
    
            for (int i=0; i<10; i++) {
                threads[i].join();
            }
            System.out.println(count);
        }
    }
    
                                       例子2
    

    增加锁synchronized(obj),此代码不管运行多少次都会得到结果:10000,为什么加了锁就可以保证线程安全,后面娓娓道来哈,先从为什么会有线程安全问题的原因说起。

    2.线程安全问题产生的原因

    主内存和工作内存

    线程1,线程2和线程3可以理解成有三个cpu同时在工作,每个cpu存在各自独立的工作内存,而主内存是所有cpu共用的,每个线程在运行代码时,先从主内存加载数据到工作内存,而工作内存又可以进行细分寄存器->L1->L2->L3,从左到右存储的容量依次减小,速度也依次减小,寄存器速度最快,L3缓存速度最慢,除了L3缓存是所有CPU共用的,其他缓存是每个CPU独有的。加缓存是为了解决CPU执行速度和内存速度不匹配的问题,因为CPU速度非常快,内存速度相对CPU速度慢很多,所以不加缓存,就会导致在对内存存取的过程中,CPU会一直空闲,造成资源的极大浪费,浪费是可耻的。


    细分的工作内存

    例如当线程1工作时,先从主内存拷贝数据到工作内存,如果在工作内存进行了数据的修改,只是改变了工作内存的数据,主内存不会立即变化,同步到主内存的时间是不确定的,而且同步到主内存后,其他的工作内存使用到该数据的都要失效重新刷新,但是什么时候去失效重新去主内存加载是不确定的,有一个MESI协议(缓存一致性协议)就是讲这方面的,大家可以百度搜索,所以在这个过程中,各个线程中的工作内存是不一致的,这就会产生线程安全的问题。

    3.一些重要的理论知识

    happens-before

    熟悉 Java 并发编程的都知道,JMM(Java 内存模型) 中的 happen-before(简称 hb)规则,该规则定义了 Java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响。
    按照官方的说法:当一个变量被多个线程读取并且至少被一个线程写入时,如果读操作和写操作没有 HB 关系,则会产生数据竞争问题。 要想保证操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在一个线程),那么在 A 和 B 之间必须满足 HB 原则,如果没有,将有可能导致重排序。 当缺少 HB 关系时,就可能出现重排序问题。

    重排序:
    在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:如图,1属于编译器重排序,而2和3统称为处理器重排序。
    这些重排序会导致线程安全的问题,一个很经典的例子就是DCL问题。JMM的编译器重排序规则会禁止一些特定类型的编译器重排序;针对处理器重排序,编译器在生成指令序列的时候会通过插入内存屏障指令来禁止某些特殊的处理器重排序。



    (1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
    (2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
    (3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
    举个例子:



    由于A,B之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。因此可以执行顺序可以是A->B->C或者B->A->C执行最终结果都是3.14,即A和B之间没有数据依赖性。

    happens-before原则有8条:
    1.程序顺序性原则
    一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作;
    2.传递性原则
    如果A happens-before B,B happens-before C,那么A happens-before C;
    3.volatile原则
    对一个volatile变量的写操作,happens-before后续对这个变量的读操作;
    4.管程锁原则
    对一个锁的解锁操作,happens-before后续对这个锁的加锁操作;
    5.线程启动规则
    假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见;
    6.线程终止规则
    线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见;
    7.线程中断规则
    对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。
    8.对象终结规则
    就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

    JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。

    通俗可以这么理解:
    1)如果第一个操作的执行顺序排在第二个操作之前,那么第一个操作的执行结果将对第二个操作可见,这就可以说两个操作之间存在happens-before关系。
    2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序),即如果两个操作之间存在happens-before关系,只有满足happens-before原则,才能保证线程安全性。

    我们分析下 例子1为什么是线程不安全的,因为多个线程都在操作count这个共享变量,当多个线程进行count++,假如线程名称为线程1,线程2,....,线程10,count初始化值为0,线程1对count进行+1之后得到的值为1,但是这个操作的结果对其它线程不一定是可见的,线程2可能拿到的count值还是0,线程2进行+1后得到的值还是1,都同步到主内存后,主内存值还是1,相当于对共享变量进行了两次+1操作,和进行一次+1的结果一样,其它线程依次类推。例子2加了锁,根据happens-before管程锁原则,JMM能保证,在线程1进行了+1之后的结果对后面的线程是可见的,所以第二个线程得到的值肯定是1,然后进行+1后变为2,这样就得到了正确的结果。

    逐一解读下这8条原则:

    程序顺序性原则:在单个线程中,因为是同一个工作内存,所以前面的操作肯定是对后面的操作内存可见的,很容易理解;
    传递性原则:这样很好理解,就不举例说明了
    volatile原则:后面将volatile会具体说明,其实原理是内存屏障
    管程锁原则:上面分析过了
    线程启动规则:为了说明这个例子,有一个简单的例子:
    package com.renlijia.multithread;
    import com.renlijia.util.ConcurrentTestUtil;
    
    /**
     * 内存可见性简单例子
     * @Author: ding
     * @Date: 2020-06-08 21:20
     */
    public class MemVisible {
            private static class ThreadTest extends Thread{
            private Boolean isRunnable = true;
    
            @Override
            public void run() {
                while (isRunnable){
                }
                System.out.println("Thread is stopped!!!");
            }
        }
        public static void main(String[] args) {
            ThreadTest thread = new ThreadTest();
            thread.start();
            //线程启动规则
            ConcurrentTestUtil.sleep(1000); //sleep1秒
            thread.isRunnable = false;
        }
    }
                
                                     例子3
    

    运行此例子,我们得到的结果是:程序进行了死循环,纳尼,为什么???
    稍微修改下main函数,注释掉一行代码ConcurrentTestUtil.sleep(1000);:

    public static void main(String[] args) {
            ThreadTest thread = new ThreadTest();
            thread.start();
    
            //线程启动规则
            //ConcurrentTestUtil.sleep(1000);
            thread.isRunnable = false;
        }
    
                                  例子4
    

    运行该程序,程序正常结束,输出:Thread is stopped!!!
    用此规则解释:在main线程中运行了一个新的线程,例子3中,先sleep了1秒,然后再将共享变量设置为false,在sleep过程中,ThreadTest线程已经运行了;而在例子4中,没有进行sleep,所以isRunnable设置为false是在ThreadTest线程运行之前执行的,根据线程启动规则,isRunnable是在ThreadTest运行之前设置的,那么此结果对ThreadTest线程是内存可见的,所以该线程能正常结束,不会死循环。

    线程终止规则:可以这么理解,在t2线程调用t1.join()或者t1.isAlive() 成功返回后,t1线程对共享变量的修改对于t2线程是可见的。
    package com.renlijia.multithread;
    
    /**
     * @Author: ding
     * @Date: 2020-10-18 23:19
     */
    public class JoinTest {
    
        private static Integer count = 0;
    
        private static class T2Test extends Thread{
    
            @Override
            public void run() {
    
                for (int i=0; i<100; i++) {
                    count++;
                }
            }
        }
    
        public static void main(String[] args) throws Exception{
    
            T2Test t2Test = new T2Test();
            t2Test.start();
    
            t2Test.join();
    
            System.out.println(count);
        }
    }
    
                                         例子5
    

    join的功能是程序阻塞,直到t2Test线程执行完毕,t2Test执行完之后,count=10000,所以根据该规则,该值对主线程是可见的,所以保证了主线程的输出值肯定是10000。

    线程中断规则:
    package com.renlijia.multithread;
    
    /**
     * @Author: ding
     * @Date: 2020-10-18 23:19
     */
    public class JoinTest {
    
        private static Integer count = 0;
        private static class T2Test extends Thread{
            @Override
            public void run() {
                for (int i=0; i<100; i++) {
                    count++;
                    System.out.println("t2Test:"+ count);
                    if (count == 30) {
                        Thread.currentThread().interrupt();
                    }
                }
            }
        }
    
        public static void main(String[] args) throws Exception{
    
            T2Test t2Test = new T2Test();
            t2Test.start();
    
            while(!t2Test.isInterrupted()) {
            }
            System.out.println("main:"+count);
        }
    }
    
                                     例子6
    

    根据例子6该规则可以理解为:T2Test执行Thread.currentThread().interrupt();后主线程执行t2Test.isInterrupted()一定会返回true。

    对象终结规则:对象执行finalize方法之前必须已经执行完了构造函数,这个比较简单。

    对象的内存布局和压缩指针

    参考前面写的这篇文章https://www.jianshu.com/p/0503cba4e798
    所以java对象在内存中除了数据信息还有其他相关的信息,对象头里面主要存放了与锁和垃圾收集器相关的字段信息,所以synchronized才能知道该对象是否已经被锁住。

    4.保证线程安全性的方案

    加锁,根据锁的不同,锁分类会很多,大体总结下:

    1.jdk的锁

    jdk锁

    synchronized和LockSupport算是悲观锁,CAS是乐观锁,jdk并发框架包其实主要是这三个锁+AQS封装起来的,而ReentrantLock等各种锁其实都不是真正意义上的锁,它的底层是LockSupport,happens-before所说的管程锁原则锁提到的锁其实指的就是synchronized和LockSupport,为什么ReentrantLock等锁能解决线程安全性,是因为有其底层的LockSupport保证的,而所有的锁最底层的c++代码是fence原语,这个fence最底层其实就是一条指令lock cmpxchg/xchg,这条指令的作用是先加锁(锁总线),然后比较交换,而这个锁是锁其他所有的cpu(总线),让其他所有的cpu暂时别动,我先执行完你才能动,然后交换数据,所以多线程的问题,其实最底层的实现还是变成了单线程,在更新数据的时候我保证原子性,就解决线程安全问题。
    synchronized有一个锁升级的概念,用流程图展示:


    synchronized锁升级

    这是synchronized从无锁到重锁的过程,升级过程是不可逆的,为了验证一下确实存在锁升级,还是用例子来说明吧。

    package com.renlijia.multithread;
    
    import com.renlijia.util.ConcurrentTestUtil;
    import org.openjdk.jol.info.ClassLayout;
    
    /**
     * @Author: ding
     * @Date: 2020-07-30 12:10
     */
    public class syn锁升级 {
    
        private static int count=110;
    
        //默认偏向锁开启时间是延迟4s
        //-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
        //关闭偏向锁  -XX:-UseBiasedLocking
    
        public static void main(String[] args) {
    
            //ConcurrentTestUtil.sleep(5000);
            //无锁
            Object o = new Object();
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
    
            //偏向锁
            synchronized (o){
                System.out.println(ClassLayout.parseInstance(o).toPrintable());
            }
    
            //轻量级锁
            new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized (o){
                        ConcurrentTestUtil.sleep(1);
                        count+=10;
                    }
                }
            }).start();
    
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
    
            //重量级锁
            for (int i=0; i<1000; i++){
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        synchronized (o){
                            ConcurrentTestUtil.sleep(1);
                            count+=10;
                        }
                    }
                }).start();
            }
    
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
    
                                                        例子7
    

    输出结果:

    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
          4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           90 49 ad 0e (10010000 01001001 10101101 00001110) (246237584)
          4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           60 49 14 10 (01100000 01001001 00010100 00010000) (269764960)
          4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           4a 8f 02 1e (01001010 10001111 00000010 00011110) (503484234)
          4     4        (object header)                           8c 7f 00 00 (10001100 01111111 00000000 00000000) (32652)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    

    代码是在64位机器运行,默认偏向锁开启时间是延迟4s,所以在程序运行过程中,是不会有偏向锁的,四种情况第一个字节最后三位分别是001 000 000 010,对照内存的布局模型,四种情况分别是:无锁 轻量级锁 轻量级锁 重量级锁;修改下代码将main第一行注释掉的代码 ConcurrentTestUtil.sleep(5000);加上,重新运行下得到:

    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
          4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           05 98 00 bf (00000101 10011000 00000000 10111111) (-1090480123)
          4     4        (object header)                           b8 7f 00 00 (10111000 01111111 00000000 00000000) (32696)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           60 d9 11 0f (01100000 11011001 00010001 00001111) (252828000)
          4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    java.lang.Object object internals:
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           3a f9 02 be (00111010 11111001 00000010 10111110) (-1107101382)
          4     4        (object header)                           b8 7f 00 00 (10111000 01111111 00000000 00000000) (32696)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    

    这种情况偏向锁是生效的,四种情况第一个字节最后三位分别是101 101 000 010,对照内存的布局模型,四种情况分别是:轻量级锁 轻量级锁 轻量级锁 重量级锁。

    2.乐观锁 VS 悲观锁

    悲观锁:总是假设最坏的情况,每次去读数据的时候都认为别人会修改,所以每次在读数据的时候都会上锁, 这样别人想读取数据就会阻塞直到它获取锁,ReentrantLock,synchronized,LockSupport,select for update等都属于悲观锁,缺点也很明显,吞吐量减小,对于写多的场景用悲观锁比较好。

    乐观锁:总是假设最好的情况,每次去读数据的时候都认为别人不会修改,所以不会上锁, 但是在更新的时候会判断一下在此期间有没有其他线程更新该数据, 可以使用版本号机制和CAS算法实现。对于写少读多的场景,用乐观锁较好,因为写多的场景,冲突概率较高,会导致retry次数较多,会严重浪费cpu资源。通过版本号实现乐观锁,一般是先获取这条记录的原版本号,以对账户加100块钱为例,update balance set amount=amount+100,version=version+1 where userId=?(用户账户) and version=?(原版本号),根据这个语句的影响行数判断是否更新成功,更新失败则进行重试。
    CAS中自旋锁也属于乐观锁的一种,当更新失败时进行自旋(相当于重试)。

    3.公平锁 VS 非公平锁

    公平锁:线程获取锁的顺序和调用lock的顺序一样,FIFO;
    非公平锁:线程lock先进行抢占,如果能抢占到,直接执行,抢占不到排队,缺点就是会导致老线程饿死,一直没机会运行,以ReentrantLock来说明:

    非公平锁:
    /**
         * Sync object for non-fair locks
         */
        static final class NonfairSync extends Sync {
            private static final long serialVersionUID = 7316153563782823691L;
    
            /**
             * Performs lock.  Try immediate barge, backing up to normal
             * acquire on failure.
             */
            final void lock() {
                if (compareAndSetState(0, 1))
                    setExclusiveOwnerThread(Thread.currentThread());
                else
                    acquire(1);
            }
    
            protected final boolean tryAcquire(int acquires) {
                return nonfairTryAcquire(acquires);
            }
        }
    
    
        /**
         * Sync object for fair locks
         */
        static final class FairSync extends Sync {
            private static final long serialVersionUID = -3000897897090466540L;
    
            final void lock() {
                acquire(1);
            }
    
            /**
             * Fair version of tryAcquire.  Don't grant access unless
             * recursive call or no waiters or is first.
             */
            protected final boolean tryAcquire(int acquires) {
                final Thread current = Thread.currentThread();
                int c = getState();
                if (c == 0) {
                    if (!hasQueuedPredecessors() &&
                        compareAndSetState(0, acquires)) {
                        setExclusiveOwnerThread(current);
                        return true;
                    }
                }
                else if (current == getExclusiveOwnerThread()) {
                    int nextc = c + acquires;
                    if (nextc < 0)
                        throw new Error("Maximum lock count exceeded");
                    setState(nextc);
                    return true;
                }
                return false;
            }
        }
    
                                                   例子8
    

    lock函数区别在于:非公平锁不是直接调用AQS的acquire,而是先compareAndSetState尝试获取一次锁,获取不到再调用acquire,这就是抢占操作。tryLock完全是非公平锁,ReentrantLock的tryLock会调用Sync的nonfairTryAcquire,nonfairTryAcquire方法先尝试获取锁,获取不到返回false,else if (current == getExclusiveOwnerThread()) 是做可重复的,当加锁的线程是当前线程时,说明锁存在重入的情况,增加计数。

    final boolean nonfairTryAcquire(int acquires) {
                final Thread current = Thread.currentThread();
                int c = getState();
                if (c == 0) {
                    if (compareAndSetState(0, acquires)) {
                        setExclusiveOwnerThread(current);
                        return true;
                    }
                }
                else if (current == getExclusiveOwnerThread()) {
                    int nextc = c + acquires;
                    if (nextc < 0) // overflow
                        throw new Error("Maximum lock count exceeded");
                    setState(nextc);
                    return true;
                }
                return false;
            }
    
                                                      例子9
    

    4.分布式锁

    参考redis文章,分布式锁实现方法有redis或者zookeeper两种方式

    5.volatile

    关于volatile网上资料很多,不重复叙述,主要注意的一点是volatile的使用场景和原理,以及它为什么不能保证线程安全的原因

    5.缓存行对齐/伪共享

    6.并发编程在项目中的实践

    附:

    package com.renlijia.util;
    
    /**
     * @Author: ding
     * @Date: 2020-06-08 21:45
     */
    public class ConcurrentTestUtil {
    
        public static void sleep(int millSeconds){
    
            try {
                Thread.sleep(millSeconds);
            }catch (Exception ex){
    
            }
        }
    }
    

    参考文章
    MESI:https://www.cnblogs.com/yanlong300/p/8986041.html
    https://blog.csdn.net/ma_chen_qq/article/details/82990603
    https://zhuanlan.zhihu.com/p/257138102?utm_source=wechat_session

    相关文章

      网友评论

          本文标题:java并发编程

          本文链接:https://www.haomeiwen.com/subject/ttycmktx.html