jvm虚拟机笔记<八> 线程安全与锁优化
一 线程安全
1.1 什么是线程安全?
如果对于一个对象可以安全地被多个线程同时使用,那么它就是线程安全的。
1.2 Java语言中的线程安全
在这里讨论线程安全,就限定于多个线程之间存在共享数据访问这个前提。
将Java语言中各种操作共享的数据分为5类:
(1)不可变
不可变对象一定是线程安全的(没有发生this引用逃逸的情况下,不会被其他线程操作,线程私有)。
如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。
如果共享数据是一个对象,那么就需要保证对象的行为不会对其状态产生任何影响即可。(如String类,调用它的substring()、replace()、concat()这些方法不会影响它原来的值)
保证对象的状态不可变最简单的方法就是把对象中带有状态的变量都声明为final。
(2)绝对线程安全
很多Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全,在多线程情况下,如果不在方法调用端做额外的同步措施的话(如Synchronized),仍然是不安全的。
要达到绝对的线程安全,即“不管运行环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的甚至是不切实际的开销。
(3)相对线程安全
在Java语言中,大部分的线程安全类都属于这种类型。如Vector、HashTable
(4)线程兼容
指对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全使用。如HashMap,ArrayList。
(5)线程对立
指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。
1.3 线程安全的实现方法
1.3.1 互斥同步(阻塞同步)
同步:指多个线程并发访问共享数据时,保证数共享数据在同一时刻只被一个线程(或者是一些,使用信号量的时候)使用。
互斥是实现同步的一种手段,互斥是因,同步是果。
(1)synchronized关键字
在Java中,最基本的互斥同步手段就是使用synchronized关键字。
原理:synchronize关键字经过编译以后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
锁定的对象:如果synchronized明确指定了对象参数,那就是这个对象的reference,如果没有明确指定,根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
过程:在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释为止。
重量级锁:Java的线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户状态转换到核心态中,因此状态转换需要消耗很多的处理器时间。对于简单的同步块,状态转换消耗的时间有可能比用户代码执行的时间还要长,所以synchronized是Java语言中的一个重量级操作。所以在必要的情况下才使用这种操作,而虚拟机本身也会做一些优化,比如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心状态。
(2)ReentrantLock
还可以使用java.util.concurrent包中的重入锁ReentrantLock来实现同步(lock()和unlock()方法配合try/finally使用)。
区别:相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁(通过带布尔值的构造函数,默认情况下是非公平的)、以及锁可以绑定多个条件
PS : 公平锁和非公平锁
公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
非公平锁:在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以实现公平锁。
1.3.2 非阻塞同步
互斥同步最主要的问题是进行线程阻塞和唤醒带来的性能问题。因此这种同步也叫做阻塞同步。
(1)基于冲突检测的乐观并发策略
互斥同步的问题: Synchronized互斥锁属于悲观锁,它有一个明显的缺点,它不管数据存不存在竞争都加锁,随着并发量增加,且如果锁的时间比较长,其性能开销将会变得很大。
解决的办法:基于冲突检测的乐观乐观并发策略。这种模式下,已经没有所谓的锁概念了,每条线程都直接先去执行操作,计算完成后检测是否与其他线程存在共享数据竞争,如果没有则让此操作成功,如果存在共享数据竞争则可能不断地重新执行操作和检测,直到成功为止,这种并发策略的而许多实现都不需要把线程挂起,因为这种同步操作称为非阻塞同步。
靠硬件保证:随着硬件指令集的发展,可以保证操作和冲突检测这两个操作具有原子性,这类指令有CAS(Compare and Swap )、LL/SC等。
CAS指令: 有3个操作数,分别是内存位置(V)、旧值(A)、新值(B)。如果A == V中的值,那么用B更新V的值。
实现:Java程序中可以使用CAS操作,由sun.misc.Unsafek类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的CAS指令,没有方法调用的过程。
CAS的逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍为A值,那我们就能说它的值没有被其线程改过了吗?可能存在一种情况,在这段期间它的值被改成了B,后来有被改回A,那么CAS操作就误认为它从来没改变过。这种漏洞称为CAS操作的“ABA”问题。大部分情况下ABA问题不会影响程序并发的正确性。
PS : 悲观锁和乐观锁
悲观锁:拿数据时,总是认为数据会被其他线程修改,如果不做同步措施,总会出现问题,所以无论数据是否出现竞争,都要进行加锁,用户态和心态转换,维护锁计数器,检查是否有被阻塞的线程需要唤醒等操作。
乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在最后更新的时候会判断一下在此期间别人有没有去更新这个数据,如果有则更新失败,反复执行操作和检测。
1.3.3 无同步方案
如果一个方法本来就不涉及共享数据,自然就无须任何同步措施去保证正确性,有些代码天生就是线程安全的。
二 锁优化
2.1 自旋锁 和 自适应自旋
2.1 锁消除
2.2 锁粗化
2.3 轻量级锁
2.4 偏向锁
偏向锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
一、重量级锁
上篇文章中向大家介绍了Synchronized的用法及其实现的原理。现在我们应该知道,Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
二、轻量级锁
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。锁的状态保存在对象的头文件中,以32位的JDK为例:
锁状态 |
25 bit |
4bit |
1bit |
2bit |
||
23bit |
2bit |
是否是偏向锁 |
锁标志位 |
|||
轻量级锁 |
指向栈中锁记录的指针 |
00 |
||||
重量级锁 |
指向互斥量(重量级锁)的指针 |
10 |
||||
GC标记 |
空 |
11 |
||||
偏向锁 |
线程ID |
Epoch |
对象分代年龄 |
1 |
01 |
|
无锁 |
对象的hashCode |
对象分代年龄 |
0 |
01 |
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
1、轻量级锁的加锁过程
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。
(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
图2.1 轻量级锁CAS操作之前堆栈与对象的状态
图2.2 轻量级锁CAS操作之后堆栈与对象的状态
2、轻量级锁的解锁过程:
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
三、偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
1、偏向锁获取过程:
(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
(5)执行同步代码。
2、偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
3、重量级锁、轻量级锁和偏向锁之间转换
图 2.3三者的转换图
该图主要是对上述内容的总结,如果对上述内容有较好的了解的话,该图应该很容易看懂。
四、其他优化
1、适应性自旋(Adaptive Spinning):从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
2、锁粗化(Lock Coarsening):锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:
1 package com.paddx.test.string; 2 3 public class StringBufferTest { 4 StringBuffer stringBuffer = new StringBuffer(); 5 6 public void append(){ 7 stringBuffer.append("a"); 8 stringBuffer.append("b"); 9 stringBuffer.append("c"); 10 } 11 }
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
3、锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
1 package com.paddx.test.concurrent; 2 3 public class SynchronizedTest02 { 4 5 public static void main(String[] args) { 6 SynchronizedTest02 test02 = new SynchronizedTest02(); 7 //启动预热 8 for (int i = 0; i < 10000; i++) { 9 i++; 10 } 11 long start = System.currentTimeMillis(); 12 for (int i = 0; i < 100000000; i++) { 13 test02.append("abc", "def"); 14 } 15 System.out.println("Time=" + (System.currentTimeMillis() - start)); 16 } 17 18 public void append(String str1, String str2) { 19 StringBuffer sb = new StringBuffer(); 20 sb.append(str1).append(str2); 21 } 22 }
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。下面是我本地执行的结果:
为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。
注:可能JDK各个版本之间执行的结果不尽相同,我这里采用的JDK版本为1.6。