Java之锁的分类、原理及实例

1,544 阅读16分钟

一、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未锁定对象哈希码、对象分代年龄
11GC标记空(不需要记录信息)
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;
    }
    
  1. 调用Condition的signal方法不代表线程可以马上执行,signal方法的作用是将线程所在的节点从等待队列中移除,然后加入到同步队列中,线程的执行始终都需要根据同步状态(即线程是否占有锁)。
  2. 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,[3,a]>[7,b]>[5,c]在索引为1的位置\color{red}{[3,a]->[7,b]->[5,c]在索引为1的位置},有两个线程A和B分别对该HashMap进行put操作,首先进行HashMap扩容,当线程执行到transfer方法中1的位置时,线程A的时间片用完,此时,e=[3,a],next=[7,b]\color{red}{e=[3,a],next=[7,b]}。线程B被调度运行,并且完成了HashMap的扩容操作,在扩容后的数组中[5,c]在索引为1的位置,[7,b]>[3,a]在索引3的位置\color{red}{[5,c]在索引为1的位置,[7,b]->[3,a]在索引3的位置}。此时线程A重新被调度继续执行,在线程A首先将[3,a]迁移到新数组,然后处理[7,b]之后,处理[7,b]的next,线程B扩容后已经把[7,b]的next指向了[3,a],[3,a][7,b]形成了循环链表,从而在获取数据遍历链表时形成死循环\color{red}{[3,a]和[7,b]形成了循环链表,从而在获取数据遍历链表时形成死循环}死循环的原因就在于在扩容时,执行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的共享模式。它相当于给线程规定一个量从而控制允许活动的线程数。