锁是最常用的同步方法之一。 在高并发环境下, 激烈的锁竞争会导致程序性能下降, 所以非常有必要讨论一下锁的性能问题以及相关的注意事项,如: 避免死锁、减小锁力度、锁分离等。
需要明确一点, 多核场景下, 使用多线程虽然可以提高系统性能, 但同时也会增加额外的系统开销, 如维护线程本身的元数据、线程调度、上下文切换等。
1. 提高锁性能的建议
锁竞争过程会导致系统性能下降, 为了将多线程的这种副作用降到最低, 有如下建议:
1.1 减少锁持有时间
线程持有锁的时间越长, 锁被竞争的可能性就越大,竞争也就更激烈。 因此我们在做程序设计时, 要尽可能减少线程持有锁的时间,从而减少线程间互斥的可能性。
1.2 减小锁力度
即缩小锁定对象的范围, 从而减少锁冲突的可能性,进而提高系统性能。
以HashMap和ConcurrentHashMap为例, HashMap进行put操作的时候, 是对整个对象加锁; 而ConcurrentHashMap是在其内部将大map分成了16个segment, 每个segement都可以理解成为一个小的HashMap, 各自持有各自的锁。 当进行put操作时,通过key的hashcode来确定具体在那个segment上, 然后对这个segement进行加锁从而执行put操作。 基于此, 多线程并发put的最佳情况是, ConcurrentHashMap可以支持16个线程同时进行写入操作, 这大大提高了吞吐量。
当然, 任何事物都会有AB两面, 锁力度的减小, 也会带来相关的同步问题。 即当想要获取全局信息时, 需要对所有子锁(segment)加锁后获取, 例如ConcurrentHashMap的size()方法。
1.3 通过读写分离来替换独占锁
这个很好理解, 读与读之间不互斥, 读与写之间做同步, 可以看成是减小锁力度的一种特殊情况,适用读多写少的场景,详细可以参考ReadWriteLock。
1.4. 锁分离
将读写锁的思想进一步延伸, 即根据应用程序的具体特点, 使用类似的分离思想。 例如JUC中的LinkedBlockingQueue, 其内部对于take操作和put操作, 其基于Queue的操作特点(尾进头出), 分别设置了tack锁和put锁,从而实现了可同时对queue进行读写操作。
1.5 锁粗化
与减少锁持有时间是相反的, 即在某些场景下, 可以通过合并锁来减少锁获取、同步和释放次数, 减少对系统资源的浪费,从而提升系统性能。 一个典型的例子是, 将for循环中的锁挪到循环外。
2. 虚拟机对锁优化所做的努力
锁存在对象头中, 因此在探讨JVM的锁优化前, 我们先了解一下Hotspot虚拟机对象头的内存布局。对象头包括以下三部分:
- Mark Word: 标记字段, 用于存储对象的运行时信息,如锁信息、代龄、hashCode、GC信息等, 占用一个字宽(4字节)
- Klass Pointer: 类型指针,用于存储对象的类型信息), 占用一个字宽(4字节)
- Array length: 数组长度, 只有当前对象是数组对象时才有
其中Mark Word是实现锁优化的关键, 其被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 2bit | 是否为偏向锁 | 锁标识位 | ||
无锁状态 | 对象hashCod | 对象代龄 | 0 | 01 | |
轻量级锁 | 指向锁记录(存储于争抢到执行权的线程栈帧中)的指针 | 00 | |||
重量级锁 | 指向重量级锁记录(存储于争抢到执行权的线程栈帧中)的指针 | 10 | |||
GC标记 | 空, 不需要记录信息, 标识当前对象可回收 | 11 | |||
偏向锁 | 偏向线程ID | Epoch | 对象代龄 | 1 | 01 |
2.2 锁偏向
锁偏向是针对加锁操作的一种优化手段, 其核心思想是: 在无多线程竞争的情况下, 尽量减少不必要的锁申请操作, 从而提升代码执行效率。 通俗点说, 若一个线程获取到了偏向锁, 在没有其他线程竞争的情况下, 无论进入还是退出同步代码块, 均布进行加锁/解锁操作, 甚至CAS操作都不进行。 JVM可以通过参数-XX:+UseBiasedLocking来启用偏向锁。 偏向锁的加锁解锁流程如下。
- 加锁
1). 检测Mark Word是否为可偏向状态,即 是否是偏向锁位为 1,锁标识位为 01;
2). 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则说明当前线程持有偏向锁,执行步骤5,否则执行步骤3;
3). 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,将Mark Word的线程ID替换为当前线程ID,否则执行步驟4;
4). 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点(执行完成),获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
5). 执行同步代码块
- 解锁
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
1). 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
2). 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;
2.3 轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
加锁
1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;
解锁
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;
2.4 自旋锁
轻量级锁膨胀后, 虚拟机为了避免线程真实的在操作系统层面挂起, 还会做最后的努力: 即锁自旋。当前线程无法获取到锁, 但也许在几个CPU时钟周期后, 就可以得到了。 基于这种假设, 虚拟机会让当前线程做几次获取锁的尝试操作, 若可以获取到, 则执行临界区代码; 否则才会挂起线程;
2.5 锁消除
Java虚拟机在JIT编译时, 通过扫描上下文, 去除掉不可能存在竞争关系的锁。
参考:
《实战Java高并发程序设计》
轻量级锁与偏向锁: https://blog.youkuaiyun.com/wolegequdidiao/article/details/45116141
【死磕Java并发】-----深入分析synchronized的实现原理: https://blog.youkuaiyun.com/chenssy/article/details/54883355