文章目录
一、基本使用
synchronized是Java并发编程中的同步机制关键字,它能保证同一个时刻只有一条线程能够执行被关键字修饰的代码,其他线程就会在队列中进行等待,等待这条线程执行完毕后,下一条线程才能对执行这段代码。
synchronized的3种使用方式:
- 修饰实例方法:作用于当前实例加锁
- 修饰静态方法:作用于当前类对象加锁
- 修饰代码块:指定加锁对象,进入同步代码块前要获得给定对象的锁
1.1、对象锁
类加载后,我们可以new出很多个实例对象,每个实例对象在JVM中都有自己的引用地址和堆内存空间,这些实例都是独立的个体,所以在实例上加锁和其他的实例肯定没有关系,不同实例的锁互不影响。
当一个对象中有同步方法或者同步代码块时,线程调用此对象进入同步区域前,就必须获得对象锁。如果此对象的对象锁被其他调用者占用,则进入阻塞队列,等待此锁被释放(同步块正常返回或者抛异常终止,由JVM自动释放对象锁)。
注意:使用对象锁时,当一个线程访问一个带synchronized的方法时,由于对象锁的存在,该对象中所有加synchronized的方法都不能被访问。
public class ObjectLock {
public static void main(String[] args) {
TestObjectLock objectLock = new TestObjectLock();
new Thread(objectLock::test1).start();
new Thread(objectLock::test2).start();
}
static class TestObjectLock {
public synchronized void test1() {
System.out.println("test1..." + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void test2() {
System.out.println("test2..." + System.currentTimeMillis());
}
}
}
1.2、类锁
每个class其中的静态方法和静态变量在JVM中只会加载和初始化一份,所以当静态方法被加上synchronized关键字后,此类的所有的实例化对象在调用该方法时,都会共用同一把锁,称之为类锁。
不管多少对象都共用同一把锁,都将是同步执行,一个线程执行结束,其他的才能够调用同步的部分,不同的类锁互不影响。
public class ClassLock {
public static void main(String[] args) {
new Thread(TestClassLock::test1).start();
TestClassLock classLock = new TestClassLock();
new Thread(classLock::test2).start();
}
static class TestClassLock {
public static synchronized void test1() {
System.out.println("test1..." + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void test2() {
synchronized (TestClassLock.class) {
System.out.println("test2..." + System.currentTimeMillis());
}
}
}
}
二、原理解析
在JDK1.6之前,synchronized属于重量级,是一个效率比较低下的锁,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,时间成本相对较高。
在JDK1.6后,JVM为了提高锁的获取与释放效率对 synchronized 进行了优化,引入了偏向锁
和轻量级锁
,所以锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),会随着竞争的激烈而逐渐升级。
2.1、对象头规范
锁是加在对象上的,无论这个对象是类对象还是实例对象,在对象的内存布局及访问定位中,我们了解到对象由三部分组成,对象头
,实例数据
和对齐填充
。synchronized关键字使用的锁对象是存储在Java对象头里的,而对象头结构是由 mark word 和 klass pointer 组成(对象是数组还有数组长度描述)。可参考文档:https://openjdk.org/groups/hotspot/docs/HotSpotGlossary.html
2.1.1、对象头内容
其中有关对象头中的内容,可以查看hotspot源码中的注释,如下:
我们将简单整理一下,换成可读性较强的表格如下:
2.1.2、对象头布局
这里我们可以利用jol分析Java的对象布局,首先需要引入依赖,如下:
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
测试代码及结果如下:
public class L {
}
public class App {
public static void main(String[] args) {
L l = new L();
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
}
从上述执行结果中,可以看出整个对象共16 bytes,其中对象头(object header)占12 bytes,还有4 bytes是对齐填充(由于 HotSpot VM 的自动内存管理系统要求对对象的大小必须是 8 字节的整数倍),另外由于这个对象里面没有任何字段,故而对象的实例数据为 0 bytes。 (可自行在L类中添加字段进行查看)
那么对象头里面的12 bytes到底存的是什么呢?上述提到过对象头分为mark word 和 klass pointer 组成。其中mark word占8 bytes,存储着上述表格中的信息,对应jol打印的对象头的第1、2行;而剩余的4 bytes则表示klass pointer类型指针,对于jol打印的对象头的第3行,存储着该对象所对应的class对象加载到元空间中的首地址。
2.1.3、 大小端模式
接下来我们就对照着上述jol打印的对象头的前两行,结合上述的表格来进行查看,在无锁的情况下,前25位应该是没有使用的,但是在jol对象头中的第一行前25个中是有被使用的,这是怎么回事呢?这里就涉及到来大小端模式存储。
一般家用笔记本都是小端模式,即高字节存在高地址,低字节存在低地址,如下图所示:
所以根据小端模式的存储,对应上述表格中的信息,jol打印的对象头信息如下:
2.2、锁状态
JDK1.6版本之后对synchronized的实现进行了各种优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。锁的状态主要有四种,即无锁、偏向锁、轻量级锁和重量级锁。
2.2.1、无锁
当一个对象被创建之后,还没有线程进入,这个时候对象处于无锁状态,其mark word中的信息如上述截图。
这里需要注意的是,偏向锁虽然默认是启动的,但是会存在延迟,JVM启动以后,大约4秒以后偏向锁才会起作用,所以我们在测试时,可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
2.2.2、偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,增加耗时)的代价而引入偏向锁。偏向锁是默认开启的:
- 开启偏向锁:
-XX:+UseBiasedLocking
- 关闭偏向锁:
-XX:-UseBiasedLocking
2.2.2.1、偏向锁加锁
当线程获取锁资源时,只有第一次使用CAS将线程ID设置到对象的mark word中,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。
以后当这个线程再次请求锁时,只需要简单的比较一下对象头中的线程ID和当前线程是否一致即可,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。
public class App {
public static void main(String[] args) {
L l = new L();
System.out.println(ClassLayout.parseInstance(l).toPrintable());
synchronized (l) {
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
}
}
上述表格中还提到了无锁不可偏向的q情况,其实从上述jol对象头 mark word 的布局中也可以看出,因为对象头 mark word 中存放 hashCode
和当前线程指针 Java Thread *
的位置时重复的,所以使用到 hashCode 时,就无法进行偏向,如 set、map key 等情况下,如下列直接打印 hashCode 也可:
public class App {
public static void main(String[] args) {
L l = new L();
// 转化成16进制,方便比较
System.out.println(Integer.toHexString(l.hashCode()));
System.out.println(ClassLayout.parseInstance(l).toPrintable());
synchronized (l) {
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
}
}
需要注意的是,虽然偏向锁是在对象头的 mark word 中保存在线程的ID,但是其实它也在栈中创建了 lock record 对象,在锁重入的情况下,还会在栈中创建多个 lock record 对象,其示意图如下:
2.2.2.2、偏向锁升级
有不同的线程请求锁(线程交替执行)时,即当另一个线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。
public class LightWeightLock {
public static void main(String[] args) throws InterruptedException {
L l = new L();
Thread t1 = new Thread(() -> {
synchronized (l) {
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
});
Thread t2 = new Thread(() -> {
synchronized (l) {
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
});
t1.start();
t1.join();
t2.start();
t2.join();
}
}
上述测试中,t1线程先来加锁并释放锁,完成后t2再来加锁(线程交替执行),但是锁对象已经偏向了t1,所以当t2来加锁就需要升级成为轻量锁了,当t2加锁时需要完成偏向锁的撤销,并将锁对象的 mark word 设置到 lock record 对象中的 displace header 中,再通过cas操作将对象头中存储指向 lock record 的地址,其内存变化如下图:
当t2执行完后,又会把锁记录释放并且恢复锁对象里面的 mark word 对象,那么t2执行完同步块后的内存如下图:
2.2.2.3、批量重偏向
如果一个类的大量对象被一个线程t1执行了同步操作,也就是大量对象先偏向了t1,t1同步结束后,另一个线程也将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。(批量重偏向和批量撤销是针对类的优化,和对象无关。)
默认偏向锁批量重偏向阈值:-XX:BiasedLockingBulkRebiasThreshold = 20
public class BiasedLockingBulkRebias {
public static void main(String[] args) throws InterruptedException {
List<L> locks = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 20; i++) {
L l = new L();
locks.add(l);
synchronized (l) {
// do nothing
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 20; i++) {
L l = locks.get(i);
synchronized (l) {
if (i == 18 || i == 19) {
System.out.println(ClassLayout.parseInstance(l).toPrintable());
}
}
}
});
t1.start();
t1.join();
t2.start();
t2.join();
}
}
2.2.2.4、批量撤销
当一个偏向锁如果撤销次数到达40的时候就认为这个对象设计的有问题,那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁,并且新实例化的对象也是不可偏向的。
默认偏向锁批量撤销阈值:-XX:BiasedLockingBulkRevokeThreshold = 40
public class BiasedLockingBulkRevoke {
public static void main(String[] args) throws InterruptedException {
List<L> locks = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 40; i++) {
L l = new L();
locks.add(l);
synchronized (l) {
// do nothing
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 40; i++) {
L l = locks.get(i);
synchronized (l) {
// do nothing
}
}
});
Thread t3 = new Thread(() -> {
for (int i = 0; i < 40; i++) {
L l = locks.get(i);
synchronized (l) {
if (i == 37 || i == 38) {
L nl = new L();
System.out.println(ClassLayout.parseInstance(nl).toPrintable());
}
}
}
});
t1.start();
t1.join();
t2.start();
t2.join();
t3.start();
t3.join();
}
}
从批量重偏向中,我们得知上述代码在t2执行至 i=19 时,会发现偏向锁重偏向的操作;但是经过了t2的执行,i<19 的对象都是轻量级锁( i>19 由于批量重偏向的原因,全部为偏向锁,偏向t2),所以t3执行时偏向锁撤销是从 i>19 时才开始的,所以当 i=38 时,才会达到偏向锁批量撤销的默认阈值40。
2.2.3、轻量锁
如果偏向锁失败,虚拟机就会升级为轻量级锁。轻量级锁不是为了代替重量级锁,它的本意是在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,因为使用轻量级锁时,不需要申请互斥量。
但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁。另外轻量级锁的加锁和解锁都用到了CAS操作。
2.2.3.1、轻量锁的加锁
当关闭偏向锁或者多个线程交替执行持有锁,就会导致偏向锁升级为轻量级锁,其步骤如下:
- 使锁对象处于无锁状态(当关闭偏向锁时,会生成一个无锁的 mark word;当偏向锁升级为轻量锁时,会进行偏向锁的撤销),并且JVM会在当前线程的栈帧中创建一个锁记录
lock record
- 将锁对象的
mark word
复制到栈帧中的lock record
中,将lock record
中的obj ref
指向当前对象 - 利用cas操作尝试将锁对象的
mark word
更新为指向lock record
的指针,如果成功表示竞争到锁,则将锁标志位变成 00 ,执行同步操作 - 如果失败说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位将变成 10 ,后面等待的线程将会进入阻塞状态。
2.2.3.2、轻量锁的释放
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在 displaced header 中的数据
- 用cas操作将取出的数据替换当前锁对象的 mark word 中,如果成功,则说明释放锁成功
- 如果cas操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁
2.2.4、自旋锁
如果轻量级锁加锁失败后,虚拟机还会进行一项称为自旋的优化手段。因为一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但是要占用处理器时间的。如果持有锁的线程很快就会释放了锁,那么自旋等待的效果就会非常好,反之如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。
所以自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,可以使用参数-XX:PreBlockSpin
来进行更改。(不过其实自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning
参数来开启)。
2.2.4.1、适应性自旋
在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如第一次自旋次数为10就拿到了锁,则第二次的自旋次数可能调整为13;但是若第一次自旋的次数为10,都还没有拿到锁,则第二次可能会调整为8次。
2.2.5、重量锁
如果说线程经过多次自旋以后还是迟迟没有拿到锁,那么线程就会由用户态切换到内核态,申请一个互斥量,并且将锁对象的 mark word 指向我们的互斥量地址,即升级为重量级锁。
每个Java对象作为锁对象时都可以关联一个Monitor对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 mark word 中就被设置指向 Monitor 对象的指针。其结构如下:
2.3、锁升级
在详细介绍完上述的锁状态后,这几个状态在实际使用的过程会随着竞争状态逐渐升级,其详细的流程示意图如下: