Synchronized实现原理与常见面试题

Java
261
0
0
2024-01-07
标签   Java面试

Synchronized 是常被我们用来保证临界区以及临界资源安全的解决方案。它可以保证当有多个线程访问同一段代码,操作共享数据时,其他线程必须等待正在操作线程完成数据处理后再进行访问。即 Synchronized 可以达到线程互斥访问的目的。

所以,我们可以了解到,Synchronized锁代表的锁机制有如下两种特性:互斥型和可见性。

  • 互斥性:同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中并发安全;
  • 可见性:确保锁在释放之前所做的操作,对之后的其他线程是可见的(即之后获取到该锁的线程获取到的共享变量是最新的)。

除此之外,JDK 1.6后还对synchronized锁进行了优化,使其摆脱了重量级锁的称号。接下来就来了解以下synchronized的实现以及优化。

一、Synchronized对应的锁对象

理论上Java中所有的对象都可以作为锁,Java中根据synchronized使用的场景不同,其锁对象也是不一样的。可以有以下场景:

场景

具体分类

锁对象

代码示例

修饰方法

实例方法

当前实例对象

public synchronized void method () {...}

...

静态方法

当前类的Class对象

public static synchronized void method () {...}

修饰代码块

代码块

( )中配置的对象

synchronized(object) {...}

所以,当一个线程要访问一段同步代码块时,它必须获取到如上表中的锁对象。那么这一过程在字节码中又是怎么表示的呢?

二、 Monitor机制与Java对象头

首先我们来看一段小Demo:

Copy
public class Demo {

    public static void main(String[] args) {
        synchronized (Demo.class) { }
        method();
    }

    private static void method() { }
}

可以看到执行同步代码块首先需要去执行monitorenter指令,退出的时候需要执行monitorexit指令。我们来观察monitorenter指令底层的逻辑,其源码如下:

Copy
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
#ifdef ASSERT
    thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
	if (PrintBiasedLockingStatistics) {
    	Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
	}
	Handle h_obj(thread, elem->obj());
	assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
       	"must be NULL or an object");
	// 在JVM启动时,我们可以通过参数选择是否启用偏向锁
	if (UseBiasedLocking) {
        // 在这里判断是否启动偏向锁
    	// Retry fast entry if bias is revoked to avoid unnecessary inflation
    	ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
	} else {
        // 启动轻量级锁
    	ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
	}
	assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
       	"must be NULL or an object");
#ifdef ASSERT
	thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
IRT_END

我们可以看到上面这个方法根据是否启动偏向锁来决定偏向锁(if(UseBiasedLocking))来决定是否使用偏向锁(调用ObjectSynchronizer::fast_enter()方法)还是轻量级锁(调用ObjectSynchronizer::slow_enter()方法)。如果不能获取到锁,那么就会按偏向锁、轻量级锁、重量级锁的顺序膨胀(关于四种锁状态后面会提及)。

在JDK 1.6之前,使用synchronized就意味着使用重量级锁,即直接调用ObjectSynchronizer::enter()方法。之所以称为“重量级”,是因为线程的阻塞和唤醒都需要OS在内核态和用户态之间转换。而JDK 1.6引入了偏向锁、轻量级锁、适应性自旋、锁消除等大量优化,synchronized的效率也变高了。

上面锁提到的锁,其中偏向锁和轻量级锁都是乐观锁,基于CAS操作,不需要条件变量之类的东西,所有不需要Monitor,而重量级锁是悲观锁,则会被monitor机制管理。

1. Monitor#

那么什么是Monitor呢?

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个对象都有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被重量级锁锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

2. Java对象头

synchronized说到底是一种锁机制,在操作同步资源时需要给同步资源加锁,那么这个锁到底存在哪里呢?答案就是对象头中。

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为对象头、实例数据和对齐填充。

其中对象头主要又包括了两部分数据:Mark Word(标记字段)、Klass Point(类型指针):

  • Mark Work:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

三、 Synchronized的锁升级#

上面提到了关于锁升级的过程,那么现在就来详细说明下四种锁状态以及锁的膨胀过程。

1. 无锁状态#

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

2. 偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

