第 13 章 线程安全与锁优化
一、线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么就称这个对象是线程安全的。
1. Java 语言中的线程安全
1) . 不可变
不可变的对象一定是线程安全的。
Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。
Java API 中不可变的类型:String,枚举类型,Long 和 Double 等数值包装类型、BigInteger 和BigDecimal 等大数据类型。
2) . 绝对线程安全
不管运行时环境下采用何种调度方式,或者这些线程如何交替执行,调用者都不需要任何额外的同步措施。
3) . 相对线程安全
通常意义下的线程安全,保证对这个对象单次的操作是线程安全的,在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,可能需要在调用端使用额外的同步手段来保证调用的正确性。
例如 Vector、HashTable、Collections 的 synchronizedCollection()方法包装的集合等。
4) . 线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
5) . 线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。Java 语言天生就支持多线程,线程对立这种排斥多线程的代码是很少出现的,通常有害,应当尽量避免。
2. 线程安全的实现方法
2.1. 互斥同步(阻塞同步)
同步:指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些, 当使用信号量的时候)线程使用。
互斥:是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。
synchronized 关键字:最基本的互斥同步手段
synchronized 关键字经过 Javac 编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。
synchronized 修饰对象,直接指定了对象参数,即对该对象锁定和解锁。
synchronized 修饰方法,根据方法的类型,实例方法取代码所在的对象实例,类方法取类型对应的 Class对象来作为线程要持有的锁。
在执行 monitorenter 指令时,首先要去尝试获取对象的锁。把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。
- synchronized 修饰的同步块对同一线程可重入
- 在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。无法强制中断、退出。
java.util.concurrent.locks.Lock接口
ReentrantLock 可重入锁,Lock 接口的一种具体实现类。
比 synchronized 增加了一些高级功能:
- 等待可中断
- 支持公平锁,默认非公平
- 锁绑定多个条件,可以同时绑定多个 Condition 对象,实现精确唤醒。
总结
synchronized:Java 语法层面,无法判断锁获取的状态,锁可以自动释放,Java 虚拟机更容易对其进行优化。
ReentrantLock:Java API,可以判断是否获取到锁,必须要在 finally 中手动释放锁。它比 synchronized 增加了一些高级功能,比如:等待可中断、公平锁、绑定多个条件。
2.2. 非阻塞同步
基于冲突检测的乐观并发策略,不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。
靠硬件来保证操作和冲突检测具有原子性。
CAS 指令
CAS 指令需要有三个操作数,分别是内存位置(在 Java 中可以简单地理解为变量的内存地址,用 V 表示)、旧的预期值(A)和准备设置的新值(B)。CAS 指令执行时,当且仅当 V 符合 A 时,处理器才会用B 更新 V 的值,否则就不执行更新。但是,不管是否更新了 V 的值,都会返回 V 的旧值,该处理过程是一个原子操作,执行期间不会被其他线程中断。
Java 中 CAS 操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个方法包装提供。
CAS 的漏洞 — ABA 问题
问题描述:一个线程将 变量 A 改成 B,后来又改成了 A,CAS 操作会误认为该变量没有改变。
解决:带有标记的原子引用类 AtomicStampedReference,
可以通过控制变量值的版本来保证 CAS 的正确性。
2.3 无同步的方案
让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性。
可重入代码:所有可重入的代码都是线程安全的,但并非所 有的线程安全的代码都是可重入的。可重入代码不依赖全局变量、存储在堆上的数据和公用的系统资源, 用到的状态量都由参数中传入,不调用非可重入的方法等。
线程本地存储:比如 ThreadLocal Post not found: ThreadLocal 学习
二、锁优化
1. 自旋锁与自适应锁
自旋锁:让后面请求锁的线程「稍等一会」,但不放弃处理器的执行时间,看持有锁的线程是否很快就会释放锁。为了让线程等待,只须让线程执行一个忙循环(自旋),这项技术就是自旋锁。
JDK 6 中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
2. 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
通过逃逸分析发现,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,不进行同步加锁。虽然有锁,但是可以被安全地消除掉。在解释执行时仍然会加锁,但在经过服务端编译器的即时编译之后,代码会忽略所有的同步措施而直接执行。
3. 锁粗化
如果虚拟机探测到有这样一串零碎的操作,都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
4. 轻量级锁
轻量级锁是JDK 6时加入的新型锁机制。
虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对 象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁。
5. 偏向锁
偏向锁也是 JDK 6 中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadId 字段,在第一次访问的时候 threadId 为空,JVM 让其持有偏向锁,并将 threadId 设置为其线程 id,再次进入的时候会先判断 threadId 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
- 本文作者: Kelly Liu
- 本文链接: http://tiantianliu2018.github.io/2020/04/05/《深入理解-Java-虚拟机》第13章阅读笔记/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!