四个问题彻底搞定 synchronized
synchronized 是 java 中一个重量级嘉宾,属于面试中常客,它很容易理解,但又比较难。大明哥认为要想彻底弄懂它,你只需要回答这四个问题就可以了:
- synchronized 的实现原理是怎样的?
- synchronized 锁的是什么?
- synchronized的锁升级过程是怎样的?
- synchronized的锁优化是怎样的?
synchronized 的实现原理是怎样的?
synchronized
是 Java 中一个重量级的关键字,它用于实现线程同步,确保多线程环境下对共享资源的安全访问。它可以修饰方法或代码块,保证一次只有一个线程可以执行同步方法或代码块内的代码。
synchronized
有两种形式上锁,一个是对方法上锁,一个是对代码块上锁。其实他们底层实现原理都是一样的。在进入同步代码之前先获取锁,锁计数 + 1,执行完同步代码后释放锁,锁计数 -1,如果获取失败就阻塞式等待锁的释放。他们的不同之处在于他们在同步块的识别方式有所不同。
当一个方法被 synchronized
修饰时,它的方法标志中会包含 ACC_SYNCHRONIZED
标志。当某个线程要访问方法时,会首先检查是否有 ACC_SYNCHRONIZED
设置,如果有,则需要先获取监视器锁,获取成功后才能执行方法,方法执行完成后再释放监视器锁。如果在该线程执行同步方法期间,有其他线程来请求执行方法,会因为无法获取监视器锁而阻塞。
而同步代码块则是使用 monitorenter 和 monitorexit 指令来实现的。我们可以理解执行 monitorenter 为加锁,执行 monitorexit 为释放锁。每个对象都维护着一个锁的计数器,为被锁定的对象该计数器为 0。当一个线程在执行 monitorenter 之前需要尝试后去锁,如果这个对象没有被锁定,或者当前线程已经拥有了该对象的锁,那么这把锁的计数器 + 1,当执行monitorexit指令时,锁的计数器也会减1。
扩展
基本使用
Synchronized
是 Java 中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:
- 原子性:确保线程互斥的访问同步代码
- 可见性:保证共享变量的修改能够及时可见。
- 有序性:有效解决重排序问题。
从语法上讲,Synchronized
可以把任何一个非null对象作为 “锁”,在HotSpot JVM实现中,这个锁有个专门的名字:对象监视器(Object Monitor)。
从语法上讲,synchronized
总共有三种用法,三种用法锁定的对象都不同:
- 当
synchronized
用于实例方法时,监视器锁(monitor)便是对象实例(this
)。
public synchronized void method() {
// 方法体
}
锁定的是调用该方法的对象实例。
- 当
Synchronized
用于静态方法时,监视器锁(monitor)便是对象的Class实例。
public static synchronized void staticMethod() {
// 方法体
}
锁定的是这个类的所有对象。
- 当
Synchronized
用于同步代码块时,监视器锁(monitor)便是括号括起来的对象实例。
public void method() {
synchronized (object) {
// 代码块
}
}
同步实现原理
同步需要依赖锁,那锁的同步又依赖谁?synchronized 给出的答案是在软件层面依赖JVM。我们来看同步方法和同步代码块是如何实现的。
同步方法
代码如下:
public class SynchronizedTest {
public synchronized void test() {
System.out.println("死磕 Java 面试...");
}
// 为了对比
public void test1() {
System.out.println("死磕 Java 面试...");
}
}
使用 javac SynchronizedTest.java
,编译 Java 文件为 .class 文件,然后使用 javap -verbose SynchronizedTest
,结果如下:
对于普通方法,其实就是常量池中多了 ACC_SYNCHRONIZED
标识符,JVM就是根据该标示符来实现方法的同步的:
当方法被调用时,调用指令将会检查方法的
ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先获取 monitor ,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
同步代码块
代码如下:
public class SynchronizedTest {
public void test() {
synchronized (this) {
System.out.println("死磕 Java 面试...");
}
}
}
反编译 .class 文件,内容如下:
如上面所提到的执行 monitorenter
为加锁,执行 monitorexit
为释放锁。
每个对象都是一个监视器锁。当monitor被占用时就会处于锁定状态。
执行 monitorenter
过程如下:
- 如果 monitor 的计数为 0,说明锁未被持有,JVM 将锁分配给执行
monitorenter
的线程,并将 monitor 的计数设置为 1。 - 如果线程已经占有了该 monitor,当前线程重入,monitor 的计数 + 1。
- 如果 monitor 被其他线程持有,那么当前线程将被阻塞,直到锁被释放。
执行 monitorexit
的过程:
- 执行
monitorexit
的线程必须是对应 monitor 的所有者,即执行monitorexit
和monitorenter
要是同一个线程。 - 执行
monitorexit
时,monitor 的计数器 - 1,如果计数器大于 0,表示当前线程还持有 monitor(可重入),锁不会被释放,如果计数器等于 0,表示当前线程不再持有 monitor ,锁被释放。 - monitorexit 指令出现两次,原因是为了兼顾执行同步代码时出现异常而导致锁无法释放的问题。所以第1次为同步正常退出释放锁,第2次为发生异步退出释放锁。
监视器(monitor)
synchronized
在 JVM 中的实现都是基于进入和退出monitor对象来实现方法同步和代码块同步,所以我们有必要来了解下 monitor。
那什么是 monitor 呢?我们可以把它理解为一种同步机制,它通常被描述为一个对象。
我们知道,在 Java 中一切皆对象,同理,在 Java 中所有的 Java 对象是天生的 monitor,每一个 Java 对象都有成为 monitor 的可能。这是因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁**,它叫内置锁**。
monitor 由 ObjectMonitor 实现,其主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
几个重要属性如下:
_header
:指向对象头,在 Java 中,每个对象都有一个对象头,其中包含了与锁和垃圾收集相关的信息。_count
:用于记录重入锁的数量。在 Java 中,同一个线程可以多次获得同一个锁(即重入锁),这个字段就是用来记录该线程获取锁的次数。_recursions
:用于记录同一个线程重复获取这个锁的次数。_waiters
:记录正在等待获取这个对象锁的线程数量。_owner
:当前拥有这个 monitor 的线程_WaitSet
:处于 wait 状态的线程,会被加入到_WaitSet
中,可通过notify()
或notifyAll()
唤醒。_EntryList
:处于等待锁 block 状态的线程,会被加入到_EntryList
中。
ObjectMonitor 中有两个队列,_WaitSet
和 _EntryList
,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter对象 ),_owner
指向持有ObjectMonitor对象的线程,当多个线程访问一段同步代码时:
- 首先会进入
_EntryList
集合,当线程获取到对象的 monitor 后,进入_owner
区域并把 monitor 中的_owner
变量设置为当前线程,同时monitor中的计数器_count
加1; - 若持有 monitor 的线程调用
wait()
,将释放当前持有的monitor,_owner
变量恢复为null,_count
自减1,同时该线程进入_WaitSet
集合中等待被唤醒; - 若持有 monitor 的线程执行完毕,也将释放当前持有的 monitor,并复位变量的值,以便其他线程进入获取monitor;
synchronized 锁的是什么?
这篇面试题大明哥已经详细介绍过 synchronized
的实现原理了,同时大明哥也在里面简单描述了 synchronized
锁住的是什么,但是依然有很多同学还是不明白 synchronized
锁住的是什么。现在大明哥告诉你,Java 一切皆对象,所以synchronized 最终锁定的是对象!只不过由于 synchronized
的用法不同,锁住的对象不同。
我们先看一个简单的例子:
public class SynchronizedTest {
public synchronized void test() {
System.out.println(Thread.currentThread().getName() + "---test begin...");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + "---test end...");
}
public static void main(String[] args) {
for (int i = 1 ; i <= 5;i++) {
new Thread(() -> {
SynchronizedTest test = new SynchronizedTest();
test.test();
}).start();
}
}
}
执行结果:
Thread-0---test begin...
Thread-1---test begin...
Thread-2---test begin...
Thread-4---test begin...
Thread-3---test begin...
Thread-4---test end...
Thread-1---test end...
Thread-3---test end...
Thread-0---test end...
Thread-2---test end...
这结果完全不符合我们的设想啊,按照我们的设想应该是 test begin...
和 test end...
应该是成对出现的。我们再换一种方式:
public class SynchronizedTest {
public void test() {
synchronized (this) {
// ...
}
}
}
执行结果:
Thread-1---test begin...
Thread-0---test begin...
Thread-3---test begin...
Thread-2---test begin...
Thread-4---test begin...
Thread-4---test end
Thread-1---test end
Thread-0---test end
Thread-3---test end
Thread-2---test end
依然没有成对出现,为什么会这样的?还是对 synchronized
锁的对象是什么理解不够。我们再次回顾 :
- 当
synchronized
用于实例方法时,监视器锁(monitor)便是对象实例
(this),锁定的是调用该方法的对象实例。 - 当
Synchronized
用于静态方法时,监视器锁(monitor)便是对象的Class实例
,锁定的是这个类的所有对象。 - 当
Synchronized
用于同步代码块时,监视器锁(monitor)便是括号括起来的对象实例
从这里我们可以看出示例 1 和示例 2 ,监视器锁都是我们 new 的 SynchronizedTest 对象。所以 new 的 5 个线程都不是互斥的,因为他们竞争的都不是同一个资源,那怎么改呢?调整为如下:
public class SynchronizedTest {
public void test() {
synchronized (SynchronizedTest.class) {
// ...
}
}
}
synchronized (SynchronizedTest.class)
锁住的这个类的所有对象。我们再看下面几个例子。
- 示例 3
public class SynchronizedTest {
public void test() {
String lock = "lock";
synchronized (lock) {
}
}
}
- 示例 4
public class SynchronizedTest {
public void test() {
String lock = new String("lock");
synchronized (lock) {
}
}
}
- 示例 5
public class SynchronizedTest {
public void test() {
String lock = new String("lock").intern();
synchronized (lock) {
}
}
}
- 示例 6
public class SynchronizedTest {
public void test() {
Integer lock = Integer.valueOf("1000");
synchronized (lock) {
}
}
}
- 示例 7
public class SynchronizedTest {
public void test() {
Integer lock = Integer.valueOf("10");
synchronized (lock) {
}
}
}
这 5 个示例的输出结果各是什么样的呢?各位小伙伴们可以自己运行下,看看是否是自己所想的那样。
synchronized的锁升级过程是怎样的?
回答
在 JDK 1.6之前,synchronized
是一个重量级、效率比较低下的锁,但是在JDK 1.6后,JVM 为了提高锁的获取与释放效,,对 synchronized
进行了优化,引入了偏向锁和轻量级锁,至此,锁的状态有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁。
锁升级就是无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁 的一个过程,注意,锁只能升级,不能降级。
原理详解
对象头
HotSpot
虚拟机中,对象在内存中存储布局可以分为三块区域:对象头(Header
)、实例数据(Instance Data
)和对齐填充(Padding
):
- 对象头:分为Mark Word 和 对象指针
- Mark Word:存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 对象指针:存储指向类元数据的指针,使得能够访问对象属于的类的信息。
- 实例数据:存储对象的实际有效信息,也就是我们在类中所定义的各种类型的字段内容。
- 对齐填充:可选字段,通常存在于对象的末尾,用于确保对象的大小是8字节的倍数(因为许多JVM都使用8字节的对象对齐)。这是出于性能考虑,使得对象的地址在内存中是对齐的。
synchronized
锁相关的信息主要是在 Mark Word 区域,我们先看看 Mark Word。
Mark Word
synchronized
用的锁存在锁对象的对象头的Mark Word中,我们先看 Mark Word 到底长什么样。
锁分类
无锁
无锁可以理解为单线程轻松愉快地运行,没有其他的线程来和其竞争。但是无锁不代表没有同步,它只是表示锁对象目前没有被任何线程显式锁定。
偏向锁
偏向锁 JDK 1.6 引入的一种锁优化机制。
何谓“偏向”?就是锁对象会偏向于第一个获得它的线程。什么意思呢。
当一个线程访问同步代码块并获取锁时,该锁会进入偏向模式,锁标志的状态将被设置为偏向(01),并且锁的拥有者被设置为当前线程(偏向锁线程 id = 当前线程 id)。当该线程执行完同步代码块后,线程并不会主动释放偏向锁。当线程再次进入同步代码块时,会首先判断此时持有锁的线程与它是否为同一线程,如果是则正常往下执行,由于此前是没有释放锁的,所以这次就不会有任何的获取锁操作。
所以,偏向锁是指当一段同步代码一直被同一个线程所访问时,就不存在所谓的多线程竞争了,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。
偏向锁的锁释放是一个被动过程,线程不会主动释放偏向锁,只有当其他线程来竞争偏向锁时,JVM 才会检测到锁的状态并触发撤销。但是撤销需要等待全局安全点(所有线程会暂停),JVM 会在全局安全点时判断锁对象是否处于被锁定状态,如果没有被锁定,且持有锁的线程不处于活动状态,则将对象头设置为无锁状态,并撤销偏向锁。
所以,引入偏向锁的目的是认为当前环境下是不存在多线程竞争的场景,可以认为是单线程环境,同一个线程多次持有锁,减少单线程环境下获取锁带来的不必要。
流程图如下:
轻量级锁
当一个线程持有偏向锁时,另外一个线程来竞争锁,这时偏向锁就会升级为轻量级锁。
轻量级锁的竞争方式一种比较轻量级的竞争方式,当某个线程没有获取到锁,它并不是立刻被挂起,而是采取自旋的方式来竞争锁资源。在竞争较少的情况下,轻量级锁通过减少线程阻塞和唤醒操作,可以提高性能。
轻量级锁的目的在于它认为系统当前的竞争环境不是激烈,如果采取阻塞和唤醒线程的方式,则会过多地消耗系统资源。如果某个线程没有获取到轻量级锁,则采取自旋的方式来判断锁资源是否已被释放。这种方式减少了上线文的切换。
但是长时间的自旋操作是非常消耗资源的,一个线程获取了轻量级锁,其他线程就只能在那里“空耗”,它们不释放 CPU 资源,但也不做任何事,这种现象叫做忙等(busy-waiting
)。所以,我们是允许短时间的忙等,用它来换取线程在用户态和内核态之间切换的开销。
触发轻量级锁的条件是两个:
- 关闭偏向锁(
-XX:-UseBiasedLocking
) - 多个线程竞争偏向锁导致偏向锁升级为轻量级锁
流程图如下:
重量级锁
轻量级锁自旋是要有限度的,你不能一直在那里空转,所以如果锁竞争环境比较严重,当自旋次数达到某个阈值(默认 10 次,可自动调整)后,就是停止自旋,此时锁膨胀为重量级锁。当其膨胀为重量级锁后,其他线程就不再是等待了,而是阻塞等待。重量级锁依赖对象内部的监视器(monitor
)实现,而 monitor
依赖的是操作系统的 MutexLock
(互斥锁)。
由于是重量级锁,那么等待锁资源的线程都会被阻塞,虽然阻塞的线程不会消耗 CPU,但是阻塞或者唤醒一个线程都需要通过底层操作系统来实现,它会涉及到上下文切换,用户态和内核态之间的转换,这本身就是一个非常重量级、高开销的操作。
锁升级过程
锁升级就是无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁 的一个过程,注意,锁只能升级,不能降级。流程图如下:
- JVM 启动后,锁资源对象直到有第一个线程访问时,它都是无锁状态,此时 Mark Word 内容如下:
偏向锁标识为 0,锁标识为 01。
- 当锁对象首次被某个线程(假如为线程 A,id 为
1000001
)时,锁就会从无锁状态升级偏向锁。偏向锁会在 Mark Word 中的偏向锁线程 id 存储当前线程的id(1000001
),偏向锁标识为 1,锁标识为 01,如下:
如果当前线程再次获取该锁对象,只需要比较偏向锁线程 id 即可。
- 当有其他线程(假如为线程 B,id 为
1000002
)来竞争该锁对象,此时锁为偏向锁,这个时候会比较偏向锁的线程 id 是否为线程 B1000002
,我们可以判断不是,所以会利用 CAS 尝试修改 Mark Word,如果成功,则线程 B 获取偏向锁成功,此时 Mark Word 中的偏向锁线程 id 为线程 B id1000002
:
- 但如果失败了,就说明当前环境可能存在锁竞争,则需要执行偏向锁撤销操作。等到全局安全点时,JVM 会暂停持有偏向锁的线程 A,检查线程 A 的状态,若线程 A状态为不活跃或者已经执行完了同步代码块,则设置锁对象为无锁状态(线程 ID 为空,偏向锁 0 ,锁标志位为01)重新偏向,同时恢复线程 A,继续获取偏向锁。如果线程 A 的同步代码块还没执行完,则需要升级为轻量级锁。
- 在升级为轻量级锁之前,持有偏向锁的线程 A是暂停的,JVM 首先会在线程 A 的栈中创建一个名为锁记录的空间(
Lock Record
),用于存放锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 到线程 A 的锁记录中(官方称之为 Displaced Mark Word ),若拷贝成功,JVM 将使用 CAS 尝试将对象头重的 Mark Word 更新为指向线程 A 的Lock Record
的指针,成功,线程 A 获取轻量级锁,此时 Mark Word 的锁标志位为 00,指向锁记录的指针指向线程 A 的锁记录地址,如下图:
- 对于其他线程而言,也会在栈帧中建立锁记录,存储锁对象目前的 Mark Word 的拷贝。也利用 CAS 尝试将锁对象的 Mark Word 更正指向自身线程的 Lock Record,如果成功,表明竞争到轻量级锁,则执行同步代码块。如果失败,那么线程尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。当然,它不会一直自旋下去,因为自旋的过程也会消耗 CPU,而是自旋一定的次数,如果自旋了一定次数后还是失败,则升级为重量级锁,阻塞所有未获取锁的线程,等待释放锁后唤醒。
synchronized的锁优化是怎样的?
在 JDK 1.6之前,synchronized
是一个重量级、效率比较低下的锁,但是在JDK 1.6后,JVM 为了提高 synchronized
的性能,HotSpot 虚拟机开发团队做了大量的优化工作,如自旋锁、自适应性自旋、锁消除、锁粗化、偏向锁、轻量级锁。其中偏向锁、轻量级锁已经在文章synchronized的锁升级过程是怎样的?讲解过,这里就不做说明了。
自旋锁
线程的阻塞和唤醒都需要依赖底层操作系统,会涉及到用户态、内核态的切换,这种操作是非常消耗资源的。
如果一个同步代码块执行的时间非常短,为了这一段很短的时间去频繁阻塞和唤醒线程其实时非常不值得的。为了解决这种很短时间的任务,Java 引入自旋锁。
何谓自旋锁?就是当一个线程尝试去获取某个锁对象时,如果该锁对象被其他线程持有,那么该线程不会被挂起,而是一直循环检测锁是否已被释放,通过自旋而不是挂起线程,可以减少线程上下文切换的开销。。
需要注意的是,自旋锁基于的条件是:任务执行时间很短,那么自旋等待的效果就会很好,反之,如果任务执行的时间比较长,那么自旋的线程就会白白浪费资源,会带来更多的性能消耗。
所以,自旋等待的时间我们需要控制下,不能长时间的自旋,如果自旋的次数超过某个阈值后还没有获取到锁,就应该使用传统的方式去挂起线程。默认自旋次数为十次,我们也可以通过 -XX:PreBlockSpin
来自行更改。
自适应性自旋
自适应性是对自旋锁的一种优化,它的次数不再是固定的,而是根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 动态调整自旋次数:根据之前的锁竞争情况,动态调整自旋的次数。如果之前的自旋锁获取经常成功,则增加自旋次数;如果很少成功,则减少自旋次数,甚至可以不自旋。
- 考虑锁的拥有者状态:如果锁的持有者正在运行,则自旋的机会就会增加,因为锁可能很快就会被释放。相反,如果锁的拥有者不在运行状态,自旋的次数可能就会减少。
锁消除
为了保证多线程环境下数据的安全性,我们在编写代码时可能会进行一些同步操作或者使用一些具有同步功能的 API(例如 StringBuffer、Vector、HashTable等)。但是在有些情况下,JVM 检测到某个锁对象的锁定状态是不会逃逸到方法或者线程的外部,那么这个锁就可以被认为是不必要的,可以被安全地去除。通过这种方式消除没有必要的锁,可以节省毫无意义的锁获取时间。例如如下代码:
public String concatStrings(List<String> strings) {
StringBuffer sb = new StringBuffer();
for(String s : strings) {
sb.append(s);
}
return sb.toString();
}
我们知道 StringBuffer 是一个线程安全的类,它内部使用 synchronized
来保证线程安全,如 append()
:
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
synchronized
作用在方法上,监视器锁为实例对象,锁定的是调用该方法的对象实例,上面例子中,锁定的是 sb
对象,但是 sb
对象是局部变量,它的引用不会逃逸出这个方法的。也就是说,sb
是不会被多个线程共享。因此,JVM 可以安全地消除掉 sb.append(s)
操作中的同步锁。
锁粗化
原则上,我们在编写代码的时候需要尽可能地控制锁的粒度,将锁的范围控制得尽可能小。在大多数情况下,这个是没问题的。但是如果我们一个操作频繁地获取、释放同一个锁对象,那么即使是没有锁竞争,也会因为频繁的锁操作而导致性能损耗。
所以,当 JVM 检测到一系列的连续锁操作实际上是对同一个对象的操作时,JVM 会尝试将这些锁操作合并为一个更大范围的锁操作,从而减少锁的获取和释放的次数。
例如(一般没人会写这种代码,仅供参考):
public void appendString(List<String> strings) {
for (String s : strings) {
synchronized (this) {
// 进行一些操作
}
}
}
在这个例子中,每次循环时都会获取并释放对 this
对象的锁。JVM 会检测到这种请,它可能会做如下优化:
public void appendString(List<String> strings,) {
synchronized (this) {
for (String s : strings) {
// 进行一些操作
}
}
}
将 synchronized (this)
移动到 for 循环外面去,这样就可以在整个循环过程中只锁定一次,而不是在每次迭代时都进行锁定和解锁。
- 点赞
- 收藏
- 关注作者
评论(0)