volatile与synchronized

      很早之前就面试就被人问到,除了synchroized同步锁意外,还有没有其他的方式来完成相关的信息同步了;这个问题记忆犹新呢,当时问的哑口无言,现在虽然也比较渣渣,所以得多总结,合理使用volatile与synchronized,也是对代码的一种优化;

    造成线程安全问题的主要诱因有两点,一是存在共享数据,二是存在多条线程共同操作共享数据。因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,而且还要把当前的线程改变后的结构让其他线程看得见,因此总结出了锁的两个特点:互斥,可见

一、synchronized。 重量级锁

      在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行。synchronized方法正常返回或者抛异常而终止,jvm会自动释放对象锁。synchronized既可以加在一段代码上,也可以加在方法上,例如:

A 写法
int number = 1; 
public synchronized void testOne(){
        try {
            System.out.println("=1==>开始---"+(++number));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("=2==>结束---"+number);
}

public static void main(String[] args){
    final MainActivity mainActivity = new MainActivity();
    for (int i = 0; i < 3; i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                mainActivity.testOne();
            }
        }).start();
    }
}
或者还可以这样写
B写法
public void testOne(){
    synchronized(MainActivity.this){
        try {
            System.out.println("=1==>开始---"+(++number));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("=2==>结束---"+number);
    }
}

打印结果:

=1==>开始---2
=2==>结束---2
=1==>开始---3
=2==>结束---3
=1==>开始---4
=2==>结束---4

这两个方法是一样的,能防止多个线程同时执行同一个对象的同步代码段,不同的是  :A写法 是修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁 ;  B写法 是修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁(需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了); 

 

二、volatile(轻量级锁)

      volatile是Java提供的一种轻量级的同步机制,同synchronized相比,volatile更轻量级,相比使用synchronized所带来的开销要小得多

     volatile是一个类型修饰符,作用是作为指令关键字出现的,确保本条指令不会因编译器的优化而省略,要求每次直接读,volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了

JMM是个抽象的内存模型,so 所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存)

-----------------volatile 是什么,特点有什么--------------------------------

精确地说就是,优化器在用到这个volatile变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份

        如上面代码段中的int类型变量number,线程A中 ++number 这个动作发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B缓存了number的初始值1,此时可能没有观测到number的值被修改了,所以就导致了信息不对等的问题。通常都是加锁synchronized或者Lock,但这些方式太重量级了,它们都是要获取当前对象锁的,开销大。比较合理的方式就是volatile,当线程A的number发生了变化,也会同时刷新主内存中的数据,当线程B再使用number变量时,它不使用当前线程中的变量副本,而是去获取主内存中的变量值,由此达到了各个线程中的数据同步

--------------------------volatile的原子性--------------------------------

但是注意了,实际运行出来的效果并不正确,主要是volatile不具备原子性,下面分析一下:

volatile int number2 = 1; 
    public void testTwo(){
        try { 
            System.out.println(Thread.currentThread().getName()+"==1==>开始---"+(++number2));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+"==2==>结束---"+(++number2));
    }

同样在main方法中开启三个线程执行该方法,多次运行效果如下:

Thread-0==1==>开始---2
Thread-1==1==>开始---3
Thread-2==1==>开始---4
Thread-1==2==>结束---5
Thread-0==2==>结束---7
Thread-2==2==>结束---6

Thread-0==1==>开始---2
Thread-1==1==>开始---3
Thread-2==1==>开始---4
Thread-2==2==>结束---5
Thread-0==2==>结束---6
Thread-1==2==>结束---5

很显然,volatile在执行复合操作时,就表现的力不从心了;其实也好理解,++number2是先读取-->叠加-->负值,分为三步走,这样就照成了较大的时间空隙,从而导致运行基数错误;那么在线程A正在做加法运算的时候,线程B去读取了主内存中还未被线程A所更新的数据去运算,这就照成了如上的两个相同的结果;也就是说volatile没有了snychronized的原子性;那么做如下改动即可:

volatile AtomicInteger number2 = new AtomicInteger(0);
public void testTwo(){
        try {
            System.out.println(Thread.currentThread().getName()+"==1==>开始---"+(number2.incrementAndGet())); 
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+"==2==>结束---"+(number2.incrementAndGet()));
    }

Thread-0==1==>开始---2
Thread-2==1==>开始---3
Thread-1==1==>开始---1
Thread-1==2==>结束---6
Thread-2==2==>结束---4
Thread-0==2==>结束---5

      结论,多次运行后,证明该方案可行,AtomicInteger原子类(AtomicBoolean/AtomicLong/AtomicInteger三个原子更新基本类型)保证了++number 的原子性;到此就分析完了,为什么volatile修饰的变量不能做复合操作了,所以大家在使用时一定要注意它的这个特性

 

--------------------------volatile的禁止指令重排序优化--------------------------

那先来简单了解一下 重排序的背景

        我们知道现代CPU的主频越来越高,与cache的交互次数也越来越多。当CPU的计算速度远远超过访问缓存时,会产生cache wait,过多的 缓存等待⌛️ 就会造成性能瓶颈。
针对这种情况,多数架构(包括X86)采用了一种将cache分片的解决方案,即将一块cache划分成互不关联地多个 slots (逻辑存储单元,又名 Memory Bank 或 Cache Bank),CPU可以自行选择在多个 idle bank 中进行存取。这种 SMP 的设计,显著提高了CPU的并行处理能力,也回避了cache访问瓶颈。

Memory Bank的划分

        一般 Memory bank 是按cache address来划分的。比如 偶数adress 0×12345000?分到 bank 0, 奇数address 0×12345100?分到 bank1。

重排序的种类

        编译期重排。编译源代码时,编译器依据对上下文的分析,对指令进行重排序,以之更适合于CPU的并行执行。

        运行期重排,CPU在执行过程中,动态分析依赖部件的效能,对指令做重排序优化。

       关于执行集优化请查看https://www.jianshu.com/p/c6f190018db1

 

java代码来说明一下吧 

int tag = 1,b;   boolean bl = false;

public void three(){ tag = 2; bl = true; }

public void doit(){ if (bl){ System.out.println(b = tag + 1); } }

 public static void main(String[] args){
        final MainActivity mainActivity = new MainActivity();
        for (int i = 0; i < 3; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() { 
                    mainActivity.three();
                    mainActivity.doit();
                }
            }).start();
        }
    }

       我在论证的时候,并没出现因为指令重排序导致的运行异常,不过原理性还是要了解的,毕竟这也是潜在的危险

      上面运行的结果如果是正常的话,结果肯定b=3; 但也有可能b仍然为2。上面我们提到过,为了提供程序并行度,编译器和处理器可能会对指令进行重排序,而上例中three方法中的tag 和bl 两个变量不存在数据依赖关系,则有可能会被重排序,先执行bl=true再执行tag=2。而此时线程B会执行doit方法,而线程A中tag=2这个操作还未被执行,所以b=tag+1的结果也有可能依然等于2。

  使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

 

三、总结

(1) volatile 一种轻量级的同步机制,具有可见性,但不具备synchronized拥有的原子性
(2) volatile 具有禁止指令重排序优化
(3) 性能方面,synchronized关键字是防止多个线程同时执行一段代码,就会影响程序执行效率,而volatile关键字在读取临界值方面性能要优于synchronized。

 

 

 

        

你可能感兴趣的:(java)