转载

死磕Synchronized

前言

今天开始来写有关Java多线程的知识,这次要介绍的是 synchronized 关键字,我们都知道它可以用来保证线程互斥地访问同步代码,也就是我们常说的 加锁 ,那么问题来了: 什么是锁?锁到底长啥样?

在开始正文之前很有必要先来了解一下锁的概念,一旦搞清楚这些概念,后面很多问题其实也就迎刃而解。

什么是锁?

其实“锁”本身是个对象,synchronized这个关键字并不是“锁”。

从语法上讲, Java中的每个对象都可以看做一把锁 ,在HotSpot JVM实现中,锁有个专门的名字: 监视器(Monitor)

Monitor对象存在于每个Java对象的对象头中,这也是为什么Java中任意对象可以作为锁的原因,有关Monitor后续会详细介绍,有了这些概念看下面这张图应该就容易多了。

死磕Synchronized

同步原理

当一个线程访问同步代码块时, 首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁 ,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

查看反编译后结果:

死磕Synchronized

线程执行 monitorenter 指令时尝试获取monitor的所有权 ,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

线程执行 monitorexit 指令用来释放monitor,执行该指令的线程必须是monitor的所有者 。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

注意:monitorexit指令出现了两次,第1次为正常释放monitor;第2次为发生异常时释放monitor。

通过上面的描述,我们应该能很清楚的看出 Synchronized的语义底层是通过一个monitor的对象来完成。

两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度 ,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

对象头

上面讲到Monitor存在于每个Java对象的对象头中,接下来就来具体看看对象头是个什么东西。

在JVM中,对象在内存中的布局分为三块区域: 对象头、实例数据和对齐填充 。如下图所示:

死磕Synchronized

对象头主要包括两部分数据: Mark Word(标记字段)和 Class Pointer(类型指针)

Class Pointer
Mark Word

Java对象头具体结构描述如下:

死磕Synchronized

锁也可以分为无锁、偏向锁、轻量级锁和重量级锁4种状态,每种都会有对应的标志位,(后续介绍)

死磕Synchronized

当一个线程获取到锁之后,在锁的对象头里面会有一个指向线程栈中锁记录(Lock Record)的指针。当我们判断线程是否拥有锁时只要将线程的锁记录地址和对象头里的指针地址进行比较就行。那么这个Lock Record到底是啥?

Lock Record(锁记录)

在线程进入同步代码块的时候, 如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝

Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表。 每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用

如下图所示为Lock Record的内部结构:

死磕Synchronized

锁的优化

从JDK6开始,就对synchronized的实现机制进行了较大调整, 包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略

锁主要存在四种状态,依次是: 无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态 ,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。 但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级

自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。

所谓自旋锁,就是指当一个线程尝试获取某个锁时, 如果该锁已被其他线程占用,就一直循环检测锁是否被释放 ,而不是进入线程挂起或睡眠状态。

死磕Synchronized

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。 如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。 所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功,如果对CAS不了解可以参考一文彻底搞懂CAS

死磕Synchronized

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。 所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定 。那它如何进行适应性自旋呢?

线程如果自旋成功了,那么下次自旋的次数会更加多 ,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之, 如果对于某个锁,很少有自旋能够成功 ,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

锁消除

在有些情况下,JVM检测到不可能存在共享数据竞争,这时会对这些同步锁进行锁消除。

比如下面这个例子:

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

在运行这段代码时, JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外 ,所以JVM可以大胆地将vector内部的加锁操作消除。

锁粗化

如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗, 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

比如上面那个例子,vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

偏向锁

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

偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在比较ThreadID的时候依赖一次CAS原子指令即可。

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可

处理流程如下:

  • 检测对象头的Mark Word字段判断是否为偏向锁状态
  • 若为偏向锁状态,则判断线程ID是否为当前线程ID,如果是则执行同步代码块
  • 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID
  • 通过CAS竞争锁失败,证明当前存在多线程竞争情况,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁

偏向锁只有遇到 其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁

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

轻量级锁

当多个线程竞争偏向锁时就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能,其具体步骤如下:

(1)在线程进入同步块时, 如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝 。此时线程堆栈与对象头的状态如下图所示:

死磕Synchronized

(2)拷贝对象头中的Mark Word复制到锁记录(Lock Record)中。

(3)拷贝成功后, 虚拟机将使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word

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

(5)如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋状态, 若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10” ,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的” ,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作, 因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢

轻量级锁所适应的场景是线程交替执行同步块的情况, 当多次CAS自旋仍未获得锁时,锁就会升级为重量级锁。

重量级锁

Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间 ,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”

锁的优劣

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。

死磕Synchronized

  • 如果是单线程使用,那偏向锁毫无疑问代价最小 ,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;
  • 如果出现了其他线程竞争 ,则偏向锁就会升级为轻量级锁;
  • 如果其他线程通过一定次数的CAS尝试没有成功 ,则进入重量级锁;

死磕Synchronized

其它

看了上面之后也可以解决另外其它问题,比如:

为什么notify/notifyAll/wait等方法要定义在Object类中?

就是因为每个对象都是一把锁,每把锁(对象)都可以调用wait方法来改变当前对象头里的指针,因此定义在Object类里是最合适的。

那为什么wait方法必须在同步代码块(synchronized修饰)里面使用?

wait方法用来挂起当前线程并释放持有的锁,你要先用synchronized加锁之后才能去释放锁,notify/notifyAll/wait等方法也会使用到Monitor锁对象,因此wait等方法需要在同步代码块中使用。

总结

有关synchronized总算介绍完了,如果有哪里不对的地方请帮忙指正,后续的话估计会写篇Java Lock的文章,谢谢!

参考

啃碎并发(七):深入分析Synchronized原理 猿码架构

Synchronized锁定的是什么
原文  https://segmentfault.com/a/1190000022421298
正文到此结束
Loading...