Skip to content

Latest commit

 

History

History
128 lines (80 loc) · 15.9 KB

11.线程安全与锁优化.md

File metadata and controls

128 lines (80 loc) · 15.9 KB

线程安全与锁优化

目录


1. 线程安全

线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

1.1 线程安全的实现方法

1.1.1 互斥同步

互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。

最基本的互斥同步手段就是synchronized关键字,这是一种块结构的同步语法。synchronized关键字经过javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的class对象来作为线程要持有的锁。

在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

从执行成本的角度看,持有锁是一个重量级的操作。

自JDK5起,Java类库中新提供了java.util.concurrent包,其中java.util.concurrent.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构来实现互斥同步。

重入锁(ReentrantLock)是Lock接口最常见的一种实现,它与synchronized一样是可重入的。ReentrantLock与synchronized相比增加了一些高级功能,主要有:等待可中断、可实现公平锁、可以绑定多个条件。

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助
  • 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁:而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock性能急剧下降,会明显影响吞吐量。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多余一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

JDK6中加入了大量针对synchronized锁的优化措施,所以性能不再是选择synchronized或者ReentrantLock的决定因素。

ReentrantLock在功能上是synchronized的超集,在性能上又至少不会弱于synchronized,那么为什么不直接抛弃synchronized? 下面这些场景下,synchronized比ReentrantLock更适合

  • synchronized是在Java语法层面的同步,简单&清晰。每个Java程序员都熟悉synchronized,但JUC中的Lock接口则并非如此。因此在只需要基础的同步功能时,更推荐synchronized
  • Lock应该确保在finally中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话,则可以由Java虚拟机来确保即时出现异常,锁也能被自动释放
  • 尽管在JDK5时代ReentrantLock曾经在性能上领先过synchronized,但这已经是十多年前的胜利了。从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用JUC中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
1.1.2 非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步。从解决问题的方式看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施,那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。随着硬件指令集的发展,现在有了另一个选择:基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施就是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码也被称为无锁编程。

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

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-swap,CAS)
  • 加载链接/条件存储(Load-Linked/St ore-Condit ional)

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。

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

CAS存在的问题也比较多,但是现在基本都有解决方案。

  • ABS问题(中途有其他线程修改了A的值,但是在结束之前又改回去了,中间一小部分时间的数据是不对的,根据业务场景判断这种释放需要处理),Java的解决思路就是加一个版本号,可以使用AtomicStampedReference来解决
  • 循环时间长开销大,这个问题的解决可以参考Java8新增的LongAdder类。在高并发场景下,大量线程会同时去竞争更新同一个原子变量,但是由于同时只有一个线程CAS操作会成功,这就造成了大量线程竞争失败后自旋继续尝试,验证损耗CPU,这时候LongAdder的思路就是将一个变量分为多个变量,让多个线程去竞争多个资源,也就是把long值分为一个base加上一个Cell数组,最后取值时就是base加上多个Cell的值
  • CAS操作是针对一个变量的,如果对多个变量操作,可以:1. 加锁来解决 2. 封装成对象类解决
1.1.3 无同步方案

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步指数保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。

可重入代码:这种代码又称纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。 如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

2. 锁优化

JDK6上有大量锁优化技术:适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁等。这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

2.1 自旋锁与自适应自旋

如果物理机器上有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这就是自旋锁。

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

在JDK6中对自旋锁的优化,引入了自适应的自旋锁。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越聪明。

2.2 锁消除

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

2.3 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快的拿到锁。

大多数情况下,上面的原则都是正确的,如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

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();
}

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

2.4 轻量级锁

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

HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据被称为Mark Word,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。

JVM开发者发现在很多情况下,synchronized中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用CAS就可以解决(当有一个线程竞争获取锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行CAS操作获取该锁.),这种情况下,用完全互斥的重量级锁是没必要的.轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞.

2.5 偏向锁

如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想.一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好.