vlambda博客
学习文章列表

Java虚拟机—线程安全和锁优化

1.线程安全

1.1定义

如果一个对象可以安全的被多个线程同时使用,那它就是线程安全的。——来自Google搜索的简单定义
当多个线程访问同一个对象时,如果不用考虑多个线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。——来自《Java Concurrency In Practise》的作者
通过这个定义,我们看出它要求:线程安全的代码必须具备一个特征,即代码本身封装了所有必要性的保障手段(如互斥同步)来保证多线程的正确调用。


1.2Java语言中的线程安全

讨论线程安全的时候,我们假设多条线程间存在共享的数据和变量,按照线程“由弱至强”的安全程度来排序,我们可以将Java语言中各种操作共享的数据分为以下5类(划分5类的依据是Brain Geotz在IBM developWorkers发表的一篇论文中提出的):
不可变
绝对线程安全
相对线程安全
线程兼容
线程对立


1.2.1不可变

在JDK1.5以后,不可变(Imumutable)的对象一定是线程安全的,不需要采取任何线程安全措施。一个Java基本类型的数据只需要用关键词final修饰,就可以拥有「不可变」属性。
如果是一个Java对象,则需要采取措施保证对象的行为对其状态不会产生任何影响。java.lang.String类就是不可变的对象,其中的各种操作如substring(),replace(),concat()等各种方法都不会影响到它原来的值,因为每次操作时都重新生成了一个全新的String对象。

public String substring(int beginIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = length() - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } if (beginIndex == 0) { return this; } return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen) : StringUTF16.newString(value, beginIndex, subLen);}

1.2.2绝对线程安全

一个类要达到Brain Geotz描述的「绝对线程安全」,需要满足:不管运行时环境如何,调用者都不需要任何额外的同步措施。这个条件其实很严格,JavaAPI中标注自己是线程安全的类,大多数都不是「绝对线程安全」的。
如Java.util.Vector是一个线程安全的容器,它的add() get() size()这类方法都是被sychronized修饰过的,所以它是相对线程安全的,在多线程的环境下,如果要达到绝对安全,仍然需要额外的同步措施。
下面的例子很好地说明了这种情况:
构造两个线程,一个线程removeThread用来不停地删除Vector中的数字,而另一个线程printThread不断地访问Vector中的数字,这样如果在printThread线程访问某个元素i前的时刻,元素i已经被removeThread删除了,那么运行的程序将会抛出ArrayIndexOutOfBoundsException异常。

