并发编程(JUC)系列(3)--volatile

169 阅读30分钟

写在前面:

文章内容是通过个人整理以及参考相关资料总结而出,难免会出现部分错误

如果出现错误,烦请在评论中指出!谢谢


1 Volatile

volatile是Java虚拟机提供的轻量级同步机制,特点:

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

这里对比上synchronized我们可以得出一个结论:

  • volatile适合一个写线程,多个多线程的情况,因为volatile不保证原子性,所以多个线程进行写操作时就会出现数据异常;因为volatile保证可见性,当只有一个线程进行写操作时,一旦数据写回主存,其他线程立马可以获取到最新的数据;另外volatile相对于synchronized较轻量
  • synchronized可以同时保证原子性和可见性,但他是重量级锁,并发度不高,但是适合多个线程同时写的情况(当然多个线程同时写也可以通过不加锁的方式进行解决,后面会进行讲解CAS)

1.1 JMM(JAVA内存模型)

1.1.1 内存模型概述

JMM本身是一种抽象的概念并不真实存在,描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式

JMM关于同步的规定:

  • 线程解锁前,必须把共享变量的值刷新回主内存
  • 线程加锁前,必须读取主内存最新值到自己的工作内存
  • 加锁解锁是同一把锁

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为器创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而Java内存模型规定所有变量都存储在主内存,主内存是共享内存区域,所有的线程都可以访问,但线程对变量的操作(读写)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作后再将变量写回主内存,不能直接操作主内存中的变量,各个线程的工作内存中主要存储着主内存的变量拷贝副本,因此不同的线程之间无法访问对方的工作内存,线程间的通信必须通过主内存来完成

1673435-20190822095001999-1852846254

需要注意,JMM和Java内存区域的划分是不同的概念层次,恰当的说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区和私有数据区域的访问方式,JMM围绕原子性、有序性和可见性展开,JMM和Java内存区域唯一相似点就是都存在共享数据区和私有数据区域

在JMM中主内存属于共享数据区域,从某种程度上应该包括堆和方法区;而工作内存属于线程私有数据区域,从某种程度上应该包括程序计数器、虚拟机栈和本地方法栈

关于主内存和工作内存的说明如下

  • 主内存

    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题

  • 工作内存

    主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题

接着了解下主内存和工作内存的数据存储方式和操作方式:

如果方法中包含的本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的栈结构中;如果本地变量时引用类型,那么该变量的引用会存储在功能内存的栈帧中,而对象实例将存储在主内存(共享数据区域--堆)中

但对于实例对象的成员变量,不管它是基本数据类型、包装类型(Integer、Double等)或者引用类型,都会被存储到堆区,至于static变量以及类本身相关信息将会存储在主内存中;需要注意在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会对将要操作的数据拷贝一份到自己的工作内存,执行完成操作之后才刷新到主内存

image-20210224205230288


线程间的通信

上面所述的步骤实际上就是实现了线程之间的通信,在Java内存模型中定义了8种操作来实现同步的细节

  • read:读取,作用于主内存把变量从主存中读取到本地内存
  • load:加载,主要作用于本地内存,把从主内存中读取的变量加载到本地内存的变量副本中
  • use:使用,主要作用于本地内存,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行这个操作
  • assign:赋值,主要作用于本地内存,它把一个从执行引擎接收到的值赋值给工作内存中的变量,每当虚拟机遇到使用变量赋值的字节码指令时执行这个操作
  • store:存储,作用于工作内存中的变量,把工作内存中的一个变量的值传递到主内存中,以便随后的write操作
  • write:写入,作用于主内存中的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中
  • lock:锁定,作用于主内存中的变量,把一个变量标识为线程独占状态
  • unlock:解锁,作用于主内存中的变量,一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

同时在Java内存模型中明确规定了要执行这些操作需要满足以下规则:

  • 不允许read和load、store和write的操作单独出现
  • 不允许一个线程丢弃它最近assign操作,即变量在工作内存中改变之后必须同步到主内存中
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量;即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

