深入理解Java虚拟机(第三版)-14. 线程安全与锁优化
14. 线程安全与锁优化
1. 什么是线程安全?
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替进行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
2. Java语言中的线程安全
我们将Java语言下的线程安全分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
1.不可变:不可变一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要进行任何线程安全保障措施。
例如:final修饰的基本类型变量不可变;类不可变,可以通过将类中的字段全部声明为final,比如Integer
实例:String,枚举类型,java.lang.Number的部分子类,如Long和Double等数值包装类、BigInteger和BigDecimal等大数据类型。但Number下的原子类AtomicInteger等是可变的
2.绝对线程安全:需要达到1中的条件,一个类要达到”不管运行时环境如何,调用者都不需要进行额外的同步措施“的条件,需要付出高昂甚至不切实际的代价。
Java API中标注线程安全的类,都不是绝对的线程安全。例如Vector,如果要做到绝对安全,需要在内部维护一组一致性的快照访问才行,每次针对其中元素进行改动时,都要产生新的快照,这样代价很大。
3.相对线程安全:即通常意义上的线程安全,保证对这个对象的单次操作是安全的,调用时不需要额外的保障措施;但是连续的调用操作,需要调用端使用额外的同步手段。
4.线程兼容:对象本身不安全,但是通过调用端正确使用同步手段来保证对象在并发环境下的安全使用。
Java中大多数类是线程兼容的,比如HashMap、ArrayList
5.线程对立:不管调用方是否采取了同步措施,都无法在多线程环境下保证线程安全。
Thread的suspend()和resume();System.setIn()和 setOut()
3.线程安全的实现方法(倾向于虚拟机如何实现同步与锁)
1.互斥同步
互斥的实现方式:临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)。
Java中,最基本的互斥手段是Synchronized,synchronized关键字在经过javac编译之后,会在同步块前后行程moniterenter和moniterexit两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果没有指定,将根据synchronized修饰的方法类型(如实例方法或类方法)来决定线程持有的锁是代码所在的对象实例还是类型对应的Class对象。
Java虚拟机规范要求,在执行moniterenter时,首先去获取对象的🔐,如果这个对象没有被锁定,或该线程已持有这个对象的锁,就把锁的计数器加一,在执行moniterexit时,将对应锁的计数器减一,一旦计数器为0,则释放该对象的锁。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到其他线程释放该锁。
推论可得:
1.synchronized是可重入锁
2.synchronized修饰的同步块在释放锁之前,会无条件的阻塞后面其他线程的进入。无法强制其释放锁,也无法强制正在等待锁的线程中断等待或超时退出。
从执行成本来看,持有锁是一个重量级操作。因为阻塞或唤醒线程,需要操作系统完成,这不可避免的陷入了用户态到内核态的转换,这种转换需要耗费很多的处理器时间。可能长于代码执行时间。后续会讲到synchronized的优化。
ReentrantLock与Synchronized区别:
ReentrantLock的优势:
1.等待可中断:当持有锁的线程长时间不释放锁,正在等待的线程可选择放弃,处理其他事情。
2.公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。ReentrantLock默认非公平,带布尔值的构造函数可以设置为公平锁,但会导致性能急剧下降,明显影响吞吐量。
3.锁绑定多个条件:一个ReentrantLock可以同时绑定多个Condition对象。Synchronized中,锁对象的wait()和notify()notifyAll()配合使用,可实现一个隐式条件,多个条件的话,需要多个对象锁。
而ReentrantLock只需要多次调用newCondition()就可以。
4.性能方面:JDK6对Synchronized进行了大量优化,两者基本持平,性能不再是决定使用谁的标准
Synchronized优势:
1.Synchronized是语法层面的同步,清晰简单,因此在只需要基础功能时,使用Synchronized
2.Lock需要确保在finally中释放锁,否则同步代码块抛异常,可能导致锁释放失败。这一点需要程序员自己保证。而Synchronized不需要,JVM保证即使异常,锁也会被自动释放
3.长远来看,JVM更可能对Synchronized进行优化,因为java虚拟机可以在线程和对象的元数据中记录Synchronized中锁的相关信息,而使用J.U.C的话,java虚拟机是很难得知具体哪个对象由特定线程持有的。
2.非阻塞同步
同步互斥的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此也被称为阻塞同步,同时也是一种悲观的并发策略。
该策略认为只要不去做正确的同步措施(加锁),那就肯定会出现问题,这会导致用户态到核心态的切换、维护锁计数器、检查是否有被阻塞的线程需要被唤醒等开销。
基于冲突检测的乐观并发策略,也称为非阻塞同步。即先进行操作,如果没有其他线程争用共享数据,那操作成功。如果共享数据被争用,发生了冲突,那再进行其他的补偿措施。最常用的补偿措施就是重试。
为什么说使用乐观并发策略需要”硬件指令集的发展“?
因为我们必须要求操作和冲突检测这两个步骤具有原子性,而如果使用同步互斥来保证原子性就失去意义了,所以我们只能靠硬件来实现这件事情。硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成。
CAS 指令是什么及CAS存在的问题
CAS指令需要三个操作数,分别是内存位置(在java中可以简单理解为变量的内存地址,用V标识),旧的预期值A和准备设置的新值B。CAS指令执行时,当且仅当,V符合A时,处理器才会用B更新V的值,否则不执行更新。但不管是否更新,都会返回V的旧值。该操作是原子操作,不会被其他线程中断。
JDK5之后,java类库才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和 compareAndSwapLong()等几个方法包装提供。HotSpot对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件的内联进去了。
Unsafe类在设计上是不会提供给用户程序调用的类(UNsafe限制了只有启动类加载器加载的class才能访问他),因此在JDK9之前只有java类库使用CAS,例如J.U.C中的整数原子类,其中的compareAndSet()和getAndIncrement()。而用户如果需要使用CAS的需求,要么采用反射突破Unsafe的访问限制,要么使用java 类库API来间接使用。
AtomicInteger 的 incrementAndGet() 方法在一个无限循环之中,不断尝试赋值,如果失败,说明在执行CAS过程中,旧值已经发生改变,于是再次循环,直到成功。如果一直失败,CPU压力会增加。
ABA 问题:JUC为了解决这个问题,提供了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量的版本来保证CAS的正确性。不过,一般情况下,ABA不会影响程序并发的正确性,如果真要解决ABA问题,使用传统的同步可能会更高效。
3. 无同步方案
如果能让一个方法本来就不涉及共享数据,那它自然不需要同步措施去保证其正确性,因此有些代码就是线程安全的。
1.可重入代码
2.线程本地存储
4. 锁优化
1.自旋锁与自适应自旋
虚拟机的开发团队注意到在许多应用上,共享数据的锁定状态知会持续很短的时间,为了这段时间去挂起和恢复线程并不值得。如果物理机有一个以上的处理器核心,能让两个或以上的线程同时并行执行,那就可以让后面请求锁的线程”稍等一会儿“,但不放弃处理器的执行时间,看持有锁的线程是否会很快释放。为了让线程等待,只需要线程执行一个忙循环(自旋),即自旋锁。
在JDK6中,已默认开启自旋。虽然自旋本身避免了线程切换的开销,但它是需要占用处理器时间的,如果锁被占用的事件很长,那么自旋会白白消耗处理器资源。因此自旋需要有一定的限度,默认自旋次数是10,用户也可以自行调节。
JDK6中,引入了自旋锁的优化,即自适应的自旋,即自旋的次数不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定的。如果在同一个锁对象上,自旋等待刚刚成功获取锁,并持有锁的线程正在运行中,那么认为这次自旋很可能成功,进而允许等待相对更长的时间。
2.锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的判定依据来源于逃逸分析的数据支持,如果判断一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它当做栈上数据对待,认为是线程私有的,同步加锁自然就不再需要。
例如 StringBuffer.append() 方法都有一个同步块,锁是sb对象,虚拟机观察变量sb,经过逃逸分析,发现他的动态作用域被限定在从concatString里,也就是sb的所有引用永远不会逃逸到concatString之外,其他线程无法访问到,所以这里虽然由锁,但可以安全的消除。在解释执行时这里仍然会加锁,但经过服务端即时编译之后,代码会忽略所有的同步措施直接执行。
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(); }
3.锁粗化
我们推荐将同步块的作用域限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能少,即使存在锁竞争,等待锁的线程也能尽快拿到锁
但如果一系列的连续操作都是同一个对象反复加锁解锁,甚至加锁的操作出现在循环体中,那即使没有竞争,频繁的进行同步互斥操作也会导致不必要的性能损耗。
上述连续的append操作就存在这个问题,都是对同一个对象加锁,因此,会把加锁同步的范围扩展(粗化)到整个操作系列外部,即第一个append之前,最后一个append之后。
4.轻量级锁
轻量级锁的设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁(Synchronized)使用操作系统互斥量产生的性能消耗。
该部分需要了解对象的内存布局(尤其是对象头),HotSpot虚拟机的对象头分为两部分
1.用于存储对象自身的运行时数据。如哈希码、GC分代年龄等,这部分数据的长度在32位或64位的虚拟机上分别会占用32或64非比特,称为”Mark Word“
2.用于存储指向方法区对象类型数据的指针。如果是数组对象,还会有一个额外部分用于存储数组长度
由于对象头信息与对象自身定义的数据无关的额外存储成本,考虑到java虚拟机的空间使用效率,Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它根据对象的桩体复用自己的存储空间。
对象的状态分为 未被锁定的正常状态、轻量级锁定、重量级锁定(锁膨胀)、GC标记、可偏向等不同的状态,这些情况下对象头的存储内容不定。
轻量级锁加锁的工作过程:
1.在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方为拷贝加了一个Displaced前缀,即Displace的 Mark Word)。
2.虚拟机使用CAS尝试把对象的 Mark Word更新为指向Lock Record的指针。
成功:该线程获取了这个对象的锁,并且锁对象的标记位(Mark Word的最后两位)变成00,标识当前对象处于轻量级锁状态
失败:至少存在一个线程与当前线程竞争该对象的锁,检查对象的Mark Word是否指向当前线程的栈帧,
是:当前线程已拥有该对象的锁,可重入,直接进入同步代码块
否:该对象已被其他线程抢占,如果存在两条以上的线程争用同一个锁,那轻量级锁不再有效,必须膨胀为重量级锁,锁标记位变成 10,此时mark word存储的是指向重量级锁的指针,后面等待的线程也进入阻塞状态。
轻量级锁解锁的工作过程
1.CAS操作,如果对象的mark word仍然指向线程的锁记录,那就用cas操作把对象当前的Mark Word和线程中复制的 Displaced Mark Word 替换回来
替换成功:同步过程完成
替换失败:其他线程尝试获取过该锁,在释放锁的同时,唤醒被挂起的线程。
性能:依据:对绝大部分的锁,在整个同步周期内都是不存在竞争的。即,如果没有竞争,轻量级锁则通过CAS避免了使用互斥量的开销,而如果存在竞争,除了互斥量的开销,还有CAS的开销。因此,在竞争的情况下,轻量级锁反而比传统的重量级锁更慢。
5.偏向锁
偏向锁就是在没有锁竞争的时候,把整个同步都消除,连基本的CAS操作都不去做了。
偏向锁的这个锁会偏向于第一个获得它的线程,在接下来的过程,该锁一直没被其他的线程获取,则持有该锁的线程将永远不需要同步。
1.假设虚拟机当前开启了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机会把对象头的标记位设置为01(未被锁定状态),把偏向模式设置为1,标识进入偏向模式。
2.同时,使用CAS操作把获取这个锁的线程ID放到对象头Mark Word的前23位
CAS成功:持有偏向锁的线程以后每次进入这个锁相关的同步块时,不需要做任何同步操作(例如加锁解锁MarkWord 更新操作)。
3.一旦有其他线程去尝试获取这个所,偏向模式马上结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设为0),撤销后标记位恢复到未锁定状态(01)或轻量级(00)状态,后续的同步操作按照上述的轻量级锁那样去执行。
当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来的对象的哈希码怎样呢?
在Java语言里,一个对象计算过哈希码,就应该一直保持不变,否则很多依赖对象哈希码的java API都可能有出错风险。
而作为绝大多数对象哈希码来源的 Object::hashCode() 方法,返回的是对象的一致性哈希码,这个值是能强制保持不变的,它通过在对象头中存储计算结果来保证一次计算后,再次调用该方法取得的哈希码永远不再发生改变
因此,当一个对象计算过哈希码之后,就再也无法进入偏向锁状态;而当一个对象处于偏向锁状态,又收到需要计算其一致性哈希码的请求(指Object::hashCode()不包含对象重写的hashCode() 方法),偏向锁会被立即撤销,并且锁膨胀为重量级锁。
性能:如果程序中大多数的锁总是被多个不同的线程访问,那偏向锁就是多余的,会导致性能下降
问题思考:
ThreadLocal
线程池
ReentrantReadWriteLock
Chapter11 逃逸分析技术
Chapter2 对象内存布局