当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。
这里讨论的线程安全,就限定于多个线程之间存在共享数据访问的这个前提。
按照线程安全的“安全程度”由强到弱排序,可以把Java中各个操作共享的数据分为以下5类:
1 不可变
不可变(Immutable)的对象一定是线程安全的。
如果共享数据是一个基本数据类型,定义时使用final关键字修饰可保证它不可变。
如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。其中最简单的是把对象中带有状态的变量都声明为final,这样在构造函数结束后,它就是不可变的。
Java API中符合不可变要求的类型:java.lang.String/java.lang.Number部分子类,枚举类。
2 绝对线程安全
绝对安全的线程的类,完全符合线程安全的定义,但在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全,如Vector。
3 相对线程安全
是通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保证措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在Java语言中,大部分线程安全类都属于这种类型,如Vector
、HashTable
、Collections
的synchronizedCollection()
方法包装的集合等。
4 线程兼容
线程兼容是指对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中是可以安全使用的。Java API中的大部分的类都是属于线程兼容的,如ArrayList和HashMap等。
5 线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中使用的代码。线程对立这种排斥多线程的代码是很少出现的,通常都是有害的,应当避免。如Thread
类的suspend()
和resume()
方法。如果两个线程同时持有一个线程对象,两个线程并发对该线程对象执行suspend()
和resume()
方法,无论是否采用了同步,都存在死锁风险。
互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
synchronized关键字是最基本的互斥同步手段。
synchronized关键字编译后会在同步块的前后分别形成monitorenter和monitorexit字节码指令。这两个字节码都需要一个reference类型的参数指明要锁定和解锁的对象。如果在代码中指定synchronized的对象参数,那就是这个对象的reference。如果没有明确指定,就根据synchronized修饰的是实例方法还是类方法,取对应的对象实例或Class对象来作为锁对象。
在执行monitorenter指令时,首先尝试获取对象的锁,如果该对象没被锁定或者当前线程已经拥有了该对象的锁,就把锁的计数器加1;在执行monitorexit的时候,会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,当前线程就会阻塞等待,直到对象锁被另外一个线程释放。
synchronized对同一个线程是可重入的,不会出现自己将自己锁死的现象。
同步块在已进入的线程执行完之前,会阻塞后面的线程的进入。
synchronized是重量级的操作:Java线程是映射到操作系统的原生线程之上的,阻塞或唤醒一个线程都需要操作系统的帮助,这就需要从用户态转换到核心态中,因此状态转换会耗费很多处理器时间,这个时间可能比用户代码执行的时间还长。
虚拟机会进行一些优化:在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中。
重入锁ReentrantLock
和synchronized相比,重入锁ReentrantLock的高级功能有:
等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他的事情,这点对处理执行时间非常长的同步块很有帮助。
可实现公平锁:公平锁是指在多个线程等待同一个锁时,必须按照申请锁的顺序来依次获得锁;非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。ReentrantLock默认是非公平,可通过public ReentrantLock(boolean fair)
使用公平锁。
锁可以绑定多个条件:一个ReentrantLock
对象可以同时绑定多个Condition
对象。而synchronized
中,锁对象的wait()
和notify()
或notifyAll()
方法可以实现一个隐含的条件,如果要和多个条件关联的时候,就必须额外添加一个锁。而ReentrantLock
只需要多次调用newCondition()
即可。
synchronized
和ReentrantLock
性能:在JDK1.6及以上,synchronized
和ReentrantLock
性能基本持平,虚拟机在未来的性能改进中会更偏向于原生的synchronized
,在synchronized
可以实现需求的情况下,优先考虑使用synchronized
。
互斥同步最主要的问题是进行线程阻塞和唤醒时带来的性能问题,这种同步也称为阻塞同步Blocking Synchronization
。从处理问题的方式来说,互斥同步属于一种悲观的并发策略:总是认为只要不去做正确的同步措施(加锁),那就肯定会出问题。无论共享数据是否真的会出现竞争,它都进行加锁、用户态核心态转换、维护锁计数器、检查是否有被阻塞的线程需要唤醒等操作。
非阻塞同步是一种基于冲突检测的乐观并发策略的同步操作:先进行操作,如果没有其他线程争用共享数据,那操作就成功;如果共享数据有争用,产生了冲突,就在采取其他的补偿措施(比如不断的重试,直到成功)。这种乐观并发策略的很多实现都不需要把线程挂起,因此称为非阻塞同步。
乐观并发策略需要硬件指令集的发展,因为上述过程中的操作和冲突检测这两个步骤需要具备原子性,而这种原子性保证如果使用互斥手段实现就失去意义,所以只能靠硬件通过一条处理器指令来完成这种从语义上看起来需要多次操作的行为。这里的非阻塞同步进行的操作主要涉及CAS(Compare And Swap)
这条指令。使用该指令完成的操作具备原子性,称为CAS操作。
CAS指令执行时,需要3个操作数:内存位置(V)、旧的预期值(A)、新值(B)。当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新。但是无论是否更新了V的值,都会返回V的旧值。
在JDK1.5之后,Java程序中才可以使用CAS操作:sun.misc.Unsafe
类里面的compareAndSwapInt()
和compareAndSwapLong()
等方法包装提供。但该类不能由用户程序的类调用(除非使用反射)。java.util.concurrent
包中的AtomicInteger
等原子类的一些方法都使用了Unsafe
类的CAS操作。
CAS的逻辑漏洞——ABA问题:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,此时并不能说它的值没有被其他线程修改过,有可能在这期间它的值先被改成了B,后又被改为了A,而CAS操作就会认为它从来没有改变过。大部分情况下ABA情况不会影响程序并发的正确性,如果需要解决ABA问题(JDK通过引入AtomicStampedReference
来保证CAS的正确性),改用传统的互斥手段可能会比原子类更高效。
同步只是保证共享数据争用时的正确性的手段,要保证线程安全,并不是一定就要进行同步。
可重入代码
可重入代码的特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数传入、不调用非可重入的方法等。
判断代码是否具有可重入性:对于一个方法,如果输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,也是线程安全的。
线程本地存储
如果能保证共享数据的代码在同一个线程中执行,就把共享数据的可见范围限制在同一个线程内,就无须同步也能保证线程间不出现数据争用的问题。
大部分使用消费队列的架构模式,还有一个应用实例是web交互模型中的“一个请求对应一个服务器线程”的处理方式。
通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
锁优化技术(HotSpot虚拟机而言)包括适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。这些技术都是为了在线程之间更高效地共享数据以及解决竞争问题,从而提高程序效率。
自旋锁与自适应自旋
在互斥同步的时候,为了消除线程切换带来的性能消耗,让等待锁的线程执行一个忙循环(自旋),而不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。这就是自旋锁。
自旋等待虽然避免了线程切换的开销,但如果锁被占用很长时间,自旋的线程只会白白浪费处理器资源,所以自旋等待的时间要有一定限度。如果自旋超过了限定的次数仍然没有成功获得锁,就使用传统的方式挂起线程。自旋默认的次数是10,可以通过参数-XX:PreBlockSpin
更改。
JDK1.6中引入了自适应的自旋锁:自旋时间不固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机就会认为这次自旋也很有可能再次成功,并将自旋等待时间延长。如果对于某个锁,自旋很少成功,那么在之后获取该锁时可能会放弃不自旋直接挂起线程。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁就无需进行。
这种情况主要是一些Java API中类自己的同步的消除,因为一般在编写代码时都会清除哪些数据存在竞争。
public class LockEliminateTest {
// raw code
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
// javac 转化后的字符串连接操作
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();
}
}
对于 javac 转化后的字符串连接操作代码: 使用了同步,因为StringBuffer.append() 方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现他的动态作用域被限制在 concatString() 方法内部;也就是所 sb 的所有引用都不会逃逸到方法之外;
所以,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了;
锁粗化
原则上,在编写代码时,总是应该将同步块的作用范围限制得尽量小,以便使需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
但是,如果一系列的连续动作都对同一对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,比如连续的append()
方法。
轻量级锁
1)重量级锁定义:使用操作系统互斥量来实现的传统锁;
2)轻量级锁的目的:是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗;(干货——轻量级锁的作用)
3)HotSpot虚拟机的对象头分为两部分信息:(干货——HotSpot虚拟机的对象头分为两部分信息)
3.1)第一部分:用于存储对象自身的运行时数据,如哈希码,GC分代年龄等;这部分数据的长度在32位和64位的虚拟机中分别为 32bit 和 64bit,官方称它为 Mark Word,它是实现轻量级锁和偏向锁的关键;(干货——Mark Word 是实现轻量级锁和偏向锁的关键)
3.2)第二部分:用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度;
3.3)对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会工具对象的状态复用自己的存储空间;
3.4)HotSpot 虚拟机对象头Mark Word 如下图所示:
4)在代码进入同步块的时候:
4.1)轻量级锁的加锁过程:(干货——轻量级锁的加锁过程)
step1)如果此同步对象没有被锁定(锁标志位为01状态):虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录的空间,用于存储对象目前的Mark Word 的拷贝;
step2)然后,虚拟机将使用CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record的指针;
step3)如果这个更新工作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为 00,即表示 此对象处于轻量级锁定状态;
step4)如果这个更新失败了,虚拟机首先会检查对象的Mark Word 是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象以及被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为 10,Mark Word中存储的就是指向重量级(互斥量)的指针,后面等待锁的线程也要进入阻塞状态;
4.2)轻量级锁的解锁过程:(干货——轻量级锁的解锁过程,其解锁过程也是通过CAS 操作来进行的)
step1)如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS 操作把对象当前的Mark Word 和 线程中复制的 Dispatched Mard Word替换回来;
step2)如果替换成功,整个同步过程就over了;
step3)如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程;
Conclusion)
C1)轻量级锁能提升程序同步性能的依据是: 对于绝大部分的锁,在整个同步周期内都是不存在竞争的;
C2)如果没有竞争,轻量级锁使用CAS 操作避免了使用互斥量的开销;但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS 操作,因此在有竞争的case下, 轻量级锁会比传统的重量级锁更慢;
偏向锁
1)偏向锁的目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能;
2)如果说轻量级锁是在无竞争的情况使用CAS 操作去消除同步使用的互斥量:那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS 操作都不做了;(干货——偏向锁的定义)
3)偏向锁的偏: 它的意思是这个锁会偏向于 第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步;偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
4)偏向锁的原理:若当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为01, 即偏向模式;同时使用CAS 操作把获取到这个锁的线程的ID 记录在对象的 Mark Word之中,如果 CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作;(干货——偏向锁的原理)
5)当有另一个线程去尝试获取这个锁时,偏向模式就结束了:根据锁对象目前是否处于被锁定的状态, 撤销偏向后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行;如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁。
Conclusion)
C1)偏向锁可以提高带有同步但无竞争的程序性能;
C2)如果程序中大多数的锁总是被多个不同的线程访问:那偏向模式是多余的;
偏向锁、轻量级锁、重量级锁适用于不同的并发场景:
前面讲述了内置锁在使用过程中的一些基本问题和解决方案,实现原理一笔带过。详细的锁分配和膨胀过程如下:
特别说明两点:
expected == null
,newValue == ownerThreadId
,因此,只有第一个申请偏向锁的线程能够返回成功,后续线程都必然失败(部分线程检测到可偏向,同时尝试CAS记录owner)。另外,当重量级锁被解除后,需要唤醒一个被阻塞的线程,这部分逻辑与ReentrantLock基本相同,详见源码|并发一枝花之ReentrantLock与AQS(1):lock、unlock。
简化版
上图记载的很详细,也有Mark Word的图解。看懂上图后,再来看《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》中的简化版流程图就能看懂了:
参考:https://www.jianshu.com/p/31766419ed45
https://www.cnblogs.com/pacoson/p/5351355.html
https://www.jianshu.com/p/36eedeb3f912