深入理解synchronzied以及底层源码剖析
synchronized 保证三大特性
- 保证原子性:保证只有一个线程可以拿到锁,进入同步代码块
- 保证可见性:执行时会定义lock原子操作,会刷新工作内存共享变量的值
- 保证有序性:加synchronized后,依然会发生重排序,只不过因为加了同步代码块,可以保证只有一个线程执行同步代码块中的代码
synchronizd 的特性
可重入特性
什么是可重入?
一个线程可以多次执行synchronized,重复获取同一把锁
可重入性原理:
synchronized的锁对象有一个计数器(recurisons变量)会记录线程获得几次锁,当计数器为0时,就释放锁
好处:
- 避免死锁
- 可以让我们更好的封装代码
不可中断特性
一个线程获得锁后,另一个线程也要获得锁,那么这个线程处于阻塞或等待状态,如果第一个线程不释放锁第二个线程将会一直阻塞或等待(不可中断)
与此对比,lock可以调用lock方法(不可中断)和trylock方法(可中断)
synchronized 原理
monitor监视器锁
monitorenter:
- synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是jvm线程执行到这个同步代码块,发现锁对象没有monitor就会主动创建(是c++代码创建),其内部有两个重要的成员变量owner:拥有这把锁的线程。recursion:记录线程拥有锁的次数,当减到0次会释放锁
monitorexit:
- recursion -1
- 在方法结束处和异常处,jvm保证每个monitorenter都有一个对应的monitorexit
对于同步方法,在反汇编后,会增加ACC_SYNCHRONIZED修饰,会隐式调用monitorenter和monitorexit
monitor竞争
执行monitorenter时,最终会调用ObjectMonitor::enter(c++代码):
- 通过CAS尝试把monitor的owner字段设置为当前线程
- 如果之前设置的owner指向当前线程,说明是重入锁,执行recursion++,记录当前重入的次数
- 如果是第一次进入该monitor,设置recursion为1,_owner为当前线程,获得成功则返回
- 如果获取锁失败,则等待锁的释放(对应ObjectMonitor对象的EnterI方法)
monitor等待
进入EnterI方法:
- 先尝试获得两次,如果还是不能则当前线程被封装成ObjectWaiter对象node,状态设置为ObjectWaiter::TS_CXQ
- 在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node节点push到_cxq列表中
- node节点被push之后,通过尝试自旋获取到锁,如果还是没有获取到锁,通过park将当前线程挂起,等待被唤醒
- 当该线程被唤醒时,会从挂起的点继续执行,通过ObjectWaiter::TryLock尝试获取到锁
monitor释放
具体实现位于ObjectWaiter的exit方法,实现锁的释放:
- 退出同步代码块让_recurison -1,当_recurison减到0时,说明锁释放了
- 根据不同的策略(QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点包装的线程,唤醒操作最终由unpark完成
monitor是重量级锁
因为函数调用涉及到操作系统用户态和内核态的切换,比如park和unpark等内核函数(在内核态运行),这样切换就会消耗大量的系统资源
JDK6 synchronized做的优化
CAS
CAS全称:Compare And Swap(比较相同再交换),是现代cpu对内存中的共享数据进行操作的特殊指令
作用:CAS可以将比较和交换转换为原子操作,这个原子操作由cpu保证。CAS可以保证共享变量赋值的原子操作
CAS依赖3个值,内存中的值V,旧的预估值X,要修改的值B。如果旧的预估值X等于内存中的值V,就将新的值B保存到内存中
比如AtomicInteger的源码中,由Unsafe类提供原子操作。其中,Unsafe类使Java拥有了像C语言指针一样操作内存空间的能力,但是由于出错概率大,因此不能直接调用,只能通过反射获得
CAS获取变量时,为了保证变量的可见性,需要用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈,多核cpu的场景
因为没有使用synchronized,所以线程不会陷入阻塞,提高了效率
但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
为什么value要使用volatile修饰?
因为要保证每个线程读取value内存中的值都是最新值(可见性),然后每次操作的时候再通过CAS判断此时有没有“落后可见性”
java对象头
其中hashCode在使用时才生成,并且整个存储采用大端存储
锁升级
jdk6实现了锁升级
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁
原理:
- 执行到一个线程访问同步块时,虚拟机看对象头mark word标志位是否是01,如果是将偏向锁标志位设置为1,表示偏向锁
- 通过CAS将mark word 前54位设置成当前线程ID,如果CAS操作成功,下次持有该偏向锁的线程再次进入这个锁相关的同步块时,虚拟机可以不用再进行任何同步操作,效率变高
撤销:
- 必须等到全局安全点
- 判断锁对象是不是偏向锁
- 如果是,恢复到无锁或者升级成轻量级锁
优点:
偏向锁在一个线程执行同步代码块时提高效率,适用于一个线程反复获得锁的情况,提高带有同步但无竞争的程序性能
如果是被多个线程访问比如线程池,就是多余的
在jkd5默认关闭,jdk6默认开启,但是在应用程序启动几秒后才开启。可以通过-XX:BiasedLockingStartupDelay=0关闭延迟
轻量级锁
轻量级锁是相当于monitor的传统锁而言,因此传统的锁机制称为重量级锁。但是,轻量级锁不能够代替重量级锁,因为只有在特定情况下开销小
适合多线程交替执行同步块的情况,但是不适合多线程同一时刻竞争的情况
原理:
- 判断当前对象是否处于无锁状态(hashcode,0,01),如果是,则jvm在当前栈帧创建一个锁记录(lock record)的空间,用于存储锁对象目前的mark word的拷贝(官方把这个拷贝加了一个前缀 displaced),将当前对象的mark word赋值到那里,并将lock record的owner指向当前对象
- jvm利用cas操作尝试将当前对象的mark word中的指针更新为指向lock record的指针,如果成功表示竞争到了锁,将锁标记置为00,执行同步操作
- 如果失败判断当前锁对象的mark word中的指针是否指向当前栈帧,如果是表示当前线程已经持有此对象,直接执行同步代码块。否则说明该锁对象被其他前程占用了,该锁升级为重量级锁。锁标志设置成10,后面等待的线程会进入阻塞状态
自旋锁
由于实现重量级锁,线程的阻塞和唤醒需要cpu从用户态转换为内核态,对cpu开销大。并且开发者注意到在许多应用上,共享数据的锁定只会持续很短,为了这点短暂时间阻塞和唤醒线程并不值得。因此我们可以让线程执行一个忙循环(自旋),不放弃处理器的执行时间,这就是自旋锁
在jdk6时引入了自适应自旋锁,意味着自旋的次数不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定
锁消除
虚拟机即时编译(JIT)运行时,根据逃逸分析,判断在一段代码中,堆上的所有数据都不可能会逃逸出去被其他线程访问到,那么可以把他们当做栈上数据来对待,认为他们是线程私有的,同步加锁自然无须进行
锁粗化
jvm会探测到一连串细小的操作都由同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这就是锁粗化
平时写代码如何对synchronized做优化
减少synchronized的范围
尽量让synchronized同步的代码尽量短,减少同步代码块的执行时间,减少锁的竞争
降低synchronized锁的粒度
将一个锁拆分为多个锁提高并发度
例如:Hashtable 和 ConcurrentHashMap
读写分离
读时不加锁,写入和删除时加锁
比如ConcurrentHashMap,CopyOnWriteArrayList,CopyOnWriteSet