美文网首页
java并发编程 -2- 并发问题以及volatile、sync

java并发编程 -2- 并发问题以及volatile、sync

作者: cf6bfeab5260 | 来源:发表于2019-05-07 11:47 被阅读0次

    1 背景

    咱们的计算机有3大重要组成:CPU、内存、硬盘。而这三个组成的速度差别是非常明显的,CPU>内存>硬盘,根据木桶原理,硬盘的读写理所应当地成为程序的瓶颈。为了均衡这三者,并且更高效地利用CPU,咱们的操作系统和java编译器做了如下动作:

    • CPU增加了缓存,以均衡和内存之间的速度差异,
    • 操作系统通过进程和线程对CPU的分时复用,让CPU利用率更高。
    • 编译器优化了代码的执行顺序。

    这三点的确达到了想要的效果,但是,同时也带来了一些并发问题:

    1.1 多核心CPU缓存导致可见性问题

    image.png
    在单核时代,如果不考虑线程切换的前提下,CPU有缓存其实是没有问题的,线程1和线程2都是操作的CPU里缓存的共享变量A,那,所以如果线程1修改了A的值,线程2是马上可以知道的,我们称其为可见性
    image.png

    但是进入了多核时代,问题就出来了,线程1和线程2可能分别操作的是不同CPU里缓存的共享变量,当A修改了cpu1里的共享变量的值的时候,线程2是不知道的,直到该值通过同步给了内存,再同步给了CPU2。

    public class CountExample {
    
        private  long count=0;
    
        public  void add10K(){
            for(int i=1;i<=10000;i++){
                this.count++;
            }
        }
    
        public long getCount() {
            return count;
        }
    }
    
     @Test
        public void cpuCacheTest() throws InterruptedException {
            CountExample countExample=new CountExample();
            Thread t1=new Thread(()->{
                countExample.add10K();
            });
    
            Thread t2=new Thread(()->{
                countExample.add10K();
            });
            //启动
            t1.start();
            t2.start();
            //等待两个线程结束
            t1.join();
            t2.join();
            log.info(countExample.getCount()+"");
        }
    
    image.png

    上面这段代码可以演示可见性问题:两个线程分别对共享变量进行10000次+1操作,结果并不是我们期望得到的20000,而是10000到20000之间的随机数。因为可能发送这种情况:当内存里的值是0的时候,cpu1和cpu2都会把0读到自己缓存里,然后加1,同步给内存,最后内存拿到的就是1,不是2。
    那上面的代码在单核上面跑,就没问题了吗??很遗憾,还是有问题。原因这部分代码还有我们马上会说到的原子性问题

    1.2 线程切换导致的原子性问题。

    什么是原子性操作:不可分割的操作叫做原子性操作。也就是要么都成功,要么都失败,不可能成功了一半。
    上面的代码的 count++这个操作时原子性操作吗? 答案是:否。 它对于java语言来说,是一个操作,但是对于操作系统来说,它是分为了3个步骤:
    1、把count的值读取到寄存器,也就是缓存。
    2、在寄存器把count的值+1.
    3、把寄存器的值同步给内存。
    由于我们线程因为分时复用机制而切换,对于2个线程来说,这3步可能发生如下情况:

    image.png
    最后拿到的结果还是1,而不是我们期望的2。

    1.3 编译优化导致的有序性问题

    编译优化会为我们做什么事情?
    编译器会根据自己的判断,把它认为顺序无关的两行代码交换执行顺序。这会带来什么问题呢?举个栗子:

    public class SingletomExamle {
        private static SingletomExamle singletomExamle;
        private SingletomExamle(){
            super();
        };
        public static SingletomExamle getInstance(){
            if(singletomExamle==null){
                synchronized (SingletomExamle.class){
                    if(singletomExamle==null){
                        singletomExamle=new SingletomExamle();
                    }
                }
            }
            return  singletomExamle;
        }
    }
    

    这是一个典型的双重检查单例模式:1、私有的变量和构造函数。2、获取对象的方法先判断是否为空,如果为空,则通过一个同步块去创建一个对象,为了防止获取到锁的时候,别的线程已经创建成功了对象,同步块里又一次做了非空判断。
    看上去完美无缺! 但是,还是有问题。
    这里的问题出在了new SingletomExample 这一句代码上。这句代码在我们的想象中,会是这样执行:
    1、分配一块内存 M。
    2、在M上初始化对象SingletomExample。
    3、把singletomExamle指向M的地址。
    由于2和3在编译器看来其实是顺序无关的,所以它交换了他们的执行顺序:
    1、分配一块内存 M。
    2、把singletomExamle指向M的地址。
    3、在M上初始化对象SingletomExample。
    导致了可能发生下面这种情况:

    image.png

    当线程1还没有初始化对象SingletomExample,但是已经把instants指向M地址,线程2的instants==null会返回true。那线程2会拿到一个还没有被初始化的instants对象,导致使用的时候出问题。

    2 volatile

    volatile这个关键字,会带来两个效果:
    1、禁用cpu缓存。
    2、禁用编译器优化。
    所以,volatile是可以解决1.1的缓存问题以及1.3的有序性问题的,比如1.3的代码如果加上volatile就完美了:

    public class SingletomExamle {
        private volatile static SingletomExamle singletomExamle;
        private SingletomExamle(){
            super();
        };
        public static SingletomExamle getInstance(){
            if(singletomExamle==null){
                synchronized (SingletomExamle.class){
                    if(singletomExamle==null){
                        singletomExamle=new SingletomExamle();
                    }
                }
            }
            return  singletomExamle;
        }
    }
    

    3 synchronized

    synchronized 会解决原子性的问题,同时cpu缓存和内存的同步也在这个原子操作之内,所以也解决了可见性问题。比如1.1的代码如果我们家上锁,就一定能得到20000:

    public class CountExample {
    
        private  long count=0;
    
        public synchronized  void add10K(){
            for(int i=1;i<=10000;i++){
                this.count++;
            }
        }
    
        public long getCount() {
            return count;
        }
    }
    
     @Test
        public void cpuCacheTest() throws InterruptedException {
            CountExample countExample=new CountExample();
            Thread t1=new Thread(()->{
                countExample.add10K();
            });
    
            Thread t2=new Thread(()->{
                countExample.add10K();
            });
            //启动
            t1.start();
            t2.start();
            //等待两个线程结束
            t1.join();
            t2.join();
            log.info(countExample.getCount()+"");
        }
    
    image.png

    3.1 synchronized的基本用法

    class X {
      // 修饰非静态方法
      synchronized void foo() {
        // 临界区
      }
      // 修饰静态方法
      synchronized static void bar() {
        // 临界区
      }
      // 修饰代码块
      Object obj = new Object();
      void baz() {
        synchronized(obj) {
          // 临界区
        }
      }
    }  
    

    1、对于静态方法的加锁,相当于:synchronized(X.class),锁的是整个类。
    2、对于非静态方法的加锁,相当于:synchronized(this),锁的是当前对象。
    3、对于代码块的加锁,锁的是传入的obj。
    那么这里的参数(X.class、this、obj)到底是啥意思的?
    它是这个所起作用的范围,比如你锁掉了X.class,那所有调用X.class的静态加锁方法的线程都会一直等待这把锁:

    @Slf4j
    public class SyncUseExample {
        private static String value;
        public static synchronized String getValue(){
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                log.error("exception",e);
            }
            return value;
        }
    
        public static synchronized void setValue(String v){
            value=v;
        }
    
    }
    
    @Test
        public void syncUseTest() throws InterruptedException {
    
    
            Thread t1=new Thread(()->{
                log.info("t1 start");
                SyncUseExample.getValue();
                log.info("t1 end");
            });
            Thread t2=new Thread(()->{
                log.info("t2 start");
                SyncUseExample.setValue("1000");
                log.info("t2 end");
            });
            //启动
            t1.start();
            t2.start();
    
            t1.join();
            t2.join();
    
        }
    
    image.png

    我们可以看到,线程 t2 "陪"着 t1 sleep了3秒钟,因为他们都是静态方法,所以都是同一把锁(SyncUseExample.class)
    我们改成非静态方法,且不同对象再试试:

    @Slf4j
    public class SyncUseExample {
        private  String value;
        public  synchronized String getValue(){
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                log.error("exception",e);
            }
            return value;
        }
    
        public  synchronized void setValue(String v){
            value=v;
        }
    }
    
    @Test
        public void syncUseTest() throws InterruptedException {
    
            SyncUseExample e1=new SyncUseExample();
            SyncUseExample e2=new SyncUseExample();
    
            Thread t1=new Thread(()->{
                log.info("t1 start");
                e1.getValue();
                log.info("t1 end");
            });
            Thread t2=new Thread(()->{
                log.info("t2 start");
                e2.setValue("1000");
                log.info("t2 end");
            });
            //启动
            t1.start();
            t2.start();
    
            t1.join();
            t2.join();
    
        }
    
    image.png

    因为线程t1 锁的是 e1对象,线程t2使用的是e2对象,所以,t2的执行,并不会等待t1锁的释放。

    3.2 锁最合理的范围

    弄清楚锁的作用范围,我们再来讨论什么才是最合理的作用范围。锁的范围太小了,不能保证原子性,锁的范围太大了,会把操作都弄成串行操作,影响性能。所以,一定要根据业务实际情况来定锁的范围。以银行转帐举个栗子: A账户转帐给账户B,B账户转账给账户C,每个账户最开始都是200块:

    public class SyncExample {
        public int money=200;
    
        public  void  transfer(SyncExample target,int amt){
            this.money=this.money-amt;
            target.money=target.money+amt;
        }
    }
    
    @Test
        public void syncTest() throws InterruptedException {
            while (true){
                SyncExample A=new SyncExample();
                SyncExample B=new SyncExample();
                SyncExample C=new SyncExample();
    
    
                Thread t1=new Thread(()->{
                    A.transfer(B,100);
                });
                Thread t2=new Thread(()->{
                    B.transfer(C,100);
                });
    
                t1.start();
                t2.start();
                t1.join();
                t2.join();
                if(A.money!=100||B.money!=200||C.money!=300){
                    log.info("A:"+A.money);
                    log.info("B:"+B.money);
                    log.info("C:"+C.money);
                    log.info("--------------------");
                }
    
            }
        }
    
    image.png

    这是没加锁的时候,跑一段时间会出现的情况:由于没有锁住B账户,所以线程t1的和t2可能同时拿到B=200的初始状态进行操作,那结果就是其中一个的操作结果被后完成的那个覆盖,那B账户最终结果可能是100 也可能是300,而不是我们期望的200。
    那我们把transfer方法加上锁呢:

    public class SyncExample {
        public int money=200;
    
        public  synchronized void   transfer(SyncExample target,int amt){
            this.money=this.money-amt;
            target.money=target.money+amt;
        }
    }
    

    结果跑一段时间:


    image.png

    还是有问题,我们不是加了锁了么,为什么呢?
    我们前面说过,非静态方法,锁的是当前实例对象,也就是说,t1锁的是账户A!并不是账户B,所以对于账户B来说,跟没加一样!
    所以我们的目标是,锁账户B:

    public class SyncExample {
        public int money=200;
    
        public void   transfer(SyncExample target,int amt){
            
            synchronized(SyncExample.class){
                this.money=this.money-amt;
                target.money=target.money+amt;
            }
        }
    
    

    这样就没有并发问题了。 但是,我们锁的范围明显太大了,导致如果账户A向B转账,C向D转账这两个完全可以一起执行的操作都会变成串行操作。
    所以,我们只需要 用两把锁掉转账双方即可:

    public class SyncExample {
        public int money=200;
    
        public void   transfer(SyncExample target,int amt){
    
            synchronized(this){
                synchronized (target){
                    this.money=this.money-amt;
                    target.money=target.money+amt;
                }
            }
        }
    }
    

    锁的范围刚刚好!完美! 但是,新的问题来了,这个代码会造成死锁。如果A转给B,同时B转给A,他们都执行了synchronized(this)这行代码,A锁住了自己,B锁住了自己,同时A等待B锁的释放,B等待A锁的释放。。。。。
    关于死锁,我们下一节接着讲。
    下一章 java并发编程 - 3 - 死锁问题

    相关文章

      网友评论

          本文标题:java并发编程 -2- 并发问题以及volatile、sync

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