谈谈你对Synchronized的理解
synchronized关键字解决的是多个线程之间访问资源的同步性
,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)
是依赖于底层的操作系统的Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程
之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态
,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized 效率低的原因。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁
、适应性自旋锁
、锁消除
、锁粗化
、偏向锁
、轻量级锁
等技术来减少锁操作的开销。
说说自己是怎么使用 synchronized 关键字?
实例方法
: 作用于当前对象实例加锁
,进入同步代码前要获得当前对象实例的锁静态方法
:也就是给当前类加锁,会作用于类的所有对象实例
,因为静态成员不属于任何一个实例对象
,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象
,因为访问静态 synchronized 方法占用的锁是当前类的锁
,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。代码块
: 指定加锁对象
,对给定对象加锁,进入同步代码块前要获得给定对象的锁。总结:
synchronized 关键字加到 static 静态方法
和 synchronized(class)代码块
上都是给 Class 类上锁
。synchronized关键字加到实例方法
上是给对象实例上锁
。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能
!
Java对象刚刚创建时没有任何线程来竞争,该对象处于无锁状态。此时偏向锁状态位 0 ,锁状态标识位 01 ,
偏向锁是指一段同步代码一直被同一个线程
所访问,那么该线程会自动获得锁。锁会偏向于当前已经占有锁的线程
。如果内置锁处于偏向状态,当有一个线程来竞争锁时,先用偏向锁,这个线程要执行该锁关联的同步代码时,不在做任何检查和切换,效率非常高
如果不存在线程竞争的
一个线程获得了锁,那么锁就进入偏向状态,Mark Word的结构变为偏向锁结构,锁标志位(lock)被改为 01 ,偏向标志位(biased lock)被改为1 ,然后线程的 id 记录在锁对象的Mark Word中
(使用CAS操作完成),以后该线程获取锁时先判断一下线程id 和标志位
,就可以直接进入同步块,都不需要CAS操作 ,这就省去了大量有关锁申请的操作,提升了程序的性能。
偏向锁适用于一个线程
,一旦有第二条线程竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。
如果锁对象时常被多个线程竞争,偏向锁就很多余,其撤销的过程会带来一些性能的开销。
JVM 在启动的时候会延迟使用偏向锁机制
,默认延迟了4000毫秒。
如果想要去掉延迟,需要添加参数-XX:BiasedLockingStartupDelay=0
开启偏向锁:
-XX:+UseBiasedLocking -XX:BasiedLockingStartupDelay=0
关闭偏向锁:
-XX:-UseBiasedLocking
程序会默认进入轻量级锁状态
锁总是被第一个占用它的线程拥有,这个线程就是锁的偏向线程,锁第一次被拥有的时候,记录下偏向线程的id 。新线程进入和退出这段加了同步锁的代码块时 ,只需要判断内置锁对象的Mark Word中的线程 id 是不是自己的 id 。
如果是
就直接使用这个锁,而不使用CAS交换
如果不是
,比如在第一次获得此锁时内置锁的线程 id 为空,就使用CAS交换 , 新线程将自己的线程 id 交换到内置锁的 Mark Word中
如果交换成功
,Mark Word中的线程id 为新线程的id,锁还是偏向锁
如果失败
,很可能需要升级为轻量级锁,保证线程之间的公平竞争锁
在循环抢锁中,每执行一轮抢占,JVM内部都会比较内置锁的偏向线程 id 与当前线程 id
,如果匹配 ,表明当前线程已经获得了偏向锁,当前线程可以快速进入临界区。
假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,这时尝试撤销偏向锁,然后膨胀到轻量级锁
偏向锁撤销的大致过程:
安全点
停止拥有锁的线程清空锁记录
,使其变为无锁状态,并修复锁记录指向的Mark Word ,清除其线程 id
唤醒
当前线程撤销偏向锁的条件:
hashcode()
方法或 System.identityHashCode()
方法计算对象的HashCode值之后,将哈希码存放到Mark Word中,内置锁变成无锁状态,偏向锁将被撤销偏向锁的膨胀
偏向锁不会主动释放
,如果偏向锁被占据,第二个线程争抢这个对象时,JVM会检查原来持有该对象锁的占有线程是否依然存活
,如果挂了,就将锁变为无锁状态,然后进行重新偏向为抢锁线程,如果线程依然存活 ,进一步检查占有线程的调用栈帧是否通过锁记录持有偏向锁
。若是存在锁记录,表明原来的线程还在使用偏向锁,发生锁竞争,撤销原来的偏向锁,将锁膨胀为轻量级锁。
轻量级锁是一种自旋锁
,
当锁处于偏向锁,又被另一个线程企图抢占时,锁会升级为轻量级锁,企图抢占的线程会通过自旋的方式尝试获取锁,不会阻塞抢锁线程。两个线程公平竞争,哪个线程先占有锁对象,锁对象的Mark Word就指向那个线程的栈帧中的锁记录,
线程自旋是需要消耗CPU的,默认最大的自旋次数是10 ,可以通过-XX:PreBlockSpin
选项更改
然而JDK1.6之后引入了自适应自旋锁
,意味着自旋的时间不是固定的,而是由前一次在同一个锁上的自旋时间
以及锁的拥有者的状态
来决定。如果自旋成功,下次自旋的次数就会更多,相反的,如果失败,下次自旋的次数就会减少。
如果持有锁的线程执行时间超过了自旋等待的最大时间任然没有释放锁,就会导致其他争用锁的线程在最大等待时间内获取不到锁,自旋不会一直持续下去,争用线程会停止自旋进入阻塞状态,该锁膨胀为重量级锁。
在抢锁线程进入临界区之前,如果内置锁没有被锁定,JVM 首先将在抢锁线程的栈帧中建立一个锁记录
,用于存储对象目前Mark Word的拷贝,然后抢锁线程进入CAS自旋
,尝试将内置锁对象头的Mark Word 的 ptr_to_lock_record
(锁记录指针) 更新为抢锁线程栈帧中锁记录的地址
大部分临界区代码的执行时间是很短的,但是也会存在执行的很慢的临界区代码段。临界区代码执行时间过长,其他线程在原地自旋等待就会空消耗CPU (空自旋
),带来很大的性能损耗。
轻量级锁的本意是为了减少多线程进入操作系统底层的互斥锁的概率,并不是要替代操作系统互斥锁,所以在竞争激烈的场景下,轻量级锁会膨胀为操作系统内核互斥锁实现的重量级锁。
重量级锁通过监视器
的方式保障了任何时间只允许一个线程通过受监视器保护的临界区代码
Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter
指令,在结束位置插入monitor exit
指令。
当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
Demo:
锁升级发生后,hashcode去哪啦
锁升级为轻量级或重量级锁后,Mark Word中保存的分别是线程栈帧里的锁记录指针和重量级锁指针,己经没有位置再保存哈希码,GC年龄了,那么这些信息被移动到哪里去了呢?
在无锁状态下,Mark Word中可以存储对象的identity hash code值。当对象的hashCode()方法第一次被调用时,JVM会生成对应的identity hash code值并将该值存储到Mark Word中。
对于偏向锁,在线程获取偏向锁时,会用Thread ID和epoch值覆盖identity hash code所在的位置。如果一个对象的hashCode()方法己经被调用过一次之后,这个对象不能被设置偏向锁。因为如果可以的化,那Mark Word中的identity hash code必然会被偏向线程Id给覆盖,这就会造成同一个对象前后两次调用hashCode()方法得到的结果不一致。
升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的Mark Word拷贝,该拷贝中可以包含identity hash code,所以轻量级锁可以和identity hash code共存,哈希码和GC年龄自然保存在此,释放锁后会将这些信息写回到对象头。
升级为重量级锁后,Mark Word保存的重量级锁指针,代表重量级锁的ObjectMonitor类里有字段记录非加锁状态下的Mark Word,锁释放后也会将信息写回到对象头。
在java中每个对象都可以成为一把锁,因为在JVM中每个对象都一个monitor(监视器锁)
。对应到C底层叫做Object Monitor
,并用c定义了很多信息。再往下到操作系统中是基于Mutex Lock互斥锁
实现,涉及到了用户态和内核态的切换,所以非常耗费资源
synchronized用的锁是存在Java对象头里的Mark Word中,锁升级功能主要依赖MarkWord中锁标志位
和释放偏向锁标志位
就是把锁干掉。当Java虚拟机运行时发现有些共享数据不会被线程竞争时
就可以进行锁消除。
那如何判断共享数据不会被线程竞争?
利用逃逸分析技术:分析对象的作用域,如果对象在A方法中定义后,被作为参数传递到B方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。 在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待, 认为它是线程私有的,同步加锁就不需要了
举个栗子:
运行上述代码会发现每次调用m1方法都会new 一个Object对象,每个线程都有锁,JIT底层会优化消除锁对象。
如果一系列的连续操作都对同一个对象反复加锁和解锁
,甚至加锁操作都是出现在循环体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗 化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗
最后贴一张大神的图再次理解下锁的升级过程: