Java并发编程之Synchronized

原创
2017/11/12 14:47
阅读数 426

引子

目前在Java中存在两种锁机制:synchronized和Lock,今天我们先来介绍一下synchronized

synchronized可以保证方法或代码块在运行时,同一时刻只有一个线程可以进入到临界区,同时它还保证了共享变量的内存可见性。

用法

Java中的每个对象都可以作为锁。 每一个Object类及其子类的实例都拥有一个锁。其中,原生类型int,float等不是对象类型,但可以通过其包装类来作为锁,

  • 普通同步方法,锁是当前实例对象。
  • 静态同步方法,锁是当前类的class对象。
  • 同步代码块,锁是括号中的对象或类。

原理

我们再来分析一波synchronized关键字的实现原理

public class SynchronizedTest {
    private static Object object = new Object();
    public static void main(String[] args) throws Exception{
        synchronized(object) {

        }
    }
    public static synchronized void m() {}
}

上述代码中,使用了同步代码块和同步方法,我们通过使用javap工具查看生成的class文件信息来分析synchronized关键字的实现细节。

  public static void main(java.lang.String[]) throws java.lang.Exception;
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #2                  // Field object:Ljava/lang/Object

         3: dup
         4: astore_1
         5: monitorenter            //监视器进入,获取锁
         6: aload_1
         7: monitorexit              //监视器退出,释放锁
         8: goto          16
        11: astore_2
        12: aload_1
        13: monitorexit
        14: aload_2
        15: athrow
        16: return

   public static synchronized void m();
   descriptor: ()V
   flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
   Code:
     stack=0, locals=0, args_size=0
        0: return
     LineNumberTable:
       line 9: 0

我们可以看到

  • 同步代码块使用了 monitorenter 和 monitorexit 指令实现。
  • 同步方法中依靠方法修饰符上的 ACC_SYNCHRONIZED 实现。

无论哪种实现,本质上都是对指定对象相关联的monitor的获取,这个过程是互斥性的,也就是说同一时刻只有一个线程能够成功,其它失败的线程会被阻塞,并放入到同步队列中,进入BLOCKED状态。

先来看看实现锁需要的数据结构 Mark Word和monitor。

Mark word 对象头

锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。

mark word 被设计为非固定的数据结构,以便在及小的空间内存储更多的信息。比如:在32位的hotspot虚拟机中:如果对象处于未被锁定的情况下。mark word 的32bit空间中有25bit存储对象的哈希码、4bit存储对象的分代年龄、2bit存储锁的标记位、1bit固定为0。而在其他的状态下(轻量级锁、重量级锁、GC标记、可偏向)下对象的存储结构为

输入图片说明

monitor

正如每个对象都有一个锁一样,每一个对象同时拥有一个由这些方法(wait,notify,notifyAll,Thread,interrupt)管理的一个等待集合。拥有锁和等待集合的实体通常被称为监视器(monitor),任何一个对象都可以作为一个监视器。

一次只有一个线程能够获得一个监视器,因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。

synchronized保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了

对象的等待集合是由Java虚拟机来管理的。每个等待集合上都持有在当前对象上等待但尚未被唤醒或是释放的阻塞线程。

因为与等待集合交互的方法(wait,notify,notifyAll)只在拥有目标对象的锁的情况下才被调用,因此无法在编译阶段验证其正确性,但在运行阶段错误的操作会导致抛出IllegalMonitorStateException异常。

这些方法的操作描述如下:

Wait 调用wait方法会产生如下操作:

如果当前线程已经终止,那么这个方法会立即退出并抛出一个InterruptedException异常。否则当前线程就进入阻塞状态。 Java虚拟机将该线程放置在目标对象的等待集合中。 释放目标对象的同步锁,但是除此之外的其他锁依然由该线程持有。即使是在目标对象上多次嵌套的同步调用,所持有的可重入锁也会完整的释放。这样,后面恢复的时候,当前的锁状态能够完全地恢复。

Notify 调用Notify会产生如下操作:

Java虚拟机从目标对象的等待集合中随意选择一个线程(称为T,前提是等待集合中还存在一个或多个线程)并从等待集合中移出T。当等待集合中存在多个线程时,并没有机制保证哪个线程会被选择到。 线程T必须重新获得目标对象的锁,直到有线程调用notify释放该锁,否则线程会一直阻塞下去。如果其他线程先一步获得了该锁,那么线程T将继续进入阻塞状态。 线程T从之前wait的点开始继续执行。

NotifyAll notifyAll方法与notify方法的运行机制是一样的,只是这些过程是在对象等待集合中的所有线程上发生(事实上,是同时发生)的。但是因为这些线程都需要获得同一个锁,最终也只能有一个线程继续执行下去。

Interrupt(中断) 如果在一个因wait而中断的线程上调用Thread.interrupt方法,之后的处理机制和notify机制相同,只是在重新获取这个锁之后,该方法将会抛出一个InterruptedException异常并且线程的中断标识将被设为false。如果interrupt操作和一个notify操作在同一时间发生,那么不能保证那个操作先被执行,因此任何一个结果都是可能的。

