• 深入理解java虚拟机笔记线程安全与锁优化3


    线程安全与锁优化

    三、锁优化

    高效并发是从JDK 5升级到JDK 6后一项重要的改进项, HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术, 如适应性自旋(Adaptive Spinning) 、 锁消除(LockElimination) 、 锁膨胀(Lock Coarsening) 、 轻量级锁(Lightweight Locking) 、 偏向锁(BiasedLocking) 等, 这些技术都是为了在线程之间更高效地共享数据及解决竞争问题, 从而提高程序的执行效率

    3.1 自旋锁与自适应自旋

    前面我们讨论互斥同步的时候, 提到了互斥同步对性能最大的影响是阻塞的实现, 挂起线程和恢复线程的操作都需要转入内核态中完成, 这些操作给Java虚拟机的并发性能带来了很大的压力。

    同时, 虚拟机的开发团队也注意到在许多应用上, 共享数据的锁定状态只会持续很短的一段时间, 为了这段时间去挂起和恢复线程并不值得。 现在绝大多数的个人电脑和服务器都是多路(核) 处理器系统, 如果物理机器有一个以上的处理器或者处理器核心, 能让两个或以上的线程同时并行执行, 我们就可以让后面请求锁的那个线程“稍等一会”, 但不放弃处理器的执行时间, 看看持有锁的线程是否很快就会释放锁。 为了让线程等待, 我们只须让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁。

    自旋锁在JDK 1.4.2中就已经引入, 只不过默认是关闭的, 可以使用-XX: +UseSpinning参数来开启, 在JDK 6中就已经改为默认开启了。 自旋等待不能代替阻塞, 且先不说对处理器数量的要求, 自旋等待本身虽然避免了线程切换的开销, 但它是要占用处理器时间的, 所以如果锁被占用的时间很短, 自旋等待的效果就会非常好, 反之如果锁被占用的时间很长, 那么自旋的线程只会白白消耗处理器资源, 而不会做任何有价值的工作, 这就会带来性能的浪费。 因此自旋等待的时间必须有一定的限度, 如果自旋超过了限定的次数仍然没有成功获得锁, 就应当使用传统的方式去挂起线程。

    自旋次数的默认值是十次, 用户也可以使用参数-XX: PreBlockSpin来自行更改。不过无论是默认值还是用户指定的自旋次数, 对整个Java虚拟机中所有的锁来说都是相同的。 在JDK 6中对自旋锁的优化, 引入了自适应的自旋。 自适应意味着自旋的时间不再是固定的了, 而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。 如果在同一个锁对象上, 自旋等待刚刚成功获得过锁, 并且持有锁的线程正在运行中, 那么虚拟机就会认为这次自旋也很有可能再次成功, 进而允许自旋等待持续相对更长的时间, 比如持续100次忙循环。

    另一方面, 如果对于某个锁, 自旋很少成功获得过锁, 那在以后要获取这个锁时将有可能直接省略掉自旋过程, 以避免浪费处理器资源。 有了自适应自旋, 随着程序运行时间的增长及性能监控信息的不断完善, 虚拟机对程序锁的状况预测就会越来越精准, 虚拟机就会变得越来越“聪明”了。

    3.2 锁消除

    锁消除是指虚拟机即时编译器在运行时, 对一些代码要求同步, 但是对被检测到不可能存在共享数据竞争的锁进行消除。 锁消除的主要判定依据来源于逃逸分析的数据支持, 如果判断到一段代码中, 在堆上的所有数据都不会逃逸出去被其他线程访问到, 那就可以把它们当作栈上数据对待, 认为它们是线程私有的, 同步加锁自然就无须再进行。 也许读者会有疑问, 变量是否逃逸, 对于虚拟机来说是需要使用复杂的过程间分析才能确定的,但是程序员自己应该是很清楚的, 怎么会在明知道不存在数据争用的情况下还要求同步呢?

    这个问题的答案是: 有许多同步措施并不是程序员自己加入的, 同步的代码在Java程序中出现的频繁程度也许 超过了大部分读者的想象。 我们来看看如代码清单13-6所示的例子, 这段非常简单的代码仅仅是输出三个字符串相加的结果, 无论是源代码字面上, 还是程序语义上都没有进行同步。

    我们也知道, 由于String是一个不可变的类, 对字符串的连接操作总是通过生成新的String对象来进行的, 因此Javac编译器会对String连接做自动优化。 在JDK 5之前, 字符串加法会转化为StringBuffer对象的连续append()操作, 在JDK 5及以后的版本中, 会转化为StringBuilder对象的连续append()操作。

    现在大家还认为这段代码没有涉及同步吗? 每个StringBuffer.append()方法中都有一个同步块, 锁就是sb对象。 虚拟机观察变量sb, 经过逃逸分析后会发现它的动态作用域被限制在concatString()方法内部。 也就是sb的所有引用都永远不会逃逸到concatString()方法之外, 其他线程无法访问到它, 所以这里虽然有锁, 但是可以被安全地消除掉。 在解释执行时这里仍然会加锁, 但在经过服务端编译器的即时编译之后, 这段代码就会忽略所有的同步措施而直接执行。

    3.3 锁粗化

    原则上, 我们在编写代码的时候, 总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步, 这样是为了使得需要同步的操作数量尽可能变少, 即使存在锁竞争, 等待锁的线程也能尽可能快地拿到锁。 大多数情况下, 上面的原则都是正确的, 但是如果一系列的连续操作都对同一个对象反复加锁和解锁, 甚至加锁操作是出现在循环体之中的, 那即使没有线程竞争, 频繁地进行互斥同步操作也会导致不必要的性能损耗。

    代码清单13-7所示连续的append()方法就属于这类情况。 如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁, 将会把加锁同步的范围扩展(粗化) 到整个操作序列的外部, 以代码清单13-7为例, 就是扩展到第一个append()操作之前直至最后一个append()操作之后, 这样只需要加锁一次就可以了。

    3.4 轻量级锁

    轻量级锁是JDK 6时加入的新型锁机制, 它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的, 因此传统的锁机制就被称为“重量级”锁。 不过, 需要强调一点, 轻量级锁并不是用来代替重量级锁的, 它设计的初衷是在没有多线程竞争的前提下, 减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

    要理解轻量级锁, 以及后面会讲到的偏向锁的原理和运作过程, 必须要对HotSpot虚拟机对象的内存布局(尤其是对象头部分) 有所了解。

    HotSpot虚拟机的对象头(Object Header) 分为两部分, 第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode) 、 GC分代年龄(Generational GC Age)等。

    这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特, 官方称它为“MarkWord”。 这部分是实现轻量级锁和偏向锁的关键。

    另外一部分用于存储指向方法区对象类型数据的指针, 如果是数组对象, 还会有一个额外的部分用于存储数组长度。 这些对象内存布局的详细内容, 我们已经在第2章中学习过, 在此不再赘述, 只针对锁的角度做进一步细化。

    由于对象头信息是与对象自身定义的数据无关的额外存储成本, 考虑到Java虚拟机的空间使用效率, Mark Word被设计成一个非固定的动态数据结构, 以便在极小的空间内存储尽量多的信息。 它会根据对象的状态复用自己的存储空间。 例如在32位的HotSpot虚拟机中, 对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码, 4个比特用于存储对象分代年龄, 2个比特用于存储锁标志位, 还有1个比特固定为0(这表示未进入偏向模式) 。 对象除了未被锁定的正常状态外, 还有轻量级锁定、 重量级锁定、 GC标记、 可偏向等几种不同状态, 这些状态下对象头的存储内容如表13-1所示。

    我们简单回顾了对象的内存布局后, 接下来就可以介绍轻量级锁的工作过程了: 在代码即将进入同步块的时候, 如果此同步对象没有被锁定(锁标志位为“01”状态) , 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record) 的空间, 用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀, 即Displaced Mark Word) , 这时候线程堆栈与对象头的状态如图13-3所示

    然后, 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。 如果这个更新动作成功了, 即代表该线程拥有了这个对象的锁, 并且对象Mark Word的锁标志位(Mark Word的最后两个比特) 将转变为“00”, 表示此对象处于轻量级锁定状态。 这时候线程堆栈与对象头的状态如图13-4所示。

    如果这个更新操作失败了, 那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。 虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧, 如果是, 说明当前线程已经拥有了这个对象的锁, 那直接进入同步块继续执行就可以了, 否则就说明这个锁对象已经被其他线程抢占了。

    如果出现两条以上的线程争用同一个锁的情况, 那轻量级锁就不再有效, 必须要膨胀为重量级锁, 锁标志的状态值变为“10”, 此时Mark Word中存储的就是指向重量级锁(互斥量) 的指针, 后面等待锁的线程也必须进入阻塞状态。

    上面描述的是轻量级锁的加锁过程, 它的解锁过程也同样是通过CAS操作来进行的, 如果对象的Mark Word仍然指向线程的锁记录, 那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。

    假如能够成功替换, 那整个同步过程就顺利完成了; 如果替换失败, 则说明有其他线程尝试过获取该锁, 就要在释放锁的同时, 唤醒被挂起的线程。轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁, 在整个同步周期内都是不存在竞争的”这一经验法则。

    如果没有竞争, 轻量级锁便通过CAS操作成功避免了使用互斥量的开销; 但如果确实存在锁竞争, 除了互斥量的本身开销外, 还额外发生了CAS操作的开销。 因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢

    3.5 偏向锁

    偏向锁也是JDK 6中引入的一项锁优化措施, 它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。 如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量, 那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不去做了。

    偏向锁中的“偏”, 就是偏心的“偏”、 偏袒的“偏”。 它的意思是这个锁会偏向于第一个获得它的线程, 如果在接下来的执行过程中, 该锁一直没有被其他的线程获取, 则持有偏向锁的线程将永远不需要再进行同步。

    如果读者理解了前面轻量级锁中关于对象头Mark Word与线程之间的操作过程, 那偏向锁的原理就会很容易理解。

    假设当前虚拟机启用了偏向锁(启用参数-XX: +UseBiased Locking, 这是自JDK 6起HotSpot虚拟机的默认值) , 那么当锁对象第一次被线程获取的时候, 虚拟机将会把对象头中的标志位设置为“01”、 把偏向模式设置为“1”, 表示进入偏向模式。

    同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。 如果CAS操作成功, 持有偏向锁的线程以后每次进入这个锁相关的同步块时, 虚拟机都可以不再进行任何同步操作(例如加锁、 解锁及对Mark Word的更新操作等) 。

    一旦出现另外一个线程去尝试获取这个锁的情况, 偏向模式就马上宣告结束。 根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”) , 撤销后标志位恢复到未锁定(标志位为“01”) 或轻量级锁定(标志位为“00”) 的状态, 后续的同步操作就按照上面介绍的轻量级锁那样去执行。 偏向锁、 轻量级锁的状态转化及对象Mark Word的关系如图13-5所示。

    细心的读者看到这里可能会发现一个问题: 当对象进入偏向状态的时候, Mark Word大部分的空间(23个比特) 都用于存储持有锁的线程ID了, 这部分空间占用了原有存储对象哈希码的位置, 那原来对象的哈希码怎么办呢?

    在Java语言里面一个对象如果计算过哈希码, 就应该一直保持该值不变(强烈推荐但不强制, 因为用户可以重载hashCode()方法按自己的意愿返回哈希码) , 否则很多依赖对象哈希码的API都可能存在出错风险。 而作为绝大多数对象哈希码来源的Object::hashCode()方法, 返回的是对象的一致性哈希码(Identity Hash Code) , 这个值是能强制保证不变的, 它通过在对象头中存储计算结果来保证第一次计算之后, 再次调用该方法取到的哈希码值永远不会再发生改变。

    因此, 当一个对象已经计算过一致性哈希码后, 它就再也无法进入偏向锁状态了; 而当一个对象当前正处于偏向锁状态, 又收到需要计算其一致性哈希码请求时, 它的偏向状态会被立即撤销, 并且锁会膨胀为重量级锁。 在重量级锁的实现中, 对象头指向了重量级锁的位置, 代表重量级锁的ObjectMonitor类里有字段可以记录非加锁 状态(标志位为“01”) 下的Mark Word, 其中自然可以存储原来的哈希码。

    偏向锁可以提高带有同步但无竞争的程序性能, 但它同样是一个带有效益权衡(Trade Off) 性质的优化, 也就是说它并非总是对程序运行有利。 如果程序中大多数的锁都总是被多个不同的线程访问, 那偏向模式就是多余的。 在具体问题具体分析的前提下, 有时候使用参数-XX: -UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

  • 相关阅读:
    bzoj3675 [Apio2014]序列分割
    bzoj3437 小P的牧场
    bzoj3156 防御准备
    bzoj1911 [Apio2010]特别行动队
    Codeforces 937.D Sleepy Game
    Codeforces 937.C Save Energy!
    Codeforces 937.B Vile Grasshoppers
    bzoj1597 [Usaco2008 Mar]土地购买
    DDA画线算法
    实验九
  • 原文地址:https://www.cnblogs.com/wangbin2188/p/16040204.html
Copyright © 2020-2023  润新知