synchronized 有以下三种作用范围:
在静态方法上加锁;
在非静态方法上加锁;
在代码块上加锁;
如下:
public class SynchronizedSample {
private final Object lock = new Object();
private static int money = 0;
//非静态方法
public synchronized void noStaticMethod(){
money++;
}
//静态方法
public static synchronized void staticMethod(){
money++;
}
public void codeBlock(){
//代码块
synchronized (lock){
money++;
}
}
}
这三种作用范围的区别实际是被加锁的对象的区别,请看下表:
作用范围 锁对象
非静态方法 当前对象 => this
静态方法 类对象 => SynchronizedSample.class (一切皆对象,这个是类对象)
代码块 指定对象 => lock (以上面的代码为例)
synchronized 的底层实现:
先说在 JDK6 以前,synchronized 那时还属于重量级锁,每次加锁都依赖操作系统 Mutex Lock 实现,涉及到操作系统让线程从用户态切换到内核态,切换成本很高;
到了 JDK6,研究人员引入了偏向锁和轻量级锁,因为 Sun 程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,每次线程都加锁解锁,每次这么搞都要操作系统在用户态和内核态之间来回切,太耗性能了。
对象存储在堆中,主要分为三部分内容,对象头、对象实例数据和对齐填充(数组对象多一个区域:记录数组长度),下面简单说一下三部分内容,虽然 synchronized 只与对象头中的 Mard Word 相关。
对象头:
对象头分为二个部分,Mard Word 和 Klass Word,👇列出了详细说明:
对象头结构 存储信息-说明
Mard Word 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息
Klass Word 存储指向对象所属类(元数据)的指针,JVM 通过这个确定这个对象属于哪个类
对象实例数据:
如上图所示,类中的 成员变量 data 就属于对象实例数据;
对齐填充:
JVM 要求对象占用的空间必须是 8 的倍数,方便内存分配(以字节为最小单位分配),因此这部分就是用于填满不够的空间凑数用的。
第二个预备知识:需要了解 Monitor ,每个对象都有一个与之关联的 Monitor 对象;Monitor 对象属性如下所示( Hospot 1.7 代码) 。
ObjectMonitor() {
_header = NULL;
_count = 0; // 重入次数
_waiters = 0, // 等待线程数
_recursions = 0;
_object = NULL;
_owner = NULL; // 当前持有锁的线程
_WaitSet = NULL; // 调用了 wait 方法的线程被阻塞 放置在这里
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 等待锁 处于 block 的线程 有资格成为候选资源的线程
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
对象关联的 ObjectMonitor 对象有一个线程内部竞争锁的机制,如下图所示
JDK 6 以前 synchronized 具体实现逻辑:
当有二个线程 A、线程 B 都要开始给我们队的经济 money 变量 + 钱,要进行操作的时候 ,发现方法上加了 synchronized 锁,这时线程调度到 A 线程执行,A 线程就抢先拿到了锁。拿到锁的步骤为: - 1.1 将 MonitorObject 中的 _owner 设置成 A 线程; - 1.2 将 mark word 设置为 Monitor 对象地址,锁标志位改为 10; - 1.3 将 B 线程阻塞放到 ContentionList 队列;
JVM 每次从 Waiting Queue 的尾部取出一个线程放到 OnDeck 作为候选者,但是如果并发比较高,Waiting Queue 会被大量线程执行 CAS 操作,为了降低对尾部元素的竞争,将 Waiting Queue 拆分成 ContentionList 和 EntryList 二个队列, JVM 将一部分线程移到 EntryList 作为准备进 OnDeck 的预备线程。另外说明几点:
所有请求锁的线程首先被放在 ContentionList 这个竞争队列中;
Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
当前已经获取到所资源的线程被称为 Owner;
处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的);
作为 Owner 的 A 线程执行过程中,可能调用 wait 释放锁,这个时候 A 线程进入 Wait Set , 等待被唤醒。
synchronized 是公平锁还是非公平锁吗?
非公平的。主要有以下二点原因:
Synchronized 在线程竞争锁时,首先做的不是直接进 ContentionList 队列排队,而是尝试自旋获取锁(可能 ContentionList 有别的线程在等锁),如果获取不到才进入 ContentionList,这明显对于已经进入队列的线程是不公平的;
另一个不公平的是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。
锁的类型:
锁从宏观上分类,分为悲观锁与乐观锁。
乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。
几种锁的概念:
重量级锁
内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。
自旋锁
首先,内核态与用户态的切换上不容易优化。但通过自旋锁,可以减少线程阻塞造成的线程切换(包括挂起线程和恢复线程)。
如果锁的粒度小,那么锁的持有时间比较短(尽管具体的持有时间无法得知,但可以认为,通常有一部分锁能满足上述性质)。那么,对于竞争这些锁的而言,因为锁阻塞造成线程切换的时间与锁持有的时间相当,减少线程阻塞造成的线程切换,能得到较大的性能提升。具体如下:
当前线程竞争锁失败时,打算阻塞自己
不直接阻塞自己,而是自旋(空等待,比如一个空的有限for循环)一会
在自旋的同时重新竞争锁
如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
如果在自旋的时间内,锁就被旧owner释放了,那么当前线程就不需要阻塞自己(也不需要在未来锁释放时恢复),减少了一次线程切换。
“锁的持有时间比较短”这一条件可以放宽。实际上,只要锁竞争的时间比较短(比如线程1快释放锁的时候,线程2才会来竞争锁),就能够提高自旋获得锁的概率。这通常发生在锁持有时间长,但竞争不激烈的场景中。
缺点
单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。
自旋锁要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。
如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
轻量级锁
自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
Mark Word是对象头的一部分;每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息。二者属于JVM的基础内容,此处不做介绍。
当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
轻量级锁的加锁过程:
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
拷贝对象头中的Mark Word复制到锁记录中;
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
缺点
同自旋锁相似:
如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁,那么维持轻量级锁的过程就成了浪费。
偏向锁
Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
如果确定竞态资源会被高并发的访问,建议通过-XX:-UseBiasedLocking 参数关闭偏向锁,偏向锁的好处是并发度很低的情况下,同一个线程获取锁不需要内存拷贝的操作,免去了轻量级锁的在线程栈中建 Lock Record,拷贝 Mark Down 的内容,也免了重量级锁的底层操作系统用户态到内核态的切换
“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。
缺点:
同样的,如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁。
对象从无锁到偏向锁转化的过程: