Java多线程——synchronized底层实现及优化

Java多线程——synchronized底层实现及优化

一、synchronized底层实现(monitor机制)

  同步代码块与同步方法是怎样实现同步的?

  1、同步代码块底层实现

  通过操作系统的两大指令monitorenter(获取锁)monitorexit(释放锁,解锁) 来获取锁的对象的监视器(monitor)

   ①执行同步代码块前要执行monitorenter指令,执行同步代码块后要执行monitorexit指令。
   ②使用synchronized实现同步,关键点就是要获取对象的监视器monitor对象,当线程获取到monitor对象后,才可以执行同步代码块,否则就只能等待。
   ③同一时刻只有一个线程可以获取到该对象的monitor监视器。(该特点使得代码块是同步的)
   ④通常一个monitorenter指令会同时对应多个monitorexit指令。(JVM要确保所获取的锁,无论是在正常执行情况或异常执行情况都能正常解锁(即便是有异常,也得先释放锁,再报异常
  2、同步方法底层实现
   当使用synchronized标记方法时,字节码会出现访问标记(ACC_SYNCHRONIZED),该标记就表示在进入该方法时,JVM需要进行monitorenter操作。在退出该方法时,无论是否正常返回,JVM均需要进行monitorexit操作。
  总结一下同步的底层实现:
   无论是同步代码块还是同步方法,其底层实现都是应用了monitor机制,只不过,同步代码块是直接执行monitorenter与monitorexit,而同步方法是设置了一个ACC_SYNCHRONIZED访问标记,这个标记的底层还是monitor机制。**

  到底什么是monitor机制呢?
  monitor机制的执行流程到底是怎样的?

  3、monitor机制

  可以将monitor理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针

   ①当JVM执行monitorenter时,如果目标对象monitor的计数器为0,表示此时该对象没有被其他线程所持有。此时JVM会将该锁对象的持有线程设置为当前线程,并且将monitor计数器+1
   ②当执⾏ monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了,并唤醒所有正在等待的线程去竞争该锁
   ③monitor机制还具有可重入性(锁的可重入性),可见下文。
  4、synchronized锁的可重入性
   ①可重入性:在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,JVM可以将计数器再次+1(可重入锁),否则需要等待,直到持有线程释放该锁。
   ②可重入性也可以用在父子类继承中,子类完全可以通过“可重入锁”调用父类的同步方法。(父类中的非private方法子类都可以用)
   ③根据synchronized锁的可重入性可以证明:在一个synchronized方法/块的内部调用本类的其他synchronized方法/块时,一定可以得到锁。(既然能进入到synchronized内部,肯定就已经拿到了当前对象的锁,当然可以访问该对象的其他同步方法)

  有了monitor机制,能否解释,synchronized为什么锁的是对象而不是其他东西?

  5、synchronized锁的是对象的原因:
   由于synchronized获取的是当前对象的监视器monitor,类中这么多的同步方法都是属于一个对象的,所以锁的是对象。

  说了这么多monitor的好处,monitor机制有什么劣势吗?

  6、monitor机制的劣势
   对象锁(monitor)机制是JDK1.6之前synchronized底层原理,又称为JDK1.6重量级锁,线程的阻塞以及唤醒均需要操作系统由用户态切换到内核态,开销非常之大,因此效率很低。

  monitor机制效率太低,太过笨重,Java还是对synchronized锁进行了优化,使用CAS操作进行全面的优化。

二、synchronized(内建锁)的优化

  无论是CAS操作,还是多种锁的产生,都是对synchronized内建锁的优化,都属于synchronized内建锁的一部分。

  1、CAS(Compare And Swap)操作

  CAS是一种乐观锁策略,但什么是悲观锁?什么是乐观锁呢?

   ①悲观锁:线程获取锁(JDK1.6之前内建锁)是一种悲观锁策略。
    a)假设每一次执行临界区代码(访问共享资源)都会产生冲突,所以当前线程获取到锁的同时也会阻塞其他未获取到锁的线程。
    b)每次上厕所都有人和我抢——只要有一个线程在访问共享资源,都有其他的线程也在竞争锁,当该线程获取到锁后,其他线程都得等待
   ②乐观锁:CAS(无锁)操作,是一种乐观锁策略
    a)假设所有线程访问共享资源时不会出现冲突,由于不会出现冲突自然就不会阻塞其他线程。因此线程就不会出现阻塞停顿的状态。(上厕所一定没人抢——可以不要锁)
    b)若出现冲突了,无锁操作使用CAS(比较交换)来鉴别线程是否出现冲突,出现冲突就重试当前操作,直到没有冲突(获取锁)为止。
    c)若线程未获取到锁,该线程利用CAS不断的在重试,看在里面的那个线程结束了没,结束了就没有冲突,该线程就可以获取到锁了,此时该线程没有停止,会在CPU上跑,只不过跑的是无用指令。(反复在重试看有无冲突,直到获取锁为止

  CAS(Compare And Swap)到底是怎样操作的?

   ③CAS的操作过程

  将CAS理解为CAS(V,O,N)
    V:当前内存地址中实际存放的值
    O:期望V中存放的值(旧值)
    N:更新后的值(新值)

    a)当V==O时:旧值与内存中的实际值相等,表明该值没有被其他线程更改过,即值O就是目前最新的值,因此可以将新值N赋值给V;
       当V!=O时,表明该值已经被其他线程修改过了,因此O值并不是当前最新值,返回V,无法修改。
    b)当多个线程使用CAS操作时,只有一个线程会成功,其余线程均失败(多个线程同时进行CAS,肯定会有一个线程优先进入将V值修改为该线程的N值,其余线程再判断当前的V与O当然是不相同的,因此会失败)。失败的线程会重新尝试(自旋)或挂起线程(阻塞)

  说了这么多CAS,那么CAS基于synchronized到底有什么区别和改进?

   ④synchronized机制与CAS的区别
    a)synchronized内建锁在优化前(老版本)最大的问题在于:在存在线程竞争的情况下,会出现线程的阻塞以及唤醒带来的性能问题,这是一种互斥同步(阻塞同步)——挂起-再唤醒(开销太大)
    b)CAS不是将线程挂起,当CAS失败后会进行一定的尝试操作并非耗时的将线程挂起,这是一种非阻塞同步 ——失败-重试

  此时小小的总结一下:
   synchronized机制为什么不好呢?
   答曰:一个线程获取锁后阻塞了其他线程使其挂起-再唤醒,效率太低。是一种阻塞同步
   CAS操作为什么好呢?
   答曰:CAS采用失败-重试,减少了挂起-再唤醒所消耗的时间,是一种非阻塞同步

   ⑤CAS自旋带来的问题

  CAS是完美的吗?CAS会带来什么其他问题?

    a)ABA问题

  ⽐如⼀个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发⽣了变化

      解决方案:添加了版本号(版本号不相同,还是不能改)
		老版本(A->B->A...)
		新版本(1A->2B->3A...)
    b)自旋(CAS)会浪费大量的CPU资源

  与线程阻塞相比,自旋会浪费大量的处理器资源。因为当前线程仍处于运行状态,只不过跑的是无用指令

      解决方案:自适应自旋——重量级锁的优化(有阻塞状态)

   根据以往(上一次)自旋等待时,能否获取锁所花费的时间,来动态调整本次自旋的时间(重复试探的循环次数)。

如果在上一次自旋时获取到锁,则稍微增加下一次自旋的时长(多重试几次)
否则会稍微减少下一次自旋时长(少重试几次)

  自旋时长结束后就会进入阻塞状态了

    "自适应自旋"说白了就是看:上一次CAS自旋是否获取到锁了,若上一次CAS自旋获取到锁了,这次CAS自旋时间就加长一些,反之,减短一些。当自旋时长结束就会进入阻塞状态(和synchronized一样了)。
    "自适应自旋"就是CAS失败-重试(自旋)操作与synchronized挂起-再唤醒(阻塞)的完美结合。JVM并不知道当前线程到底能否或还有多久获取锁,因此JVM会采用"自适应自旋"尽量做到完美。
    c)公平性问题
     CAS自旋的线程始终比synchronized阻塞的线程 优先获取到锁。

      公平性:等待时间最长的线程优先获取锁

      内建锁无法实现公平机制,而lock体系可以实现公平锁。
  2、Java1.6后全新的锁状态分析

  了解锁的类型前,先来看看什么是Java对象头?

   ①Java对象头
    a) Java对象头Mark Word字段存放内容:对象的HashCode、分代年龄、锁标记位
    b)32位JVM下的Mark Word在这里插入图片描述
   ②Java(状态)锁的分类—对应MarkWord中的标志位

Java多线程——synchronized底层实现及优化_第1张图片

  四种锁的级别是什么?四种锁之间相互转换的策略是什么?

   锁的四种状态级别:无锁<偏向锁<轻量级锁<重量级锁
   根据竞争状态的激烈程度,锁只能自动升级,不能降级,为了提高锁获取与释放的效率。

  偏向锁,轻量级锁,重量级锁到底指的是什么意思?

  3、偏向锁
   ①偏向锁的引入:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。为了让线程获取锁的开销降低而引入偏向锁。
    偏向锁是锁状态中最乐观的一种锁:从始至终只有一个线程请求一把锁
   ②偏向锁的获取
    a)当一个线程访问某对象的同步块并成功获取到该对象的锁时,会在对象头和栈帧中的锁记录字段存储锁偏向的线程ID,以后该线程再进入和退出该对象的其他同步块时,不需要进行CAS操作来加锁和解锁,直接进入。(同一线程再尝试获取锁不需要自旋过程了
    b)当线程访问同步块失败时(当前偏向锁已被其他线程锁占用),使用CAS竞争锁,并将偏向锁升级为轻量级锁
   ③偏向锁的撤销(开销比较大,一般不撤销,一直让一个线程拿着)
    a)偏向锁使用一种等待竞争出现才释放锁的机制,所以当其他线程竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,并将锁膨胀为轻量级锁(持有偏向锁的线程依然存活的时候)
     PS:;两个同时存活的线程,一个已经持有该偏向锁,另一个也来竞争该偏向锁,则偏向锁才会升级为轻量级锁。
    b)如果当前偏向锁的持有线程已经终止,则将锁对象的对象头设置为无锁状态,其他线程就有机会直接拿到该偏向锁了。(原本拥有该偏向锁的线程已经终止,另一个线程可以直接获取该偏向锁)
   ④偏向锁头部Epoch字段值在这里插入图片描述
    Epoch字段表示:此对象偏向锁的撤销次数
    默认撤销40次时,表示此对象不再适用于偏向锁,当下次(第41次撤销)线程再次获取此对象时,直接变为轻量级锁。(升级锁)
   ⑤ JDK6之后,偏向锁默认开启(一个锁最初默认都是偏向锁,后来会一步步升级)
  4、轻量级锁
   ①多个线程在不同的时间段请求同一把锁,也就是不存在锁竞争的情况。针对这种状况,JVM采用了轻量级锁来避免线程的阻塞与唤醒。(只要有竞争,立马升级为重量级锁
   ②轻量级锁加锁
    将MarkWord中的内容拷到当前线程的栈帧中用于存储锁记录的空间(Displaced Mark Word),然后尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。
    若成功(当前并没有线程获得锁):当前线程获得锁。
    若失败(当前已有线程获得锁):表示其他线程正在占用该锁,当前线程便尝试使⽤⾃旋来获取锁。若自旋到一定次数后还未获取到锁,轻量级锁就会膨胀成重量级锁。
   ③轻量级锁解锁
    使用CAS操作将Displaced Mark Word替换回到对象头。
    若成功:表示没有竞争发生。
    若失败:表示当前锁存在竞争,锁已经膨胀为重量级锁,需要在释放锁的同时唤醒那些被挂起的线程。
  5、重量级锁
   多个线程在同一时刻请求同一把锁,线程之间的切换需要从用户态到内核态,切换成本非常高。
  6、锁升降级的特点
   根据竞争状态的激烈程度,锁会自动进行升级,但锁不能降级>——为了提高锁获取与释放的效率(轻量级升级为重量级就证明此时有大量的线程在竞争,不能再还原了)
  有关这三种锁的升级请参考:Java锁的升级策略

  对比一下三种锁的特点

  7、********三种锁的特点比较********
   ①偏向锁只会在第一次请求锁时采用CAS操作,并将锁对象的标记字段记录为当前线程地址。在此后的运行过程中,持有该对象偏向锁的线程再进入其它同步代码块时无需加锁操作。
   针对的是锁仅会被同一线程持有的情况
   偏向锁升级为轻量级锁:某个线程访问锁时发现该偏向锁被其他线程所占用,即刻升级为轻量级锁。
   ②轻量级锁采用CAS操作,将锁对象标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段,每次都采用CAS来替换标记的指针。
   针对的是多个线程在不同时间段申请同一把锁的情况
   轻量级锁升级为重量级锁:某个线程访问锁时发现该轻量级锁被其他线程所占用,该线程使用CAS自旋获取该轻量级锁,若到一定次数(自旋的次数)还未获取到锁,将该轻量级锁升级为重量锁
   ③重量级锁会阻塞、唤醒请求加锁的线程。
    针对的是多个线程同时竞争同一把锁的情况,JVM采用自适应自旋来避免在面对非常小的同步代码块时,仍会被阻塞和唤醒的状况。
  8、其他优化方式
   ①锁粗化

   将多次连接在一起的加锁、解锁操作合并为一次操作。将多个连续的锁扩展为一个范围更大的锁。(多次append(),第一次append()加锁,最后一次append()解锁)

   ②锁的消除

   删除不必要的加锁操作。根据代码逃逸技术,如果判断一段代码中,堆上的数据不会逃逸出当前线程,则认为此代码时线程安全的,无需加锁。 (不存在共享竞争的问题,就不需要锁了)

	// 虽然StringBuffer的append是一个同步方法,但是StringBuffer对象是在方法体里面创建的,
	因此每个线程都会有其自己的StringBuffer对象,不存在共享资源的竞争,不需要锁机制。
	public class Test{
		 public static void main(String[] args) {
			 StringBuffer sb = new StringBuffer();
			 sb.append("a").append("b").append("c");
		 }
	}

你可能感兴趣的:(Java多线程,Java多线程,synchronized优化)