一、Java之锁的分类
Java并行编程中经常听说各种锁的术语,如偏向锁、自旋锁、乐观锁等等,但是这些锁之间有什么关系呢,锁到底是怎么分类的呢?根据锁的分类规则分为以下7大类,分段锁是一种锁的设计,并不是一种具体的锁。
- 偏向锁/轻量级锁/重量级锁
- 可重入锁/非可重入锁
- 共享锁/独占锁
- 公平锁/非公平锁
- 悲观锁/乐观锁
- 自旋锁/非自旋锁
- 可中断锁/不可中断锁
- 分段锁
下面我们逐一介绍每一类锁的原理和对应的实例。
二、Java之锁的原理
1.偏向锁/轻量级锁/重量级锁
这三种锁指的是synchronized锁的状态,Java1.6之前是基于重量级锁,Java1.6之后对synchronized进行了优化,为了减少获取和释放锁带来的性能消耗,引入了偏向锁、轻量级锁以及锁的升级机制。在介绍Java锁之前,先认识一下Java的对象结构。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。其中MarkWord属于对象头的一部分,主要用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。MarkWord的最后2bit是锁状态标志,对象所处的状态决定了MarkWord存储的内容。
标志位 | 状态 | 存储内容 |
---|---|---|
01 | 未锁定 | 对象哈希码、对象分代年龄 |
11 | GC标记 | 空(不需要记录信息) |
00 | 轻量级锁定 | 指向锁记录的指针 |
10 | 膨胀(重量级锁定) | 执行重量级锁定的指针 |
01 | 可偏向 | 偏向线程ID、偏向时间戳、对象分代年龄 |
- 偏向锁: 是Java1.6引入的锁的优化。是在只有一个线程执行同步代码块时,使用CAS操作在对象头部信息中写入拿到锁的线程ID/锁级别等信息。
- 轻量级锁: 是指当锁是偏向锁时,被另一个线程所访问,偏向锁就会升级为轻量级锁,在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,即并不存在实际的竞争,或只有短时间的锁竞争,利用 CAS 操作解决,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高了性能。
- 重量级锁: 是互斥锁,利用操作系统的同步机制实现的,开销相对较大。当多个线程直接有实际竞争,且锁竞争时间长时,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却获取不到锁的线程进入阻塞状态。
锁的升级:无锁-->偏向锁-->轻量级锁-->重量级锁
综上,偏向锁的性能最好,是只有一个线程执行同步代码块,当存在多个线程交替执行时,轻量级锁利用CAS操作和线程自旋获取锁,性能中等,当多个线程存在实际竞争时,重量级锁会把获取不到锁的线程阻塞,性能最差。
2.可重入锁/非可重入锁
在同一个线程中,外层方法获取锁之后,在进入内层方法时会自动获取锁则为可重入锁,进入内层方法时需要重新获取锁的为不可重入锁。如下代码,当某线程执行方法methodA()时,获取到对象锁,当执行方法methodB时,不需要重新获取锁。可重入锁的一个好处是可一定程度避免死锁。
synchronized void methodA() {
// 省略同步代码
.....
methodB();
}
synchronized void methodB() {
// 省略同步代码
......
}
3.共享锁/独占锁
- 共享锁: 同一把锁可以被多个线程同时获得。
- 独占锁: 同一把锁只能同时被一个线程获得。
读写锁是共享锁和独占锁很好的例子。读锁是共享锁,可以保证并发读。写锁是独占锁,只能同时被一个线程获取。
共享锁和独占锁是通过AQS来实现, AQS提供了独占锁和共享锁必须实现的方法,具有独占锁功能的子类,它必须实现tryAcquire、tryRelease、isHeldExclusively等;共享锁功能的子类,必须实现tryAcquireShared和tryReleaseShared等方法,带有Shared后缀的方法都是支持共享锁加锁的语义。
- AQS(AbstractQueuedSynchronizer): 是java.util.concurrent.locks包下基础的抽象类,提供了Java锁的基础框架。下面介绍一下AQS中关键的数据结构。
- AQS中维护了一个共享状态state。1)state使用volatile修饰保证线程间内存可见性;2)getState()和setState()方法采用final修饰,禁止AQS子类重写;3)compareAndSetState()方法采用乐观锁思想的CAS算法,使用final修饰的禁止子类重写。
// 源码 private volatile int state; protected final int getState() { return state; } protected final void setState(int newState) { state = newState; } protected final boolean compareAndSetState(int expect, int update) { // See below for intrinsics setup to support this return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
- CLH队列(Craig, Landin, and Hagersten locks) 是AQS中FIFO的双向队列,其内部通过节点head和tail记录队首和队尾元素,队列元素类型为Node。AQS利用CLH队列完成同步状态state的管理。当前线程获取同步状态失败时,AQS则会将该线程等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
- 入队列: tail指向新节点、新节点的prev指向当前最后的节点,当前最后一个节点的next指向当前节点。
- 出队列: 节点的线程释放同步状态后,将会唤醒它的后继节点(next),而后继节点将会在获取同步状态成功时将自己设置为首节点。
- Node 是AQS的静态内部类,表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),condition队列的后续节点(nextWaiter)。
// AQS静态内部类 Node static final class Node { ...... /** 线程的状态: * SIGNAL-表示后续节点需要唤醒。当前节点的后续节点的线程通过park被阻塞了,当前节点在释放或取消通过unpark解除其阻塞; * CANCELLED-取消状态。当前节点因为的线程因为超时或中断被取消; * CONDITION-等待状态。当前节点在condition队列中; * PROPAGATE-传播状态,共享锁的释放。此状态是为了优化锁的竞争,使队列中的线程一个一个的被唤醒; * 0-一般为节点的初始状态。 */ volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread; Node nextWaiter; ...... }
- ConditionObject: 是AQS的内部类,实现了Condition接口,为AQS提供条件变量的支持。synchronized控制同步时,可以配合Object的wait(),notify(),notifyAll() 系列方法实现等待/通知模式。而Lock呢?提供了条件Condition接口,配合await(),signal(),signalAll() 等方法实现等待/通知机制。
public class ConditionObject implements Condition { /** First node of condition queue. */ private transient Node firstWaiter; /** Last node of condition queue. */ private transient Node lastWaiter; }
- AQS中维护了一个共享状态state。1)state使用volatile修饰保证线程间内存可见性;2)getState()和setState()方法采用final修饰,禁止AQS子类重写;3)compareAndSetState()方法采用乐观锁思想的CAS算法,使用final修饰的禁止子类重写。
- 调用Condition的signal方法不代表线程可以马上执行,signal方法的作用是将线程所在的节点从等待队列中移除,然后加入到同步队列中,线程的执行始终都需要根据同步状态(即线程是否占有锁)。
- ConditionObject对象都维护了一个单独的等待队列,AQS所维护的CLH队列是同步队列,它们节点类型相同,都是Node。
4.公平锁/非公平锁
- 公平锁: 指多个线程按照申请锁的顺序来获取锁。会造成性能低下,大量的时间花费在线程调度上。
- 非公平锁: 指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。优点是提高了响应速度,不用把大量时间花费在线程调度上,而是花费在执行代码上。
公平锁的实现方式:
ReentrantLock lock=new ReentrantLock(true);//true表示获取公平锁
非公平锁的实现方式:
ReentrantLock lock=new ReentrantLock();//默认是非公平锁
ReentrantLock lock=new ReentrantLock(false);
ReentrantLock源码:FairSync和NonfairSync是ReentrantLock类中的两个静态内部类,它们都继承了Sync,Sync也是ReentrantLock类中的一个静态内部类,继承了上一节中的AQS。
// 同步器,用于实现所有的同步机制,比如公平/非公平
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
/**
* Performs non-fair tryLock. tryAcquire is implemented in
* subclasses, but both need nonfair try for trylock method.
*/
@ReservedStackAccess
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); // 获取当前线程
int c = getState(); // 获取锁的状态,0表示没有被其他线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) { // 利用CAS更新锁状态
setExclusiveOwnerThread(current); // 设置锁的持有者为当前线程
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 重入锁的实现
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
......
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
// 非公平锁
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires); // 父类中的方法
}
}
// 公平锁
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread(); // 获取当前线程
int c = getState(); // 获取锁的状态,0表示没有被其他线程获取
if (c == 0) {
if (!hasQueuedPredecessors() && // 判断存在等待时间更长的线程,没有返回false(当前线程获取到锁,判断当前队列中是否有先驱节点,有则返回true)
compareAndSetState(0, acquires)) { // 利用CAS更新锁状态
setExclusiveOwnerThread(current); // 设置锁的持有者为当前线程
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 重入锁的实现
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
5.悲观锁/乐观锁
- 悲观锁: 认为对于同一个数据的并发操作,一定会发生修改的,即使没有修改,也会认为修改。因此对于同一份数据的并发操作,悲观锁采取加锁的形式。适合写操作较多的场景。
- 乐观锁: 认为对于同一个数据的并发操作,是不会发生修改的,在更新数据时,会采用尝试更新,通常使用CAS自旋实现数据更新。乐观的认为,不加锁的并发操作是没有事情的。适合读操作较多的场景。
悲观锁的实现方式: 利用Java中的各种锁;
乐观锁的实现方式: 1)CAS算法;2)版本号机制。
- CAS算法(compare and swap): 是一种非阻塞无锁算法,在线程开启的时候,会从主存中为每个线程拷贝一个变量副本到各自的运行环境中,CAS算法中包含三个参数(V,E,N),V表示要更新的变量(也就是从主存中拷贝过来的值)、E表示预期的值、N表示新值。如果内存位置V的值与预期原值E相等,那么处理器会自动将该位置值更新为新值N,否则处理器不做任何操作。 当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。Java1.5中新增的java.util.concurrent包就是建立在CAS之上的,在Lock实现中有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现。
- 缺点: 1)循环时间太长;2)只能保证一个共享变量原子操作;3)会出现ABA问题。
- 优点: 是CPU指令级的操作,只有一步原子操作,所以非常快。而且CAS避免了请求操作系统来裁定锁的问题,直接在CPU内部就搞定了。
- 版本号机制: 一般在数据表中加上一个版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读到的version值与当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
6.自旋锁/非自旋锁
自旋锁指尝试获取锁的线程不会立即阻塞或释放CPU,而是采用循环的方式去尝试获取锁,减少线程上下文切换的消耗,缺点是循环会消耗CPU。而非自旋锁如果拿不到锁,就直接放弃,加入等待队列、陷入阻塞等。
自旋锁是采用让当前线程不停地的在循环体内执行实现的,当循环的条件被其他线程改变时 才能进入临界区。当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不断增加时,性能下降明显,每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间短,适合使用自旋锁。
7.可中断锁/不可中断锁
如果线程A正在执行锁中的代码,线程B在等待获取该对象锁,如果等待时间过长,想让B去做其他事情,可以让线程B自己中断或者别的线程中断它。
在 Java 中,synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,只能等到拿到锁以后才能进行其他的逻辑处理。而Lock是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直等到获取到锁才离开。ReentrantLock实现了Lock接口,实现的lockInterruptibly方法源码调用了sync.acquireInterruptibly,为AQS中的方法。
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
8.分段锁
分段锁是一种锁的设计,在ConcurrentHashMap中,其并发的实现是通过粒度更小的分段锁以提高并发操作的效率。下面我们分析一下HashMap、Hastable、ConcurrentHashMap的源码,进一步理解分段锁出现的原因。
1)线程不安全的HashMap: 在多线程场景下,使用HashMap的put操作会出现数据不一致或死循环问题。为什么会出现这种情况呢?
- put操作导致数据不一致
比如有两个线程A和B,线程A希望put一个key-value对到HashMap中,首先计算记录要插入的桶的位置,然后获取到该桶中的链表头节点,此时线程A的时间片用完了,而线程B被调度得以执行,线程B成功将记录put到了桶中。假设线程A计算出来的桶索引和线程B计算出来的桶索引是相同的,当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头,继续执行put操作,以至于覆盖了线程B已经插入的记录,造成了数据不一致的行为。
- put操作导致死循环问题
Java8对HashMap进行了优化,链表长度大于8时转换为红黑树的存储结构、resize优化等,但是依然是线程不安全的,为了简化分析,使用Java7的HashMap源码进行线程不安全分析。
HashMap的实现使用一个Node数组,每个数组项里面有一个链表的方式来实现,因为HashMap使用key的hashCode来寻找存储位置**(h&(length-1)),不同的key可能具有相同的hashCode,这时就出现哈希冲突了,也叫做哈希碰撞,为了解决哈希冲突,有开放地址方法和链地址方法。HashMap的使用了链地址方法**,也就是将哈希值一样的entry保存在同一个数组项里面,可以把一个数组项当做一个桶,桶里面装的entry的key的hashCode是一样的。
transient Node<K,V>[] table; //变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问
......
// Node: HashMap的静态内部类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
......
}
HashMap的数组的初始长度为16,loadFact为0.75,当数据记录超过阈值且发生哈希冲突时,HashMap将会进行扩容操作,每次都会变为原来大小的2倍,直到设定的最大值之后就无法再resize了。进行扩容,数组长度会发生变化,存储位置会重新计算 index = h&(length-1) 也可能会发生变化,先来看看transfer方法。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; // 1
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // 2
newTable[i] = e; // 3
e = next; // 4
}
}
}
假设有一个长度为2的HashMap,,有两个线程A和B分别对该HashMap进行put操作,首先进行HashMap扩容,当线程执行到transfer方法中1的位置时,线程A的时间片用完,此时,。线程B被调度运行,并且完成了HashMap的扩容操作,在扩容后的数组中。此时线程A重新被调度继续执行,在线程A首先将[3,a]迁移到新数组,然后处理[7,b]之后,处理[7,b]的next,线程B扩容后已经把[7,b]的next指向了[3,a],。死循环的原因就在于在扩容时,执行transfer方法中的步骤2、3、4使用的是头插法,会造成链表的反转,进一步形成循环链表。
2)线程安全效率低的Hashtable: Hashtable使用synchronized来保证线程安全,但在线程竞争激烈时Hashtable的效率非常低。当一个线程访问Hashtable的同步方法时,其他线程访问Hashtable的同步方法时,可能会进入阻塞或轮询状态。既不能使用put方法添加元素,也不能使用get方法来获取元素。
3)线程安全的ConcurrentHashMap ConcurrentHashMap中的分段锁称为Segment,类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的某个segment进行加锁操作。
// ConcurrentHashMap的静态内部类
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
当put元素的时候,并不是对整个HashMap进行加锁,而是先通过hashcode确定元素的分段位置,然后对这个分段进行加锁,所以当多线程put操作时,只要不是放在一个分段中,就可以实现真正的并行插入。但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
三、Java之锁的实例
- Synchronized: 是一种非公平,悲观,独享,互斥,可重入,重量级锁;
- ReentrantLock: 是一种默认非公平但可实现公平的,悲观,独享,互斥,可重入,重量级锁。
- ReentrantReadWriteLock: 是一种默认非公平但可实现公平的,悲观,写独享,读共享,读写,可重入,重量级锁,实现了ReadWriteLock接口,可以通过readLock()获取读锁,通过writeLock()获取写锁。
- Semaphore: 是一种共享锁。Semaphore是一种计数信号量,用于管理一组资源,内部是基于AQS的共享模式。它相当于给线程规定一个量从而控制允许活动的线程数。