从零开始学Java_第四篇 聊聊锁机制

        

 

今天我们来聊聊 Java 中的锁机制。相信大家对于锁这个概念并不陌生,Java 中实现同步的方式主要有两种,一种是使用关键字 syncronized 修饰方法或代码块,另一种是使用 Lock 方法实现线程同步。 这两种方式的实现原理、实现区别在文中我们都会涉及。

   

Lock 中常用的是 ReenTrantLock (可重入锁),所以本文以 ReenTrantLock 作为例,来简要分析下 Lock 与 synchronize 两种方式的实现原理。

 

  •  ReenTrantLock 

    

在高竞争条件下有更好的性能,控制锁粒度更精细,可中断。ReentrantLock 是基于AQS 实现的。

 

AQS(AbstractQueueSynchronizer)是基于 FIFO 的队列实现,或是构建锁同步容器的基础框架。对于队列的各种操作在 AQS 中都已经实现了,一般子类只用重写 tryAcquire 获取对象状态(参数:int )和tryRelease释放(参数:int)方法。

 

ReenTrantLock 可以通过构造方法创建公平锁和非公平锁,因为公平锁会额外消耗资源,所以如果没有特殊情况都是使用的非公平锁。

 

如果线程 a 调用了 ReenTrantLock 的 lock() 方法,那么线程 a 将独占锁,整个调用链十分简单:

 

NonFairSync.lock()  ----->   AbstractQueuedSynchronizer.compreAndSetState(0,1)  ----->   AbstractOwnableSynchronizer.setExclusiveOwnerThread(Thread.currentThread)

 

第一个获取锁的线程做两件事

 

1 设置 AbstractQueuedSynchronizer的 state 为1 (同步状态,0表示未锁)

2 设置 AbstractOwnableSynchronizer的 thread 为当前线程 (AbstractOwnableSynchronizer表示独占模式的当前拥有者)

 

精简代码如下:

final void lock(){    if(compareAndSetState(0,1)){        setExclusiveOwnerThread(Thread.currentThread());    }else{        acquire(1);    } }

 

当第二个线程想要获取锁时,会执行 else,调用 acquire 方法,进而调用 acquire 中的 tryAcquire 方法,若 tryAquire 返回失败,则将其加入等待队列。

 

  • synchronized

 

synchronized 修饰的代码块在编译的时候会在该代码块开始和结束的时候插入  moniterenter 和 moniterexit 。任何对象都有 monitor 与之关联,当一个 monitor 被持有后,就处于锁定状态。在执行 enter 指令时首先要尝试获取对象锁。如果这个对象没被锁定,或当前线程已经有这个对象的锁,则锁的计数器加 1,相应的在执行 exit 时计数器减 1,当计数器为 0 时,释放锁。

 

重量级锁,是 JDK1.6 之前,内置锁的实现方式。重量级锁就是采用互斥量来控制对互斥资源的访问。然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。所以在 JDK1.6 之后对锁进行了优化。

 

优化后锁主要存在四中状态,依次是:

  • 无锁状态

  • 偏向锁状态

  • 轻量级锁状态

  • 重量级锁状态

 

还引进了一些锁优化策略如锁粗化,锁消除,轻量锁,偏向锁,自旋锁等。

 

  • 锁粗化

     

将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

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

 

  • 锁消除

     

在有些情况下,JVM 检测到不可能存在共享数据竞争,这是 JVM 会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐形的加锁操作。

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

 

  • 轻量锁

理解轻量锁必须从 HotSpot 虚拟机对象头的内存布局来介绍,HotSpot 虚拟机的对象头由两部分组成。

第一部分是存储对象自身运行时的数据,如 hash 码,GC 分带年龄,锁标记等官方成为 Mark Word,第二个部分是指向到对象的 Class的元数据信息。如下图:

 

 

  1. 在代码进入同步块时,如果同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁标记(Lock Record)的空间

     

  2. 虚拟机将使用 CAS 操作尝试将对象头中的  MarkWord  更新为指向当前线程的  Lock Record  指针,如果更新成功了,那么这个线程就拥有了这个对象的锁,并将  Mark Word  中锁标记位改成  00,表示对象处于轻量级锁状态。

    

 

 

 

  1. 如果更新状态失败了,虚拟机将会检查对象中的  Mark Word  是否指向当前线程的栈帧。

    1. 如果是则直接进入代码块执行。

    2. 如果不是说明有线程竞争。

      1. 如果有两个以上的线程在抢占资源,那轻量级锁就不再有效,要膨胀为重量锁,所状态更改为 10;

      2. Mark Word 中存储的就是指向重量级锁的指针,后面等待的锁就要进入阻塞状态。

 

大致流程图如下:

 

 

 

 

  轻量级锁性能提升的依据是 “对于绝大多数的锁”在整个同步周期中都是不存在竞争的,这是一个经验依据。如果没有竞争,轻量锁使用  CAS  操作避免使用系统互斥量的开销。

 

  • 偏向锁

     

