1. 前言
在公司开发, 高并发是不可避免需要考虑的. 这部分记录JVM中的线程安全和锁优化技术.
2. 线程安全
2.1 线程安全定义
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的;
2.2 线程安全种类
分为以下5类: 不可变, 绝对线程安全, 相对线程安全, 线程兼容, 线程对立;
2.2.1 不可变对象
该对象一定是线程安全的, 无论是对象的方法实现还是方法的调用者, 都不需要采取任何的线程安全保障措施;
1. 如果共享数据是一个基本数据类型, 那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的;
2. 不妨想想java.lang.String类的对象: 它是一个典型的不可变对象, 调用它的substring(), replace(), concat() 这些方法都不会影响它原来的值, 只会返回一个新构造的字符串对象;
2.2.2 绝对线程安全
绝对安全的线程的类, 完全符合线程安全定义的定义, 但在Java API中标注自己是线程安全的类, 大多数都不是绝对的线程安全.
2.2.3 相对线程安全
是通常意义上的线程安全, 它需要保证对这个对象单独的操作是线程安全的, 在调用的时候不需要做额外的保证措施. 但是对于一些特定顺序的连续调用, 就可能需要在调用端使用额外的同步手段来保证调用的正确性.在Java语言中, 大部分线程安全类都属于这种类型.
如Vector, Hashtable, Collections的synchronizedCollection()方法包装的集合等.
2.2.4 线程兼容
线程兼容是指对象本身不是线程安全的, 但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中是可以安全使用的. Java API中的大部分的类都是属于线程兼容的.
如ArrayList和HashMap等.
2.2.5 线程对立
线程对立是指无论调用端是否采取了同步措施, 都无法在多线程环境中使用的代码. 线程对立这种排斥多线程的代码是很少出现的, 通常都是有害的, 应当避免.
如Thread类的suspend()和resume()方法. 如果两个线程同时持有一个线程对象, 两个线程并发对该线程对象执行suspend()和resume()方法, 无论是否采用了同步, 都存在死锁风险.
2.3 线程安全的实现方法
2.3.1 互斥同步
- 互斥同步: 是常见的并发正确性保障手段;
- 同步: 是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻被一个线程使用.
- 互斥: 互斥是实现同步的一种手段; 临界区, 互斥量和信号量都是主要的互斥实现方式. 因此, 在这4个字里面, 互斥是因, 同步是果; 互斥是方法, 同步是目的;
- 最基本的互斥同步手段就是 synchronized关键字: synchronized关键字经过 编译之后, 会在同步块的前后分别形成 monitorenter 和 monitorexit 这个两个字节码指令, 这两个字节码都需要一个 reference类型的参数来指明要锁定和解锁的对象; 如果java程序中的synchronized明确指定了对象参数, 那就是这个对象的reference; 如果没有明确指定, 那就根据 synchronized修饰的实例方法还是类方法, 去取对应的对象实例或Class 对象来作为锁对象;
- 根据虚拟机规范的要求: 在执行monitorenter指令时, 如果这个对象没有锁定或当前线程已经拥有了那个对象的锁, 锁的计数器加1, 相应的, 在执行 monitorexit 指令时会将锁计数器减1; 当计数器为0时, 锁就被释放了;
- 对于monitorenter 和 monitorexit 的行为描述中, 有两点需要注意:
a. synchronized同步块对同一条线程来说是可重入的, 不会出现自己把自己锁死的问题;
b. 同步块在已进入的线程执行完之前, 会阻塞后面其他线程 的进入; - 除了synchronized之外, 还可以使用 java.util.concurrent 包中的重入锁(ReentrantLock)来实现同步;
2.3.1.1 Synchronized与ReentrantLock比较
2.3.1.1.1 相似点
这两种同步方式有很多相似之处, 它们都是加锁方式同步, 而且都是阻塞式的同步, 也就是说当如果一个线程获得了对象锁, 进入了同步块, 其他访问该同步块的线程都必须阻塞在同步块外面等待, 而进行线程阻塞和唤醒的代价是比较高的
2.3.1.1.2 区别
这两种方式最大区别就是对于Synchronized来说, 它是java语言的关键字, 是原生语法层面的互斥, 需要jvm实现.
而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁, 需要lock()和unlock()方法配合try/finally语句块来完成.
2.3.1.1.3 Synchronized的基本使用
Synchronized进过编译, 会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令. 在执行monitorenter指令时, 首先要尝试获取对象锁. 如果这个对象没被锁定, 或者当前线程已经拥有了那个对象锁, 把锁的计算器加1, 相应的, 在执行monitorexit指令时会将锁计算器就减1, 当计算器为0时, 锁就被释放了. 如果获取对象锁失败, 那当前线程就要阻塞, 直到对象锁被另一个线程释放为止.
public class Main {
public static void main(String[] args) {
Runnable runnable = new MyThread();
new Thread(runnable, "t1").start();
new Thread(runnable, "t2").start();
}
}
class MyThread implements Runnable {
@Override
public void run() {
synchronized (this) {
for(int i=0;i<10;i++)
System.out.println(Thread.currentThread().getName()+":"+i);
}
}
}
运行结果:
t1:0
t1:1
t1:2
t1:3
t1:4
t1:5
t1:6
t1:7
t1:8
t1:9
t2:0
t2:1
t2:2
t2:3
t2:4
t2:5
t2:6
t2:7
t2:8
t2:9
2.3.1.1.4 ReentrantLock的基本使用
由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁, 相比Synchronized, ReentrantLock类提供了一些高级功能, 主要有以下3项:
1. 等待可中断, 持有锁的线程长期不释放的时候, 正在等待的线程可以选择放弃等待, 这相对于Synchronized来说可以避免出现死锁的情况。
2. 公平锁, 多个线程等待同一个锁时, 必须按照申请锁的时间顺序获得锁, Synchronized锁非公平锁, ReentrantLock默认的构造函数是创建的非公平锁, 可以通过参数true设为公平锁, 但公平锁表现的性能不是很好.
3. 锁绑定多个条件, 一个ReentrantLock对象可以同时绑定对个对象.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static void main(String[] args) {
Runnable runnable = new MyThread();
new Thread(runnable, "t1").start();
new Thread(runnable, "t2").start();
}
}
class MyThread implements Runnable {
private Lock lock=new ReentrantLock();
@Override
public void run() {
lock.lock();
try{
for(int i=0;i<5;i++)
System.out.println(Thread.currentThread().getName()+":"+i);
}finally{
lock.unlock();
}
}
}
运行结果:
t1:0
t1:1
t1:2
t1:3
t1:4
t2:0
t2:1
t2:2
t2:3
t2:4
关于synchronized 和 ReentrantLock 性能的分析:
1. 多线程环境下 synchronized的吞吐量下降得非常严重, 而 ReentrantLock 则能基本保持在同一个比较稳定的水平上; 与其说ReentrantLock性能好, 还不如说 synchronized还有非常大的优化余地;
2. 虚拟机在未来的性能改进中肯定也会更加偏向于原生的 synchronized, 所以还是提倡在 synchronized能实现需求的情况下, 优先考虑使用 synchronized 来进行同步;
2.3.2 互斥同步非阻塞同步
- 阻塞同步(互斥同步)的问题: 就是进行线程阻塞和唤醒所带来的性能问题, 互斥同步属于一种悲观的并发策略, 无论共享数据是否真的会出现竞争, 它都要进行加锁, 用户态核心态转换, 维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作;
- 非阻塞同步定义: 基于冲突检测的乐观并发策略, 通俗的说, 就是先进行操作, 如果没有其他线程争用共享数据, 那操作就成功了; 如果共享数据有争用, 产生了冲突, 那就再采用其他的补偿措施, 这种乐观的并发策略的许多实现都不需要把线程挂起, 因此这种同步操作称为 非阻塞同步;
- 为什么作者要说使用乐观并发策略需要“硬件指令集的发展”才能进行呢? 因为 我们需要 操作 和 冲突检测 这两个步骤具备原子性, 靠什么来保证呢?
a. 硬件: 保证一个从语义上看起来需要多次操作的行为只通过一次处理器指令就能完成
2.3.2.1 CAS(Compare-and-Swap)操作的使用
// Atomic 变量自增运算测试(incrementAndGet 方法的原子性)
public class AtomicTest {
public static AtomicInteger race = new AtomicInteger(0);
public static void increase() {
// 输出正确结果,一切都要归功于 incrementAndGet 方法的原子性
race.incrementAndGet();
}
public static final int THREADS_COUNT = 20;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
/**
* incrementAndGet() 方法的JDK 源码
* Atomically increment by one the current value.
* @return the updated value
*/
public final int incrementAndGet() {
for(;;) {
int current = get();
int next = current + 1;
if(compareAndSet(current,next)) {
return next;
}
}
}
}
2.3.2.2 CAS操作的ABA问题和解决方法
- 问题描述:
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就说它的值没有被其他线程改变过了吗? 如果在这段期间它的值曾经被改为了B, 之后又改回了A, 那CAS操作就会误认为它从来没有被改变过, 这个漏洞称为 CAS操作的 ABA问题; - 解决方法:
J.U.C 包为了解决这个问题, 提供了一个带有标记的原子引用类“AtomicStampedReference”, 它可以通过控制变量值的version 来保证CAS的正确性. 不过目前来说这个类比较鸡肋, 大部分cases 下 ABA问题 不会影响程序并发的正确性, 如果需要解决ABA问题, 改用传统的互斥同步可能会比原子类更高效;
2.3.3 无同步方案
如果一个方法本来就不涉及共享数据, 那它自然就无须任何同步措施去保证正确性, 因此会有一些代码天生就是线程安全的; 下面介绍两类线程安全代码:
- 第一类线程安全代码——可重入代码:也叫作纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误;
a. 所有的可重入代码都是线程安全的;
b. 如何判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同 - 第二类线程安全代码——线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能够保证在同一线程中执行? 如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也可以保证线程间不出现数据争用问题;
3. 锁优化
锁优化技术(HotSpot虚拟机而言)包括适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。这些技术都是为了在线程之间更高效地共享数据以及解决竞争问题,从而提高程序效率。
3.1 自旋锁与自适应自旋
3.1.1 问题
前文中我们提到,互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程很不值得;
3.1.2 自旋锁定义
针对上面的问题, 提出的解决方法:
为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁;
- jdk1.6中 自旋锁是默认开启的,可以使用 -XX:+UseSpinning 参数来开启;
- 自旋等待的时间必须要有一定的限度: 如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10,用户可以用参数 -XX:PreBlockSpin 来更改;
3.1.3 自适应自旋定义
- 自适应自旋锁:jdk1.6 中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个cycle;
- 如果对于某个锁,自旋很少成功获得过, 那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源;
3.2 锁消除
3.2.1 定义
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检查到不可能存在共享数据竞争的锁进行消除;
3.2.2 判定依据
来源于逃逸分析的数据支持;如果判定在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行了;
3.3 锁粗化
3.3.1 问题描述
如果一系列的连续操作都对同一个对象反复加锁和解锁, 甚至加锁操作是出现在循环体中的, 那即使没有线程竞争, 频繁地进行互斥同步操作也会导致不必要的性能损耗;
3.3.2 锁粗化的定义
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁, 将会把加锁同步的范围扩展(粗化)到整个操作序列的外部;
3.4 轻量级锁
3.4.1 重量级锁定义
使用操作系统互斥量来实现的传统锁;
3.4.2 轻量级锁的作用
是在没有多线程竞争的前提下, 减少传统的重量级锁使用操作系统互斥量产生的性能消耗;
3.4.3 对象头Mark Word
HotSpot虚拟机的对象头分为两部分信息:
第一部分:
用于存储对象自身的运行时数据, 如哈希码, GC分代年龄等; 这部分数据的长度在32位和64位的虚拟机中分别为 32bit 和 64bit, 官方称它为 Mark Word, 它是实现轻量级锁和偏向锁的关键;
第二部分:
用于存储指向方法区对象类型数据的指针, 如果是数组对象的话, 还会有一个额外的部分用于存储数组长度;
对象头信息是与对象自身定义的数据无关的额外存储成本, 考虑到虚拟机的空间效率, Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息, 它会工具对象的状态复用自己的存储空间;
HotSpot 虚拟机对象头Mark Word 如下图所示:
3.4.4 实现逻辑
在代码进入同步块的时候:
轻量级锁的加锁过程:
step1. 如果此同步对象没有被锁定(锁标志位为01状态): 虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录(Lock Record) 的空间, 用于存储对象目前的Mark Word 的拷贝;
step2. 然后, 虚拟机将使用CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record的指针; 并将线程栈帧中的Lock Record里的owner指针指向Object的 Mark Word.
step3. 如果这个更新工作成功了, 那么这个线程就拥有了该对象的锁, 并且对象Mark Word的锁标志位将转变为 00, 即表示 此对象处于轻量级锁定状态;
step4. 如果这个更新失败了, 虚拟机首先会检查对象的Mark Word 是否指向当前线程的栈帧, 如果指向, 则说明当前线程已经拥有了这个对象的锁, 那就可以直接进入同步块继续执行, 否则说明这个锁对象以及被其他线程抢占了. 如果有两条以上的线程争用同一个锁, 那轻量级锁就不再有效, 要膨胀为重量级锁, 锁标志的状态值变为 10, Mark Word中存储的就是指向重量级(互斥量)的指针, 后面等待锁的线程也要进入阻塞状态;
轻量级锁的解锁过程:
step1. 如果对象的Mark Word仍然指向着线程的锁记录, 那就用CAS 操作把对象当前的Mark Word 和 线程中复制的 Dispatched Mard Word替换回来;
step2. 如果替换成功, 整个同步过程就over了;
step3. 如果替换失败, 说明有其他线程尝试过获取该锁, 那就要在释放锁的同时, 唤醒被挂起的线程;
3.4.5 结论
- 轻量级锁能提升程序同步性能的依据是: 对于绝大部分的锁,在整个同步周期内都是不存在竞争的;
- 如果没有竞争,轻量级锁使用CAS 操作避免了使用互斥量的开销;但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS 操作,因此在有竞争的case下, 轻量级锁会比传统的重量级锁更慢;
3.5 偏向锁
3.5.1 偏向锁的目的
消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能;
如果说轻量级锁是在无竞争的情况使用CAS 操作去消除同步使用的互斥量:那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS 操作都不做了;
它的意思是这个锁会偏向于 第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步;
3.5.2 偏向锁的原理
若当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为01, 即偏向模式;同时使用CAS 操作把获取到这个锁的线程的ID 记录在对象的 Mark Word之中,如果 CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作;
当有另一个线程去尝试获取这个锁时,偏向模式就结束了:根据锁对象目前是否处于被锁定的状态, 撤销偏向后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行;
3.5.3 结论
- 偏向锁可以提高带有同步但无竞争的程序性能;
- 如果程序中大多数的锁总是被多个不同的线程访问:那偏向模式是多余的;
3.5.4 偏向锁流程
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
因此 流程是这样的 偏向锁->轻量级锁->重量级锁
简单的讲,就是在锁对象的对象头中有个ThreadId字段,这个字段如果是空的,第一次获取锁的时候,就将自身的ThreadId写入到锁的ThreadId字段内,将锁头内的是否偏向锁的状态位置1.这样下次获取锁的时候,直接检查ThreadId是否和自身线程Id一致,如果一致,则认为当前线程已经获取了锁,因此不需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段。提高了效率。
4. synchronized原理图
附上一张synchronized原理图, 帮助理解整个过程.
5. 参考链接
<<深入理解Java虚拟机—-JVM高级特性与最佳实践>>(第二版, 周志明)
https://www.jianshu.com/p/31766419ed45
https://blog.youkuaiyun.com/chenchaofuck1/article/details/51045134
https://www.cnblogs.com/pacoson/p/5351355.html
https://www.jianshu.com/p/36eedeb3f912