volatile关键字专题

第一章 volatile关键字概览

1.1多线程下变量的不可见性

1.1.1概述
  • 在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到该线程修改后的最新值
//多线程间共享变量的不可见性
/**
 * 研究多线程下变量访问的不可见性问题
 *      1.准备两个线程
 *      2.准备一个成员变量
 *      3.开启两个线程,一个负责修改,一个负责读取
 */
public class VisiblityDemo01 {
    //main为主线程
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
        //主线程中获取myThread对象里的flag成员
        while(true){
            if (myThread.getFlag())
            System.out.println("主线程flag="+myThread.getFlag());
        }
    }
}
class MyThread extends Thread{
    private boolean flag=false;//定义一个成员变量
    @Override
    public void run() {
        try {
            //如果不加休眠,这个线程会很快执行完成
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag=true;
        System.out.println("MyThread线程flag="+flag);
    }
    public boolean getFlag() {
        return flag;
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

image-20220625144602529

  • 子线程中已经将flag设置为true,但main方法中始终没有读取到这个true。

1.2变量不可见性的内存语义

  • JMM(java Memory Mode):java内存模型,是java虚拟机规范中定义的一种内存模型,它是标准化的,屏蔽了底层不同计算机的区别

  • java内存模型描述了java程序中各种变量(线程共享变量)的访问规则,以及JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

  • 细节规定:

    • 所有的共享变量都存储在主内存。这里的共享变量指的是实例变量和类变量(static),局部变量是线程私有的,因此不存在竞争的问题。
    • 每个线程都有自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
    • 线程对变量的操作(读、取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
    • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存来中转。

    image-20220626095659254

  • 多线程下变量不可见性例子的问题分析

    image-20220626100912604

    • 首先,flag是成员变量,存放在主内存,子线程启动后,从主内存中读取flag的值并放入对应的工作内存中,主线程也同时读入flag的值放入主线程的工作内存中,此时两个flag都是false。
    • 子线程将flag的值修改后传递给主内存
    • 主线程中的while(true)调用的是系统比较底层的代码,速度非常快,快到没有时间在去读取主存中的值,所以主线程中flag一直是false,main线程何时从主内存中读取最新的值,无法控制
  • 可见性问题的原因

    • 所有的共享变量存在于主内存,每个线程有自己的本地内存,而且线程读写数据是通过本地内存交换 的,所以才导致了可见性的问题。

1.3共享变量不可见性的解决方案

  • 方案一:加锁
    • 某一个线程进入synchronized代码块前后,执行过程
      • 线程获得锁
      • 清空工作内存
      • 从主内存中copy最新的值到工作内存成为副本
      • 执行代码
      • 将修改后的值写回主内存
      • 线程释放锁
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
        //主线程中获取myThread对象里的flag成员
        while(true){
            synchronized (myThread) {
                if (myThread.getFlag())
                    System.out.println("主线程flag=" + myThread.getFlag());
            }
        }
    }
  • 方案二:使用volatile关键字

    • volatile的工作原理

      image-20220628105900495

      • 子线程从主内存中读取数据放入其对应的工作内存
      • 子线程将值改为true,此时flag的值还没有写入主内存
      • main方法读取flag的值为false
      • 当子线程将flag的值写回去后,使其他线程对此变量的副本失效(通过底层的嗅探机制)
      • main线程再次操作flag时,会从主内存读取最新的值,放入工作内存
    • 总结:volatile保证了不同线程对共享变量操作的可见性,也就是说一个线程如果修改了volatile修饰的变量,当修改后的值写回主内存时,另外的线程立即看到最新的值

private volatile boolean flag=false;//定义一个成员变量

第二章 volatile的其他特性

2.1volatile特性概述

  • volatile除了可以实现并发下共享变量的可见性,还有其他的特性
  • volatile的原子性问题:volatile不能保证原子性操作
  • 禁止指令重排序:volatile可以防止指令重排序操作

2.2volatile不保证原子性

  • 所谓原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。volatile不保证原子性。
//研究原子性操作的问题
public class AtomDemo03 {
    public static void main(String[] args) {
        ThreadTarget threadTarget = new ThreadTarget();
        for (int i = 0; i < 100; i++) {
            new Thread(threadTarget,"Thread"+i).start();
        }
    }
}

class ThreadTarget implements Runnable{
    private volatile int count=0;
    @Override
    public void run() {
        //每个线程将count加10000次
        for (int i = 0; i < 10000; i++) {
            count++;           System.out.println(Thread.currentThread().getName()+"count============="+count);
        }
    }
}
//执行结果小于100 0000次,加上volatile结果也是小于100 0000,可知volatile不能保证原子性 
  • 原理分析:

    • 以上问题的产生主要发生在count++上

    • count++操作有3个步骤

      • 从主内存中读取数据到工作内存
      • 对工作内存中的数据进行++操作
      • 将工作内存中的数据写回到主内存
    • count++操作不是一个原子性操作,也就是说在某一个时刻对某一个操作的执行,有可能被其他线程打断,下面举例

      cede84a81f4b6a1019238e9687297d2

      • 假设这个时候count的值是100,线程A需要对count进行自增1的操作,首先A需要从主内存中读取变量count的值,由于CPU的切换关系,此时CPU的执行权被切换到了B线程。A线程就处于就绪状态,B线程处于运行状态。
      • 线程B也需要从主内存中读取count的值,由于线程A对count的值没有进行任何的修改,此时B读到的还是100
      • 线程B工作内存中count执行了+1操作,但是未刷新到主内存中
      • 此时CPU的执行权切换到了A线程上,由于此时线程B没有将工作内存中的数据刷新到主内存,因此A线程工作内存中的变量值还是100,没有失效,线程A对工作内存中的数据进行+1操作。
      • 线程B将101写入主内存
      • 线程A将101写入主内存
      • 虽然计算了2次,但是只对count进行了一次修改
  • 小结:

    • 在多线程的环境下,volatile关键字可以保证共享数据的可见性,但是不能保证对数据操作的原子性(在多线程环境下volatile修饰的变量也是线程不安全的)。
    • 在多线程下,要保证数据的安全性,需要使用锁机制。

2.3原子性操作的问题解决

2.3.1锁机制
  • 可以给count++操作添加锁,那么count++操作就是临界区的代码,临界区只能有一个线程去执行,所以count++就变成了原子操作
//研究原子性操作的问题

/**
 * 定义一个共享变量
 * 开启100个线程,每个线程负责为变量累加10000次
 * 在线程执行完毕之后看变量的结果
 */
public class AtomDemo04 {
    public static void main(String[] args) {
        ThreadTarget01 threadTarget = new ThreadTarget01();
        for (int i = 0; i < 100; i++) {
            new Thread(threadTarget, "Thread" + i).start();
        }
    }
}

//使用锁机制来保障原子性操作,不使用volatile加锁也能保障原子性操作
class ThreadTarget01 implements Runnable {
    private volatile int count = 0;
    @Override
    public void run() {
        //这时用this也行
        //对象锁(this)和类锁(class)产生的效果不同,对象锁只对当前对象加锁,而类锁是对指定类加锁。
        synchronized (ThreadTarget01.class) {
            //每个线程将count加10000次
            for (int i = 0; i < 10000; i++) {
                synchronized (this) {
                    count++;

                    System.out.println(Thread.currentThread().getName() + "count=============" + count);
                }
            }
        }
    }
}
2.3.2使用原子类
  • java从JDK1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法签单,性能高效,线程安全地更新一个变量的方式

  • AtomaicInterger

    • 原子型Interger,可以实现原子更新操作

    808f0c48200842a58c7ca058b4c1672

//研究原子性操作的问题

import java.util.concurrent.atomic.AtomicInteger;

/**
 * 定义一个共享变量
 * 开启100个线程,每个线程负责为变量累加10000次
 * 在线程执行完毕之后看变量的结果
 */
public class AtomDemo05 {
    public static void main(String[] args) {
        ThreadTarget02 threadTarget = new ThreadTarget02();
        for (int i = 0; i < 100; i++) {
            new Thread(threadTarget, "Thread" + i).start();
        }
    }
}

//使用原子类来保证volatile变量的原子性
class ThreadTarget02 implements Runnable {
//    private volatile int count = 0;
    private AtomicInteger atomicInteger=new AtomicInteger();//默认值为0,底层是用volatile修饰的
    @Override
    public void run() {
            for (int i = 0; i < 10000; i++) {
                synchronized (this) {
                    //自增后获取值
                    System.out.println(Thread.currentThread().getName() + "count=============" + atomicInteger.incrementAndGet());
                }
            }
    }
}

2.4禁止指令重排序

2.4.1概述
  • 什么是重排序:为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

  • 原因:一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。JMM对底层尽量减少约束,使其能发挥自身优势。因此,在执行程序时,为了提高性能 ,编译器和处理器常常会对指令进行重排序。一般重排序可以分如下三种:

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

    image-20220630140855350

2.4.2重排序的好处

可以提高处理的速度

下面的代码a和b之间不存在数据依赖,所以可以进行重排序

3f51eb66839250e8fc32fb5bdd947b2

2.4.3重排序问题案例演示
  • 重排序虽然可以提高执行的效率,但在并发执行下,JVM虚拟机底层并不能保证重排序下带来的安全性等问题
  • 以下为测试代码
/**
 * 验证重排序的问题
 */
public class OutOfOrderDemo01 {
    private static int a=0,b=0;
    private static int i=0,j=0;
    public static void main(String[] args) throws Exception {
        int count=0;//计数器
        while(true){
            count++;
            a=0;
            b=0;
            i=0;
            j=0;
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a=1;
                    i=b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b=1;
                    j=a;
                }
            });
            t1.start();
            t2.start();
            //join方法也是一种线程阻塞方法,让一个线程等待另一个线程执行完成
            //当某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到join方法加入的join线程执行完为止
            //此处的join方法是阻塞主线程,从而让t1或t2完全执行,但不会影响两个子线程争夺cpu时间片
            t1.join();
            t2.join();
            System.out.println("第"+count+"轮比较:i="+i+",j="+j);
            //改变此处条件可进行验证
            if (i==0&&j==0){
                break;
            }
        }
        System.out.println("结束了");
    }
}