3. 轻量级锁#

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

4. 重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。

简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源,导致性能低下。

5. 锁膨胀(升级)#

synchronized关键字锁修饰的代码块在第一次被执行时,锁对象就会从无锁状态变成偏向锁(此时会通过CAS修改对象头里的锁标志位)。执行完同步代码快后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断吃有锁的线程是否就是自己(持有锁的线程 ID 也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也不需要重新加锁。如果自始自终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

一旦又第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性操作。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

显然,此忙等是有限度的(JVM有个计数器会记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK 1.6之前,synchronized直接加重量锁,很明显现在得到了很好的优化。

有一点需要特别注意:锁只能按照偏向锁、轻量级锁、重量级锁的顺序升级,而不能降级。

所以综上所述,偏向锁通过对比Mark Word解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

常见面试题#

1、synchronized锁住的是什么?

synchronized本身并不是锁,锁本身是一个对象,synchronized最多相当于“加锁”操作,所以synchronized并不是锁住代码块。Java中的每一个对象都可以作为锁。具体表示有三种形式,当修饰普通同步方法,锁是当前实例对象;当修饰静态同步方法,锁是synchronized括号里配置的对象。

2、synchronized锁升级的过程?

当没有竞争出现时,默认使用偏向锁。JVM会利用CAS操作,在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。 如果有另外的线程试图锁定某个已经被偏向过的对象,JVM就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作Mark Word来试图获取锁,如果重试成功,就使用轻量级锁;否则在自旋一定次数后进一步升级为重量级锁。

3、为什么说Synchronized是非公平锁,这样的优缺点是什么?

非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能产生线程饥饿现象。

4、为什么说synchronized是一个悲观锁?乐观锁的实现原理又是什么?什么是CAS,它有什么特性?

Synchronized显然是一个悲观锁,因为它的并发策略是悲观的:不管是否会产生竞争,任何的数据都必须加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。 随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略。先进行操作,如果没有任何其他线程征用数据,那操作就成功了; 如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施。这种乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。 乐观锁的核心算法是CAS(Compared And Swap,比较并交换),它涉及到三个操作数:内存值、预期值、新值。当且仅当预期值和内存值相等时才将内存指修改为新值。 这样处理的逻辑是,首先检查某块内存的值是否跟之前读取时的一样,如不一样则表示期间此期望值已经被别的线程更改过,舍弃本次操作,反之则说明期间没有其他线程对此内存进行操作,可以把新值设置给此块内存。 CAS具有原子性,它的原子性由CPU硬件指令实现保证,即使用JNI调用Native方法调用由C++编写的硬件级别指令,JDK中提供了Unsafe类执行这些操作。

5、跟Synchronized相比,可重入锁ReenterLock其实现原理有什么不同?

其实,锁的实现原理基本都是为了达到一个目的:让所有线程都能看到某种标记。 Synchronized通过在对象头中设置标志实现这一个目的,是一种JVM原生的锁实现方式;而ReenterLock以及所有基于Lock接口的实现类,都是通过一个volatile修饰的int型变量,并保证每个线程都能拥有对该int值的可见性和原子修改,其本质基于AQS框架实现的。

6、尽可能详细地对比下Synchronized和ReenterLock的异同。

ReennterLock是Lock的实现类,是一个互斥的同步锁。从功能角度,ReenterLock比Synchronized的同步操作更精细(因为可以像普通对象一样使用),甚至实现Synchronized没有的高级功能,如: 从锁释放的角度,Synchronized在JVM层面上实现的,不但可以通过一些监控工具监控Synchronized的锁定,而且在代码执行出现异常时,JVM会自动释放锁定;但是使用Lock则不行,Lock是通过代码实现的,要保证锁一定会被释放,就必须将unLock()放到finall{}中。

等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,对处理执行时间非常长的同步块很有用。

带超时的获取锁尝试:在指定的时间范围内获取锁,如果时间到了仍然无法获取则返回。

可以判断是否有线程在排队等待获取锁。

可以响应中断请求:与Synchronized不同,当获取到锁的线程被中断时,能够响应中断,中断异常将会被抛出,同时锁会被释放。

可以实现公平锁。