synchronized
synchronized 是最常用的实现同步的手段,在 Java SE 1.6 以及之后的版本,对 synchronized 进行了优化,使 synchronized 整体的性能得到了很大的提升,下面看下 synchronized 的相关实现。
示例
下面是一个基本的使用示例:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Created by Jikai Zhang on 2017/5/2.
*/
public class SynchronizedTest
public static int counter = 0;
public synchronized static void increase() {
for(int i = 0; i < 10000; i++) {
counter++;
}
}
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(100);
for(int i = 0; i < 10; i ++) {
executor.execute(new Runnable() {
@Override
public void run() {
increase();
}
});
}
executor.shutdown();
while
synchronized 代码执行之前,要首先锁住一个对象,具体为下面三种情况:
* 对于普通方法(非静态)方法,锁住的是当前实例对象
* 对于静态方法,锁住是当前类的 Class 对象
* 对于同步方法块,锁住的是 synchronized 括号中配置的对象。
synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两条指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。当执行 monitorenter 指令时,会首先尝试获取对象的锁,如果对象没被锁定,或者当前线程已经拥有了那个对象的锁,就把锁的计数器加 1,相应的,在执行 monitorexit 指令时会将锁的计数器减 1,当计数器为 0 时,锁就会被释放。如果获取锁失败,那么当前线程就要进入阻塞状态,直到另外一个线程释放锁。
Java 为了优化 synchronized 的性能,引入了偏向锁、轻量级锁、重量级锁、自旋锁等概念。偏向锁不需要任何同步,它适用于单线程环境,不会有其他线程竞争,也就不需要同步。轻量级锁使用 CAS 指令同步,它适用于虽然多个线程执行,但线程间没有竞争,例如线程 1 在 0-5 时刻内执行,线程 2 在 6-10 时刻内执行,两者的执行时刻没有重叠。重量级锁就是我们通常意义上的锁,适用于多个线程竞争的情况,同一个时间内只有一个线程获得锁,其余的线程都处于阻塞状态。
对象头
我们知道每个 Java 对象都可以作为一个锁,而 Java 对象将锁的相关信息存在了对象头中。如果当前对象为数组对象,则对象头由下面 3 部分组成,每部分为一个字长(32位虚拟机长为 32 bits, 64 位虚拟机长尾 64 bits):
* Mark Word:存储对象的 hashCode 或者锁信息
* Class Metadata Address:存储到象数据类型的指针
* Array Length: 数组的长度
如果当前对象是非数组对象,那么对象头只包含 Mark Word 和 Class Metadata Addres。在 Mark Word 中,固定使用最后两位 bit 来存储当前对象的锁标记。而其他位中存储的内容会随着当前对象的锁状态改变而发生改变。下面是对象锁标志位的几种状态:
1. 01 - 未锁定状态 / 可获取偏向锁状态
2. 00 - 轻量级锁定
3. 10 - 重量级锁定
4. 11 - GC 标记
当锁标志位为 01 时,Mark Word 用剩余位中的一个 bit 位来标识当前锁处于可获取偏向锁状态还是未锁定状态。如果该位为 1 表明现在是处于偏向锁状态,如果是 0 表明是未锁定状态。
图片来自 Java 并发编程的艺术
偏向锁
获取
线程获得偏向锁的前提条件是当前对象处于可获取偏向锁的状态,什么意思呢,就是说当前对象的锁状态标志位为 01,并且偏向锁标志位为 1,如果偏向锁标志位为 0,则线程无法获得偏向锁,只能获取轻量级锁。当线程获取锁时,如果发现对象处于可获取偏向锁的状态,会首先查看对象的 Mark Word 中是否保存了当前线程的 ID(见上面对象头处于不同锁状态时的数据分布图),如果发现保存了当前线程 ID,说明当前线程已经获得了偏向锁,那么就直接执行同步块中的代码。如果发现没有保存当前线程 ID,那么尝试使用 CAS 操作将当前线程 ID 写入 Mark Word 中,从上面的图示中,我们知道如果没有其他线程获得偏向锁,那么 Mark Word 的前 25 位就是保存的对象的 hashCode,如果线程发现对象中的 Mark Word 的前 25 位就是保存的对象的 hashCode,那么 CAS 操作就执行成功,如果发现不是 hashCode,说明之前已经有线程获取了偏向锁,证明系统中至少有两个线程在执行,那么此时就要撤销偏向锁。所以我们说线程获取了偏向锁,就等同于下面两个条件都成立:
- 当前对象处于可获取锁的状态,锁状态标志位为 01,并且偏向锁标志位为 1
- 线程通过 CAS 操作成功将线程 ID 写入 Mark Word 中
所以我们说只有第一个访问锁对象的线程才有机会获得对象的偏向锁。偏向锁是不会主动释放的,只有出现了竞争才会释放偏向锁。
释放
偏向锁释放分为两种情况,如果获取偏向锁的线程不处于运行状态,就将对象置为无锁状态(偏向锁标志位置为 0,线程 ID 置为空),此时另外一个线程只能尝试获取线程的轻量级锁。如果获取偏向锁的线程正在执行,会挂起运行线程,然后将对象置为轻量级锁状态(锁状态标志位置为 00),随后再恢复挂起的线程,将偏向锁升级为轻量级锁,此时另外一个线程可以通过自旋尝试获得锁,当自旋到一定次数仍然获取不到轻量级锁,对象锁就会由轻量级锁升级为重量级锁,获取不到锁的线程就会被阻塞。
轻量级锁
获取
轻量级锁通过 CAS 操作进行同步,适用于有多个线程执行但不存在竞争或者竞争很少的情况。当代码执行到同步块时,发现对象处于无锁状态(锁状态标志位为 01,偏向锁标志位为 0),那么虚拟机就首先在当前线程的堆栈中创建一个名为锁记录(Lock Record)的空间,用于存储对象目前的 Mark Word 的拷贝(官方为这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word,这里保存拷贝是为了释放锁时将数据还原回来),然后虚拟机使用 CAS 操作将对象的 Mark Word 更新为指向 Lock Record 的指针(CAS 保证了即便多个线程竞争,也只有一个能更新成功),如果更新动作成功了,那么当前线程就获取到了该对象的锁,同时会将 Mark Word 中的锁标志位置为 00。所以,我们说线程获取到了轻量级锁,就等同于下面条件成立:
当前线程成功将 Mark Word 中的内容(除了最后两位)修改为了指向线程堆栈中锁记录的指针
如果线程更新 Mark Word 失败,会尝试自旋来获得锁(就是执行循环,不断尝试获取的锁),当自旋获取锁失败了之后,对象锁就由轻量级锁升级为重量级锁,失败线程就会被阻塞。同时,Mark Word 的记录值会修改为指向互斥量(重量级锁)的指针,Mark Word 的锁记录标志位也会置为 10.
释放
轻量级的解锁过程也是通过 CAS 操作来完成的,如果对象的 Mark Word 中仍然指向当前线程堆栈中的 Lock Record,就使用 CAS 操作将 Mark Word 的备份值复制回去,如果复制成功,就将锁状态的标志位置为 01,如果复制失败,说明当前的轻量级锁已经膨胀为重量级锁了,那么在释放锁的同时,要唤醒正在等待的线程。
如果存在大量的竞争,除了互斥量的开销,还额外发生了 CAS 操作,因此有竞争的情况下,轻量级锁反而更慢。
下面是锁转换的状态图:
自旋锁
某些情况下,线程刚进入阻塞状态锁就被释放了,因为线程的阻塞和唤醒都需要转入内核状态中完成,这给系统的并发性带来了很大的压力。为此提出了自旋锁的解决方法,在线程获取不到锁时,先不急着进入阻塞状态,而是执行几次循环尝试获取锁,看看在这几次循环里锁会不会被释放。自旋锁会消耗 CPU,所以自旋次数不易过多,自旋默认的次数为10次,可以通过 -XX:PreBlockSpin 来修改。 JDK 1.6 中引入了自适应自旋锁,会根据当前程序的执行情况来动态决定自旋的次数,可以更加有效的处理自旋锁。