面试官:请详细说下synchronized的实现原理 - 知乎 (zhihu.com)
大佬写的太好了建议看大佬的。
在了解synchronized
关键字的底层原理前,需要先简单了解下Java的内存模型,看看synchronized
关键字是如何起作用的。
这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这种内存模型会出现什么问题呢?
该问题Java内存模型是通过synchronized
关键字和volatile
关键字就可以解决,那么synchronized
关键字是如何解决的呢,其实进入synchronized
块就是把在synchronized
块内使用到的变量从线程的本地内存中擦除,这样在synchronized
块中再次使用到该变量就不能从本地内存中获取了,需要从主内存中获取,解决了内存不可见问题。
synchronized
关键字可以保证只有一个线程拿到锁,访问共享资源。synchronized
在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性。synchronized
关键字实现的是悲观锁,每次访问共享资源时都会上锁。synchronized
关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。synchronized
关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。synchronized
关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。在Java中,"synchronized"主要有三种使用方式:
同步方法:
在方法声明中使用 “synchronized” 关键字,确保在调用该方法时,只有一个线程可以执行该方法。其他线程必须等待当前线程执行完毕才能访问该方法。
class MySync implements Runnable {
private static int i = 0; //共享资源
private synchronized void add() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
add();
}
}
public static void main(String[] args) throws Exception {
MySync MySync = new MySync();
Thread t1 = new Thread(MySync);
Thread t2 = new Thread(MySync);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
这段代码的结果很容易得到
20000
再试试这段代码
class MySync implements Runnable {
private static int i = 0; //共享资源
private synchronized void add() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
add();
}
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(new MySync());
Thread t2 = new Thread(new MySync());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
结果
18659
第二个示例中的add()
方法虽然也使用synchronized
关键字修饰了,但是因为两次new MySync()
操作建立的是两个不同的对象,也就是说存在两个不同的对象锁,线程t1和t2使用的是不同的对象锁,所以不能保证线程安全。如果synchronized关键字作用于类对象,即用synchronized
修饰静态方法,问题则迎刃而解。
静态同步方法:
类级别的同步,确保在同一时刻只有一个线程可以访问该类的静态同步方法。使用 static synchronized
关键字来实现。
class MySync2 implements Runnable {
private static int i = 0; //共享资源
private static synchronized void add() {
i++;
}
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
add();
}
}
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(new MySync2());
Thread t2 = new Thread(new MySync2());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
在这个例子中,lockObject
是一个共享的对象,用于在多个线程之间建立同步。
同步块:
使用同步块可以在方法内的特定代码块上应用同步。这对于只需要在某个代码片段上进行同步的情况很有用,而不是整个方法。
class MySync3 implements Runnable {
static int i = 0; //共享资源
@Override
public void run() {
synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
for (int j = 0; j < 10000; j++) {
i++;
}
}
}
public static void main(String[] args) throws Exception {
MySync3 mySync = new MySync3();
Thread t1 = new Thread(mySync);
Thread t2 = new Thread(mySync);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
在jdk1.6之前,synchronized
被称为重量级锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。
在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头、实例数据和填充对齐。因为synchronized
用的锁是存在对象头里的,这里我们需要重点了解对象头。如果对象头是数组类型,则对象头由Mark Word、Class MetadataAddress和Array length组成,如果对象头非数组类型,对象头则由Mark Word和Class MetadataAddress组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:
内容 | 说明 | 长度 |
---|---|---|
Mark Word | 存储对象的hashCode、分代年龄和锁标记位 | 32bit |
Class MetadataAddress | 存储到对象类型数据的指针 | 32bit |
Array length | 数组的长度 | 32bit |
在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:
其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的。
在jdk1.6之前,synchronized
只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,
简单介绍下其数据结构(不全)
ObjectMonitor() {
_header = NULL;
_count = 0; //锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到
_waiters = 0, //等待线程数
_recursions = 0; //锁的重入次数
_owner = NULL; //指向持有ObjectMonitor对象的线程地址
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_cxq = NULL ; //阻塞在EntryList上的单向线程列表
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}
其中 _owner、_WaitSet和_EntryList 字段比较重要,它们之间的转换关系如下图
从上图可以总结获取Monitor和释放Monitor的流程如下:
wait()
方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。
monitorenter //进入同步代码块的指令
{
//同步代码块
}
monitorexit //结束同步代码块的指令
同步代码块的实现是由monitorenter
和monitorexit
指令完成的,其中monitorenter
指令所在的位置是同步代码块开始的位置,第一个monitorexit
指令是用于正常结束同步代码块的指令,第二个monitorexit
指令是用于异常结束时所执行的释放Monitor指令。
同步方法是通过Access flags
后面的标识来确定该方法是否为同步方法。Access flags
后边的synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。
在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,无锁,偏向锁,轻量级锁,重量级锁。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级。这种只能升级不能降级的策略是为了提高获得锁和释放锁的效率。
偏向锁(Biased Locking)是为了优化无竞争情况下的锁性能而引入的一种锁机制。偏向锁假定在对象的竞争情况下,总是由同一线程多次获得锁。
偏向锁的获取流程:
初始状态:
刚创建的对象处于无锁状态,没有任何线程持有该对象的锁。
偏向锁标记:
当一个线程第一次访问一个对象并获取锁时,JVM会将对象的Mark Word 设置为偏向锁标记,同时记录持有锁的线程ID。这表示该对象偏向于第一次获得它的线程。
再次获取锁:
当同一个线程再次尝试获取该对象的锁时,JVM会检查对象的Mark Word,发现是偏向锁,并且线程ID与当前线程ID一致。此时,无需进行任何同步操作,直接认为当前线程已经持有了该对象的锁,可以继续执行。
获取失败,撤销偏向锁:
如果有其他线程尝试获取同一个对象的锁,偏向锁就会失效,JVM会撤销偏向锁,将对象的Mark Word 恢复为无锁状态。这时,转而使用轻量级锁或重量级锁来确保多线程间的同步访问。
轻量级锁(Lightweight Lock)是Java中用于优化多线程同步的一种机制。轻量级锁的目标是在无竞争的情况下,减少传统的重量级锁的性能开销。
轻量级锁的获取流程:
Mark Word 检查:
每个对象的头部都包含一个Mark Word,用于存储对象的状态信息。当线程尝试获取轻量级锁时,首先检查对象的Mark Word。如果对象的Mark Word 表示没有被锁定,那么当前线程尝试使用CAS(Compare And Swap)操作来将对象的Mark Word 设置为锁定状态。
CAS 尝试:
如果对象的Mark Word 表示没有被锁定,当前线程会尝试使用CAS原子操作,将对象的Mark Word 替换为指向当前线程的锁记录(Lock Record)。这个锁记录包含了指向对象Monitor的指针。
成功获取锁:
如果CAS操作成功,表示当前线程成功获取了轻量级锁,可以继续执行同步块中的代码。在这个阶段,轻量级锁的状态仍然处于无锁状态,只是Mark Word 中包含了指向锁记录的指针。
获取失败,膨胀为重量级锁:
如果CAS操作失败,表示有其他线程持有了锁,那么当前线程就需要尝试其他手段。此时,轻量级锁会膨胀为重量级锁,这时候JVM会尝试使用互斥量来确保数据的同步访问。膨胀为重量级锁的过程通常涉及到线程的阻塞和唤醒,增加了同步的开销。
注意轻量级锁竞争失败会直接膨胀为重量级锁,并不会自旋。
重量级锁是Java中用于多线程同步的一种较为激进的锁机制,用于解决多个线程之间的竞争问题。
重量级锁的获取流程:
初始状态:
刚创建的对象处于无锁状态,没有任何线程持有该对象的锁。
锁竞争:
当一个线程尝试获取一个对象的锁时,发现该对象的锁已经被其他线程持有。此时,发生锁竞争。
阻塞等待:
当锁竞争发生时,请求锁的线程会被阻塞,进入等待状态。这时,JVM会将该线程放到一个等待队列中。
争夺锁:
当持有锁的线程释放锁时,JVM会从等待队列中选择一个线程唤醒。这个被唤醒的线程会尝试再次获取锁。
竞争成功:
如果竞争锁的线程在被唤醒后成功获取了锁,那么它就可以继续执行同步块中的代码。此时,其他线程仍然处于阻塞状态。
竞争失败:
为激进的锁机制,用于解决多个线程之间的竞争问题。