image-20220701165859424

  • 分析,以上代码中定义的i和j可能出现的有4种情况

    • 第一种情况为线程t1(也就是线程A)先全部执行完成后线程t2(线程B)再执行,因此a=1,i=0,b=1,j=1

    • 第二种情况为线程B先全部执行完成后,线程A在执行,因此b=1,j=0,a=1,i=1

    • 第三种情况为线程A或线程B先执行一段时间,然后另一个线程在执行,因此a=1,b=1,i=1,j=1

    • 第四种情况为指令重排,相当于下面的执行顺序,此时i=0,a=1,j=0,b=1,这就证明了确实有重排序这么回事。

      image-20220701170746098

2.4.4volatile可以禁止重排序
  • 用volatile关键字来修饰共享变量,可以禁止生排序,上面的程序不再跳出循环,保证了业务的安全性
private volatile static int a=0,b=0;
private volatile static int i=0,j=0;
2.4.5小结
  • volatile关键字可以实现多线程间共享变量的可见性,不能保证操作的原子性,可以实现禁止重排序。

第三章、volatile的内存语义

3.1volatile写读建立的happens-before关系

3.1.1概述
  • 为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。
  • 从JDK5开始,提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
  • 所以为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。
  • 简单总结:happens-before是一种保证并发下操作对内存可见性的约束,也就是前一个操作的结果可以被后续的操作获取。