Timed Wait(定时等待) 定时版本的wait方法,wait(long mesecs)和wait(long msecs,int nanosecs),参数指定了需要在等待集合中等待的最大时间值。如果在时间限制之内没有被唤醒,它将自动释放,除此之外,其他的操作都和无参数的wait方法一样。并没有状态能够表明线程正常唤醒与超时唤醒之间的不同。需要注意的是,wait(0)与wait(0,0)方法其实都具有特殊的意义,其相当于不限时的wait()方法,这可能与你的直觉相反。

由于线程竞争,调度策略以及定时器粒度等方面的原因,定时等待方法可能会消耗任意的时间。

我们再来分析一下monitor的数据结构每一个线程都有一个可用monitor列表,同时还有一个全局的可用列表,先来看monitor的内部结构

输入图片说明

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL
  • EntryQ:关联一个系统互斥锁(semaphore), 阻塞所有试图锁住monitor失败的线程
  • RcThis:表示blocked或waiting在该monitor上的所有线程的个数
  • Nest:用来实现重入锁的计数
  • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)
  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁

monitor的作用是什么呢? 在 java 虚拟机中,线程一旦进入到被synchronized修饰的方法或代码块时,指定的锁对象通过某些操作将对象头中的LockWord指向monitor 的起始地址与之关联,同时monitor 中的Owner存放拥有该锁的线程的唯一标识,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码

Java 为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低我们引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的MarkWord里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线

输入图片说明

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点,它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

偏向锁默认是开启的,我们可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

轻量级锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录Displaced Mark Word,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

输入图片说明

轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁

因为自旋会消耗CPU,为了避免无用的自旋,一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后再唤醒这些线程。

重量级锁

当锁处于这个状态下,其他线程试图获取锁都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程。 由于jvm线程是映射于操作系统原生线程,在阻塞或者唤醒线程时候,需要从用户态转换到内核上,这个耗费有时候会耗费时间超过代码执行时间

我们再分析一下三种不同锁适合的场景

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。

锁的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性

锁的优化

在保证程序正确性的前提下,我们可以用三类方法可以降低锁的竞争:减少持有锁的时间,降低请求锁的频率,或者用其他协调机制取代独占锁

降低锁的粒度

减小锁竞争发生可能性的最有效方式是尽可能缩短持有锁的时间。这可以通过把不需要用锁保护的代码移出同步块来实现

如果一个操作持有锁的时间超过2毫秒,并且每一个操作都需要这个锁,那么无论有多少个空闲处理器,应用程序的吞吐量都不会超过每秒 500 个操作。如果能够减少持有这个锁的时间到 1 毫秒,就能将这个与锁相关的吞吐量提高到每秒 1000 个操作。

降低锁的请求频率

降低锁竞争的另一种方法是降低线程请求锁的频率。分拆锁 和分离锁 是达到此目的两种方式。相互独立的状态变量,应该使用独立的锁进行保护。有时开发人员会错误地使用一个锁保护所有的状态变量。这些技术减小了锁的粒度,实现了更好的可伸缩性。但是,这些锁需要仔细地分配,以降低发生死锁的危险。

如果一个锁守护多个相互独立的状态变量,你可能能够通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性。通过这样的改变,使每一个锁被请求的频率都变小了。分拆锁对于中等竞争强度的锁,能够有效地把它们大部分转化为非竞争的锁,使性能和可伸缩性都得到提高。

独占锁的替代方法

用于减轻竞争锁带来的性能影响的第三种技术是放弃使用独占锁,而使用更高效的并发方式管理共享状态。例如并发容器,读 写锁,不可变对象,以及原子变量

java.util.concurrent.locks.ReadWriteLock 实现了一个多读者单写者锁:多个读者可以并发访问共享资源,但是写者必须独占获得锁。对于多数操作都为读操作的数据结构,ReadWriteLock 比独占锁提供更好的并发性。

JVM编译器也会对锁继续一系列的优化一般来说有锁消除和锁粗化2种方式

锁消除

jvm即时编译器在运行时候,如果被检测到不可能存在共享数据竞争的锁进行消除。锁消除检测主要依据是来源于对线程的逸出分析,如果判断在一段代码中,堆上的所有数据都不会逸出,就认为是线程私有的,从而达到线程封闭,同步也就无需进行

锁粗化

我们在编写代码的时候,总是将同步块作用范围越小越好,但如果一系列操作都是对一个对象反复加锁和解锁,甚至出现在循环体中,就算没有线程竞争,也会导致不必要的性能损耗。那么对于此种代码,jvm会扩大其锁的颗粒度,对这一部分代码只采用一个同步操作来进行

总结

本篇文章主要探讨了synchronized关键字的实现原理 以及synchronized锁的几种不同状态的来提升锁的性能 最后我们简单介绍了几种锁的优化方式降低锁粒度锁的请求频率和独占锁的替代方法, 同时JVM也会对不需要的锁进行清除对频繁锁请求进行粗化 如何正确使用锁是并发编程的关键,当然在某些时候如果能在并发设计用避免使用锁无疑是更好的方法.

展开阅读全文
加载中
点击加入讨论🔥(1) 发布并加入讨论🔥
打赏
1 评论
0 收藏
0
分享
返回顶部
顶部