1.1.2 硬件内存架构

image-20210224205647419

就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核一般指一枚处理器中集成两个或多个完整的计算引擎(内核),这样就支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行;在CPU内部有一组CPU寄存器,寄存器是CPU直接访问和处理的数据,是一个临时存放数据的空间

一般CPU都会从内存存取数据到寄存器然后进行处理,但由于内存的处理速度远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存之间添加了CPU缓存,CPU缓存比较小,但是访问速度比主内存快得多,如果CPU总是操作主内存同一位置的数据,直接从缓存中提取而无需从主内存存取

需要注意寄存器并不每次数据都可以从缓存中读取,这种现象被称为缓存命中率,从缓存中取到就命中,不从缓存中取出而是内存中取出就是未命中,可见缓存命中率的高低会影响CPU的执行性能,这就是CPU、缓存以及主内存之间的交互过程

总之当一个CPU需要访问主存时,会先读取一部分主存数据存储到CPU缓存,进而读取缓存到寄存器,当CPU需要写数据到主存时,同样会刷新寄存器中的数据到CPU缓存,然后把数据刷新到主内存

1.1.3 Java线程和硬件处理器

了解硬件的内存架构后,接着了解JVM线程的实现原理;在Windows和Linux操作系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即在使用Java线程时Java虚拟机内部转而调用当前操作系统的内核级线程来完成当前任务

内核线程是由操作系统内核支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上,每个内核线程可以视为内核的一个分身,这也是操作系统可以同时处理多任务的原因

由于我们编写的多线程属于语言层面,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),由于每个轻量级进程都会映射一个内核线程,因此可以通过调度轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器

轻量级进程和内核线程一对一线程模型如下

img

每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务

1.1.4 Java内存模型和硬件内存架构的关系

从前面的讲解应该意识到多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致,对于硬件内存来说只有寄存器、缓存和主内存,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或寄存器中

因此Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉

image-20210224214759932

1.1.5 JMM存在的必要性

现在理解了Java内存区域划分、硬件内存架构、Java多线程的实现原理与Java内存模型的具体关系后,那么接着会来聊下Java内存模型存在的必要性

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存,用于存储线程私有的数据,线程和主内存的变量操作必须通过工作内存间接完成

主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程对同一个主存中的实例对象的变量进行操作就有可能诱发线程安全问题

假设主内存中存在一个共享变量X,现在线程A和B分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是,不确定

B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题

image-20210224215632827

为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性进行展开

1.1.6 Java内存模型承诺

Java内存模型保证原子性、可见性和有序性

1.1.6.1 原子性

原子性指一个操作是不可中断的,即使在多线程环境下,一个操作一旦开始就不会被其他线程影响

比如对于一个静态变量int x,两条线程同时对它赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点

有点需要注意,对于32操作系统而言,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的(因为long和double占8个字节,存储的时候需要占用两个slot),也就是说如果存在两个线程对long类型或者double类型的数据进行读写是存在相互干扰的

因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值

即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意