3.1.2happens-before规则

具体一共有六项规则:

  • 程序顺序规则(单线程规则)
    • 一个线程中的每个操作,happens-before于该线程中的任意后续操作。
    • 简单来说,同一个线程中前面的所有写操作对后面的操作可见。
  • 锁规则(Synchronized,Lock等)
    • 对一个锁的解锁,happens-before于随后对这个锁的加锁
    • 简单来说,线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)
  • volatile变量规则
    • 对一个volatile域的写,happens-before于任意后续对这个volatile域的读
    • 简单来说,如果线程1写入了volatile变量v,接着线程2读取了v,那么 ,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)
  • 传递性
    • 如果A happens-before B,且B happens-before C,那么A happens-before C
  • start()规则
    • 如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
    • 简单来说,就是线程A在执行过程中,启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。线程B启动后,线程A再对变量修改线程B未必可见。
  • join()规则
    • 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
    • 简单来说,就是A线程写入所有变量,在任意其它线程B调用A.join(),或者A.isAlive()成功返回后,都对B可见。
3.1.3volatile写读建立的happens-before规则
  • happens-before有一个原则是:如果A是对volatile变量的写操作,B是对同一个变量的读操作,那么hb(A,B)
  • 测试:
/**
 * 测试volatile的happens-before规则
 * 定义两个线程,一个负责对变量的读,一个负责写
 */
