java内存模型、volatile关键字、synchronized

25 篇文章 1 订阅
6 篇文章 0 订阅

目录

1、jvm管理的内存区域划分

 2、JMM:Java内存模型

3、volatile关键字

3.1、volatile保证可见性

3.1.1、synchronized关键字也是通过内存屏障来保证可见性的 

3.2、volatile不保证原子性

3.3、volidate保证有序性(即:禁止指令重排序)

4、volatile使用场景

5、volatile与synchronized的区别

6、volatile原理


        volatile关键字虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的,因此在讲述volatile关键之前,我们先来了解一下与内存模型相关的概念和知识,然后分析了volatile关键字的实现原理,最后给出了几个使用volatile关键字的场景。

1、jvm管理的内存区域划分

        jvm内存模型详解记录

       

 2、JMM:Java内存模型

        JVM运行程序的实体是线程,每个线程在被创建时JVM都会为其创建一个自己私有的工作内存。而Java内存模型规定所有的变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但是线程对这些变量的操作只能在自己的工作内存中进行,不能直接操作主内存中的变量,要先将变量从主内存中拷贝到线程自己的工作内存中再对其进行操作,操作完成后再将操作后的变量写回主内存当中,因此不同的线程也无法访问对方的工作内存,线程间的通信必须通过主内存来完成。

Java内存模型的抽象结构图:

 关于JMM我们需要知道的点:

  1. 工作内存和Java内存模型并不是真实存在于java虚拟机中,而是一种规范和定义。
  2. 共享变量存储于主内存之中,每个线程都可以访问。这里的变量指的是实例变量和类变量。局部变量是线程私有的,不存在共享。
  3. 每个线程都有私有的工作内存或者称为本地内存,工作内存存储的是共享变量的副本。
  4. 线程不能直接操作主内存,只有先操作了工作内存之后才能写人主内存。
  5. 不同的线程不能直接访问对方工作内存中的变量,线程间变量的值传递需借助主内存作为中转来完成。

3、volatile关键字

        volatile是java虚拟机提供的轻量级同步机制。其三大特性为保证可见性不保证原子性禁止指令重排

3.1、volatile保证可见性

        如果A线程和B线程同时获取主内存中的同一个变量,之后A线程修改了这个变量,但是此时B线程并不知道A线程已经对数据进行了修改,所以要具有可见性让线程之间进行通讯。当线程A修改完以后线程B也能知道此时该变量的值已经变为A修改后的数据,实现可见性。

volatile的可见性是通过什么来保证的?

        volatile是通过内存屏障来保证可见性的,Load屏障保证volatile变量每次读取数据的时候都强制从主内存读取;Store屏障每次volatile修改之后强制将数据刷新会主内存。

具体验证volatile可见性的代码如下:

public class MyData {
    // 次数为 0
    int count = 0;

    // 调用该方法,次数设置为 10
    public void mydata(){
        this.count = 10;
    }
}


public class VolidateTest {

    public static void main(String[] args) {

        MyData myData = new MyData();

        System.out.println(Thread.currentThread().getName() + "线程开始执行的 count 值:" + myData.count);

        new Thread(() -> {
            // Thread.currentThread().getName() :返回正在被执行的线程的名称
            System.out.println(Thread.currentThread().getName() + "线程拿到的 count 值:" + myData.count);

            try {
                /*
                 *  线程睡3秒
                 * 1.TimeUnit.SECONDS.sleep()这个方法可以精确到任意时间,指定DAYS、HOURS、MINUTES,SECONDS、MILLISECONDS和NANOSECONDS
                 * 2.Thread.sleep()参数只能是毫秒,只可以精确到毫秒数
                 */
                TimeUnit.SECONDS.sleep(3);

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.mydata();
            //执行后的结果信息
            System.out.println(Thread.currentThread().getName() + "线程修改后的count 值:" + myData.count);
        }, "myThread").start();

        // 此时如果main线程执行到这里,进入死循环,代表没有可见性。
        while (myData.count == 0) {

        }
        //此时代表main线程已拿到myThread线程修改后的结果
        System.out.println(Thread.currentThread().getName() + "线程执行结束,最终的 count 值:" + myData.count);
    }
}

执行以上代码可以看到代码进入死循环,代表没有可见性,main线程并不知道myThread线程已经将变量修改。如图:

 此时我们只需要将实体类中的变量count加上volatile关键字即可实现可见性。

public class MyData {
    // 次数为 0
    volatile int count = 0;

    // 调用该方法,次数设置为 10
    public void mydata(){
        this.count = 10;
    }
}

执行结果:

此时main线程已经知道myThread线程修改了变量的值,实现了可见性。

3.1.1、synchronized关键字也是通过内存屏障来保证可见性的

        monitor锁是重量级锁,不适合用于保证可见性。

        sychronized底层是通过monitorenter的指令来进行加锁的、通过monitorexit指令来释放锁的。但是很多人都不知道的一点是:

        monitorenter指令其实还具有Load屏障的作用。也就是通过monitorenter指令之后,synchronized内部的共享变量,每次读取数据的时候被强制从主内存读取最新的数据。

        monitorexit指令也具有Store屏障的作用,也就是让synchronized代码块内的共享变量,如果数据有变更的,强制刷新回主内存。

        这样通过这种方式,数据修改之后立即刷新回主内存,其他线程进入synchronized代码块后,使用共享变量的时候强制读取主内存的数据,上一个线程对共享变量的变更操作,它就能立即看到了。大致流程如下图:

代码示例:

@Slf4j
public class SynchronizedVisibility {
    private static final Object lock = new Object();
    private static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (true) {
                synchronized (lock) {
                    if (!run) {
                        break;
                    }
                }
            }
        });

        t1.start();

        log.debug("停止t1线程");
        synchronized (lock) {
            run = false;
        }
    }
}

3.1.2、Lock相关的工具类保证可见性

   Lock相关的工具类的lock方法能够保证同一时刻只有一个线程获得锁,然后执行同步代码块,并且确保执行Lock相关的工具类的unlock方法执行前,会把变量的修改刷新到主内存中。

3.2、volatile不保证原子性

        原子性:指的是在一次操作或者多次操作中,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。

测试代码如下:

public class MyData {
    // 次数为 0
    volatile int count = 0;

    // 调用该方法,次数设置为 10
    public void mydata(){
        this.count = 10;
    }

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

public class VolidateTest {

    public static void main(String[] args) {

        MyData myData = new MyData();

        // 循环创建20个线程,每个线程执行add方法一千次
        for (int i = 1; i <=20 ; i++) {
            new Thread(() -> {
                for (int j = 1; j <=1000 ; j++) {
                    myData.add();
                }
            }, String.valueOf(i)).start();
        }

        // Thread.activeCount() : 此方法返回活动线程的当前线程的线程组中的数量。
        // 如果当前线程大于2,代表除了当前main线程还有其他后台GC线程
        while (Thread.activeCount() > 2) {
            // Thread.yield() :使当前线程从执行状态(运行状态)变为可执行态(就绪状态)
            // 当还有其他线程时,main线程进行礼让,让其他线程先执行
            Thread.yield();
        }
        // 输出20个线程执行完毕后,count的最终值
        System.out.println(Thread.currentThread().getName()+" 最终值: " + myData.count);
    }

}

执行以上main方法 两次,可得到结果如图: 

      运行上述代码可知,count的输出值每次都不一样,但是不会出现期望结果20000,而且属性count还用volatile修饰了,按照之前的逻辑,加了volatile关键字,count值每次修改都会被刷新至主存,且其它线程每次也都是从主存中获取最新的count值,那为什么还会出现这种情况呢?
        虽然volatile会使不同的线程每次从主内存中读取,而不是从线程本地工作内存中读取,这样是保证了数据的可见性。但是需要注意的是:如果修改实例变量中的数据,例如count++,也就是count=count+1,这个操作并不是一个原子操作,它包含下面三步:

  • (1)从内存中取出count的值;
  • (2)计算count的值;
  • (3)将count的值写回内存中。

        若在上述步骤(3)中,线程1计算完count的值,还未来得及将count的值写回内存,线程2来获取count的值,此时线程2拿到未被线程1修改的count,同样执行count=count+1操作,执行完成后同样需要将count写回主内存,这时就会将线程1写入主内存中的值覆盖。虽然是两个线程执行分别执行了count=count+1,但是由于开始拿到的count值是同一个,实际上count的值只增加了一次,因此导致最后的count值不符合预期值10000。具体流程如下图所示:

在这里插入图片描述

 上述问题的根本原因:自增操作是非原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

 解决volidate无法保证原子性的 方法:

1.加synchronized同步锁。(但是此处使用synchronized显得太重了,杀鸡用牛刀!)

2.使用java.util.concurrent.atomic下的AtomicInteger可以保证原子性(CAS自旋锁),代码如下:

public class MyData {
    // 次数为 0
    volatile int count = 0;

    // 调用该方法,次数设置为 10
    public void mydata() {
        this.count = 10;
    }

    public synchronized void add() {
        count++;
    }

    //new AtomicInteger(); ()里不写默认为0,相当于现在atomicInteger = 0
    AtomicInteger atomicInteger = new AtomicInteger();

    public void addMyAtomic() {
        //等同于++ , 带原子性的++
        atomicInteger.getAndIncrement();
    }
}


public class VolidateTest {

    public static void main(String[] args) {

        MyData myData = new MyData();

        // 循环创建20个线程,每个线程执行add方法一千次
        for (int i = 1; i <=20 ; i++) {
            new Thread(() -> {
                for (int j = 1; j <=1000 ; j++) {
                    myData.add();
                    myData.addMyAtomic();
                }
            }, String.valueOf(i)).start();
        }

        // Thread.activeCount() : 此方法返回活动线程的当前线程的线程组中的数量。
        // 如果当前线程大于2,代表除了当前main线程还有其他后台GC线程
        while (Thread.activeCount() > 2) {
            // Thread.yield() :使当前线程从执行状态(运行状态)变为可执行态(就绪状态)
            // 当还有其他线程时,main线程进行礼让,让其他线程先执行
            Thread.yield();
        }
        // 输出20个线程执行完毕后,count的最终值
        System.out.println(Thread.currentThread().getName()+" 最终count值: " + myData.count);
        System.out.println(Thread.currentThread().getName()+" 最终atomicInteger值: " + myData.atomicInteger);
    }

    /* 执行结果:
        main 最终count值: 20000
        main 最终atomicInteger值: 20000
    */
}

3.2.1、synchronized的原子性​

        synchronized 底层实际上通过JVM来实现的,同一时间只能有一个线程去执行synchronized 中的代码块。

        原子性:既然同一时间只有一个线程去运行里面的代码,那么这个操作就是不能被其它线程打断的,所以这里天然就具有原子性了。

3.3、volidate保证有序性(即:禁止指令重排序)

volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。

如果不使用volatile,在多线程环境中线程交替执行,由于编译器优化重排,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

示例: volatile修饰的变量,保证其变量flag 前代码先执行。

private int num = 0;
private volitale boolean flag = false;

// 如果flag 不用volitale 修饰,多线程情况下有可能会出现指令重排后,先执行flag=true。
// 可能会导出代码逻辑错误。
public void test() {
    num = 1;
    flag = true;
}

计算机在执行程序时,为了提高性能,编译器和处理器通常会对指令做出重排

        为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

        写屏障:确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。

        读屏障:确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

        volitale有序性:只是保证了本线程内相关代码不被重排序。

4、volatile使用场景

volatile适用于以下场景:

  1. 某个属性被多个线程共享,其中一个线程修改了此属性,其他线程可以立即获得修改后的值,比如线程循环标识boolean flag;
  2. volatile还可以用于单例模式,可以解决单例双重检查对象初始化代码执行乱序问题。

volatile应用于单例模式代码:

public class Singleton {

    private volatile static Singleton singleton = null;

    public Singleton() {
        System.out.println(Thread.currentThread().getName() + "生成singleton");
    }

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

        上述单例模式中使用了双重检验,如果不用volatile关键字修饰属性又会怎样?
单线程情况下,属性singleton不加volatile关键字也不会出现任何问题;但是多线程情况下,会出现指令重排序的情况,就有可能出现空指针问题。首先需要了解的是对象创建包含下面三个过程:

  1. 分配内存空间;
  2. 调用构造函数,初始化对象;
  3. 返回地址给引用。

        由于步骤2和步骤3不存在数据依赖关系,而且无论是重排前还是重排后的执行结果在单线程中并没有发生改变,因此这种重排优化是允许的。若此时先执行步骤3,步骤2还未执行完,另一个线程来执行if (singleton == null)会返回false,此时对象未完全生成,是个半成品,当访问对象方法或属性时,就会抛出空指针异常。使用volatile避免指令重排序,同时保证写回主存中的对象只有一个,实现真正意义上的单例。

5、volatile与synchronized的区别

        1.关键字volatile是线程同步的轻量级实现,所以volatile的性能略胜于synchronized,并且volatile只能修饰变量,而synchronized可以修饰方法、代码块等;
        2.多线程访问volatile不会发生阻塞,而synchronized会出现阻塞;
        3.volatile能保证数据的可见性,但不能保证原子性;而synchronized可以保证原子性,也可以间接保证可见性,因为它会将私有内存和共有内存中的数据做同步。
        4.关键字volatile解决的是变量在多个线程之间的可见性;而synchronized关键字解决的是多个线程之间访问资源的同步性

6、volatile原理

        Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

  在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

        当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

  而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

        volatile 性能:volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值