1.1.6.2 概念-指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排,一般分为以下三种

  • 编译器优化的重排

    编译器在不改变单线程序语义的前提下,可以重新安排语句的执行顺序

  • 指令并行的重排

    现代处理器采用指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性后(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

  • 内存系统的重排

    由于处理器使用缓存和读写缓冲区,加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差

其中编译器优化的重排属于编译器重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题

下面阐明这两种重排优化可能带来的问题

编译器重排

线程 1             线程 2
1: x2 = a ;      3: x1 = b ;
2: b = 1;         4: a = 2 ;

两个线程同时执行,分别1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2

从程序的执行顺序上看,似乎不太可能出现x1 = 1x2 = 2 的情况,但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况

线程 1              线程 2
2: b = 1;          4: a = 2 ; 
1:x2 = a ;        3: x1 = b ;

这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的

这里之所以可能发生重排,是因为X2和a、b之间没有数据依赖性,而X1和a、b之间也没有数据依赖性

处理器指令重排

1.1.6.3 指令重排带来的问题

我们假设这样一种情况:

image-20210305172128333

这里I_Result是一个对象,该对象有一个属性r1用来保存结果,那么现在r1可能出现几种情况?

  • 情况一:线程1先执行,这是ready判断为假,那么r1就是1
  • 情况二:线程2先执行num=2,这时线程1执行到r1同样是1
  • 多线程下还可能有多种情况,这里就不列举了

但是还有一种情况,注意num和ready之间不存在数据依赖关系,因此num=2ready=true可能发生指令重排,交换顺序

加入线程2先执行,执行到ready=true,此时线程1开始执行,那么num的值现在还是0,最终的结果就是0

1.1.6.3 内存屏障

volatile的底层实现原理就是内存屏障,Memory Barrier

  • 对volatile变量的写指令后会加入写屏障
  • 对volatile变量的读指令前会加入读屏障

volatile保证可见性的原理

1.1.6.4 可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值

但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题

另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题

1.1.6.5 有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有问题,毕竟对于单线程而言确实如此

但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致

要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象

1.2 volatile保证可见性

举个例子,主内存中有一个student对象,目前有三个线程操作该对象:

image-20210126143828104

主内存中存放的就是创建的student对象,而三个线程各自的工作内存中存放的是student对象的副本,当t1对student对象进行操作时,实际上修改的值t2和t3并不知道,只有当t1将新的student对象写回到主内存时,主内存就会通知其他线程变更为最新的student对象

主内存更新之后其他线程可以第一时间获取到最新的共享变量,这就是可见性,对于线程可见

1.2.1 可见性测试

加入共享变量中没有添加volatile关键字:

public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.add60();
            System.out.println(Thread.currentThread().getName() + "\t change value:" + myData.num);
        },"A").start();

        while (myData.num == 0) {

        }

        System.out.println(Thread.currentThread().getName() + "mission over");
    }
}

class MyData {
    int num = 0;

    public void add60() {
        this.num = 60;
    }
}

首先MyData定义了共享变量和共享变量的修改操作

main方法中新起一个线程对共享变量进行修改,main线程监控共享变量的变化,如果没有变化就一直在死循环中

结果:

image-20210126150245866

从结果中可以看出main线程一直在循环过程中,共享变量的修改并没有对main线程进行通知

共享变量添加volatile关键字:

public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.add60();
            System.out.println(Thread.currentThread().getName() + "\t change value:" + myData.num);
        },"A").start();

        while (myData.num == 0) {

        }

        System.out.println(Thread.currentThread().getName() + "mission over");
    }
}

class MyData {
    volatile int num = 0;

    public void add60() {
        this.num = 60;
    }
}

结果:

image-20210126150620834

1.3 volatile不保证原子性

1.3.1 测试volatile不保证原子性

public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    myData.addPlus();
                }
            },String.valueOf(i)).start();
        }

        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + "\t finally value : " + myData.num);
    }
}

class MyData {
    volatile int num = 0;

    public void add60() {
        this.num = 60;
    }

    public void addPlus() {
        this.num++;
    }
}

Thread.activeCount()静态方法计算当前的活跃线程,我们已知现在有main线程和GC线程

Thread.yield()静态方法使当前线程礼让其他线程,让其他线程先执行

结果:

image-20210126152725585

如果保证原子性,那么每次操作时其他线程都不能干涉,最终结果一定是20000,可是结果始终都不是20000表示不保证原子性

1.3.2 不保证原子性原理

为什么每次运行的结果都不是20000呢?

image-20210126153504113

假设共享变量初始为0,3个线程分别拿到了变量副本,假设3个线程这时同时调用方法将自己的变量副本变为1;此时三个线程互相之间不知道,当t1线程准备将自己的副本写回主内存时突然被挂起了,这时t2线程将自己的变量副本写回到主内存,主内存通知其他线程修改自己的变量副本,而这时t1线程的变量副本已经变为1了,因此对于t1来说实际上并没有进行修改,也就是t1自己加1的操作被抵消了,当t1线程取得CPU执行权之后再将1写回到主内存,这时就等于丢失了一次加1

