JVM相关及其拓展(七) — 线程安全与锁优化

线程安全与锁优化

首先需要并发的正确性,然后在此基础上实现高效。

线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

线程安全的代码必须具备一个特征:代码本身封装了所有必要的正确保障性手段,令调用者无需关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。

线程安全的“安全程度”由强至弱分为以下5类:

  • 不可变

    只要一个不可变的对象被正确的构建出来,那其外部的可见状态永远不会改变

    不发生 this引用逃逸 情况下成立 -- 在构造函数返回之前,其他线程已经取得了该对象的引用。

    实现方案:

    • 如果共享数据是一个基本数据类型,只要在定义时用final修饰
    • 如果共享数据是一个对象,最简单的就是 吧对象中带有状态的变量都声明为final

    符合不可变要求的类型:String、枚举类(Enum)、Long,Double以及BigInteger等大数据类型

  • 绝对线程安全

    完全满足线程安全的定义,即达到”不管运行环境如何,调用者都不需要任何额外的同步措施。”

  • 相对线程安全

    保证对这个对象单独的操作是线程安全的,调用时不需做额外的保障措施,但是对于一些特定顺序的连续调用,就需要在调用端使用额外的同步手段保证调用的正确性。

    大部分的线程安全类都属于这种类型,例如Vector,HashTable,synchronizedCollection()

  • 线程兼容

    对象本身并非线程安全,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全的使用。

    例如ArrayList,HashMap

  • 线程对立

    无论调用端是否采取了同步措施,都无法在多线程环境中并发使用。应当尽量避免

    例如Thread中的suspend()和resume()

线程安全的实现方法

①通过代码实现线程安全 ②通过虚拟机本身实现同步与锁

互斥同步 (阻塞同步)

同步:在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用。

互斥:实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方法

互斥是因,同步是果;互斥是方法,同步是目的

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),就会出现问题,无论共享数据是否真的会出现数据竞争,都要进行加锁。

实现手段:

  • synchronized

  • ReentrantLock

synchronizedReentrantLock的异同:

  • 两者都是可重入锁

    可重入锁:当一个线程得到一个对象锁后,再次请求该对象锁时是可以再次得到该对象锁的。自己可以再次获得自己的内部锁。

  • synchronized依赖于JVM而ReentrantLock依赖于API

    synchronized底层用Mutex(互斥量)实现,ReentrantLock继承自Lock接口,Lock接口又依赖于AQS实现

  • synchronized的锁状态无法在代码中判断,ReentrantLock通过isLocked()判断

  • synchronized非公平锁,另一个可以是公平也可以是非公平的

  • synchronized不可被中断,另一个调用lockInterrupbity()即可中断

  • ReentrantLock可以提高多个线程的读操作的效率

非阻塞同步

基于冲突检测的乐观并发策略,即先进行操作,若无其他线程争用共享数据,操作成功;反之,产生了冲突再去采用其他的补偿措施(最常见自旋——不停重试,直到成功为止)。

为了保证操作和冲突检测具备原子性,需要用到硬件指令集,比如:

  • 测试并设置
  • 获取并增加
  • 交换
  • 比较并交换(CAS)
  • 加载链接/条件存储
CAS操作&Atomic原子操作类分析

无同步方案

不用同步的方式保证线程安全,因为有些天生就是安全的。

有以下两类:

  • 可重入代码/纯代码(Reentrant Code/Pure Code)

    在代码执行的时候在任何时刻去中断,再去执行另外的代码,在控制权返回后,原来的程序不会出现任何的错误。

    可重入性是它的基本特征,满足可重入性的代码一定是线程安全的,反之,满足线程安全的代码不一定是可重入的。

    共同特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态栏都由参数中传入、不调用非可重入的方法等

    判定依据:返回结果是可预测的,只要是输入了相同的数据就能返回相同的结果,就满足可重入性的要求。

  • 线程本地存储(Thread Local Storage)

    把共享数据的可见范围限制在同一个进程之内,无须同步也可以保证线程之间不出现数据争用的情况。

    使用ThreadLocal类可实现本地存储的功能。

