深入理解Java虚拟机 第2版 周志明著(十)

第13章 线程安全与锁优化

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

13.1.1 Java语言中的线程安全
我们可以将Java语言中各种操作共享的数据分为以下5类:不可变,绝对线程安全,相对线程安全,线程兼容和线程对立。

  1. 不可变:不可变的对象一定线程安全的。如果共享数据是一个基本数据类型,只要在定义时使用final关键字修饰就可以,如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行(最简单的就是把对象中带有状态的变量都声明为final)。
  2. 绝对线程安全:完全满足线程安全的定义。
  3. 相对线程安全:通常意义上讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
  4. 线程兼容:线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时指的是这个一种情况。JavaAPI中大部分的类都是属于线程兼容的。
  5. 线程对立:是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码,由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

12.1.2 线程安全的实现方法

  1. 互斥同步:
    在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference:如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
    根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
    除了synchronized之外,还可以使用java.util.concurrent(J.U.C)包下的重入锁(ReentrantLock)来实现同步,基本用法上,与synchronized相似,都具备线程重入特性,相比synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁、以及锁可以绑定多个条件。
    • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断性对处理执行时间非常长的同步快很有帮助。
    • 可实现公平锁:是指多个线程等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平锁,ReentrantLock默认也是非公平锁,但可以通过带布尔值的构造函数要求使用公平锁。
    • 锁可以绑定多个条件:指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁,而ReentrantLock则无需这样做,只需要多次调用newCondition()方法即可。
  2. 非阻塞同步:
    互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,所以也叫阻塞同步。从处理问题的方式说,互斥同步(阻塞同步)属于一种悲观的并发策略。
    随着硬件指令的发展,我们有了新的选择:基于冲突检测的乐观并发策略:先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断的重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
    CAS:比较并交换。CAS指令需要有3个操作数,分别是内存位置(V),旧的预期值(A)和新值(B),CAS指令执行时,当且仅当V符号旧预期值A时,处理器用新值B更新V的值,否则就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。
  3. 无同步方案:
    要保证线程安全,并不一定就要进行同步,两者没有因果关系,同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
    • 可重入代码:不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法。可以用一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
    • 线程本地存储:每个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为健,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值中找回对于的本地线程变量。

13.2 锁优化

  1. 自旋锁:互斥同步对性能最大的影响是阻塞的实现,挂起和恢复的操作都需要转入内核态中完成。如果物理机器上有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器的时间的,因此,如果锁被占用的时间很短,自旋等待效果会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源。因此,自旋等待的时间必须要有一定限度,如果超过限定的次数,就应当使用传统的方式去挂起线程了,默认值是10次。

  2. 自适应自旋:自适应自旋锁意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

  3. 锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
    锁消除的主要判定依据是逃逸分析的数据支持。

  4. 锁粗化:如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把锁同步的范围扩展(粗化)到整个操作序列的外部。

  5. 轻量级锁:
    获取轻量级锁,其步骤如下:

    1. 在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝(官方把这个拷贝加了一个Displaced的前缀,即Displaced Mark Word)。
    2. 拷贝对象头中的Mark Word复制到锁记录(Lock Record)中;
    3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5);
    4. 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
    5. 如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行(3),若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

    轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

    1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word;
    2. 如果替换成功,整个同步过程就完成了,恢复到无锁状态(01);
    3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程;

    Lock Record是线程私有的数据结构,每一个被锁住的对象Mark Word都会和一个Lock Record关联,同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
    Lock Record的数据结构
    在这里插入图片描述
    在这里插入图片描述

  6. 偏向锁:引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令。
    轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
    当一个线程访问同步块并获取锁时,会在对象头里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识以及ThreadID即可,处理流程如下:

    1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
    2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
    3. 如果测试线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
    4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
    5. 执行同步代码块;

    偏向锁的释放采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是没有正在执行的代码)。其步骤如下:

    1. 暂停拥有偏向锁的线程;
    2. 挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;
      在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值