内置锁
Java提供了一种内置的锁机制来支持原子性和可见性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个是作为锁的对象引用,一个是锁保护的代码块。每一个Java对象都可以用做一个实现同步的锁,这种锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。线程进入同步代码块之前会自动获得锁,并且在退出同步代码块时(正常返回,或者是异常退出)会自动释放锁。
同步代码块以关键字synchronized修饰,例如:
synchronized(锁对象引用){
//锁保护的代码块
}
如果synchronized修饰的是对象的方法,那么被修饰的方法体就是同步代码块,锁的对象引用就是被修饰的方法所在的对象。
public class SyncTest {
public synchronized void method() {
//方法体就是同步代码块
}
}
如果synchronized修饰的是静态方法,那么被修饰的方法体就是同步代码块,锁的对象引用就是被修饰的方法所在的Class对象。
public class SyncTest {
public static synchronized void method() {
//方法体就是同步代码块
}
}
内置锁的特性
-
互斥:同一时间最多只有一个线程能够持有这种锁。
线程尝试获取一个被其它线程持有的内置锁,线程必须等待(自旋)或者阻塞(自旋策略失效),并且因为请求内置锁被阻塞的线程不能被中断。 -
可重入:如果某个线程试图获取一个已经由它持有的内置锁,那么这个请求就会成功。
实现原理:为每个所关联一个获取计数值和一个所有者线程。当计数值为0,表示这个锁没有被任何线程持有。当线程请求一个未被持有的锁,JVM将所有者线程设置为请求线程,并且将计数值置为1。如果同一个线程再次请求这个锁,计数值递增,退出同步代码块计数值将递减。如果计数值为0,这个锁将被释放。
synchronized的原理
当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),作为所要加锁和解锁的锁对象。
自旋
因为监视器锁实现的同步是互斥同步,互斥导致的Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合 posix 接口的操作系统(如 macOS 和绝大部分的 Linux),上述操作是通过 pthread 的互斥锁(mutex)来实现的。这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,这些操作给操作系统的并发性能带来了很大的开销。
自旋的实现原理就是,如果线程请求获取监视器锁失败,并不立刻阻塞线程,而是让线程执行一个忙循环(自旋)。自旋之后再次尝试获取锁。如果获取锁失败,这个过程会循环一定次数,超过某个阀值,如果还是获取不到锁,才阻塞线程。自旋可以通过-XX:+UserSpinning参数来开启,自旋的次数通过-XX:PreBlockSpin来更改(默认是10)。
自旋虽然避免了线程切换的损耗,但是需要占用处理器时间。自旋的效果取决于锁被占用的时间,如果锁被占用的时间很短,自旋等待的效果就会很好,反之,自旋只会白白消耗处理器资源,带来性能上的损耗。
JDK1.6引入了自适应的自旋锁。
锁优化
自旋锁
见自旋小节
重量级锁
重量级锁是 JVM中传统的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。
轻量级锁
轻量级锁的目标是在没有多线程的竞争下,减少重量级锁使用的操作系统互斥量产生的性能消耗。
原理
轻量级锁的实现依赖对象头的标记字段。Java的对象头被设计为能够根据对象的状态复用自己的储存空间,对象头的标记字段有2bit用于储存锁状态,不同的锁状态对应的对象头的内容及状态之间转换如下图:
加锁过程
当进行加锁操作时,JVM会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录(Lock Record),并且将锁对象的标记字段复制到该锁记录中。
然后,JVM会尝试用 CAS(compare-and-swap)操作将锁对象的标记字段替换为锁记录的指针。如果操作成功,那么这个线程就获取了这个对象的锁,并且标记字段的锁标志位转变为“00”,表示该锁处于轻量级锁定状态。
如果标记字段替换操作失败,JVM会先检查对象的标记字段是否指向当前线程的栈帧,如果是表明当前线程已经持有了该对象的锁。否则说明这个对象的锁已经被其它线程所持有了,这时,轻量级锁膨胀为重量级锁,锁的标记字段的锁标志位变为“10”,标记字段存储的就是指向重量级锁(互斥量)的指针,后面的线程要进入阻塞状态。
解锁过程
轻量级锁的解锁过程也要通过CAS操作来进行。如果锁对象的标记字段仍然指向线程的锁记录,JVM尝试用CAS操作将锁对象的标记字段替换为锁记录中的复制过来的标记字段。如果CAS操作成功,则释放了锁。否则说明有其他线程尝试获取过该对象的锁,那么在释放锁的同时,唤醒被挂起的线程。
性能分析
轻量级锁提升性能的依据是:对于绝大部分的锁,在整个同步期间都是不存在竞争的。如果没有竞争,轻量级锁使用CAS操作避免了重量级锁使用互斥量的开销。如果存在竞争,除了重量级锁的互斥量开销,还带来了CAS操作的开销,性能反而比重量级锁差。
偏向锁
偏向锁的目的也是消除无竞争条件下的同步原语。偏向锁会偏向于第一个获取到它的线程,如果在获取到锁之后的过程中,没有发生锁竞争,那么持有偏向锁的线程将永远不需要再进行同步。相比轻量级锁,偏向锁能够消除轻量级锁多次加锁的CAS操作。
加锁过程
具体来说,在线程进行加锁时,如果该锁对象支持偏向锁,那么 JVM会通过 CAS 操作,将当前线程的ID记录在锁对象的标记字段之中,并且将标记字段的锁标志位置为“01”,即偏向模式。
如果操作成功,在接下来的运行过程中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:最后三位是否为 101,是否包含当前线程的ID,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果都满足,那么当前线程持有该偏向锁,可以直接返回。
当有另外的线程尝试获取这个锁时,偏向模式宣告结束。如果当前对存于未锁定状态,撤销偏向恢复至未锁定(标记字段的锁标志位为“01”)。如果处于锁定状态,则升级为轻量级锁(标记字段的锁标志位为“00”),后续的同步操作便按照轻量级锁的规则来进行。
偏向锁失效
如果某一类锁对象的总撤销数超过了一个阈值(对应JVM参数-XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 JVM会宣布这个类的偏向锁失效。
如果总撤销数超过另一个阈值(对应 JVM参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。
其它优化手段
- 锁消除
JVM通过逃逸分析,如果判断出在一段代码中,堆上的所有数据都不会逃逸出去从而被其它线程访问到,就可以把他们当做栈上数据看待,同步加锁就不需要进行。 - 锁粗化
如果一系列的连续操作都是对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中(如StringBuffer的连续多次append操作),JVM会将加锁同步的范围扩展到这个操作系列的外部。