锁优化

锁优化是为了在线程之间更高效的共享数据,以及解决竞争性问题。

锁的状态共分为4种:无锁状态、偏向锁、轻量级锁和重量级锁。锁随着竞争情况可以升级,但升级后不能降级

无锁状态->偏向锁->轻量级锁->重量级锁

1. 自旋锁与适应性自旋

互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,影响系统的并发性能。同时,共享数据的锁定状态只会持续很短的一段时间,不值得去挂起和恢复线程。

自旋锁:若物理机器有一个以上的处理器,能使多个线程同时并行执行,让后面的请求锁线程(通过自旋——CPU忙循环执行空指令)等待,但不放弃处理器的执行时间,看看持有锁的线程是否很快释放锁。

自旋等待不能代替阻塞,虽然避开了线程切换的开销,但要占用处理器时间,因此自旋等待必须有一定的上限,若超过了次数没有成功,就需要去挂起线程。

自适应自旋锁:自旋时间不固定,由该锁上次的自旋时间及锁的拥有者状态决定。

  • 对于某个锁,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,JVM就会认为这次自旋也会再次成功获得锁,进而允许等待持续相对更长的时间
  • 对于某个所,自选很少成功获得锁,以后再获取这个锁时可能忽略自旋过程,以避免浪费处理器资源。

2. 锁消除

JVM即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为他们是线程私有的,同步加锁操作自然就无须进行。

例如StringBuffer对象的连续append()

3. 锁粗化

JVM探测到一串零碎的操作都对同一对象进行加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外代码

4. 轻量级锁

在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁并不是用来代替重量级锁的

必须先了解 HotSpot虚拟机的对象(对象头部分)的内存布局:分为两部分

  • Mark Word:存储自身的运行时数据,如:HashCode、GC分代年龄和锁信息,这部分数据的长度在32和64位中的JVM中分别为32bit和64bit。它是实现轻量级锁和偏向锁的关键。
  • 存储指向方法区对象类型数据的指针,如果是数组对象的话,额外会存储数据的长度。
Mark Word

加锁过程

代码进入同步块时,如果同步对象未锁定(标记位为01),虚拟机会在当前线程的栈帧中建立一个锁记录(Lock Record)的空间,用于存储对象目前的Mark Word拷贝(Displaced Mark Word)。

然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。

  • 更新成功,那么当前线程拥有了该对象的锁,且对象Mark Word的锁标志位为00 ,处于轻量级锁定状态。
  • 更新失败,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧
    • 已指向表明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行
    • 没指向表明该对象已被其他线程抢占。

如果有两条以上的线程竞争同一个锁,轻量级锁就无法使用,需要膨胀为重量级锁,Mark Word的锁标志位变为10,存储的是指向重量级的指针,后面等待锁的也会进入阻塞状态。

解锁过程

若对象的Mark Word仍然指向线程的Lock Record,那就用CAS操作把对象当前的Mark Word和Displaced Mark Word替换回来

  • 替换成功,就完成了整个同步过程
  • 替换失败,说明有其他线程尝试获取锁,就要在释放锁的同时,唤醒被挂起的线程

栈帧:用于支持虚拟西进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表,操作数栈,动态链接和方法返回地址信息。第一个方法从调用开始到执行完成,就是一个栈帧从入栈到出栈的过程。

5. 偏向锁

消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。提高一个对象在很长一段时间内都只被一个线程用做锁对象场景下的性能。

偏向锁可以提高带有同步但无竞争的程序性能。

这个锁会偏向于第一个获得它的线程,如果后续该锁没有被其他线程获取,则持有偏向锁的线程将永远不会进行同步。

加锁过程

JVM启用了偏向锁模式,当锁对象第一次被线程获取的时候,JVM会把锁标记位置为01,即偏向模式。使用CAS操作记录锁的线程ID到Mark Word中。

  • CAS操作成功。持有偏向锁的线程在每次进入和退出同步块是,只要比较一下Mark Word存储的线程ID是否相同。
    • 相同代表线程已经获得了锁,不需要再用CAS操作加锁和解锁
    • 不同,就需要CAS操作竞争锁,竞争成功,替换Mark Word中的ThreadID为当前竞争线程的ID

