美文网首页Java 杂货铺Java基础JVM
java的可见性、有序性和原子性

java的可见性、有序性和原子性

作者: 可人的食堂大爷 | 来源:发表于2018-08-30 16:43 被阅读136次

    话不多说,先上一张图

    java内存模(jie)型(gou)

    没错,我们今天聊的东西,跟他没啥关系。

    上面这是java的内存结构(我就是忽悠你们来的)。

    今儿主要先聊一聊java的内存模型(嗯,也不是非想跟你们聊,主要是标题得从这玩意儿来引出)。

    但是也不能干聊不是?想起兄弟们曾经对我灵魂的拷问(无图言屌),所以我就又从网上盗了一张图。

    来来来,看图说话:

    java真·内存模型

    1.java中所有的变量都是存在主内存里的。

    2.各自的线程在工作的时候会自己拿到一块工作内存。里面保存了该线程用到的变量的副本。

    3.线程对变量的操作,都是对自己工作内存中变量的操作,不能操作主内存。

    4.最终工作内存中的数据是需要同步回主内存,来完成主内存中变量的更新的。

    举个例子吧:

    就好比主内存就是大哥,线程就是小弟,大哥每天督促小弟们干活,小弟们彼此不怎么交流,每天就是干了活,然后自己找个本记下来,找时间告诉大哥。

    那么咱们来看下面的这一个场景:

    大哥说让仨小弟进点儿货。让一个小弟联系货源,一个小弟联系运输公司,一个小弟联系装卸工。小弟1去联系货源了,联系好了告诉大哥,“货源联系好了”。这时候小弟2来了,一听货源好了,就去联系运输公司了。运输公司联系好了,小弟2还没回去告诉大哥呢,小弟3跑过去问大哥了,一听大哥说货源好了,也跑出去联系运输公司了。结果来了俩运输公司,对着成堆的货物干瞪眼。

    这里面有几个问题呢?

    1.小弟2忙活清了,应该打个电话赶紧通知大哥,他找到运输公司了。

    2.大哥知道小弟2出去忙活事情了,小弟3这时候来了,大哥应该别让小弟3着急,等小弟2干完了,小弟3再上。

    3.小弟2要是告诉大哥,他出去找运输公司了,大哥怎么还会让小弟3接着去找运输公司呢?

    如果在这个场景里,我们会说,这不一群智障么,这种错误都能发生,办事不过脑子的么?!

    是的,java就是这么的智障。

    其实呢,上面阐述的三个问题,也就是我们java内存模型在设计的时候,所围绕的三个问题:

    可见性,有序性和原子性。(终于聊到重点了朋友们!)

    下面要上定义了!

    可见性:一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。

    Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

    有序性:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。

    java内存模型所保证的是,同线程内,所有的操作都是由上到下的,但是多个线程并行的情况下,则不能保证其操作的有序性。

    原子性:一个操作不能被打断,要么全部执行完毕,要么不执行。

    基本数据类型的访问都是原子性的(默认64位,32位的机器对long、double这种占8个字节的是非原子性的),而针对非原子性的数据,多线程的访问则是不安全的。

    以上是java内存模型中,单线程针对这三种问题作出的最基本的控制,但是并发编程的场景中, 多线程的出现会导致这三个问题频频发生。

    那么聪明的你一定会问了,我们应该如何去控制呢?(不问的都拉出去弹鸡儿)

    一个一个来:

    可见性:

    volatile关键字:通过volatile关键字修饰内存中的变量,该变量在线程之间共享

    其实对于可见性而言,无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。

    但是volatile只是对关键字进行了修饰,保证了其可见性,而对于线程的有序性和变量的原子性,volatile根本没有什么卵用,也就是什么意思呢?

       private static volatile int volatileCount = 0;
       private static void volatileCount() {
            for (int i = 0; i < 10; i++) {
                Executors.newFixedThreadPool(3).execute(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("volatile count: " + ++volatileCount);
                });
            }
        }
    

    代码中的volatile,加与不加,没多少区别。
    因为volatile只能保证其变量的可见性。但是每个线程啥时候开始跑,我们根本不知道。当A线程访问到volatileCount = 1时,并且开始执行时,如果B线程此时也访问到了,他也一样会执行,虽然读取volatileCount时,我们都能保证他是最新的,但是我们不能保证,当A线程在操作的时候,B线程不能进去插一脚。

    原子性:
    java.util.concurrent.atomic,原子类了解一下?
    在java中,我们知道++操作实际上并不是线程安全的,为了保证线程间的变量原子性,java引入了atomic类。它的作用就是保证,使用的变量,一定是原子性的。
    代码咱看一下:

        private static AtomicInteger atomicCount = new AtomicInteger(0);
        private static void atomicCount() {
            for (int i = 0; i < 10; i++) {
                Executors.newFixedThreadPool(3).execute(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //atomicCount.incrementAndGet()方法的意思是让其自增 1,等同于++
                    System.out.println("atomic count: " + atomicCount.incrementAndGet());
                });
            }
        }
        //    atomic count: 5
        //    atomic count: 1
        //    atomic count: 3
        //    atomic count: 2
        //    atomic count: 8
        //    atomic count: 10
        //    atomic count: 4
        //    atomic count: 6
        //    atomic count: 7
        //    atomic count: 9
    

    结果也放在下面了,可见,我们保证了它的唯一性,他是一定会自增到10的。说白了,就是缓存锁机制。
    关于缓存锁的机制,下面有一个官方解释,可以学习一下:

    缓存锁是指通过锁住CPU缓存,在CPU缓存区实现共享变量的原子性操作。
    如果缓存在处理器的缓存行中,内存区域在LOCK操作期间被锁定,当它执行锁操作,回写主内存时,处理器不在总线锁上声言LOCK#信号,而是修改内部内存地址,并允许它的缓存一致性机制来保证操作的原子性。因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效。
    缓存锁使用的是比较并交换策略(Compare And Swap简称CAS),CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。

    这段话也解释了为什么虽然我们不能保证其有序性,但是,我们依然能正常的自增到10。

    有序性:
    synchronized,日常用的最多的东西,当使用synchronized关键字时,只能有一个线程执行直到执行完成后或异常,才会释放锁。所以可以保证synchronized代码块或方法只会有一个线程执行,保障了程序的有序性。

        private static void synchronizedCount() {
            for (int i = 0; i < 10; i++) {
                Executors.newFixedThreadPool(3).execute(->() {
                    @Override
                    public void run() {
                        synchronized (MyClass.class) {  // 通过synchronized关键字来保证线程之间的有序性
                            System.out.println("synchronized count: " + ++synchronizedCount);
                        }
                    }
                });
            }
        }
    

    结果看上去很美好,有序,且从1到10,一个不差。
    但是,synchronized能不能保证原子性和可见性呢?还记得单例的DCL写法么?

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

    如果不加volatile会有什么问题呢?
    java内存模型(jmm)并不限制处理器重排序,在执行instance=new Singleton();时,并不是原子语句,实际是包括了下面三大步骤:
    1.为对象分配内存
    2.初始化实例对象
    3.把引用instance指向分配的内存空间
    这个三个步骤并不能保证按序执行,处理器会进行指令重排序优化,存在这样的情况:
    优化重排后执行顺序为:1,3,2, 这样在线程1执行到3时,instance已经不为null了,线程2此时判断instance!=null,则直接返回instance引用,但现在实例对象还没有初始化完毕,此时线程2使用instance可能会造成程序崩溃。
    所以说,实际上synchronized只是保证了其有序性,并没有办法保证其原子性,而其可见性,是依靠java的内存模型来保证的。

    OK,基本说清,就此打住,老少爷们们,咱下期再见。


    相关文章

      网友评论

      本文标题:java的可见性、有序性和原子性

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