public class VisibilityDemo03 {
    private int a=1;
    private int b=2;
    //写操作
    public void write(){
        a=3;
        b=a;
    }
    //读操作
    public void read(){
        System.out.println("a="+a+",b="+b);
    }

    public static void main(String[] args) {
        while(true){
            VisibilityDemo03 v= new VisibilityDemo03();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    v.write();
                }
            }).start();;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    v.read();
                }
            }).start();;
        }
    }
}
  • 以上测试会出现4种结果
    • a=3,b=3(先写后读)
    • a=1,b=2(先读后写)
    • a=3,b=2(读写各执行一半)
    • a=1,b=3(由于不可见性导致的线程之间的不可见性问题)
  • 解决上面的第四种情况按照volatile的happens-before规则,只需要在b变量前加volatile关键字,因为写线程写入b及之前的写操作都对读线程可见。加上volatile,就不会出现第四种情况
3.1.4volatile重排序规则小结(未理解)

image-20220702170515158

  • 写volatile变量时,无论前一个操作是什么,都不能重排序(也就是写volatile变量之前的操作需要对另一个线程可见)
  • 读volatile变量时,无论后一个操作是什么,都不能重排序(读后续的操作也需要对其他操作可见)
  • 当先写volatile变量,后读volatile变量时,不能重排序

第四章、volatile的高频使用

4.1long和double的原子性

4.1.1概述
  • 在java中,long和double都是8个字节共64位,如果是一个32位的系统,读写long或double问题会涉及到原子性问题,因为32位系统要读完一个64位的变量,需要分两步执行,每次读取32位,这样double和long变量的赋值就会出现问题
  • 如果有两个线程同时写一个变量内存,一个进程写低32位,另一个进行写高32位,可能会导致获取的64位数据出现问题(失效数据)。

image-20220704144434226

4.1.2案例演示
/**
 * long和double在32位操作系统和64位操作系统下的原子性问题探究
 * long和double是用64位来存储的,而32位系统中最大就是32位,因此需要存两次,不是原子性操作
 */

public class LongAndDoubleTest {
    public static void main(String[] args) throws InterruptedException {
        String sysNum=System.getProperty("sun.arch.data.model");
        System.out.println(sysNum);
        ThreadDemo t1=new ThreadDemo(1);
        ThreadDemo t2=new ThreadDemo(-1);
        Thread thread1 = new Thread(t1);
        Thread thread2 = new Thread(t2);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}
class ThreadDemo implements Runnable{
    //通过对这个值进行赋值,检测原子性的问题
    private static long TestLong=0;
    private long value;

    public ThreadDemo(long value) {
        this.value = value;
    }

    public long getValue() {
        return value;
    }

    public void setValue(long value) {
        this.value = value;
    }

