文章目录
- 在Java中, 造成线程安全问题的主要原因就是JMM内存模型, 其让每个线程都有自己的工作内存, 而每个工作内存中的数据都是统一由进程汇总完全复制得来, 俺么当他们自己内存中数据放回到进程中内存的是就会出现数据不一致的问题
- 因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式有个高尚的名称叫互斥锁,即能达到互斥访问目的的锁
- 也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。
- 在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作)
- 同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能), 还可以保证有序性,这点确实也是很重要的。
synchronized的三种应用方式
- synchronized关键字最主要有以下3种应用方式,下面分别介绍
-
修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
-
修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
-
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
- 此时我们发现synchronize的实现离不开对象, 都是给对象在上锁, 所以我们研究一下对象
理解Java对象头与Monitor
- 在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:
实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
头对象,它是实现synchronized的锁对象的基础,,一般而言,synchronized使用的锁对象是存储在Java对象头里的(也就是说synchronize的锁的本质不是这个对象, 而是锁的这个对象头里还有一个对象),jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成
-
Mark Word
存储对象的hashCode、锁信息或分代年龄或GC标志等信息 -
Class Metadata Address
类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 -
对象头的锁信息是与对象自身定义的数据没有关系的额外存储成本,除了上述列出的Mark Word默认存储信息外,还有如下可能存储的信息:
- 轻量级锁: 指向栈中记录的的指针, 以及其锁标志位
- **重量级锁:指向互相量(真正锁的那个对象)**的指针, 以及其锁标志位
- **偏向锁:**是否为偏向锁的标志位
-
其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的
-
主要分析一下重量级锁也就是通常说synchronized的对象锁,其中指向互斥量的那个指针是monitor对象(也称为管程或监视器锁)。
-
在Java中的每个对象的对象头都存在着一个 monitor对象与之关联,Java对象与其 monitor对象之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
-
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
- ObjectMonitor中有两个队列,_WaitSet 和 _EntryList
- 每个等待锁的线程都会被封装成ObjectWaiter对象
- _owner指向持有ObjectMonitor对象的线程, 也就是正在访问同步代码块的线程
- 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程检查_count为0, 进入 _Owner 区域并把monitor中的owner变量设置为当前线程, 同时monitor中的计数器count加1,此时也就意味着这个线程获取到了monitor对象, 当检查到owner不为null的时候就会执行owner指向的这个线程
- 如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
可以得出结论
- monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
- Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。
- synchronized是可重入的,所以不会自己把,自己锁死, 因为在monitor中,多次获取他只是计数器count++即可, 当释放的时候只要计数器count–到0 , 即可说明是无锁状态
- synchronized锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞。而操作系统实现阻塞需要线程之间的切换, 从用户态转换到内核态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。所以这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁
- 对此我们不得不实现锁优化
Java虚拟机对synchronized的优化
- 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级
偏向锁
- 偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段
- 经过研究发现,在**大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁. **
- 偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
- 对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失
- 需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁
- 尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。
- 轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”
- 所以轻量级锁就是我们常说的乐观锁, 比如自旋锁
- 轻量级锁所适应的场景是少量线程交替执行同步块的场合,如果存在大量线程同一时间访问同一锁的场合,导致自旋很长时间,就会导致轻量级升级为为重量级锁。
锁消除
- 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在编译时通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
锁粗化
- 如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
总结
- 所以在对比lock锁和synchronize的锁的时候就会发现很多不同之处
- 首先synchronize的是JVM实现的, 是一个关键字, 而lock是一个类, 是基于AQS实现的
- 要从数据结构上说, 他俩真的差不多,都是维护着一个变量和CAS操作的队列, 但是JVM的更细节, 他为了提高出队速度, 将一个队分成了俩个队列, 分别为_WaitSet 和 _EntryList, lock只维护了一个, 至于那个变量键值一模一样, 都是表示现在上锁的次数
- JVM对synchronized的做了优化, 引入了偏向锁和轻量级锁和锁消除和锁膨胀, 但是Lock则完全依靠系统阻塞挂起等待线程
- 当然Lock比synchronized更适合在应用层扩展,可以继承AbstractQueuedSynchronizer定义各种实现,比如实现读写锁(ReadWriteLock),公平或不公平锁;同时,Lock对应的Condition也比wait/notify要方便的多、灵活的多, 比如trylock。
- synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁
- 但是synchronized会自动释放锁, Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁
- 所以Lock锁灵活适合大量同步的代码的同步问题,synchronized锁有优化适合代码少量的同步问题。
volatile
- 最前面我们说到每个线程都有自己的工作内存, 线程再运行期间大多是在自己的运行期间去那数据, 那么如果我们想要去主内存去拿数据, 就得使用volatile
- 当一个变量被volatile修饰后,这个变量就对所有线程均可见。白话点就是说当一个线程修改了一个volatile修饰的变量后,对其写数据都是在主内存写, 其他线程可以立刻得知这个变量的修改,拿到最这个变量最新的值。
- 此外volatile的还可以指禁止JVM指令对其重排优化。
- 但是要注意, volatile关键字能保证变量的可见性和其自身的有序性,但是不能保证变量的原子性
volatile与内存屏障
- Java内存模型其实是通过内存屏障(Memory Barrier)来实现的volatile
- 内存屏障其实也是一种JVM指令,使用了volatile或者synchronize的后Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令来禁止特定的指令重排序。
- 内存屏障的要求就是内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
- 因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。(保证了有序性)
- volatile有关的禁止指令重排的行为:
- 当第二个操作volatile写时,不论第一个操作是什么,都不能重排序。这个规则保证了volatile写之前的操作不会被重排到volatile写之后。
- 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排。这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
- 当第一个操作为volatile写,第二个操作为volatile读时,不能重排。
常见内存屏障指令
- LoadLoad屏障
对于Load1; LoadLoad; Load2 ,操作系统保证在Load2及后续的读操作读取之前,Load1已经读取。 - StoreStore屏障
对于Store1; StoreStore; Store2 ,操作系统保证在Store2及后续的写操作写入之前,Store1已经写入。 - LoadStore屏障
对于Load1; LoadStore; Store2,操作系统保证在Store2及后续写入操作执行前,Load1已经读取。 - StoreLoad屏障
对于Store1; StoreLoad; Load2 ,操作系统保证在Load2及后续读取操作执行前,Store1已经写入,开销较大,但是同时具备其他三种屏障的效果。
总结
- volatile实现了Java内存模型中的可见性和有序性,它的这两大特性则是通过内存屏障来实现的,同时volatile无法保证原子性。