轻量锁的引入是为了提升在没有现车竞争的情况下,执行同步代码块的效率。那么还有一种特殊情况就是始终只有一个线程在执行同步块。在这种情况下,即使使用轻量锁也需要多个 CAS 操作的。

 

当开启了偏向锁功能,当代码进入同步块的时候,虚拟机会检查当前线程是否处于无锁状态(01),是否没有锁标记位(锁标记位是0)。

如果线程处于无锁状态并且没有锁标记,JVM 利用 CAS 操作,把获取到这个对象锁的线程 ID 记录在对象的 Mark Word 中。这样,当线程下次再次进入同步块的时候不需要进行任何获取锁的操作,即可访问互斥资源。节约了频繁获取锁和释放锁的开销。

偏向锁在获取锁之后,直到有竞争出现才会释放锁。也就是说,如果长期没有竞争,偏向锁是一直持有锁的。当另一个线程获取该对象的锁时,偏向锁模式就会宣告结束。

  • 自旋锁

轻量锁竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是 JVM 会让线程做几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。。

 

为什么用自旋锁?

线程的阻塞和唤醒需要 CPU  从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。

同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的

所以引入自旋锁。

 

自旋锁可以减少线程的阻塞,适用于锁竞争不激烈,且占用锁时间非常短的代码块来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。

如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 CPU 做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,造成 CPU 的浪费。

在单核 CPU 上,自旋锁是无用,因为当自旋锁尝试获取锁不成功会一直尝试,这会一直占用 CPU,其他线程不可能运行,同时由于其他线程无法运行,所以当前线程无法释放锁。


扩展知识:

 

  •  CAS 原理是什么 ? 为什么 CAS 操作会延迟本地调用?

 

这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:

 

 

其意思是所有的 CPU 会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于 BUS 对称分布,因此这种结构称为“对称多处理器”。

 

而 CAS的 全称为 Compare-And-Swap,是一条 CPU 的原子指令,其作用是让 CPU 比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说 CAS 是靠硬件实现的,JVM 只是封装了汇编调用,那些 AtomicInteger 类便是使用了这些封装后的接口。

 

Core1 和Core2 可能会同时把主存中某个位置的值 Load 到自己的 L1 Cache 中,当 Core1 在自己的 L1 Cache 中修改这个位置的值时,会通过总线,使 Core2 中 L1 Cache 对应的值“失效”,而 Core2 一旦发现自己 L1 Cache 中的值失效(称为 Cache 命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果 Cache  一致性流量过大,总线将成为瓶颈。而当 Core1 和 Core2 中的值再次一致时,称为“Cache 一致性”,从这个层面来说,锁设计的终极目标便是减少 Cache 一致性流量。

而 CAS 恰好会导致 Cache 一致性流量,如果有很多线程都共享同一个对象,当某个 Core CAS 成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除 CAS ,降低 Cache 一致性流量。

 

 

  • volatile 与 syncronized 

 

volatile 本质是在告诉 JVM 当前变量在寄存器中的值是不确定的,需要从主存中读取。只能修饰变量,实现变量的修改可见性,但不能具备原子性,不会造成线程阻塞。

 

synchronize 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞,可以保证修改可见性和原子性。

 

深入了解 volatile 之前,需要先了解 JVM 在运行时内存的分配过程。

 

JVM 中有一个内存区域是 JVM 虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时的变量值信息。

 

当线程访问某一个对象时候值的时:

 

  1. 首先通过对象的引用找到对应在堆内存的变量的值;

  2. 然后把堆内存变量的具体值 load 到线程本地内存中,建立一个变量副本;

  3. 之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值;

  4. 在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。

     

    如下图所示:

     

 

以上就是 JVM 在运行时内存分配过程,我们可以看出如果变量不加 volatile 修饰,线程修改的其实是自己线程栈中的副本值,改来改去都与主存中的值无关,只有最后在线程退出前的某一时刻才把线程栈中的值写回到主存中。

 

而 volatile 关键字的意义就在于线程在每次使用变量的时候,都会从主存中读取,所以读取到的值都是变量修改后的值,当其他线程来读时也是从主存中读取,这样就保证了变量的可见性。

 

当且仅当满足以下条件市,才应该使用 volatile 变量:

 

  • 对变量的写入操作不依赖变量的当前值,或者你能保证只有单个线程更新变量的值

     

  • 该变量不会与其他状态变量一起纳入不变性条件中

     

  • 在访问变量时不需要加锁

 

 

 

 

参考资料:

 

https://www.cnblogs.com/daxin/p/3364014.html 

          ----  volatile关键字作用

 

https://www.open-open.com/lib/view/open1352431526366.html 

          ---- JVM底层是如何实现synchronized的

 

https://blog.csdn.net/hsuxu/article/details/9472389 

           ---- Java轻量级锁原理详解

 

 

关注一下,我写的就更来劲儿啦 

 

 

posted @ 2019-03-06 23:22  大数据江湖  阅读(205)  评论(0编辑  收藏  举报