很早之前就面试就被人问到,除了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 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修饰的变量不能做复合操作了,所以大家在使用时一定要注意它的这个特性
那先来简单了解一下 重排序的背景
我们知道现代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。
三、总结
(1) volatile 一种轻量级的同步机制,具有可见性,但不具备synchronized拥有的原子性
(2) volatile 具有禁止指令重排序优化
(3) 性能方面,synchronized关键字是防止多个线程同时执行一段代码,就会影响程序执行效率,而volatile关键字在读取临界值方面性能要优于synchronized。