关于java:面试官你了解Java中的锁优化吗

38次阅读

共计 5190 个字符,预计需要花费 13 分钟才能阅读完成。

锁优化

文章已同步至 GitHub 开源我的项目: JVM 底层原理解析

​ 高效并发是 JDK5 降级到 JDK6 后一项重要的改良,HotSpot 虚拟机开发团队在这个版本上破费了微小的资源去实现各种锁优化。比方,自旋锁,自适应自旋锁,锁打消,锁收缩,轻量级锁,偏差锁等。这些技术都是为了在线程之间更高效的共享数据及解决竞争问题。从而进步程序的运行效率。

自旋锁和自适应自旋锁

  1. 自旋锁

​ 在互斥同步的时候,对性能影响最大的就是阻塞的实现,挂起线程,复原线程等的操作都须要用户态转为内核态去实现。这些操作给性能带来了微小的压力。

​ 虚拟机的开发团队也留神到,共享数据的锁定状态只会继续很短的工夫。为了这很短的工夫让线程挂起,而后转为内核态的工夫可能比锁定状态的工夫更长。所以,咱们能够让期待同步锁的过程不要进入阻塞,而是在原地略微期待一会儿,不要放弃处理器的执行工夫,看看持有锁的线程是不是很快就会开释锁。为了让线程期待,咱们能够让线程执行一个 忙循环(原地自旋), 这就是自旋锁。

​ 自旋锁在 JDK1.4.2 之后就曾经引入,然而默认是敞开的。咱们能够应用 -XX:+UseSpinning 参数来开启。在 JDK1.6 之后就默认开启了。自旋锁并不是阻塞,所以它防止了用户态到内核态的频繁转化,然而它是要占用处理器的执行工夫的。

​ 如果占有对象锁的线程在很短的工夫内就执行完,而后开释锁,这样的话,自旋锁的成果就会十分好。

​ 如果占有对象锁的线程执行工夫很长,那么自旋锁会白白耗费处理器的执行工夫,这就带来了性能的节约。这样的话,还不如将期待的线程进行阻塞。默认的自旋次数是 10,也就是说,如果一个线程自旋 10 次之后,还没有拿到对象锁,那么就会进行阻塞。

​ 咱们也能够应用参数 -XX:PreBlockSpin 来更改。

  1. 自适应自旋锁

    ​ 无论是应用默认的 10 次,还是用户自定义的次数,对整个虚拟机来说所有的线程都是一样的。然而同一个虚拟机中线程的状态并不是一样的,有的锁对象长一点,有的短一点,所以,在 JDK1.6 的时候,引入了 自适应自旋锁

    ​ 自适应自旋锁意味着自旋的工夫不在固定了,而是依据以后的状况动静设置。

    ​ 次要取决于 同一个锁上一次的自旋工夫 锁的拥有者的状态

    ​ 如果在同一个对象锁上,上一个获取这个对象锁的线程在自旋期待胜利了,没有进入阻塞状态,阐明这个对象锁的线程执行工夫会很短,虚拟机认为这次也有可能再次胜利,进而容许此次自旋工夫能够更长一点。

    ​ 如果对于某个锁,自旋状态下很少取得过锁,阐明这个对象锁的线程执行工夫绝对会长一点,那么当前虚拟机可能会间接省略掉自旋的过程。避免浪费处理器资源。

    ​ 自适应自旋锁的退出,随着程序运行工夫的增长以及性能监控零碎信息的不断完善,虚拟机对程序的自旋工夫预测越来越精确,也就是 虚拟机越来越聪慧了

锁打消

​ 锁打消指的是,在即时编译器运行的时候,代码中要求某一段代码块进行互斥同步,然而虚拟机检测到不须要进行互斥同步,因为没有共享数据,此时,虚构机会进行优化,将互斥同步打消。

​ 锁打消的次要断定根据来源于 逃逸剖析 的数据反对。具体来说,如果虚拟机判断到,在一段代码中,创立的对象不会逃逸进来到其余线程,那么就能够把他当作栈上数据看待,同步也就没有必要了。

​ 然而,大家必定有疑难,变量是否逃逸,写代码的程序员应该比虚拟机分明,该不该加同步互斥程序员很自信。还要让虚拟机通过简单的过程间剖析吗,这个问题的答案是:

​ 有许多互斥同步的要求并不是程序员本人退出的,互斥同步的代码在 Java 中呈现的水平很频繁。

​ 咱们来举一个例子。

public String concat(String s1, String s2){return s1 + s2;}

​ 上边的代码很简略,将两个字符串连贯,而后返回,不波及到任何互斥同步的要求。

​ 然而,咱们来编译它

 0 new #2 <java/lang/StringBuilder>
 3 dup
 4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
 7 aload_1
 8 invokevirtual #4 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
11 aload_2
12 invokevirtual #4 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
15 invokevirtual #5 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
18 areturn

​ 会发现,字节码中呈现了 StringBuilder 的拼接操作。因为字符串是不可变的,在编译阶段会对 String 的连贯主动优化。也就是用 StringBuilder 来连贯。咱们都晓得,这个类是线程平安的,也就是说 StringBuilder 的拼接操作是须要互斥同步的条件的。此时,代码流程可能是以下这样的

public String concat(String s1, String s2){StringBuilder sb = new StringBuilder();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();}

​ 此时,代码会有互斥同步,锁住 sb 这个对象。这样的话,就会呈现程序员没有退出互斥同步条件,但字节码中以及有了。

​ 这个时候,锁打消 就发挥作用了,通过虚拟机的逃逸剖析,发现 sb 这个对象不会逃逸进来,别的线程相对不会拜访到它,sb 的动静作用域就在此办法中,此时,锁打消就会将这里的互斥同步进行打消。

​ 运行的时候,就会疏忽到同步措施间接执行。

锁粗化

​ 原则上,咱们编写代码的时候,总是举荐将同步代码块的作用范畴尽可能的放大,只有共享数据的中央同步即可。这样是为了使得同步的操作变少,期待锁的线程能尽快的拿到锁。

​ 然而,如果一段代码中从头至尾都锁的是同一个对象,那么就会对这个对象进行反复的加锁,开释,加锁,开释。频繁的进行用户态和内核态的切换,效率竟然变低了。

​ 上边的代码就是这种状况,每一次的 append 操作都对 sb 进行加锁开释,加锁开释,如果虚拟机探测到有一串系统的操作对一个对象反复的加锁,开释,此时,虚拟机就会把加锁同步的范畴粗化到整个操作的最外层。以上边的代码为例,虚拟机扩大到第一个 append 到最初一个 append。这样的话,只须要加锁开释一次即可。

轻量级锁

​ 轻量级锁是 JDK1.6 之后退出的新型锁机制,轻量级是绝对应于操作系统互斥量来实现的传统锁而言的。因而,传统锁就被称之为重量级锁。然而,要留神,轻量级并不是用来代替重量级的,它设计的初衷是在 没有多线程竞争的前提下,缩小传统的重量级锁带来的性能耗费问题的。

​ 首先,要了解轻量级锁以及后边的偏差锁,必须要先晓得,HotSpot 中对象的内存布局。对象的内存布局分为三局部,一部分是 对象头(Mark Word),一部分是 实例数据 ,还有一部分 对其填充,为了让对象的大小为 8 字节的整数倍。

​ 对象头中包含两局部的数据包含,对象的哈希码,GC 分代年龄,锁状态等。如果对象是数组,那么还会有额定的一部分存储数组长度。

​ 这些内容在第二章运行时数据区中的对象的实例化内存布局与拜访定位 + 间接内存 咱们曾经说过了。不再赘述,此处咱们只针对锁的角度进一步细化。

​ 因为对象头中存储的信息是与对象本身定义数据无关的额定存储老本,所以为了节约效率,他被设计为一个动静的数据结构。会 依据对象的状态复用本人的存储空间。具体来说,会依据以后锁状态给每一部分的值赋予不同的意义。

​ 在 32 位操作系统下的 HotSpot 虚拟机中对象头占用 32 个字节,64 位占用 64 个字节。

​ 咱们以 32 位操作系统来演示。以下是不同锁状态的状况下,各个局部数据的含意。

​ 接下来咱们就能够介绍轻量级锁的工作过程了。

加锁过程

  • 在代码行将进入同步块的时候,虚拟机就会在以后栈帧中建设一个名为锁记录(Lock Record)的空间。而后将堆中对象的对象头拷贝到 锁记录(官网给它加了 Displaced 前缀)便于批改对象头的援用时存储之前的信息。此时线程栈和对象头的状况如下:

  • 而后,虚拟机将应用 CAS(原子) 操作尝试把堆中对象的对象头中前 30 个字节更新为指向 锁记录 的援用。

    • 如果胜利,代表以后线程曾经领有了该对象的对象锁。而后将堆中对象头的锁标记位改为 00。此时,代表对象就处于 轻量级锁定 状态。状态如下所示

    • 如果失败,也就是堆中对象头的锁状态曾经是 0,则意味着对象的对象锁别拿走了。

      • 虚构机会判断对象的前 30 个字节是不是指向以后线程

        • 如果是,阐明以后线程曾经拿到了对象锁,能够直 接执行同步代码块
        • 如果不是,阐明对象锁被其余线程拿走了,必须期待。也就是进入 自旋模式,如果在自旋肯定次数后仍为取得锁,那么轻量级锁将会收缩成重量级锁。
      • 如果发现有两条以上线程争用同一个对象锁,那么轻量级锁就不在无效,必须 收缩为分量锁,将对象的锁状态改为 10。此时,堆中对象的对象头前 30 个字节的援用就是指向重量级锁。