    @Override
    public void run() {

        for (int i = 0; i < 10000; i++) {
            TestLong=value;
            long temp=TestLong;
            if (temp!=1F&&temp!=-1f){
                System.out.println("出现错误"+temp);
                break;
            }
        }
        System.out.println("运行OK");
    }
}
  • 测试结果在32位和64位的系统中是不一样的。
    • 32位会输出:出现错误,因为32位环境无法一次读取long类型数据,多线程环境下对long变量的读写是不完整的,导致temp变量即不等于1,也不等于-1
    • 64位会输出:运行正确
4.1.3小结
  • 结论:如果在64位的系统中,那么对64位的long和double的读写都是原子操作,可以直接一次性读写long或double的整个64bit。如果在32位的JVM上,long和double就不是原子性操作了。
  • 解决方案:使用volatile关键字来防止此类现象
    • 在32位的操作系统中,对于64位的long和double,如果没有被volatile修饰,对么对其操作可以不是原子的。在操作时,可以分成了两步,每次对32位操作。
    • 如果使用volatile修饰long和double,那么其读写都是原子操作。
    • 在实现JVM时,可以自由的选择是否把读写long和double作为原子操作
    • java中对于long和double类型的写操作产是原子操作,而是分成32位的写操作。
    • 在JSR-133之前的规范中,读也是分成了两个32位的读,但是从JSR-133规范开始,即JDK5开始,读操作也都具有原子性。
    • java中对除了long和double的其他类型的读写都是原子操作
    • 对于引用类型的读写操作都是原子操作,无论引用类型的实际类型是32位的值还是64位的值
    • java商业虚拟机已经解决了long和double的读写操作的原子性

4.2volatile的双重检查锁在单例中的应用

4.2.1单例概述
  • 单例是需要在内存中永远只能创建一个类的实例
  • 单例的作用:节约内存和保证共享计算的结果正确,以及方便管理
  • 单例模式的适用场景:
    • 全局信息类:例如任务管理器对象,或者需要一个对象记录整个网站的在线流量等信息。
    • 无状态(无状态就是一次操作,不能保存数据。无状态对象(Stateless Bean),就是没有实例变量的对象。不能保存数据,是不变类,是线程安全的。具体来说就是只有方法没有数据成员的对象,或者有数据成员但是数据成员是可读的对象)工具类:类似于整个系统的日志对象等,我们只需要一个单例日志对象负责记录,管理系统日志信息。
4.2.2提供8种单例
  • 饿汉式2种、懒汉式4种、静态内部类1种、枚举1种

  • 饿汉式单例:在获取单例对象前对象已经创建完成

  • 懒汉式单例:在真正需要单例的时候才能创建出该对象。

4.2.3饿汉式单例的2种写法
  • 特点:在获取对象前对象已经创建完成。缺点是可能需要加载的东西太多,影响性能。

  • 饿汉式(静态常量):

    package singleton;
    /**
     * 饿汉式单例第一种:通过静态常量来定义
     * 步骤:
     *  1.构造器私有
     *  2.定义一个静态常量保存一个唯一的实例对象
     *  3.提供一个方法返回单例对象
     */
    
    public class SingleTonDemo01 {
        //2.定义一个静态常量保存一个唯一的实例对象,final可以防止反射带来的影响
        private static final SingleTonDemo01 INSTANCE=new SingleTonDemo01();
        //1.构造器私有
        private SingleTonDemo01() {
        }
        //3.提供一个方法返回单例对象
        public static SingleTonDemo01 geInstance(){
            return INSTANCE;
        }
    }
    class Demo{
        public static void main(String[] args) {
            SingleTonDemo01 s1 = SingleTonDemo01.geInstance();
            SingleTonDemo01 s2 = SingleTonDemo01.geInstance();
            System.out.println(s1.hashCode());
            System.out.println(s2.hashCode());
        }
    }
    
  • 饿汉式(静态代码块):

    package singleton;
    
    /**
     * 饿汉式单例第一种:通过静态常量来定义
     * 步骤:
     *  1.构造器私有
     *  2.定义一个静态常量保存一个唯一的实例对象,通过静态代码块来初始化
     *  3.提供一个方法返回单例对象
     */
    
    public class SingleTonDemo02 {
        //2.定义一个静态常量保存一个唯一的实例对象,final可以防止反射带来的影响
        private static final SingleTonDemo02 INSTANCE;
        static{
            INSTANCE=new SingleTonDemo02();
        }
        //1.构造器私有
        private SingleTonDemo02() {
        }
        public static SingleTonDemo02 geInstance(){
            return INSTANCE;
        }
    } 
    class Demo02{
        public static void main(String[] args) {
            SingleTonDemo02 s1 = SingleTonDemo02.geInstance();
            SingleTonDemo02 s2 = SingleTonDemo02.geInstance();
            System.out.println(s1.hashCode());
            System.out.println(s2.hashCode());
        }
    }
    
4.2.4懒汉式单例的4种写法
  • 特点:在真正需要单例的时候才创建出该对象。在java程序中,有时候可能需要推迟一些高开销对象的初始化操作,并且只有在使用这些对象的时候才初始化,此时,可以采用延迟初始化。

  • 要正确的实现线程安全的延迟初始化需要一些技巧,否则很容易出问题。

  • 懒汉式第一种(线程不安全):

    package singleton;
    /**
     * 懒汉式单例:(线程不安全)
     * 步骤:
     * 1.构造方法私有
     * 2.定义一个对象但不初始化
     * 3.暴露一个方法如果为null就创建,否则就返回对象
     */
    public class SingleTonDemo03 {
        //2.定义一个对象但不初始化,不能加final,加final编译不通过
        private static SingleTonDemo03 instance;
    
        //1.构造方法私有
        private SingleTonDemo03() {
    
        }
        //3.暴露一个方法如果为null就创建,否则就返回对象
        public static SingleTonDemo03 getInstance() {
            if (instance == null) {
                instance = new SingleTonDemo03();
            }
            return instance;
        }
    }
    class Test03 {
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    SingleTonDemo03 instance = SingleTonDemo03.getInstance();
                    System.out.println("t1:" + instance.hashCode());
    
    
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    SingleTonDemo03 instance = SingleTonDemo03.getInstance();
                    System.out.println("t2:" + instance.hashCode());
    
                }
            });
            t1.start();
            t2.start();
        }
    }
    //可能会输出hashcode不同的情况
    //这种方式是线程不安全的,假如两个线程同时调用getInstance(),可能会同时将instance判断为空,此时创建2个SingleTonDemo03对象
    
  • 懒汉式第二种(线程安全,性能差)

    • 使用synchronized关键字修饰方法,但性能差,并发下只能有一个线程正在进入获取单例对象
    package singleton;
    /**
     * 懒汉式单例:(线程不安全)
     * 步骤:
     * 1.构造方法私有
     * 2.定义一个对象但不初始化
     * 3.暴露一个方法如果为null就创建,否则就返回对象
     */
    public class SingleTonDemo04 {
        //2.定义一个对象但不初始化,不能加final,加final编译不通过
        private static SingleTonDemo04 instance;
        //1.构造方法私有
        private SingleTonDemo04() {
    
        }
        //3.暴露一个方法如果为null就创建,否则就返回对象
        //4.加上synchronized关键字来同步,解决线程安全问题
        public static synchronized SingleTonDemo04 getInstance() {
            if (instance == null) {
                instance = new SingleTonDemo04();
            }
            return instance;
        }
    }
    class Test04 {
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    SingleTonDemo04 instance = SingleTonDemo04.getInstance();
                    System.out.println("t1:" + instance.hashCode());
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    SingleTonDemo04 instance = SingleTonDemo04.getInstance();
                    System.out.println("t2:" + instance.hashCode());
    
                }
            });
            t1.start();
            t2.start();
        }
    }
    
  • 懒汉式第三种(线程不安全,对上面性能差的优化)

    package singleton;
    /**
     * 懒汉式单例:对槴synchronized的优化
     * 在方法上加synchronized不如在方法内部加synchronized,可以使效率更高。
     */
    public class SingleTonDemo05 {
        //2.定义一个对象
        private static SingleTonDemo05 instance;
        //1.构造器私有
        private SingleTonDemo05() {
    
        }
        //创建一个方法来初始化对象
        public static SingleTonDemo05 getInstance(){
            if (instance==null){
                //在此处加锁,可以让线程进入到这里等待,如A和B线程同时执行到了此处,此时还是会出现线程安全的问题,A执行完成后,因为B也执行到这里了,B还是会再创建一次对象
                synchronized (SingleTonDemo05.class){
                    instance=new SingleTonDemo05();
                }
            }
            return instance;
        }
    }
    class Test05{
        public static void main(String[] args) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(SingleTonDemo05.getInstance().hashCode());
                }
            }).start();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(SingleTonDemo05.getInstance().hashCode());
                }
            }).start();
        }
    }
    
  • 懒汉式第4种(双重检查模式,常用

    package singleton;
    /**
     * 懒汉式第4种:双重检查机制,需要加volatile关键字来禁止重排序
     */
    public class SingleTonDemo06 {
        //定义一个私有变量
        private static volatile SingleTonDemo06 instance;
        //构造方法私有
        private SingleTonDemo06(){
    
        }
        //暴露一个方法来获取
        public static SingleTonDemo06 getInstance(){
            if (instance==null)
            {
                synchronized (SingleTonDemo06.class){
                    //解决上个例子中的安全性问题,假如多个线程运行到此处,继续进行判断,可以解决创建多次对象的问题,检查二次
                    if (instance==null){
                        instance=new SingleTonDemo06();
                    }
                }
            }
            return  instance;
        }
    }
    class Test{
        public static void main(String[] args) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println(SingleTonDemo06.getInstance().hashCode());
                    }
                }).start();
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println(SingleTonDemo06.getInstance().hashCode());
                    }
                }).start();
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println(SingleTonDemo06.getInstance().hashCode());
                    }
                }).start();
        }
    }
    
    • 上述案例分析:

      • 用二次对instance等于null的判断,来解决多个线程创建多次对象的问题

      image-20220706162739941

      • 上图介绍了使用volatile关键字的原因
        • 防止重排序带来的空指针异常(NPE)
          • 使用new Singleton06()是一个非原子操作,编译器可能会重排序[构造函数可能在整个对象初始化完成前执行完成,即赋值操作(只是在内存中开辟一片区域直接返回内存的引用 )],而线程C在线程A赋值完成时判断instance不为null了,此时C拿到的将是一个没有初始化完成的半成品。这样是很危险的。因为极有可能线程B会继续拿着这个没有初始化的对象中的数据进行操作,此时容易触发“NPE”异常。
        • 保证了可见性
          • 由于可见性问题,线程A在自己的工作线程内创建了实例,但此时还未同步到主存中;此时线程C在主存中判断instance还是null,那么线程C又将在自己的工作线程中创建一个实例,这样就创建了多个实例。
          • 如果加上了volatile修饰instance,保证了可见性,一旦线程A返回了实例,线程C可以立即发现instance不为null
