中北软院创新实验室

锁的问答

2018-09-08  本文已影响1人  HikariCP

面试问题

synchronized关键字,实现原理(和Lock对比着说,说到各自的优缺点,synchronized从最初性能差到jdk高版本后的锁膨胀机制,大大提高性能,再说底层实现,Lock的乐观锁机制,通过AQS队列同步器,调用了unsafe的CAS操作,CAS函数的参数及意义;同时可以说说synchronized底层原理,jvm层的moniter监视器,对于方法级和代码块级,互斥原理的不同,+1-1可重入的原理等)

synchronized

synchronized的实现原理

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程可以执行该方法或代码块,同时它还可以保证对共享变量操作的内存可见性。

实现同步的方式

synchronized实现同步的方式一般有3种,普通同步方法、静态同步方法、同步代码块,分别锁的是当前实例对象,类对象,synchronized括号中修饰的类或对象。

由于Java保证任何一个对象都有其对应的monitor,或者说管程对象存在。对于synchronized修饰的方法或代码块JVM本质上都是通过获取,释放monitor对象来对方法,同步块进行同步的。

同步代码块: 具体实现规则又有所不同,同步代码块是在代码块前插入一条monitor.enter指令,代码块的退出位置再插入一条monitor.exit指令。以及插入的一条,异常结束时供异常处理器调用的monitor.exit指令。

同步方法: 方法级的同步是隐式的,并不需要像同步代码块一样通过字节码指令来控制。它在方法调用和返回操作的时候,JVM可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志来区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor,执行结束或异常终止则会释放monitor。

synchronized优化

在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,所以这也就是synchronized效率低下的原因。而在jdk1.6的时候Java也对该弊端进行了改进,为了减少获取锁和释放锁锁带来的性能上的损耗引入了偏向锁和轻量锁。

Java锁一共四种状态,无锁,偏向,轻量,重量。

偏向锁

偏向锁的核心思想:不存在多线程竞争,并且应由一个线程多次获得锁。

获取锁
当线程访问同步块时,会使用 CAS 将线程 ID 更新到锁对象头文件的 Mark Word 标志字段中,如果更新成功则获得偏向锁,并且之后该线程每次进入这个对象锁相关的同步块时都不需要再次获取锁了。

释放锁

由于线程释放锁只有竞争才会释放,所以对于偏向锁线程是不会主动释放锁的,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。暂停当前线程,判断对象是否处于被锁定的状态,撤销偏向锁,恢复到无锁态或轻量锁态。

轻量锁

轻量锁的核心思想:对于绝大部分的锁,在整个生命周期内都是不会存在竞争的。没有多线程竞争的前提下,减少传统的重量级锁的使用,及操作系统互斥量产生的性能消耗。

获取锁

根据当前对象头的Mark Word标志词中的锁标记为来判断当前对象的锁状态是否处于无锁状态,若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态

释放锁

轻量级锁的释放也是通过CAS操作来进行的,取出在获取轻量级锁保存在Displaced Mark Word中的数据,用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

适应性自旋锁

防止线程切换的损耗,某个线程在被强占cpu时间片后进入阻塞状态并后续唤醒是十分不值得的,所以让该线程自旋等待一段时间。jdk1.6后引入了适应性自旋锁,通过每次自旋并实时的监控上传自旋的状态来决定自旋的次数。

锁消除

即JVM会通过变量逃逸机制来判断,当前操作的变量是否是栈封闭的环境中运行,如果是,那么它便会大胆的对一些通过加锁机制来保证并发安全的变量进行去锁操作。比如栈封闭情况下的StringBuffer和Vector等。

锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

public void vectorTest(){
    Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
    }

    System.out.println(vector);
}

对象头

Java对象头和Monitor是synchronized实现同步的机制。Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等

image

锁标志状态图

image

Monitor

每一个被锁住的对象都会和一个monitor关联。在JVM层面monitor是由C++中的ObjectMonitor对象来实现的。

Monitor对象的结构图:

image

Lock

AQS

当state=0时,则说明没有任何线程占有共享资源的锁,当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待,AQS内部通过内部类Node构成FIFO的同步队列来完成线程获取锁的排队工作,同时利用内部类ConditionObject构建等待队列。所以说AQS是有两种多个等待队列的。

AQS作为基础组件,对于锁的实现存在两种不同的模式,即共享模式(如Semaphore)和独占模式(如ReetrantLock)

image

而相应的tryRelease方法公平锁和非公平锁则是通用的Sync类的tryRelease函数,它重写了AQS的tryRelease函数。tryRelease函数主要是对当前锁的同步队列的State的值进行更改。使其的状态值State-1。同时release函数在tryRelease函数返回值为true的时候,当队列中还有元素的时候,选择去唤醒头元素的后继元素。

  //标识线程已处于结束状态
    static final int CANCELLED =  1;
    //等待被唤醒状态
    static final int SIGNAL    = -1;
    //条件状态,
    static final int CONDITION = -2;
    //在共享模式中使用表示获得的同步状态会被传播
    static final int PROPAGATE = -3;

参考:

ReentrantLock

ReentrantLock底层是依赖CAS方法和volatile变量来实现同步的,并且还依赖于一些LockSupport类的方法。

重入锁的静态内部类Sync实现自抽象类AQS,依赖于AQS的同步队列及int类型的值来保证同步。Sync内部类又分为公平的和不公平的,公平是指,等待时间最长的线程优先获得锁。保证公平会影响性能,线程会严格按照先进先出的方式来获取锁。一般也不需要,所以默认不保证,而不公平则是保证每次调用lock函数获取锁的时候,都会尝试先通过CAS的方式获取一次锁(修改一次该独占锁的State数值,及设置该独占锁的Owner线程),获取不到才去同步队列中等待。synchronized锁也是不保证公平的。

公平锁和非公平锁都重写了AQS的lock和tryAcquire函数。区别是,lock函数非公平锁比公平锁多通过CAS尝试获取一次锁,获取不到才和公平锁一样通过调用AQS类的acquire来处理。AQS类的acquire函数的处理逻辑是,先通过tryAcquire通过CAS的方式获取锁,如果获取到直接返回,否则调用addWaiter函数将该线程封装成同步队列中的等待节点,并插入到同步队列中。然后调用acquireQueued自旋的方式获得锁,获取不到的话,就中断当前线程。

LockSuport的函数基本上全依赖Unsafe类来实现。

获取锁的执行流程:

image

CAS

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable{
    
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

    // ....
}

public final class Unsafe {

    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    
    // ....
}

synchronized和Lock用哪个

首先两者最大的区别就是一个是隐式锁,一个是显示锁。

jdk1.6之后对synchronized进行了大量的优化操作,导致在Java1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地。所以两者在使用的时候,Synchronized锁用起来比较简单。Lock锁还得顾忌到它的特性。

所以我们绝大部分时候还是会使用Synchronized锁,用到了Lock锁提及的特性,带来的灵活性才会考虑使用Lock显式锁。

上一篇下一篇

猜你喜欢

热点阅读