package JustCoding.Practise.LearnJVM;import java.util.Vector;public class ThreadSafeTest { private static Vector<Integer> vector = new Vector<>(); public static void main(String[] args){ while (true){ for(int i=0; i<10; i++){ vector.add(i); } Thread removeThread = new Thread(new Runnable() { @Override public void run() { for(int i=0; i<vector.size(); i++){ vector.remove(i); } } }); Thread printThread = new Thread(new Runnable() { @Override public void run(){ for(int i=0; i<vector.size(); i++){ System.out.println(vector.get(i)); } } }); removeThread.start(); printThread.start(); //不要同时产生过多的线程,否则会导致操作系统假死 while (Thread.activeCount()>20); } }}

报错结果:

Exception in thread “Thread-553519” java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 10
at java.base/java.util.Vector.get(Vector.java:780)
at JustCoding.Practise.LearnJVM.ThreadSafeTest$2.run(ThreadSafeTest.java:28)
at java.base/java.lang.Thread.run(Thread.java:834)

1.2.3相对线程安全

相对线程安全,就是我们通常意义下的线程安全。它需要保证对这个对象单独的操作是线程安全的,我们在调用时无需做额外的保障措施。但是对于一些特殊情况下的连续操作,就不一定是安全的了。
在Java语言中大部分线程安全的类都是相对线程安全的,如:Vector、HashTable、Collections的sychronizedCollection()方法包装的集合等。


1.2.4线程兼容

线程兼容是指,对象本身并不是线程安全的,但是完全可以通过在调用方使用正确的同步手段来保证对象在并发情况下的线程安全。如ArrayList和HashMap就是典型的线程兼容类。


1.2.5线程对立

线程对立的意思就是「线程不兼容」,意味着无论调用方是否采取同步措施,都不能保证在多线程环境下的线程安全的一段代码。
典型的例子是Thread类的两个方法:suspend()和resume()以及System.setIn()和System.setOut()方法。


1.3线程安全的实现方法

了解了什么是线程安全之后,那我们如何实现线程安全呢?其实线程安全不仅与代码的编写有很大的关系,而且虚拟机的同步和锁机制也起到了非常重要的作用。
线程安全的实现方法主要分为2大类:非同步手段和同步手段,其中同步手段主要包括互斥同步(阻塞同步)和非阻塞同步。


1.3.1非同步手段

要保证线程安全,并不一定要进行同步。同步只是保证多条线程操作共享数据时正确性的手段,如果一个方法本身不涉及到共享数据,那么它本身就是「线程安全」的,也无需任何同步措施来保证其线程安全。

可重入代码(Reentrant Code)

可重入代码也叫纯代码,表示可以在代码执行的任意时刻停止它,转而去执行另一段代码(或者递归调用本身),而在控制权返回时,原程序不会出现任何错误。
所有可重入的代码都是线程安全的,反正则不一定。我们可以通过一个简单的原则判断一段代码是否是可重入的:如果一个方法,它的返回结果是可预测的,即只要输入相同的数据就能返回相同的结果,那么它就是「可重入的」同样也是「线程安全」的。

线程本地存储(Thread local Storage)

如果一段代码中所需要的数据必须与其他线程共享,但是能保证共享数据这段的代码块可以在一个线程中执行,那么,无需同步也可以保证线程之间没有数据争用,即线程安全。
大部分使用消费队列的架构模式,如生产者-消费者模式,都会将产品的消费过程尽量在一个线程中消费完。如很多“一个请求对应一个服务器线程”的Web服务器,就采用的是线程本地存储解决了线程安全的问题。


1.3.2互斥同步(阻塞同步)

互斥同步是常见的并发正确性保障手段,「同步」是指在多线程并发共享访问数据的情况下,保证被共享的数据在任意「时刻」内只能被一条线程访问。
「互斥」是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。
在Java中实现「互斥同步」的最基础手段就是使用sychronized关键字。其次我们还可以使用java.util.concurrent并发包下 的重入锁(ReentrantLock).
sychronized 关键字经过编译后在同步块地两端形成monitorenter和monitorexit两条字节码指令,这两个字节码的参数都是一条reference引用类型的对象,而这个引用指向sychronized关键字要锁定和解锁的对象。

根据sychronized关键词修饰的是实例方法还是类方法,去取对应的对象实例或Class类对象来做锁定/解锁的对象。

下面,重点说一下sychronized关键字、monitorenter和monitorexit指令和他们实现互斥同步的原理:

Java虚拟机—线程安全和锁优化



首先,任何一个对象都有一个monito与之关联,当一个对象的 monitor被持有后,此对象进入「锁定」状态。根据Java虚拟机规范的要求,在执行monitorenter指令时,会按下列方式尝试获取一个对象的monitor:
1.如果reference引用指向的对象的monitor“进入计数器”为0,则当前线程成功进入monitor称为对象持有者,并将计数器值设置为1。
2.如果当前线程已经「拥有」这个对象的monitor,则它可以重入此对象的monitor,此时进入计数器值+1。
3.如果此对象的monitor正在被其他线程持有,则当前线程将被阻塞,知道monitor的进入计数器的值等于0时,才能重新尝试获取其monitor。
同样,在执行monitorexit指令时:

Java虚拟机—线程安全和锁优化

线程把monitor的进入计数器值减1,若-1后的值为0,则该线程退出monitor,并让出monitor拥有者的角色;若-1后值>0,则继续持有该monitor。
可以看见,通过monitor的设置配合monitorenter和monitorexit指令,使得任意时刻只有1条线程单独操作对象,从而保证了「互斥同步」的实现。
最后,还有两点需要强调:
1.sychronized同步块对于同一条线程来说是「可重入」的,所以不会出现把自己锁死的情况。
2.为什么说sychronized是「重量级」的操作?因为在线程进入monitor被「阻塞」时,会使操作系统从「用户态」切换到「内核态」,同样,线程由阻塞状态被唤醒时,则会使操作系统由内核态切换为用户态。而这样的操作和状态需要消耗很多CPU时间,有时转换时间>用户代码的执行时间。所以如非所需,应该尽量避免sychronized操作。

1.3.3非阻塞同步(Non-Blocking-Synchronization)

从处理问题的方式来说,互斥同步(阻塞同步)属于一种「悲观」的并发策略,即认为只要不做正确的同步措施例如加锁,则一定会出现问题,无论共享数据是否真的会出现竞争。
随着硬件指令集的发展,我们有了另外一个选择:
基于冲突检测的「乐观」并发策略。
简单说,就是先进行操作,如果没有其他线程争用共享数据,则操作正常完成;如果共享数据被线程争用则采取其他补救措施(如不断重试,知道成功为止)。这种乐观的并发策略的许多实现都不需要将线程挂起,因此称为非阻塞同步。



2.锁优化

高效并发时从JDK1.5到1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本上花费了大量精力去实现各种锁优化技术,如:
适应性自旋(Adaptive Spining)
锁消除(Lock Elimination)
锁粗化(Lock Coarsening)
轻量级锁(LightWeight Locking)
偏向锁(Biased Locking)
这些技术都是为了在线程之间更高效地共享数据,以解决竞争问题从而提高程序的执行效率。

2.1自旋锁与自适应自旋

在上面讨论互斥同步时,我们提到此过程中对性能最大的影响是线程的阻塞,因为由运行和阻塞间状态的切换都需要在操作系统「内核态」完成,频繁地在「用户态」和「内核态」之间切换会比较消耗性能。
由于多条线程共享同一个数据区域时的锁定状态往往只会持续很短的时间,所以为了这很短的时间去阻塞OR恢复线程就是比较「不合算」的,如果物理机器上有2个及以上的CPU,那么我们就可以让两个线程并行执行,在线程A独占数据区域时,另一个线程B不挂起(阻塞)而是稍等片刻但是不放弃CPU时间,因为可能很快线程A就会完成对共享数据的操作后释放锁。
让线程B「稍等片刻」的方式就是让线程执行一个忙循环,即所谓的「自旋」,这样的技术就是「自旋锁」。
自旋锁在JDK1.42中就已经引入,默认关闭,可通过-XX:+UseSpining参数开启。
在JDK1.6以后引入了自适应的自旋锁,且默认开启。自适应意味着自旋的时间不再是固定的,而是由上一个在同一个锁上的自旋时间和锁拥有者的状态来决定。


2.2锁消除

锁消除是指虚拟机JIT即使编译器在运行时,对一些「代码上要求同步」,但是被检测到不存在共享数据竞争的锁进行消除。
是否进行锁消除的依据来源于逃逸分析中的数据支持,如果一段代码中,堆上的数据都不会逃逸出去从而被别的对象引用,则可以把它当做「虚拟机栈」中的私有数据,即线程私有的,从而不需要进行加锁操作。


2.3锁粗化

大多数情况下,我们希望锁的范围尽可能小,一段代码块中尽量只在共享数据实际作用域内才进行同步,这样即使存在锁竞,那等待锁的线程也会较快地拿到锁。但是有些情况譬如一系列的操作都对同一个对象反复加锁和解锁,甚至加锁解锁的操作是在循环体内不断重复,那么即使没有线程竞争,此操作也会引起较大地性能损耗。
如果虚拟机探测到有这样一段零碎的操作都是在对同一个对象进行加锁,将会将加锁范围拓宽延伸(粗化)到整个操作序列的外部。


2.4轻量级锁

轻量级锁是JDK1.6时加入的,相对于操作系统的传统的互斥锁「重量级锁」,轻量级锁用来在多线程条件下,减少重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁的实现,和Java虚拟机的内存布局相关,在HotSpot虚拟机的对象头(Object Header)中,分为两部分数据:
1.运行时数据如HashCode、GC分代年龄等
2.存储方法区对象类型数据的指针
其中第1.部分的32位(或64位)的数据,官方称为“Mark Word”,它是实现轻量级锁的关键。
如在32bit位的HotSpot虚拟机中,对象未被锁定的正常状态下,32位的Mark Word中25bit用于存储对象的哈希码(HashCode),4bit存储对象的分代年龄,2bit存储锁标志位,1bit位固定为0,而在对象处于其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)时,对象头中的Mark Word部分如下:



Java虚拟机—线程安全和锁优化


在代码进入同步块时,如果此同步对象未被锁定,锁标志位“01”,虚拟机将在当前线程的栈帧中建立一个名为锁记录Lock Record的空间用于存储锁对象对象头中Mark Word的拷贝,官方称为Displaced Mark Word。此时线程堆栈和对象头状态如下图:

Java虚拟机—线程安全和锁优化

然后虚拟机将用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果更新成功,则表示当前线程成功持有了该对象的锁,Mark Word的锁标志位将变为“00”表示对象处于「轻量级锁定」状态。此时示意图如下:

如果更新操作失败,虚拟机会首先检查对象的Mark Word是否指向当前线程的栈帧:
是:说明当前线程已经持有该对象的锁,可以直接进入同步块继续执行;否:说明该线程的锁被其他线程抢占。如果有2条以上的线程正在争用同一个锁,则轻量级锁将不再有效,而是膨胀为「重量级锁」,锁标志位的状态值变为“10”,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入「阻塞」状态。
上述过程描述的是轻量级锁的加锁过程,它的解锁过程也是由CAS操作来进行的,如果对象的Mark Word仍然指向Lock Record,那就用CAS操作把线程对象当前的Mark Word和线程中复制的Lock Record中的Displaced Mark Word替换回来,如果替换成功,则完成同步过程;如果失败,说明有其他线程尝试过获取该锁,则需要在释放锁的同时唤醒被挂起的线程。


2.5偏向锁

偏向锁也是JDK1.6中引入的一项锁优化,目的是消除数据在无竞争条件下的同步原语,进一步提高程序运行性能。
作为对比,轻量级锁就是在无竞争条件下使用CAS操作去消除同步使用的互斥量,而偏向锁就是在无竞争的情况下消除整个同步,连CAS操作都不需要。
偏向锁的「偏」的意思是它会“偏向于”第一个获取它的线程,如果在接下来的过程中该偏向锁没有被其他线程获取,则此线程永远不需要进行同步。

原理:

假设当前虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程每次重入时,虚拟机都可以不用作任何同步操作。
「偏向模式」持续到有其他线程尝试获取这个偏向锁时就宣告结束了。根据锁对象目前是否处于被锁定的状态,撤销偏向后的状态会恢复到轻量级锁定(“00”)或未锁定(“01”)状态。
后续的操作就如同上面介绍的轻量级锁那样继续执行,如下图所示:



偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade-Off)性质的优化,也就是说它并不一定总是对程序运行有利,如果程序运行过程中的大多数锁总是被多个不同线程访问,那偏向模式就是多余的。在具体问题具体分析的情况下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。