synchronized这个关键字对应Java程序猿来说是非常的熟悉,只要遇到要解决线程安全问题的地方都会使用这个关键字。接下来一起来探讨一下synchronized到底时怎么实现线程同步,使用synchronized实现线程同步到底好不好。
在讨论synchronized之前,是否大家对cup怎么实现多线程是否有所了解?
上下文切换
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。
上下文切换既然会影响多线程执行速度,那怎么优化上下文切换带来的弊端呢?
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
synchronized原理
在Java SE 1.6之前synchronized都会使多线程进入到等待状态,因此带来了非常平凡的上下文切换,导致线程执行效率下降,而Java SE 1.6以后进行了大量的优化。
我们将从以下几个方面来了解synchronized:
- synchronized怎么获取锁?
- synchronized偏向锁、轻量级锁、重量级锁实现原理;
synchronized怎么获取锁?
JVM规范定义:JVM基于进入与退出Monitor对象与来实现方法同步和代码块同步:
- 代码块同步:使用monitorenter和monitorexit指令实现。
- 方法同步:使用另外一种方式,但是同样是使用这两个指令实现。只是具体表现形式有所不同
synchronized的实现有以下三种方式:
- 普通同步方法,锁是当前实例对象。
- 静态同步方法,锁时当前类的Class对象。
- 同步代码块,锁是synchronized括号里配置的对象。
public class SynchronizedTest {
public synchronized void test1() {
}
public void test2() {
synchronized (this) {
}
}
}
使用javap -verbose SynchronizedTest.class工具查看生成的class文件信息分析synchronized实现:
省略部分代码,如下所示:
{
public com.zero.test.SynchronizedTest();
.....
public synchronized void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 5: 0
public void test2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
......
}
从上面可以看出同步代码块使用monitorenter和monitorexit指令实现的,同步方法使用方法修饰符上的ACCSYNCHRONIZED实现。无论哪一种方式,其本质都是一个对象的监视器(monitor)进行获取。
监视器锁(Monitor)本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。本文不对Monitor做更细致的讲解。
Java对象头
Java对象保存在内存中时,由以下三部分组成:
- 对象头
- 实例数据
- 对齐填充字节
java的对象头也由三部分组成:
- Mark Word
- 指向类的指针
- 数组长度(只有数组对象才有)
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。
偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
(1)偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。
(2)关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
锁的对比
下面对偏向锁、轻量级锁和重量级锁进行比较:
笔者的微信公众号,每天一篇好文章: