JAVA锁机制

介绍

  1. synchronized
    synchronized是Java中的一个很重要的关键字,主要用来加锁,synchronized 的使用方法比较简单,主要可以用来修饰方法和代码块。根据其锁定的对象不同,可以用来定义同步方法和同步代码块。

  2. AQS
    AbstractQueuedSynchronizer(抽象队列同步器,以下简称AQS)出现在JDK1.5中。AQS是很多同步器的基础框架,比如ReentrantLock, CountDownLatch和Semaphore等都是基于AQS实现的。除此之外,我们还可以基于AQS,定制出我们所需要的同步器。

  3. CAS
    CAS是一项乐观锁技术,是Compare And Swap的简称,顾名思义就是先比较再替换。CAS 操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。在进行并发修改的时候,会先比较A和V中取出的值是否相等,如果相等,则会把值替换成B,否则就不做任何操作。

synchronized

synchronized在jdk1.6之前直接是重量级锁;synchronized在jdk1.6之后出现了锁升级
synchronized所添加的锁有以下几个特点。

  • 互斥性
    同—时间点,只有一个线程可以获得锁,获得锁的线程才可以处理被synchronized修饰的代码片段。
  • 阻塞性
    只有获得锁的线程才可以执行被synchronized修饰的代码片段,未获得锁的线程只能阻塞,等待锁释放。
  • 可重入性
    如果一个线程已经获得锁,在锁未释放之前,再次请求锁的时候,是必然可以获得锁的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//同步方法,对象锁
public synchronized void dosth(){
System.out.println( "Hello world");
}
//同步方法,类锁
public synchronized static void dosth(){
System.out.println("Hello world" );
}


//同步代码块,类锁
public void dosth1(){
synchronized (Demo.class){
system.out.println( "Hello world" );
}
}
//同步代码块,对象锁
public void dosth1(){
synchronized (this){
system.out.println( "Hello world" );
}
}


AQS

在AQS内部,维护了一个FIFO队列和一个volatile的int类型的state变量。在state=1的时候表示当前对象锁已经被占有了, state变量的值修改的动作通过CAS来完成。 FIFO队列用来实现多线程的排队工作,当线程加锁失败时,该线程会被封装成一个Node节点来置于队列尾部。 当持有锁的线程释放锁时,AQS会将等待队列中的第一个线程唤醒,并让其重新尝试获取锁。

  • 同步状态-state
    AQS使用一个volatile int类型的成员变量来表示同步状态,在state=1的时候表示当前对象锁已经被占有了。它提供了三个基本方法来操作同步状态: getState(), setState(int newState),和compareAndSetState(int expect,int update)。这些方法允许在不同的同步实现中自定义资源的共享和独占方式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    //同步状态
    private volatile int state;
    //获取状态
    protected final int getstate() {
    return state;
    }
    //设置状态
    protected final void setstate( int newState) {
    state = newState;
    }
    /CAS更新状态
    protected final boolean compareAndSetstate(int expect,int update) {
    // see below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this,stateOffset,expect,update);
    }

  • FIFO队列-Node
    AQS内部通过一个内部类——Node,AQS就是借助他来实现同步队列的功能的。 当线程尝试获取资源失败时,AQS 会将该线程包装成一个Node 节点,并将其插入同步队列的尾部。在资源可用时,队列头部的节点会尝试再次获取资源。(在AQS 中,Node 也用于构建条件队列。当线程需要等待某个条件时,它会被加入到条件队列中。当条件满足时,线程会被转移回同步队列。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//Node类用于构建队列
static final class Node {
//标记节点状态。常见状态有CANCELLED(表示线程取消〉、SIGNAL(表示后继节点需要运行)
volatile int waitstatus;
//前驱节点
volatile Node prev;
//后继节点
volatile Node next;
//节点中的线程,存储线程引用,指向当前节点所代表的线程。
volatile Thread thread;
}
//队列头节点,延迟初始化。只在setHead时修改private transient volatile Node head;1/队列尾节点,延迟初始化。
private transient volatile Node tail;
//入队操作
private Node enq(final Node node){
for (;;) {
if (t == null) {
//必须先初始化
if (compareAndSetHead(new Node()))
tail = head;
}else {
node. prev = t;
if ( compareAndSetTail(t,node)) {
t.next = node;
return t;
}
}
}
}

CAS

相对于synchronized这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。
CAS的主要应用就是实现乐观销和锁自旋。

  1. ABA问题
    比如说一个线程1从内存位置V中取出A,这时候另一个线程2也从内存中取出A,并且2进行了一些操作变成了B,然后2又将V位置的数据变成A,这时候线程1进行CAS操作发现内存中仍然是A,然后1操作成功。尽管线程1的CAS操作成功,但是不代表这个过程就是没有问题的。
  • 部分乐观锁的实现是通过版本号(version)的方式来解决ABA问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加不会减少。
  • 在Java中,可以借助AtomicStampedReference,它是Java并发编程中的一个类,用于解决多线程环境下的“ABA”问题。AtomicStampedReference通过同时维护一个引用和一个时间戳,可以解决ABA问题。它允许线程在执行CAS操作时,不仅检查引用是否发生了变化,还要检查时间戳是否发生了变化。这样,即使一个变量的值被修改后又改回原值,由于时间戳的存在,线程仍然可以检测到这中间的变化。

乐观锁和悲观锁

乐观锁(Optimistic Locking)其实是一种思想。乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
乐观锁的具体实现细节:主要就是两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是CAS
相对于乐观锁,还有悲观锁,这是一种对数据的修改抱有悲观态度的并发控制方式,一般在认为数据被并发修改的概率比较大的时候,需要在修改之前先加锁的时候使用。
JAVA中的synchronized就是一种悲观锁,但是这种悲观锁机制存在以下问题:

  • 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  • —个线程持有锁会导致其它所有需要此锁的线程挂起。
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

锁升级

在JDK 1.6及之前的版本中,synchronized锁是通过对象内部的一个叫做监视器锁(也称对象锁)来实现的。当一个线程请求对象锁时,如果该对象没有被锁住,线程就会获取锁并继续执行。如果该对象已经被锁住,线程就会进入阻塞状态,直到锁被释放。这种锁的实现方式称为“重量级锁”,因为获取锁和释放锁都需要在操作系统层面上进行线程的阻塞和唤醒,而这些操作会带来很大的开销。
在JDK 1.6之后,synchronized锁的实现发生了一些变化,引入了“偏向锁”“轻量级锁”“重量级锁”三种不同的状态,用来适应不同场景下的锁竞争情况。

  • 无锁
    当一个线程第一次访问一个对象的同步块时,JVM会在对象头中设置该线程的Thread lD,并将对象头的状态位设置为“偏向锁”。这个过程称为“偏向”,表示对象当前偏向于第一个访问它的线程。

  • 偏向锁(Biased Locking)
    当一个synchronized块被线程首次进入时,锁对象会进入偏向模式。 在偏向锁模式下,锁会偏向于第一个获取它的线程,JVM会在对象头中记录该线程的ID作为偏向锁的持有者,并将对象头中的Mark Word 中的一部分作为偏向锁标识。 在这种情况下,如果其他线程访问该对象,会先检查该对象的偏向锁标识,如果和自己的线程ID相同,则直接获取锁。如果不同,则该对象的锁状态就会升级到轻量级锁状态。

    触发条件:首次进入synchronized块时自动开启,假设JVM启动参数没有禁用偏向锁。

  • 轻量级锁(Lightweight Locking)
    当有另一个线程尝试获取已被偏向的锁时,偏向锁会被撤销,锁会升级为轻量级锁。 在轻量级锁状态中,JVM为对象头中的Mark Word 预留了一部分空间,用于存储指向线程栈中锁记录的指针。

    当一个线程尝试获取轻量级锁时,JVM的做法是:

    1. 将对象头中的Mark Word复制到线程栈中的锁记录(Lock Record):每个Java对象头部都有一个Mark Word,它用于存储对象自身的运行时数据,如哈希码、锁状态信息、代年龄等。当线程尝试获取轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录空间,然后将对象头中的Mark Word复制到这个锁记录中。这个复制的Mark Word被称为“Displaced Mark Word”。

    2. 尝试通过CAS操作更新对象头的Mark Word:接下来,JVM尝试使用CAS(Compare-And-Swap)操作, 将对象头的Mark Word更新为指向锁记录的指针。如果这个更新操作成功,那么这个线程就成功获取了这个对象的轻量级锁。 如果替换成功,则该线程获取锁成功;如果失败,则表示已经有其他线程获取了锁,则该锁状态就会升级到重量级锁状态。

    触发条件:当有另一个线程尝试获取已被偏向的锁时,偏向锁会升级为轻量级锁。

  • 重量级锁(Heavyweight Locking)
    当轻量级锁的CAS操作失败,即出现了实际的竞争,锁会进一步升级为重量级锁。 当锁状态升级到重量级锁状态时,JVM会将该对象的锁变成一个重量级锁,并在对象头中记录指向等待队列的指针。 此时,如果一个线程想要获取该对象的锁,则需要先进入等待队列,等待该锁被释放。当锁被释放时,JVM会从等待队列中选择一个线程唤醒,并将该线程的状态设置为“就绪”状态,然后等待该线程重新获取该对象的锁。

    触发条件:当轻量级锁的CAS操作失败,轻量级锁升级为重量级锁。