线程安全与锁优化

什么是线程安全

过往在使用synchronized关键字的时候,通常都会和线程安全问题相挂钩。那么这个线程安全的定义又是什么呢? 在我学习《深入JVM虚拟机》这本书中提到了一段话我觉得解释的不错: "当多个线程访问一个对象是,如果不用考虑这些线程在运行时环境下的的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这对象的行为都可以获得正确的结果,那这个对象就是线程安全的 " 这个定义是比较严谨的,从它的描述来看线程安全必须具备的一个特征就是; 一段代码本身封装了所有必要的正确性保障手段(如互斥同步等), 另调用者无需关心多线程的安全问题,更无须自己采取任何措施来保证多线程的正确调用。


Java语言中的线程安全分类

为了更好的理解线程安全, 我们将线程安全性分解为更多的层次, 将线程安全程度有高到低进行排列: 不可变 -> 绝对线程安全 -> 相对线程安全 -> 线程兼容 -> 线程对立

  • 不可变(Immutable)

    在Java 语言中, 不可变对象一定是线程安全的(例如String), 无论是对象的方法实现还是方法的调用者,都不需要采取任何措施来保障线程安全性问题,在java中实现不可变对象通常用final修饰, 一旦一个不可变对象被正确构建出来(没有发生引用逃逸问题, 具体可参考final关键字的内存可见性), 那么对外部的可见状态就永远不会改变,多线程之中就永远处于一致状态,此种安全性也是最简单和最纯粹的。

  • 绝对线程安全

    绝对安全的定义是满足所有之前线程安全的定义的, 但是要做到这点基本上是非常苛刻的。 Java中的大部分API大部分标榜了线程安全的类基本都不是绝对线程安全的,如果操作不当,还是会出现多线程同时读写情况下出现数据安全问题。比如Vector类在多线程读和remove的情况下, 可能就会出现数组越界的异常。 以及可以参考Collections.SynchronizedList的迭代器(Iterator), 该类原作者还特意标明了该方法需要 Must be manually synched by user (用户手动同步)。此类方法并不符合调用方任意操作都能获取正确结果的线程安全定义。

  • 相对线程安全

    相对线程安全就是我们通常定义的线程安全,需要保证对这个对象单独操作是线程安全的,我们在调用的时候不需要做额外的保护措施,但是对于一些特定顺序的连续调用,就可能需要在调用段使用额外的同步手段来保证调用的正确性。Java中比较常见的线程安全类型基本都属于这种类型,例如上面举例的Vector,HashTable,以及Collections.synchronized集合方法包装的集合类。

  • 线程兼容

    线程兼容通常是对象本身并不具备线程安全性,但是通常可以通过一些同步手段来实现线程安全。我们平常所说的线程非安全,基本都是在说着一种情况。Java API绝大多数类都是线程兼容的例如 ArrayList,HashMap

  • 线程对立

    线程对立是指无论调用段是否采取了同步措施, 都无法在多线程环境中并发使用的代码,这种代码通常都是有害的,应当尽量避免。

    最典型的例子就是Thread类的suspend()和resume()方法,如果有两个线程同时持有线程对象,一个尝试去终端中断线程,一个尝试去恢复线程,如果是并发进行的话,无论调用时是否进行了线程同步,目标线程都是存在死锁风险的。也正是这个原因jdk 废弃了这两个方法。


线程安全的实现方法
  • 互斥同步(悲观锁实现)

    Synchronized是在学习Java时候最常见的一种线程安全保障手段,基本原理就是在多个线程并发访问共享数据是,保证共享数据在同一个时刻只能被一个线程锁占用。 Synchronized在经过编译之后会形成monitorenter和monitorexit两个字节指令。根据虚拟机规范的要求,在执行monitorenter的时候首先尝试获取对象的锁, 如果这个对象没有被锁定,或者当前线程已经获取到该对象的锁(锁重入),把锁的计数器加1,相应的momitorexit指令会将锁计数器减1,当计数器为0是,锁被释放。 除此之外,还有java.util.concurrent包下的ReentrantLock也是互斥锁的一种实现,区别在于一种是java语义层面的实现,一种是jvm级别的实现。

  • 非阻塞同步(乐观锁实现)

    互斥同步虽然实现了保障线程安全问题,但是在线程阻塞和唤醒的同时也带来了性能问题,因此也称为阻塞同步。随着指令集的发展,基于CAS(Compare-and-Swap)实现的非阻塞同步出现了。 基本思想就是先进行操作,如果没有其他线程争抢共享数据,那么操作就成功了。如果产生了冲突,则可以通过补偿机制(类似重试, 直到成功),这种同步措施并不需要当前线程挂起。Java中比较常见的就是java.util.concurrent包下的一些Atomic* 类(原子类)。不过,尽管CAS实现了非阻塞的同步,但是也带来了一些 比如ABA问题和大量线程空转导致的cpu资源浪费等问题。

  • 非同步方案

    要保证线程安全,并不是一定要进行线程同步,同步只是保证在共享数据争用时数据的正确性,但是如果一个方法本身就不涉及共享数据,那么他本身就是线程安全的,就没有必要对其进行同步。常见的两类如下:

    • 可重入代码
    • 线程本地存储(可参考 ThreadLocal)
Synchronized 优化点

jdk1.6之后HotSpot虚拟机开发团队话费了大量精力去实现各种锁优化技术,如适应性自旋锁,锁消除,锁粗化,轻量级锁和偏向锁等。

  • 自旋锁和适应性自旋锁

    自旋锁的实现可以参考前面非阻塞同步的内容,当有两个或以上的线程同时争抢一个共享数据的时候, 我们可以让后面请求锁的线程不放弃CPU执行时间,通过一种忙循环的方式实现去等待获取锁。但是我们也知道,这样的方式势必会造成CPU的资源浪费。因此通常自旋的线程等待一定时间后如果还没有获取到锁,那么就会用传统的方式将线程挂起。 jdk1.6之后还引入了自适应的自旋锁,区别在与自适应的自旋锁自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来定。如果一个锁对象上,自旋等待刚刚成功获得锁,进而会将允许自旋等待持续相对更长的时间。 如果对于某个锁,自旋很少成功获得过,拿在以后要获取这个锁是将可能省略掉自旋的过程,直接挂起,避免CPU资源的浪费。

    • 注: 自旋锁主要还是在"重量级"锁的场景下,通过自旋的方式来减少通常线程挂起导致的性能损耗。
  • 锁消除

    锁消除是指虚拟机在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的环境,此时就会消除原有代码上标明的同步机制,这个操作叫锁消除。锁消除的判定依据主要来源于逃逸分析(可参考占小狼的 浅谈HotSpot逃逸分析) 。

  • 锁粗化

    原则上来说,我们编码的时候,总是推荐将同步代码块的作用范围限制得尽量小。但是如果一系列的连续操作都是对同一个对象反复进行加锁和解锁,甚至加锁操作是在循环体中,那即使没有线程竞争,频繁进行互斥同步操作也会导致不必要的性能损耗。此时虚拟机就会把枷锁同步的范围扩展(粗化)到整个操作序列的外部,减少反复加锁的性能损耗。

  • 偏向锁和轻量级锁

    关于偏向锁和轻量级锁以及锁膨胀过程,我们在下一个篇幅继续说明。

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