解锁过程

当有另一个线程去尝试获取偏向锁时,CAS替换ThreadID失败,就要撤销偏向锁。(撤销偏向锁,需要等待原持有偏向锁的线程到达全局安全点所有线程都是暂停的,没有字节码正在执行,暂停线程,并检查状态)。判断原持有偏向锁的线程是否处于活动状态

  • 无活动则置为无锁状态(锁标志为01,是否偏向锁状态0)
  • 还处于活动状态,则升级为轻量锁(标志位为00)

关闭偏向锁模式

使用JVM参数 -XX:BlasedLockingStartupDelay=0可以关闭延迟,因为偏向锁需要应用启动后过几秒激活

-XX:UseBlasedLocking=false关闭偏向锁

三种锁的升级

锁的升级,锁的升级

三种锁的比较

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

重量级锁:本质上是依赖操作系统的Mutex Lock互斥量来实现同步操作。由于线程间的切换需要从用户态转向核心态,转换成本较高,耗时相对较长。

一个线程如何判断自己是否取得锁?

线程在获取锁之前会判断对象的Mark Word中是否存放自己的threadId,存放且相同则重入;不同,则使用CAS进行切换,锁升级为轻量级锁,释放偏向锁,清空Mark Word,线程开始竞争,竞争成功的就存入自己的ThreadId,失败的开始自旋。

调用Thread.holdsLock()

其他锁类型及其概念

1.乐观锁

由于在进程挂起和恢复执行过程中需要很大的开销进行切换。所以有了乐观锁概念。

每次去拿数据的时候都认为别人不会修改,但在更新的时候会去判断在此期间是否数据发生修改,没有被修改则进行数据更新。如果因为修改过产生冲突就失败就重试到成功为止(自旋)。

实例:例如Atomic原子类

使用场景:适合读取操作比较频繁的场景

2.悲观锁

每次获取数据的时候,担心数据被修改,所以每次都要加锁,确保操作过程中数据不会发生改变,操作完成后再解锁让其他线程操作。

在某个资源不可用的时候,就将CPU让出,把当前等待的线程切换为阻塞状态。等到资源可用,将阻塞线程唤醒,进入Runnable状态等待CPU调度。

实例:例如synchronized

使用场景:比较适合写入操作频繁的场景

3.互斥锁

通过排他性,同时只允许一个访问者对其进行访问来保证资源的有效同步,但无法限制线程对该资源的访问顺序

4.死锁

两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,他们都将无法推进下去。

死锁形成必须要求四个条件:

  • 互斥条件:一个资源每次只能被一个线程使用

  • 请求与保持条件:一个线程引请求资源而阻塞时,对已获得的资源保持不放

  • 不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

常见死锁类型:

  • 静态的锁顺序死锁 所有需要多个锁的线程,都要以相同的顺序获得锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class DeadLockTest{
    private final Object lockA = new Object();
    private final Object lockB = new Object();
    public void a(){
    synchronized(lockA){
    synchronized(lockB){
    System.out.println("func A")
    }
    }
    }

    public void b(){
    synchronized(lockB){
    synchronized(lockA){
    System.out.println("func b")
    }
    }
    }
    }
  • 动态的锁顺序死锁 自定义锁的顺序,确保所有线程以相同的顺序获得锁

  • 协作对象之间发生的死锁 避免在持有锁的情况下调用外部的方法

死锁预防:

  • 以确定的顺序获得锁

    将所有的锁都按照特定顺序进行获取,防止死锁发生。

    银行家算法:允许进程动态的申请资源,但在系统进行资源分配之前,先计算此次资源分配的安全性,若分配不会导致进入不安全状态,则分配;否则等待。

  • 超时放弃

    例如synchronized只要线程没有获得锁,就会永远等待下去,Lock提供了tryLock()可以实现超时放弃


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!