了解并发的底层原理有助于从更高层次认知多线程的工作原理,从应用角度讲,有助于我们构建高效健壮的并发应用和解决实际的生产问题。并发的实现并不是仅仅由JVM实现,而是JVM联合处理器指令共同完成,本节就volatile及synchronized的原理做一个初步认知,思维导图如下:
============================================================================
思维导图:
原理阐述:
相比synchronized而言,volatile是轻量级的同步工具,如果应用得当的话,执行效率要高于synchronized,它不会引起上下文切换。但是volatile不能保证操作的原子性,只能保证共享变量修改的可见性。注意,这里不是说volatile一定不能保证操作的原子性,在有些情况(基本的读写)下还是可以保证的。在实际需要用volatile做同步时,应当注意避免在以下两种情况下使用:
1.有数据依赖关系的操作中,比如最经典的i++的例子。
2.包含在不等式中,如volatile i=1;if(i>var){......}
一个开发中常用的例子就是使用volatile修饰布尔变量,如volatile boolean flag=false这种情况。如果一定要保证原子操作的话,可以考虑使用synchronized或lock来保证,下面就二者的原理展开阐述。
一.volatile的实现原理
要了解volatile的实现原理,从以下两个方面展开:
1.为什么volatile可以保证操作的可见性?
对于一个volatile变量在写的时候(假使你已经了解了线程工作时缓存和主存交互过程),在写操作的时候会发出cpu lock指令,这个指令会触发两件事:
1.把当前线程中的共享变量从缓存刷到系统内存
2.把其他线程缓存了相同内存地址的数据置为无效。
解释上述两点之前,给出可见性的定义:一个线程修改共享变量,其他线程可以读到这个修改后的新值。lock指令在执行的时候,在早期的处理器中是会锁住总线,独占共享内存。总线是信息通道,锁住意味着其他处理器也不能访问系统的其他内存。这种机制开销较大,在现在的处理器架构中,一般会锁缓存,其他缓存了相同共享变量的处理器将不能对其进行操作。处理器可以通过嗅探来感知其他处理器的缓存及系统内存和自己缓存的数据是否一致(缓存一致性原则),如果检测到共享变量被修改,那么会将自己的缓存行置为无效,下次再读的时候会重新从系统内存中加载,保证了读取的值是最新的,是可见的。
2.为什么volatile不能保证操作的原子性?
线程从系统内存读取共享变量到工作内存,再进行后续计算,以下是这一过程的示意图:
以i++这一计算过程为例说明为什么volatile不能保证操作的原子性,i++操作可分解为三步:
1.从主存读取i的值到工作内存
2.执行+1运算
3.回写到主存
假使现在有两个线程访问这段代码,i是用volatile修饰的共享变量,初始值为1。线程1在执行到步骤2时,因其他原因(比如中断或者阻塞)让出了CPU使用权,此时线程2获得执行机会执行完这三步后i的值变为2,并将i的值更新到系统内存,接着线程1继续执行,注意此时的线程1并不会从重新从主存读取变量i的值,因为内存屏障(禁止指令重排序)保证了读写操作的顺序,如果这时候再重新从主存读就会引起程序混乱,线程1执行完后把2写到主存中,这和我们期待的结果i=3是不一致的。总而言之:线程只保证刚开始读到的共享变量的值是最新的,从load到store这中间的过程是不安全的,其他线程在这期间修改了共享变量的值,对于已经读取了共享变量的线程而言是不会再读的,后续的计算会延用第一次读取的值,除非还有读操作。
一.synchronized的锁原理剖析
synchronized可以保证并发操作的三性:可见性,原子性和顺序性。
任何对象都有一个关联的monitor对象,synchronize的加锁和解锁过程是借助monitor对象来实现,在同步代码块开始和结束的地方会插入monitorenter和monitorexit指令控制锁的获得和释放。在指令执行到monitorenter的时候,就会尝试获得对象的锁。任何java对象都有一把锁,锁存在于对象的对象头中,确切的说是在mark word中。对象头在32位和64位的系统中长度不一致,数组对象和非数组长度也不一致,具体见下:
长度 | 内容 | 说明 |
32/64bit | Mark Word | 存储对象的hashCode或锁信息等。 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/32bit | Array length | 数组的长度(如果当前对象是数组) |
其中mark word的结构如下:
锁的状态会随着对象状态的变化而变化,从上面可以看出锁的类型有:无锁,轻量级锁,偏向锁,重量级锁,级别依次升高。锁只能上升,不能下降,下面看看锁的上升过程:
偏向锁的引入是基于这样的实际情况得出的:大多数情况下锁是由同一个线程获得的,为了降低获得锁的开销,引入偏向锁。当某个线程访问同步块的时候,会将对象头中和栈中锁偏向的线程id指向自己,下次再访问同步块的时候,会检查锁偏向的线程id是否和自己的线程id一致,如果一致,表示是同一个线程,继续执行同步块中的代码。如果不一致,这个时候会检查偏向锁标志位是否设置了,如果没有设置,表示处于无锁状态,那么使用CAS竞争,如果设置了,则使用CAS尝试将锁偏向的线程id指向自己。
轻量级锁就是线程2在竞争锁的时候,发现另一个持有偏向锁的线程1还活着,那么会暂停线程 1,将锁标志位修改成轻量级锁,继续执行代码,而线程2通过自旋获取锁。
重量级锁就是线程2自旋获取锁不成功,锁膨胀进入重量级锁,线程2阻塞等待线程1释放锁资源。
整个完整的偏向锁->轻量级锁->重量级锁的上升过程如下:
2.判断当前对象头是否是偏向锁;
3.判断拥有偏向锁的线程1是否还存在;
4.线程1不存在,直接设置偏向锁标识为0(线程1执行完毕后,不会主动去释放偏向锁);
5. 使用cas替换偏向锁线程ID为线程2,锁不升级,仍为偏向锁;
6.线程1仍然存在,暂停线程1;
7.设置锁标志位为00(变为轻量级锁),偏向锁为0;
8.从线程1的空闲monitor record中读取一条,放至线程1的当前monitor record中;
9.更新mark word,将mark word指向线程1中monitor record的指针;
10.继续执行线程1的代码;
11.锁升级为轻量级锁;
12.线程2自旋来获取锁对象;
了解了整个过程后,就来比较下三种锁的优缺点及应用场景:
1.偏向锁
优点:获取释放锁代价小
缺点:如果多个线程竞争锁,会消耗资源
应用场景:适用于只有一个线程访问同步块
2.轻量级锁
优点:竞争锁时,线程不阻塞
缺点:自旋会消耗CUP
应用场景:追求响应时间快
3.偏向锁
优点:不消耗资源
缺点:线程阻塞
应用场景:追求大吞吐量