线程安全与锁优化

一、线程安全的实现方法

(一)互斥同步

  • 互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)都是主要的互斥实现方式。

    互斥量和信号量在系统中的任何进程都是可见的,临界区的作用范围仅限于本进程。

  • java中,最基本的互斥同步手段就是synchronized关键字,该关键字经过编译之后,会在同步块的前后分别形成monitorentermonitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
  • 根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,则把锁的计数器加1,相应的在执行monitorexit指令时将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放位置。
    package tystudy.javabasic.jvm;
    
    public class MonitorTest {
        public static void main(String[] args) {
            final Object lock = new Object();
            synchronized(lock) {
                System.out.println("hello");
            }
        }
    }
    
    E:\myworkspace\my-study\common-project\java-basic\target\classes>javap -c tystudy.javabasic.jvm.MonitorTest
    Compiled from "MonitorTest.java"
    public class tystudy.javabasic.jvm.MonitorTest {
      public tystudy.javabasic.jvm.MonitorTest();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method java/lang/Object."":()V
           4: return
    
      public static void main(java.lang.String[]);
        Code:
           0: new           #2                  // class java/lang/Object
           3: dup
           4: invokespecial #1                  // Method java/lang/Object."":()V
           7: astore_1
           8: aload_1
           9: dup
          10: astore_2
          11: monitorenter
          12: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
          15: ldc           #4                  // String hello
          17: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          20: aload_2
          21: monitorexit
          22: goto          30
          25: astore_3
          26: aload_2
          27: monitorexit
          28: aload_3
          29: athrow
          30: return
        Exception table:
           from    to  target type
              12    22    25   any
              25    28    25   any
    }
    
  • java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态(内核态)中,因此状态转换需要耗费很多的处理器时间。
    • 内核态:控制计算机的硬件资源,并提供上层应用程序的运行环境。
    • 用户态:上层应用程序的活动空间,应用程序的执行必须依托于内核态提供的资源。
    • 系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口。

    用户态与内核态

  • 对于代码简单的同步块,状态转换消耗的时间可能比用户代码执行的时间还长。所以synchronized是java语言中一个重量级的操作。虚拟机本身也会进行一些优化,比如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。
  • 除了synchronized还可以使用ReentrantLock实现同步,它是表现为api层面的互斥锁。相比synchronized增加了几个高级功能:等待可中断、公平锁、绑定条件。jdk1.6之前ReentrantLock性能更优,jdk1.6后对synchronized进行了优化,性能与ReentrantLock差不多。

(二)非阻塞同步

  • 随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略。通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步(non-blocking synchronization)。

  • 硬件保证一个从语义看起来需要多次操作的行为通过一条处理器指令就能完成,这类指令常用的有:

    • 测试并设置(test-and-set)
    • 获取并增加(fetch-and-increment)
    • 交换(swap)
    • 比较并交换(compare-and-swap,cas)
    • 加载链接/条件存储(load-linked/store-conditional,ll/sc)
  • cas指令需要有3个操作数,分别是内存位置(V)、预期值(A)和新值(B)。cas指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述处理过程是一个原子操作。

  • jdk1.5之后,java程序中才可以使用cas操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()compareAndSwapLong()等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器cas指令,没有方法调用的过程,或者可以认为是无条件内联进去了。

  • cas存在ABA问题,juc为了解决这个问题提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证cas的正确性,如果要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

二、锁优化

(一)自旋锁与自适应自旋

  • 挂起和恢复线程的操作都需要转入内核态完成,这些操作给系统的并发性带来很大的压力。所以可以让后面请求锁的那个线程忙循环(自旋)等待,每次自旋一次就查看持有锁的线程是否已经释放锁。而不需要进入内核态挂起线程。
  • 自旋锁在jdk1.4.2中就已经引入,不过默认是关闭的,可以通过-XX:+UseSpinning参数来开启,==jdk1.6中默认开启==。
  • 自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。所以自旋超过指定次数仍然没有获取锁应该使用传统方式挂起线程。==自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。==
  • jdk1.6中引入了自适应的自旋锁,即对于经常很快就可以获取锁的情况会多自旋一会,对于很少能够通过自旋获取锁的就尽早或直接进入内核态挂起线程。

(二)锁消除

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

  • 我们也知道,对于String是一个不可变类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在jdk1.5之前,会转化为StringBuffer对象的连续append操作,在jdk1.5之后会转化为StringBuilder对象的连续append。对于StringBuffer的连续append,这个方法是同步的,锁就是this即StringBuffer对象。虚拟机会观察这个锁,发现它的攻台作用域被限制在concatString方法内部。也就是说,锁对象的所有引用永远不会“逃逸”到concatString方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译后,这段代码就会忽略掉所有的同步而直接执行了。

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

(三)锁粗化

  • 原则上,我们编写代码的时候,总是推荐将同步块的作用范围限制得尽量小。但是对于一系列的连续操作都是对同一对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能消耗。
  • StringBuffer的连续append方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

(四)轻量级锁

  • 轻量级锁是相对于使用操作系统互斥量来实现的传统锁而言的。
  • 对象头分为三个部分:
    • mark word:hashcode、gc分代年龄等信息、指向锁记录的指针、指向重量级锁的指针、偏向线程id、偏向时间戳等
    • 指向方法区对象类型数据的指针
    • 如果是数组,这里会存储数组长度
  • 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用cas操作避免了使用互斥量的开销,如果存在锁竞争,除了互斥量的开销外,还额外发生了cas操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
1、轻量级锁的加锁过程:

在代码进入同步块的时候,如果此同步对象没有被锁定(锁标识为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这个拷贝叫Displaced Mark Word)。然后虚拟机将使用cas操作尝试将对象的mark word更新为指向Lock Record的指针。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为00,即表示此对象处于轻量级锁定状态。

如果这个更新动作失败了,虚拟机首先会检查对象Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。

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

2、轻量级锁的解锁过程

解锁过程也是通过cas操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用cas操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功了,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

(五)偏向锁

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

假设当前虚拟机启用了偏向锁(==-XX:+UseBiasedLocking,这是jdk1.6的默认值==),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为01,即偏向模式。同时使用cas操作把获取到这个锁的线程的id记录在对象Mark Word之中,如果cas操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如LockingUnlocking及对Mark WordUpdate等)。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。后续的同步操作就如上面介绍的轻量级锁那样执行。
如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的,使用-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

你可能感兴趣的:(线程安全与锁优化)