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

2019-05-07  本文已影响0人  cf6bfeab5260

1 背景

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

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

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 - 死锁问题

上一篇下一篇

猜你喜欢

热点阅读