4.2.5静态内部类单例方式
  • 引入:JVM在类初始化阶段(即在Class被加载后,且线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。(静态内部类的加载是在程序中调用静态内部类的时候加载的,和外部类的加载没有必然关系, 但是在加载静态内部类的时候发现外部类还没有加载,那么就会先加载外部类, 加载完外部类之后,再加载静态内部类)
package singleton;
/**
 * 单例的第7种实现方式,通过静态内部类的方式
 * 类的始始化发生在类加载完成后,初始化时JVM会获取类的锁,这个锁可以同步多个线程对同一个类的初始化
 * 静态内部类是在调用时都会进行加载,因此可以实现延迟性加载
 * 
 * 步骤:
 *  1.构造器私有
 *  2.通过内部类的方式进行对象的初始化
 *  3.暴露一个方法获取对象
 */
public class SingleTonDemo07 {
    //1.构造器私有
    private SingleTonDemo07(){
        
    }
    //2.通过内部类的方式进行对象的初始化
    private static class inner{
        private static final SingleTonDemo07 instance=new SingleTonDemo07();
    }
    //3.暴露一个方法获取对象,这个方法只能是静态的,因为静态方法才能访问静态内部类
    public static SingleTonDemo07 getInstance(){
        return inner.instance;
    }
}
  • 小结:
    • 静态内部类是在被调用时才会被加载,这种方案实现了懒汉式单例的一种思想,需要用到的时候才去创建单例,加上JVM的特性,这种方式又实现了线程安全的创建单例对象。
    • 和基于volatile的双重检查锁定方案相对,基于静态类初始化的方案代码更加简洁。但是基于volatile的双重检查锁定方案有一个额外优势:不仅可以对静态字段延迟初始化,还可以对实例字段实现延迟初始化。
4.2.6枚举实现单例
  • 枚举本身是一种多例模式,如果枚举中只定义一个对象,就是单例。
package singleton;

/**
 * 枚举实现单例
 */
public enum SingleTonDemo08 {
    instance;
}

4.3volatile的使用场景

4.3.1纯赋值操作
  • 概述:
    • volatile不适合做a++操作,会有安全问题
    • 适合做纯赋值操作,如boolean flag=false/true
package useVolatile;

import java.util.concurrent.atomic.AtomicInteger;

//测试纯赋值操作与与纯赋值操作
public class UseVolatileDemo01  implements Runnable{
    volatile boolean flag=false;
    AtomicInteger atomicInteger=new AtomicInteger();//创建一个原子类,进行累加
    @Override
    public void run() {
        //进行10000次赋值
        for (int i=1;i<=10000;i++){
            flag=true;//纯赋值操作,如果是纯赋值操作是正常的
            //flag=!flag;//非纯赋值操作,如果是这样则会出现false和true两种情况,线程不安全,不能保证原子性操作
            atomicInteger.incrementAndGet();
        }
    }
}
class Test{
    public static void main(String[] args) throws InterruptedException {
        UseVolatileDemo01 useVolatileDemo01 = new UseVolatileDemo01();
        Thread t1 = new Thread(useVolatileDemo01);
        Thread t2 = new Thread(useVolatileDemo01);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //t1 t2执行完成后输出
        System.out.println(useVolatileDemo01.flag);
        System.out.println(useVolatileDemo01.atomicInteger);
    }
}
  • 小结:volatile可以适合做多线程中的纯赋值操作:如果一个共享变量自始至终只被各个线程赋值,而没有其他操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,足以保证线程安全。
4.3.2触发器
  • 概念:按照volatile的可见性和禁止重排序以及happens-before规则,volatile可以作为刷新之前变量的一个触发器。我们可以将某个变量设置为volatile修饰,其他线程一旦发现该变量修改的值后,触发获取到该变量之前的操作,都将是最新的且可见。
package useVolatile;

/**
 * volatile可以作为刷新变量之前的一个触发器,将一个变量设置为volatile,
 * 其他线程一旦发现该变量的值被修改后,就会触发获取到该变量之前的操作都是最新的可见的
 */
public class UseVolatileDemo02 {
    int a = 1;
    int b = 2;
    int c = 3;
    volatile boolean flag = false;

    public void write() {
        a = 100;
        b = 200;
        c = 300;
        flag = true;
    }

    public void read() {
        while (flag)
            System.out.println("a=" + a + ",b=" + b + ",c=" + c);
    }

    public static void main(String[] args) {
        UseVolatileDemo02 useVolatileDemo02 = new UseVolatileDemo02();
        new Thread(new Runnable() {
            @Override
            public void run() {
                useVolatileDemo02.write();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                useVolatileDemo02.read();
            }
        }).start();
    }
}

4.4volatile与synchronized的区别

  • volatile只能修改实例变量和类变量,而synchronized可以修饰方法及代码块
  • volatile保证数据的可见性,但不保证原子性(多线程进行写操作,不保证线程安全),synchronized是一种排他(互斥)机制
  • volatile用于禁止指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题
  • volatile可以看成是一个轻量级的synchronized,volatile不保证原子性,但如果是对一个共享变量进行多个线程的纯赋值操作,没有其他操作,就可以用volatile来代替synchronized,因为赋值本身就有原子性,而volatile又保证了可见性,因此线程是安全的。

4.5volatile总结

  • volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag,或者作为触发器,实现轻量级同步。
  • volatile属性的读写的操作都是无锁的,它不能替代synchronize,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
  • volatile只能作用于属性,用volatile来修饰属性,这样compiler就不会对这个属性做指令重排。
  • volatile提供了可见性,任何一个线程对其修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主存中读取。
  • volatile提供了happens-before保证,对volatile变量v的写入happens-before于所有其他线程后续对v的读操作。
  • volatile可以使用long和double在32位系统中的赋值是原子性的。
  • volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性
posted @   是韩信啊  阅读(28)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示