1.3.3 字节码角度分析不保证原子性

示例

public class NotAtomic {
    volatile int num = 0;

    public void add() {
        num++;
    }
}

对应的字节码指令

image-20210224222645978

从字节码指令中可以看出

num++被拆分成了3个指令:

1、执行getfield指令拿到原始的num

2、执行add指令进行加1操作

3、执行putfield指令把累加的值写回

由于volatile关键字并不保证原子性,而这里实际上获取副本之后执行了2个指令,在两个指令中间就存在线程之间竞争CPU的执行权,从而其中一个线程可能影响另外一个线程的执行情况

1.3.4 volatile不保证原子性的解决方案

可以使用synchronized关键字,但是这样会严重影响执行性能

因此可以使用原子类AtomicInteger

public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    myData.addPlus();
                }
            },String.valueOf(i)).start();
        }

        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + "\t finally value : " + myData.atomicInteger);
    }
}


class MyData {
    public AtomicInteger atomicInteger = new AtomicInteger();


    public void addPlus() {
        atomicInteger.getAndIncrement();
    }
}

测试结果

image-20210224224424951

这里无论如何执行都保证了最后的结果为20000

1.4 单例模式和volatile

1.4.1 多线程环境下的单例模式

一开始先考虑下什么是单例模式?

保证一个类仅有一个实例,并提供一个访问它的全局访问点

单例模式有很多种实现方式

饿汉式

public class Singleton {  
     private static Singleton instance = new Singleton();  
     private Singleton (){
     }
     public static Singleton getInstance() {  
     return instance;  
     }  
 }  

这种方式在类加载时就完成了初始化,明显类加载比较慢,但是获取对象的速度比较快

这种方式基于类加载机制避免了多线程带来的问题,因为没有涉及到对共有资源的操作

但是没有达到懒加载的效果

懒汉式

public class Singleton {  
      private static Singleton instance;  
      private Singleton (){
      }   
      public static Singleton getInstance() {  
      if (instance == null) {  
          instance = new Singleton();  
      }  
      return instance;  
      }  
 }  

这种方式只有在用户第一次调用初始化的时候才会导致初始化,但是因为对共有资源进行操作,且操作并不是原子性的,所以肯定存在线程安全问题

懒汉式+synchronized

public class Singleton {  
      private static Singleton instance;  
      private Singleton (){
      }
      public static synchronized Singleton getInstance() {  
      if (instance == null) {  
          instance = new Singleton();  
      }  
      return instance;  
      }  
 }  

这种方式在线程每次调用getInstance方法时都需要进行同步,造成不必要的同步开销,对性能造成影响

双重检查模式(DCL)

public class Singleton {  
      private static Singleton instance;  
      private Singleton (){
      }   
      public static Singleton getInstance() {  
      if (instance== null) {  
          synchronized (Singleton.class) {  
          if (instance== null) {  
              instance= new Singleton();  
          }  
         }  
     }  
     return instance;  
     }  
 }  

这种方式可以保证大多数情况下不会出现问题,同时也尽可能保证了性能问题

当第一次进行检查时并不会加锁,保证多线程不必要进行同步,当某一个线程判断并拿到锁之后,就会创建新的对象,这时已经进行过第一次判断的线程会接着争夺锁资源,然后进去进行第二次判断;但是对于没有经过第一次判断的线程在第一次判断的时候就直接跳过了抢夺锁资源的步骤直接返回

刚才说到这种情况下也可能会出现问题,在下面会对这种情况提出解决方案

静态内部类单例模式

DCL在某些情况下也可能存在失效问题,有些时候推荐使用静态内部类单例模式代替

public class Singleton { 
    private Singleton(){
    }
      public static Singleton getInstance(){  
        return SingletonHolder.sInstance;  
    }  
    private static class SingletonHolder {  
        private static final Singleton sInstance = new Singleton();  
    }  
} 