解锁过程

​ 如果堆中对象头的前 30 个字节指向以后线程,阐明以后线程领有对象锁,就用 CAS 操作将加锁的时候复制到栈帧锁记录中的对象头替换到堆中对象的对象头。并将堆中对象头的锁状态改为 01。

  • 如果替换胜利,阐明 解锁实现
  • 如果发现有别的线程尝试过获取堆中对象的对象锁,就要在开释锁的同时,唤醒被阻塞的线程。

后言

​ 轻量级锁晋升性能的根据是:绝大多数的锁在整个同步过程中都是不存在竞争的。这样的话,就通过 CAS 操作防止了应用操作系统中互斥量的开销。

​ 如果的确存在多个线程的锁竞争,除了互斥量自身的开销之外,还额定产生了 CAS 操作的开销。因而,在有竞争的状况下,轻量级锁会比传统的重量级锁更慢。

偏差锁

​ 偏差锁也是 JDK1.6 之后引入的个性,他的目标是 打消数据在无竞争状态下的同步原语,进一步提高程序的运行速度。

​ 轻量级锁是在无竞争的状况下利用 CAS 原子操作来打消操作系统的互斥量,偏差锁就是在无竞争的状况下把整个同步都打消。

​ 偏差锁的 就是偏心的偏,他的意思是 这个锁会偏差于第一个取得它的线程。如果在接下来的执行过程中,该锁始终没有被其余线程获取,那么持有偏差锁的线程就不须要在同步,间接执行。

​ 假如以后虚拟机开启了偏差锁(1.6 之后默认开启),当锁对象第一次被线程获取的时候,虚构机会将对象头中最初 2 字节的锁标记位的值不做设置,仍旧是 01,将倒数第三个字节偏差模式设置为 01。也就是开启偏差模式。同时应用 CAS 原子操作将获取到这个对象锁的线程记录在对象头中。如果操作胜利,那么当前持有偏差锁的线程每次进入同步代码块时,虚拟机都不会在进行同步操作。

​ 一旦呈现别的线程去获取这个锁的状况,偏差模式立马完结。依据锁对象目前是否被锁定来决定是否撤销偏差,撤销后锁标记位复原到未锁定状态(01)或轻量级锁定(00)。后续的操作就依照轻量级锁去执行。

​ 偏差锁,轻量级锁的状态转化如下:

问题:

​ 之前的轻量级锁加锁的时候,会将对象的 hash 码,分代年龄等数据拷贝进去,便于应用。然而,咱们发现,偏差锁的过程中并未拷贝,此时,如果要应用原来对象头的数据,怎么办?

​ 虚拟机的实现也思考到了这个问题。

​ 对象的哈希码并不是创建对象的时候计算的,而是第一次应用的时候,计算的。比方下边 String 的 hash 办法源码

/**
 演示 hash 的计算工夫
 作者:杜少雄
*/
public int hashCode() {
        int h = hash;
        // 如果之前没有算过,则调用的时候才进行计算。否则间接返回
        if (h == 0 && value.length > 0) {char val[] = value;

            for (int i = 0; i < value.length; i++) {h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

​ 如果一个对象计算过哈希码,那么不论调用多少次,它的哈希值都应该是一样的。

​ 当一个对象计算过 hash 码的时候,阐明这个对象的哈希码要被用,那么,这个对象就无奈进入偏差锁状态。

​ 如果虚拟机收到一个正在偏差锁的对象的哈希码计算申请,就会立刻进行偏差锁模式,收缩为重量级锁。就会在重量级锁的栈帧中拷贝的锁状态地位中存储对象的运行时数据结构。

后言

​ 偏差锁能够进步带有同步然而无竞争的程序性能,然而它同样是一个带有衡量效益的优化。如果程序中大多数的锁总是被不同的线程拜访,那么偏差模式就是多余的。具体问题剖析之后,咱们能够应用参数 -XX:-UseBiasedLocking 来禁止应用偏差锁优化从而进步程序的运行速度。须要 具体问题,具体分析

文章已同步至 GitHub 开源我的项目: JVM 底层原理解析

正文完
 0