美文网首页Android专题android
android 多线程 — 锁优化

android 多线程 — 锁优化

作者: 前行的乌龟 | 来源:发表于2019-04-13 15:07 被阅读51次

    ps: 这篇文章看资料时头疼,写起来时更头疼,写完了说实话也没多大用,充其量也就是多了解了一些锁的内容,也许扣字眼的面试官会让我们回忆起这段蛋疼的经历,但是锁优化的知识点非常重要,他决定了一个 VM 多线程并发性能的天花板和地板,当然这都是从事 VM 改造和开发的 coder 才会头疼的,但是我们了解一下会让我们心里更清楚,也能加深对 VM 的理解

    没妹子镇楼我觉得我写不下去



    涉及到的概念

    锁优化这里涉及到大量没听过的概念,一般我们在 android 开发中用不到,估计后台 JVM 调优的同学应该会接触,最常用的应该是 VM 的同学,了解概念,理解原理,我们在写代码时会通透很多,有种因为生而知之而油然而生优越感,这种感觉欲罢不能啊

    4种锁:

    2种混合型锁:

    2种特性:

    锁的性能从最优开始:偏向锁—>轻量锁—>自适应自旋锁—>重量锁

    上述除了互质锁, 基本都是在 java JDK 1.6 时为了提高并发性能而添加进来的,少部分在 JDK 1.4 就有,但是当时需要手动设置 JVM,在 JDK 1.6 之后这些都变成了默认设置,不用再手动设置了,JDK 1.6 开发团队为了提高并发性能使用了 HotSpot 虚拟机,花费了大量的精力去实现各种锁优化技术

    经过观察,虚拟机开发团队注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,这也是后面一系列锁优化措施出现的源泉,也是根本


    Synchronize 原理

    先来看看 Synchronize 关键字,为啥,因为 Synchronize 就是我们最常用,使用最广泛的线程安全模式,Synchronize 使用的就是 java 传统的互质锁,所以我们得先看 Synchronize 原理才能一步步的深入进去

    简单来说 Synchronize 是通过 JVM 的对象监视器 Monitor 进入和退出命令来实现对方法、同步块的同步的,在进入同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令,本质就是获取一个 Monitor,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的,而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。

    大概其就是这样,继续深入请自己查找资料


    重量锁(互质锁)

    重量锁(互质锁)就是我们说的对象锁,也是 Synchronize 使用的锁,只有持有锁的线程才能执行同步方法,其他竞争线程都会阻塞在同步队列中

    互斥锁存在的问题是性能不够好,体现在3方面:

    上面提到的问题是 JVM 限制并发性能提高的元凶,但是奈何线程的调度不是代码级的而是内核级的,虽然 Thread 我们可以随便用,但是 Thread 只是内核暴露给我们的,线程的调度完全由内核决定,我们控制不了,即便我们可以 sleep,yield,join,wait,notify ,但是这只是我们自己觉得我们可以控制,但是其实内核多线程的调度由有更多我们看不见的地方。因为是内核实现,这不是我们能修改的,更不是随便能修改的,所以 java 开发团队在 VM 层面找询方法,这造成了后面一系列锁的出现,目的是更高效的调度线程, 减少线程阻塞的机会,减少线程间的切换


    自旋锁 / 自适应自旋锁

    自旋锁是互斥锁的进步,自旋锁在有其他线程竞争同步代码时,我们可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。这种优化思路就是基于前面说的锁阻塞的时间总是很短的考量

    自旋锁在 JDK 1.4 中就已经引入,只不过默认是关闭的,在 JDK 1.6 中就已经改为默认开启了,但是自旋锁也有自身的局限:

    • 自旋不能代替阻塞 - 自旋虽然避免了线程切换的开销,但是自旋不会让出 CPU 时间,这同样会浪费大量的 CPU 时间,所以自旋默认次数是10次,如果自旋超过了限定的次数,那么后面的线程就得老老实实取的取挂起阻塞了
    • 不适用于单核心系统 - 同样大家想想,在单核心上使用自旋有效果吗,会一直卡住 CPU 谁都别想执行操作,性能反而会更烂

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


    轻量锁

    简单来说:在线程没有竞争的时候,采用CAS操作,避免使用互斥量的开销,这里涉及到对象头的概念。

    轻量锁是 JDK 1.6 之中加入的,不是用来代替重量级锁的,基于优化在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

    原理是在对象头 Object Header 存储信息的能力,官方称它为“Mark Word”,使用 CAS 原子操作更新 Mark Word 中的内容,看示例:


    • 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后使用CAS操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,可以直接进入同步块继续执行。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁

    • 轻量锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢


    偏向锁

    简单来说:相对于轻量级锁,减少了锁重入的开销,对于第一个获得锁的线程,后面的执行如果该锁没有被其他线程获取,则该线程将不再进行同步(CAS操作),简单的可以这样理解:获取偏向锁的线程是不会主动去释放偏向锁,需要等待其他线程来竞争

    竞争的逻辑:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能

    偏向锁也是 JDK 1.6 中引入的一项锁优化,目的在于消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。偏向锁的“偏”,是指这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步,达到消除同步,消除锁的目的。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。轻量级锁和偏向锁都是在没有竞争的情况下出现,一旦出现竞争就会升级为重量级锁

    偏向锁可以提高带有同步但无竞争的程序性能,它同样是一个带有效益权衡性质的优化,它并不一定总是对程序运行有利,如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的

    优势:

    • 如果不存在多线程同时竞争一把锁的时候,减少CAS操作
    • 老线程重复使用锁,无需任何CAS操作
    • 新线程获取偏向锁,但是没有竞争,只需要在满足条件的时候CAS偏向线程ID即可
    • 完美支持重入功能,而且没有任何CAS操作

    混合型互斥锁 / 混合型自旋锁

    因为程序员往往并不能事先知道哪种方案会更好(比如, 不知道运行环境的CPU核的数量), 操作系统也不知道一段指令是不是针对单核或者多核环境下做过优化, 所以大部分操作系统并不严格区分互斥锁和自旋锁。 实际上,绝大部分现代的操作系统采用的是混合型互斥锁和混合型自旋锁

    • 混合型互斥锁 - 在多核系统上起初表现的像自旋锁一样, 如果一个线程不能获取互斥锁, 它不会马上被切换为休眠状态, 因为互斥量可能很快就被解锁, 所以这种机制会表现的像自旋锁一样。 只有在一段时间以后还不能获取锁, 它就会被切换为休眠状态。 如果运行在单核/单CPU上, 这种机制将不会自旋

    • 混合型自旋锁 - 起初表现的和正常自旋锁一样, 但是为了避免浪费大量的CPU时间, 会有一个折中的策略。 这种机制不会把线程切换到休眠态,也许会决定放弃这个线程的执行(马上放弃或者等一段时间)并允许其他线程运行, 这样提高了自旋锁被解锁的可能性(大多数情况, 线程之间的切换操作比使线程休眠而后唤醒它要昂贵, 尽管那不是很明显)


    锁削除

    锁削除是指虚拟机对检测到不可能存在共享数据竞争的锁进行削除。锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

    对于同步会不会被竞争,程序员我们自己应该是很清楚的,那么为什么还要有个锁消除呢?有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。我们来看看下面代码,这段非常简单的代码仅仅是输出三个字符串相加的结果,无论是源码字面上还是程序语义上都没有同步

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

    Javac 会转化成字符串连接操作

    public String concatString(String s1, String s2, String s3) {  
        StringBuffer sb = new StringBuffer();  
        sb.append(s1);  
        sb.append(s2);  
        sb.append(s3);  
        return sb.toString();  
    }  
    

    在 JDK 1.5 之前,会使用 StringBuffer 对象进行 append()操作,在 JDK 1.5 以后的版本中,会使用StringBuilder,这个例子其实就很好的说明了问题,上面的偏向锁也是为了减少锁的使用


    锁膨胀

    如果一有系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(膨胀)到整个操作序列的外部,只需要加锁一次就可以

    由于加锁和解锁的开销很大,如果不断的加锁和解锁操作都是对于同一个对象,虚拟机会把整个加锁同步的范围扩张到操作序列的外部,就是只加一次锁。


    CAS 擦做

    随着硬件指令集的发展,除了互斥之外我们多了一个选择:基于冲突检测的乐观并发政策,先进性操作,如果没有其他线程共享数据,则操作成功;如果共享数据有争用,产生冲突,那就采取其他措施(不断重试),这种乐观的并发政策的许多实现不用把线程挂起。乐观并发政策需要操作和冲突检测两个步骤具有原子性,需要底层硬件完成

    x86中通过cmpxchg汇编指令来完成CAS操作。CAS(Compare-and-Swap 比较和交换)与平台相关,它有三个操作数,内存位置值(V),旧的预期值(A),新值(B)。CAS指令执行时,当V=A时,处理器用B的值跟新V的值,否则不执行。上述的过程是一个原子操作。JDK1.5引入的CAS,它在sun.misc.Unsafe类里面方法提供。里面调用了Native方法

    下面给一个JUC包下面的Atomic类的部分源代码,执行自增操作。用到CAS,里面用到了循环一直判断。里面没有进行加锁处理。但是也有逻辑漏洞,在111和222如果其他线程被执行,获得V(V运来是A),将他修改为B,后来又修改会A,则执行222代码的时候认为V没有改变过,这就是“ABA”问题。这个问题一般没有什么影响

    //该方法实现了i++的非阻塞的原子操作   
       public final int getAndIncrement() {   
             for (;;) { //循环,使用CAS的经典方式,这是实现non-blocking方式的代价   
                int current = get();//得到现在的值     111  
                int next = current + 1;//通过计算得到要赋予的新值   
                if (compareAndSet(current, next)) //关键点,调用CAS原子更新,  222  
                     return current;   
             }   
         }   
    

    这段看看就成了,我也没找到资料怎么说他们比较的


    最后

    上面说的这些只是在向大家介绍概念,面试的时候好忽悠,能加点印象分,但实际上这些优化手段都是有 VM 自省决定什么时候执行的,一般至少做 android 开发的 coder 们不用为此头疼,我们老老实实的用 Synchronize 就好,下面有一些经典分析大家看看吧:

    如果对选择哪种方案感到疑惑, 那就使用互斥锁吧,并且大多数现代的操作系统都允许在获取锁的时候自旋一段时间(混合型互斥锁)。 只有在一定条件下使用自旋锁才可以提高性能, 事实上, 你现在在做的项目可能没有一个能在通过自旋锁提高性能。 也许你考虑使用你自己定义的”锁对象”, 它可以在内部使用互斥锁或者自旋锁(例如: 在创建锁对象时, 用哪种机制是可配置的), 刚开始在所有的地方都是用互斥锁, 如果你认为在有些地方用自旋锁确实可以提高性能, 你可以试试, 并且比较两种情况的结果(使用一些性能评测工具), 但一定要在单核和多核环境上测试之后再下结论(如果你的代码是夸平台的, 也要尽可能在不同的平台上测试下)。


    参考文章:

    相关文章

      网友评论

        本文标题:android 多线程 — 锁优化

        本文链接:https://www.haomeiwen.com/subject/teyhwqtx.html