第一次加载Singleton类时并不会初始化sInstance,只有第一次调用getInstance方法时虚拟机加载SingletonHolder类才会初始化sInstance

这种方式很明显保证了线程安全,因为公共资源只有在加载SingletonHolder类才会创建一次,之后调用时都不会进行操作

1.4.2 DCL不安全的原因及解决方案

1.4.2.1 DCL不安全的原因

之所以双端检锁机制不一定安全,是因为存在指令重排序

当某一个线程执行到第一次检查,读取到instance不为null时,instance的引用对象可能没有完成初始化

instance = new Singleton()可以分为三个步骤(伪代码):

memory = allocate() //1、分配对象内存空间

instance(memory) //2、初始化对象

instance = memory //3、设置instance指向刚分配的内存地址,注意 此时instance != null

这里步骤2和步骤3并不存在数据上的依赖关系,即在单线程的情况下,无论步骤2和3哪个先执行都不会影响最终的结果,那么编译时就有可能存在

  1. 分配对象内存空间
  2. 设置instance指向刚刚分配的内存地址, 此时instance != null (重点)
  3. 初始化对象

CPU和编译器在指令重排时并不会关心是否影响多线程的执行,当存在多个线程访问getInstance方法时,刚好发生指令重排,就有可能出现这种情况:

当第一个线程拿到锁并进行第二次检查之后,先分配对象内存空间,然后再将instance指向刚才分配的内存空间(这个时候instance已经不为null!),但此时instance还没有初始化完成

如果这时又有一个线程来调用getInstance方法,在第一次进行检查的时候直接返回false,那么这个线程返回的就是没有初始化完成的instance,那么就可能产生异常

10.4.2.2 DCL不安全字节码分析

对于Singleton类通过编译之后生成的字节码指令:

image-20210307011047974

我们先对这段指令进行解释:

首先获取静态变量instance,然后判断变量是否为null,如果不为null就直接跳转到37行指令,将instance存储到局部变量表,最后将instance变量返回

如果变量为null,就获取Singleston的class对象复制一份存储到局部变量表(为了将class对象作为对象锁),然后再次获取变量instance并进行非空判断,然后再加锁

下面是关键的部分:

17行通过new关键字创建对象,将对象调入栈

20行表示复制一份对象引用

21行表示利用上面复制的对象引用,调用构造方法

24行表示利用上面的对象引用复制给instance变量


那么现在就会出现一个问题,如果JVM先执行24行指令再执行21行指令

image-20210307012006090

那么现在就出现了问题: 我们知道在synchronized内部会发现指令重排,只是因为synchronized可以保证只有一个线程进入同步代码块,所以不会出现问题

但是这里在外面实际上也在进行if (instance== null)判断,如果这时在instance还没有初始化完成,就通过putstatic指令给instance变量赋值

而t2线程不用考虑在第一次进行判断的时候不用考虑对象锁,获取到的就是没有初始化完成的instance实例,而且不为null

1.4.2.3 DCL不安全解决方案

我们都知道volatile禁止指令重排,那么只需要在资源上添加volatile关键字,也就是

public class Singleton {  
      private volatile static Singleton instance;  
      private Singleton (){
      }   
      public static Singleton getInstance() {  
      if (instance== null) {  
          synchronized (Singleton.class) {  
          if (instance== null) {  
              instance= new Singleton();  
          }  
         }  
     }  
     return singleton;  
     }  
 }  

线程池的工作就是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其他线程执行完毕后,再从队列中取出任务来执行

特点:

  • 线程复用;重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 控制最大并发数量
  • 提高响应速度;当任务达到时,不需要等待创建线程就可以立即执行
  • 提高线程可管理性;线程是稀缺资源,如果无限制的创建,不仅会消耗资源,还会降低系统的稳定性;线程池可以对线程进行统一的分配、调优和监控

个人公众号目前正初步建设中,如果喜欢可以关注我的公众号,谢谢!

二维码