首先回顾下利用Synchronized实现同步的基础:java中的每一个对象都可以作为锁,具体的表现为一下几种形式。
- Synchronized加在普通方法上,锁的是当前的实例对象。
- Synchronized加在静态方法上,锁的是当前类的Class对象。
- 对于同步方法块,锁的是Synchronized括号里的对象。 锁到底在哪里?锁里面会存储什么信息? 从JVM规范中可以看到Synchronized在JVM里面的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但是对实现方法同步和代码块同步的实现细节是不一样的。对代码块的同步是使用monitorenter和monitorexit指令来实现的,进入monitorenter指令后,线程将持有Monitor对象,退出monitorenter指令后,线程将释放该Monitor对象。而对于同步方法是使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否是同步方法。
Java 对象头
synchronized用的锁是加载java对象头里的。在jdk1.6JVM中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中java对象头由Mark Word、指向类的指针以及数组长度三部分组成。
java对象头信息如下:
| 长度 | 内容 | 说明 |
|---|---|---|
| 32/64bit | Mark Word | 存储对象的hashcode、锁、对象年龄等信息 |
| 32/64bit | Class Metadata Address | 存储对象类型数据的指针 |
| 32/32bit | Array Length | 数组的长度(如果当前对象是数组) |
java对象头里的Mark Word里默认存储的是对象的hashcode、分代年龄和锁标记。在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化,在64位虚拟机下,Mark Word可能变化为存储的数据如下:
锁的升级
jdk1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”、“轻量级锁”、“重量级锁”,在jdk1.6中,锁共分为了4种状态,级别从低到高分别是无所状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态可以升级,但是不能降级,也就意味着,偏向锁升级成轻量级锁后不能降级为偏向锁。这种锁能升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
1.偏向锁
偏向锁是为了让同一线程获得锁的代价更低而引入的。当一个线程访问同步块并获取锁的时候,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID, 以后该线程在进入和退出同步块时不再需要进行CAS操作来加锁和解锁,只需要需去对象头的Mark Word中去判断一下是否存储着当前线程的偏向锁。如果有,则表示线程已经获得了锁。如果没有,则需要进一步判断Mark Word中偏向锁的标识是否设置的是1,如果设置的是1,就尝试使用CAS将对象头的偏向锁指向当前线程。如果设置的不是1,就要使用CAS竞争锁。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。
(1)偏向锁的撤销
一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象那不适合做偏向锁,最后唤醒暂停的线程。下图线程1演示了锁的初始化流程,线程2演示了偏向锁的撤销流程。
(2)关闭偏向锁
- -XX:BiasedLockingStartupDelay=0用来关闭偏向锁的启动延迟。
- -XX:UseBiasedLocking=false用来关闭偏向锁。
2.轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会现在当前线程的栈帧中创建锁记录(Lock Record)空间,并将对象头的Mark Word复制到锁记录(Lock Record)中(这份拷贝称之为Displaced Mark Word)。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录(Lock Record)的指针。如果这个更新动作成功了, 当前线程就获得了该对象的锁,并将Mark Word的锁标志位设置成00;如果失败了,说明当前还有别的线程在竞争锁,当前线程就会自旋来尝试获取锁。
另外一种情况是,已前处于偏向锁状态,当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换Mark Word中的线程ID为自己的 ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果替换成功了,则表示没有锁发生,如果失败,则表示当前存在锁竞争,锁就会膨胀成重量级锁。
3. 自旋锁与重量级锁
轻量级锁CAS抢锁失败,线程将会被挂起进入阻塞状态。如果正在持有锁的线程在很短的时间内释放资源,那么进入阻塞状态的线程无疑又要申请锁资源。JVM提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。从jdk1.7开始,自旋锁默认启用,自旋次数由JVM设置决定,但是自旋会消耗CPU,所以这个次数设置的重试次数不能过多。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这 个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。
4. 锁的优缺点对比
| 锁 | 有点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的区别 | 如果线程间存在竞争,会带来额外的消耗 | 适用于只有一个线程访问同步代码块 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的相应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行时间非常快 |
| 重量级锁 | 线程竞争不使用自旋。不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
参考文献
Java并发编程的艺术, 方腾飞, 魏鹏, 程晓明.