synchronized

当两个或多个线程试图同时访问同一资源时,为了确保代码的正确执行,防止并发编程造成的数据不一致性,应当使用synchronized关键字对类或者对象加锁。

如何使用

public class SynchronizedTest {

    public void test(){
        synchronized (this){    //执行代码必须要先拿到当前实例对象的锁 锁住的是当前实例对象
            //TODO
        }
    }

    public synchronized void test1(){ //等价于 synchronized(this)
        //TODO
    }

    public void staticTest(){
        synchronized (SynchronizedTest.class){  //执行代码必须要拿到类对象的锁 锁的是类对象
            //TODO
        }
        
    }
    public synchronized static void staticTest1(){  //等价于synchronized(SynchronizedTest.class)
        //TODO
    }

}

从以上可以看出,synchronized的使用方式分为两种,分别是同步方法和同步代码块。
(⚠️注意:锁定的不是具体的某些代码,而是某个对象,如代码中的第一二个方法锁的是实例对象;第三四个方法锁的是类对象)。

可重入性

synchronized 不光可以加锁,还有个比较重要的特性就是 可重入性。什么是可重入性呢?可重入性就是指 一个线程已经拥有某个对象的锁,再次申请仍然可以获得该对象的锁

public class Reentry {

    public synchronized void m1(){
        System.out.println("m1 start");
        m2();
        System.out.println("m1 end");
    }

    public synchronized void m2(){
        System.out.println("enter m2");
    }

    public static void main(String[] args) {
        new Reentry().m1();
    }
}

//out 
m1 start
enter m2
m1 end

实现原理

我们对 如何使用 中对代码进行 javap 反解析出对应的汇编指令。(使用IDEA插件(jclasslib),能够在IDE中直接查看。)如下是 javap 结果:

-------------------------------------
public void test();
    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
-------------------------------------
public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return

从上面可以看出

  • 同步代码块 使用了2个JVM命令 monitorentermonitorexit来实现;
  • 同步方法使用 flags:ACC_SYNCHRONIZED 来实现。

先来看看 monitorentermonitorexit
根据 JVM虚拟机规范第6章monitorenter/monitorexit 介绍来看,monitor 可以理解为一个监视器,每个对象与一个监视器关联,每个监视器与一个被锁计数器关联,监视器被锁住时只有拥有一个拥有者。
尝试执行 monitorenter 的线程必须遵循以下条件:

  • 如果监视器关联的被锁计数器为0,则线程持有该监视器,并将计数器加1;
  • 如果尝试进入的线程已经拥有关联的监视器,那么重新进入监视器,并将计数器加1;
  • 如果另一个线程已经拥有监视器 ,则尝试进入的线程将阻塞,直到该监视器的计数器为零为止,然后再次尝试获取所有权。

执行monitorexit 的线程必须是原拥有监视器的所有者,每执行一次计数器减1,直到计数器为0时,退出监视器。
如果尝试进入的线程已经拥有关联的监视器,那么重新进入监视器,并将计数器加1。这句话就解释了synchronized的可重入性。

再来看看flags:ACC_SYNCHRONIZED
根据 JVM虚拟机规范第2章 Synchronization 介绍来看,synchronized 方法 会有一个flags:ACC_SYNCHRONIZED 的标识,当方法调用时会检查这个标识是否能获取监视器,调用synchronized方法时ACC_SYNCHRONIZED会设置为1,执行线程将持有监视器,在执行线程拥有监视器的时间内,没有其他线程可以进入它。
如果在synchronized方法调用期间引发了异常并且该synchronized方法不处理该异常,则在将该异常重新抛出该方法之前,该方法的监视器将自动退出synchronized

可以看出,两者本质上都会需要获取监视器,如果获取不到就不会被阻塞,直到获取到监视器。那么这里有个注意的点,即 synchronized 的方法如果抛出异常且不处理的话,方法的监视器就会自动退出。这样就会导致其他线程访问到异常产生时的数据。

锁优化

在JDK1.6之前,synchronized的实现直接去调用 monitorentermonitorexit 。而这两个操作都需要去找操作系统申请锁,中间会有一次从 用户态 --> 内核态 的过程。(_Java的线程是映射到操作系统原生线程的_)所以 synchronized 的效率都比较低。
JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁消除

锁消除是JIT编译器在动态编译同步块的时候,借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。也就是针对不可能存在竞争的共享数据的锁进行消除。如下代码所示:

public String test2(){
    StringBuffer sb = new StringBuffer();
    for (int i = 0; i < 99; i++) {
        sb.append(i);
    }
    return sb.toString();
}

我们都知道 StringBufferappend 方法都会加锁,但从上述例子中我们可以看到 StringBuffer 对象是在方法内部,并不会被其他线程访问到,此时的锁就会被消除。

锁粗化

锁粗化是JIT发现如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。如下代码所示:

for(int i = 0;i < 99; i++){  
    synchronized(this){  
        do();  
} 

//锁粗化
synchronized(this){
    for(int i = 0;i < 99; i++){
         do();
      }   
}
         

自旋锁

自旋锁的出现是因为每次阻塞或唤醒一个线程都需要操作系统的帮忙,这样的状态转换都挺耗费时间,后来发现共享数据的锁定状态都比较短暂,很明显这样都状态切换很不值得。自旋锁的理念就是让想要持有锁的线程等待,做一个循环去获取锁,看持有锁的线程是否会很快的释放锁。如果一旦释放,马上就得到锁。这个过程称之为自旋。但这个自旋也不是无限的,达到一定次数之后,仍然没有获取到锁就会被挂起。
自旋锁也是有利弊的,如果说持有锁的线程很快的释放了锁,那么这种方案的效率是很不错的,因为它避免了线程切换的开销;但如果持有锁的线程没有很快的释放了锁,那么自旋的线程也同时占用了处理器的时间,这样也是一种浪费。如果自旋的线程很多,那对处理器也是一种负担。所以自旋锁的使用场景就比较重要了,一般持有锁的执行时间短,争抢锁的线程数少的场景使用自旋会比较合适。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

自适应自旋锁

JDK1.6中,默认自旋的次数为10次,那如果自旋的线程再多自旋几次就能等到释放锁了呢,古人云:行百里者半九十。JDK1.6也对此做了一些优化,就是自适应自旋锁。自适应自旋锁表示自旋的次数不是固定的一个值,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果线程自旋成功了,那下一次线程的自旋就会增加,因为虚拟机认为上次的自旋成功,那么这次也大概率会成功,故增加线程的次数;那么相应的如果线程经常性自旋失败了,那么下次就会减少自旋次数甚至省略自旋过程,以免浪费处理器资源。

偏向锁

偏向锁的意思就是会偏向于第一个访问锁的线程,大部分的情况下锁其实并不存在多线程竞争,而且总是由同一个线程多次获得,这样会让线程获得锁的代价更低。
当一个线程访问同步块并获取锁时,检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;若为可偏向状态,则测试线程ID是否为当前线程ID,如果是 执行同步代码块;否则通过CAS操作竞争锁,竞争成功 ,则将Mark Word的线程ID替换为当前线程ID,否则CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

  • 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁:-XX:-UseBiasedLocking

轻量级锁

当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁。轻量级锁并不是取代重量级锁,而是在大多数情况下同步块并不会出现严重的竞争情况,所以引入轻量级锁可以减少重量级锁对线程的阻塞带来的开销。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向对象头的 mark word。如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果这个更新操作失败了,虚拟机判断对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,在若干个自旋后,如果还没有获得锁,则才被挂起。
轻量级解锁时,会使用原子的 CAS 操作来将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。(因为之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现替换回到对象头时失败了,就表示在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改。)

锁膨胀

synchronized 同步锁执行过程中一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,他们会随着竞争情况逐渐升级,此过程为不可逆。(这种策略是为了提高获得锁和释放锁的效率)。所以 synchronized 锁膨胀过程其实就是无锁 → 偏向锁 → 轻量级锁 → 重量级锁的一个过程。
锁的状态是跟存储在java对象的markword有关,markword是java对象数据结构中的一部分,markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态;对象的所处的状态,决定了markword存储的内容,如图所示。
image.png
下图是很多blog中非常流行对一个锁膨胀的图 也比较详细的还原了整个锁膨胀的过程。
image.png

  • 当一个锁对象创建后,没有被任何线程访问过时,是无锁状态。同时它也是可偏向的,就是当第一个线程访问它时会认为有且仅有第一个线程会访问它,默认会偏向这个线程。锁标志位为01。
  • 当一个线程(a)来获取时,会首先检查锁标志位是否为01,然后检查是否为偏向锁,若不是,则修改为偏向锁(即线程(a)为第一个获取锁),与此同时将 Mark Word 的 线程id 更改为自己的 线程id。
  • 后续再有一个线程(b)来获取时,发现是偏向锁,再判断下当前 Mark Word 中的 线程id 是否为自己的 线程id,若是,则获取偏向锁,执行同步代码。若不是,就使用 CAS 操作 尝试替换 Mark Word 中的 线程id,若成功,则线程(b)表示获取到偏向锁 ,执行同步代码。(线程a使用完之后并不会主动把偏向锁去除,等待其他线程竞争才会释放锁)。
  • 如果失败,则表明当前存在锁竞争,则执行偏向锁的撤销工作,撤销偏向锁的操作需要等到全局安全点(在这个时间点上没有字节码正在执行)才会执行,然后暂停持有偏向锁的线程,同时检查该线程的状态,如果该线程不处于活动状态或者已经退出同步代码块,则设置为无锁状态(线程 ID 为空,是否为偏向锁为 0 ,锁标志位为01)重新偏向,同时恢复该线程。如果线程仍处于活动状态,则会遍历该线程栈帧中的锁记录,检查锁记录的使用情况,如果仍然需要持有偏向锁,则撤销偏向锁,升级为轻量级锁。
  • 在升级到轻量级锁之前,持有偏向锁的线程(a)是暂停的,JVM会在原持有偏向锁的线程(a)的栈帧中创建一个 Lock Record(锁记录),然后拷贝对象头的 Mark Word 的内容到原持有偏向锁的线程(a)的Lock Record 中,将对象的 Mark Word 更新为指向Lock Record的指针,这时线程(a)获取轻量级锁,此时 Mark Word 的锁标志为00。
  • 线程(a)获取到轻量级锁之后,JVM 会唤醒线程(a),线程(a)执行完毕之后就会释放轻量级锁。
  • 与此同时,对于其他线程也会在各自的栈帧中建立Lock Record,存储锁对象的 Mark Word 的拷贝,JVM 利用 CAS 操作尝试将锁对象的 Mark Word 更正指向当前线程的 Lock Record,如果成功,表明竞争到锁,则执行同步代码块,如果失败,那么线程尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。自旋存在一定次数的限制,如果超出则升级为重量级锁,阻塞所有未获取锁的线程,等待释放锁后唤醒。
  • 轻量级锁的释放,会使用 CAS 操作将之前拷贝过来的 Mark Word 内容替换回对象头中,成功,则表示没有发生竞争,直接释放。如果失败,表明锁对象存在竞争关系,这时会轻量级锁会升级为重量级锁,然后释放锁,唤醒被挂起的线程,开始新一轮锁竞争,注意这个时候的锁是重量级锁。

    参考链接

    Java工程师成神之路
    深入理解多线程(五)—— Java虚拟机的锁优化技术
    【死磕Java并发】—–深入分析synchronized的实现原理
    【死磕 Java 并发】—– synchronized 的锁膨胀过程
    Java锁---偏向锁、轻量级锁、自旋锁、重量级锁


Ekko
2 声望0 粉丝