Java并发编程之锁机制
阅读原文时间:2023年07月13日阅读:1
  • 悲观锁与乐观锁

    • 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题
    • 乐观锁则认为对于同一个数据的并发操作,有可能不会发生修改的。在更新数据的时候,会采用尝试更新,不加锁的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的
  • 可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁,可一定程度避免死锁

  • 共享锁与独占锁

    • 独享锁是指该锁一次只能被一个线程所持有
    • 共享锁是指该锁可被多个线程所持有
  • 互斥锁与读写锁:独享锁/共享锁的具体实现

  • 公平锁与非公平锁

    • 公平锁是指多个线程按照申请锁的顺序来获取锁
    • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,可能会造成优先级反转或者饥饿现象
  • 分段锁:一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作

  • 偏向锁/轻量级锁/重量级锁

  • 自旋锁:当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。默认次数为10次,可以通过参数-XX:PreBlockSpin来调整

  • 自适应自旋锁:自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

synchronized

原理探究

synchronized加锁的四种方式

public class SynchronizedTest {

    // 静态方法上加synchronized关键字,锁是当前类的class对象
    public static synchronized void sync1() {

    }

    // 普通方法上加synchronized关键字,锁是当前实例对象,与sync1仅作用域不同
    public synchronized void sync2() {

    }

    // synchronized代码块,锁是括号里面的对象
    public void sync3() {
        synchronized (this) {

        }
    }

    // synchronized代码块,class上加锁,与sync3仅作用域不同
    public void sync4() {
        synchronized (SynchronizedTest.class) {

        }
    }
}

查看汇编代码,执行javac -encoding UTF-8 SynchronizedTest.java、javap -v SynchronizedTest.class

  public static synchronized void sync1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED // ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 19: 0

  public synchronized void sync2();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 23: 0

  public void sync3();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0 // 将第一个引用类型本地变量推送至栈顶
         1: dup // 复制栈顶一个字长的数据,将复制后的数据压栈
         2: astore_1
         3: monitorenter // 加锁
         4: aload_1
         5: monitorexit // 释放锁
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return

  public void sync4();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         // ldc将常量池中#2推送至栈顶,即class com/SynchronizedTest
         0: ldc           #2                  // class com/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter // 加锁
         5: aload_1
         6: monitorexit // 释放锁
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit // 释放锁
        13: aload_2
        14: athrow
        15: return

java 指令集

  1. 对于同步方法
  • JVM采用ACC_SYNCHRONIZED标记符来实现同步。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁(monitor),然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被堵塞。
  • 同步方法是隐式的,会在运行时常量池中的method_info结构体中存放ACC_SYNCHRONIZED标识符access_flags

Monitor

无论是同步方法还是同步代码块都是基于监视器Monitor实现

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor中有几个关键属性:

  • _owner:指向持有ObjectMonitor对象的线程
  • _WaitSet:存放处于wait状态的线程队列
  • _EntryList:存放处于等待锁block状态的线程队列
  • _recursions:锁的重入次数
  • _count:用来记录该线程获取锁的次数

ObjectMonitor中有几个关键方法:

  • enter(TRAPS)
  • exit(TRAPS)
  • wait(jlong millis, bool interruptable, TRAPS)
  • nofity(TRAPS)
  • notifyAll(TRAPS)

解决三大问题

  • 保证原子性:同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到
  • 保证有序性:单线程重排序要遵守as-if-serial语义,而synchronized修饰的代码,同一时间只能被同一线程访问
  • 保证可见性
    • 线程解锁前,必须把共享变量的最新值刷新到主内存中
    • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值

synchronized锁优化

Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,状态转换需要花费很多的处理器时间。

Java对象头

  1. Klass Word(类指针):存储对象的类型指针,该指针指向它的类元数据。从JDK 1.6 update14开始,64位的JVM正式支持了-XX:+UseCompressedOops(默认开启),可以压缩指针,起到节约内存占用的作用。oop(ordinary object pointer)即普通对象指针,下列指针将压缩至32位:
  • 每个Class的属性指针(静态成员变量)
  • 每个对象的属性指针(对象变量)
  • 普通对象数组的每个元素指针
  1. 指针压缩:
  • 如果GC堆大小在4G以下,直接砍掉高32位,避免了编码解码过程(偏移量除以/乘以8)
  • 如果GC堆大小在4G以上32G以下,则启用-XX:+UseCompressedOops命令
  • 如果GC堆大小大于32G,压指失效,使用原来的64位
  1. -XX:+UseCompressedClassPointers
  • Java8使用Metaspace存储元数据,开启后类元信息中的指针也用32bit的Compressed版本,即Klass Word
  • 依赖-XX:+UseCompressedOops

  1. 32位虚拟机占用32个字节,不同状态下各个比特位区间大小有变化
  2. biased_lock:偏向锁标记,为1时表示对象启用偏向锁
  3. age:默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15
  4. identity_hashcode
  • 采用延迟加载技术,只有在需要时使用System.identityHashCode(Object x)计算后写到该对象头中
  • 偏向锁没有存储HashCode的地方,偏向锁期间调用System.identityHashCode(x)会造成锁升级
  • 轻量级锁和重量级锁所指向的lock record或monitor都有存储HashCode的空间
  • 用户自定义hashCode()方法所返回的值不存在Mark Word中,只针对identity hash code
// 引入依赖
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.11</version>
</dependency>

// 查看对象布局信息
ClassLayout layout = ClassLayout.parseInstance(new A());
System.out.println(layout.toPrintable());

可通过ClassLayout查看对象布局信息,即对象占用空间情况

锁升级机制

锁升级是单向的: 无锁 -> 偏向锁 -> 轻量级锁(自旋锁和自适应自旋锁) -> 重量级锁

持有偏向锁的线程不会主动释放偏向锁,而是等待其他线程来竞争才会释放锁。这样偏向锁保证了总是同一个线程多次获取锁的情况下,每次只需要检查标志位就行,效率很高

当检测到偏向锁且不归属当前线程,会暂停原持有偏向锁线程,检测其执行状态:

  • 如果偏向锁线程已退出同步代码块,清除偏向锁标识,转为无锁状态,由当前线程获取轻量级锁
  • 如果偏向锁线程未退出同步代码块,由其膨胀为轻量级锁
锁消除

JIT编译器借助逃逸分析(Escape Analysis)技术来判断同步块所使用的锁对象是否只能够被一个线程访问,如果被证实,就会取消对这部分代码的同步

public class EscapeAnalysis {

    public static Object object;

    public Object methodEscape1() {  // 方法逃逸:方法返回值逃逸
        return new Object();
    }

    public Object methodEscape2() {  // 方法逃逸:作为参数传递到其它方法中
        Object object=new Object();
        xxx(object)
    }

    public void threadEscape1() {// 线程逃逸:赋值给类变量
        object = new Object();
    }

    public void threadEscape2() { // 线程逃逸:其他线程中访问的实例变量
        Object obj=new Object();
        new Thread(() -> xxx(obj)).start();
    }

    public void eliminate1() { // o未逃逸,可自动清除锁
        Object o=new Object();
        synchronized (o){
            xxx();
        }
    }

    public void eliminate2() { // buffer未逃逸,append操作加锁可自动清除锁
        StringBuffer buffer=new StringBuffer();
        buffer.append("hello");
        buffer.append("world");
        buffer.append("!");
    }
}
  • 标量替换:把不存在逃逸的对象拆散,将成员变量恢复到基本类型,直接在栈上创建若干个成员变量
  • 栈上分配:目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换。栈上分配随着方法结束而自动销毁,垃圾回收压力减小
锁粗化

尽量减小锁的粒度,可以避免不必要的阻塞。但是如果在一段代码中连续的用同一个监视器锁反复的加锁解锁,甚至加锁操作出现在循环体中的时候,就会导致不必要的性能损耗,这种情况就需要锁粗化。

for(int i=0;i<100000;i++){
    synchronized(this){
        do();
}

会被粗化成:

synchronized(this){
    for(int i=0;i<100000;i++){
        do();
}

显式锁Lock

特性

特性

API

能响应中断

lockInterruptbly()

非阻塞式的获取锁

tryLock()

支持超时

tryLock(long time, timeUnit)

可实现公平锁

ReentrantLock(ture)

可以绑定多个条件

newCondition()

使用范式

Lock lock = new ReentrantLock();
lock.lock();
try{
    ...
}finally{
    lock.unlock();
}

原理分析

基于AbstractQueuedSynchronizer,队列同步器实现

好处

可重写方法

同步状态获取/修改

setState():拥有锁的线程调用本身具有原子性,不需要使用cas进行设置

同步状态获取流程图

  1. 右下方【线程进入等待状态】的流程图中“结束”不是真正意思上的结束,外层是一个死循环。只有前驱节点为头结点,且获取同步状态成功才会退出循环。节点的就绪、挂起、获取同步都是在循环里完成的,很重要!!!
  2. 响应中断获取同步状态只是在中断检测的处理方式上不同,Thread.interrupted()检测到中断状态后直接抛出了InterruptedException
  • LockSupport.park(),线程挂起
  • 调用Thread的interrupt方法,设置中断标识为true,且内部会调用Parker::unpark(),唤醒挂起线程
  • 调用Thread.interrupted()返回并清除中断标识
  • 中断状态为true抛出异常
公平和非公平模式下的区别

graph TB
A((非公平模式))-->B{CAS抢锁}
B-->|成功|D((成功))
B-->|失败|C{state=0}
C-->|是|G{抢锁}
G-->|成功|D
G-->|失败|F
C-->|否|E{重入}
E-->|是|D
E-->|否|F((进队列))

A1((公平模式))-->C1{state=0}
C1-->|是|B1{AQS队列}
B1-->|为空|D1{CAS抢锁}
B1-->|不为空|F1
D1-->|成功|E1((成功))
D1-->|失败|F1
C1-->|否|G1{重入}
G1-->|是|E1
G1-->|否|F1((进队列))

同步状态释放

graph TB
A((开始))-->B{tryRelease}
B-->|成功|D{后继节点}
D-->|为空或者取消状态|E[倒序查找有效后继节点]
D-->|否|F[唤醒后继节点]
B-->|失败|C[失败]
E-->F

  • tryRelease:getState() - releases是否等于0,即释放后state==0
  • 后继节点:头结点的后继节点,修改head的waitStatus=0并唤醒后继节点(head的waitStatus=0不执行唤醒后继节点)。非公平模式下,被唤醒的后继节点有可能抢锁失败,会再次把head的waitStatus修改为-1,自旋再次抢锁,若再失败线程挂起,等待下次唤醒
  • 倒序查找有效后继节点
    • 节点加入双向队列时,双向链表的建立非原子操作,先建立的是Prev指针(正常查找可能找不到该节点)
    • 取消节点时,先断开的是Next指针,Prev指针并未断开(查找可能中断)
同步队列与等待队列

ReentrantReadWriteLock

  • 支持写锁降级,不支持读锁升级
  • 非公平读锁下为了避免写锁饥饿,会判断头节点的下一个节点是否为排他节点(即写请求),如果是,当前的读锁堵塞
  • 每个线程持有读锁的次数,使用ThreadLocal记录
  • 使用写锁时需要先释放读锁,如果有两个读取锁试图获取写入锁,且都不释放读取锁时,就会发生死锁。因为写锁是排它锁,两个线程都会因为有其他线程持有读锁而无法获取写锁
  • 读锁不支持Condition(读锁在某一时刻最多可以被多个线程拥有,对于读锁而言,其他线程没有必要等待获取读锁,等待唤醒是毫无意义的)
StampedLock

JDK 8新增的读写锁StampedLock,跟读写锁ReentrantReadWriteLock不同,它并不是由AQS实现,有三种访问模式:

  • 写锁writeLock:功能和读写锁的写锁类似
  • 悲观读锁readLock:功能和读写锁的读锁类似
  • 乐观读锁Optimistic reading:一种优化的读模式,解决读锁和写锁互斥问题

StampedLock可以将三种模式是锁进行有条件的互相转换

  • tryConvertToWriteLock():将其他锁转换为写锁

    • 当前邮戳为持有写锁模式,直接返回当前的邮戳;
    • 当前邮戳为持有读锁模式,则会释放读锁并获取写锁,并返回写锁邮戳;
    • 当前邮戳持有乐观锁,通过CAS立即获取写锁,成功则返回写锁邮戳;失败则返回0;
  • tryConvertToReadLock:将其他锁转换为读锁

    • 当前邮戳为持有写锁模式,则会释放写锁并获取读锁,并返回读锁邮戳;
    • 当前邮戳为持有读锁模式,则直接返回当前读锁邮戳;
    • 当前邮戳持有乐观锁,通过CAS立即获取读锁,则返回读锁邮戳;否则,获取失败返回0;
  • tryConvertToOptimisticRead:将其他锁转换为乐观锁

    • 当前邮戳为持有读或写锁,则直接释放读写锁,并返回释放后的观察者邮戳值;
    • 当前邮戳持有乐观锁,若乐观锁邮戳有效,则返回观察者邮戳;

Oracle 官方的例子:

class Point {
    private double x, y;// 成员变量
    private final StampedLock sl = new StampedLock();// 锁实例

    /**
     * 写锁writeLock
     * 添加增量,改变当前point坐标的位置。
     * 先获取到了写锁,然后对point坐标进行修改,然后释放锁。
     * 写锁writeLock是排它锁,保证了其他线程调用move函数时候会被阻塞,直到当前线程显示释放了该锁,也就是保证了对变量x,y操作的原子性。
     */
    void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    /**
     * 乐观读锁tryOptimisticRead
     * 计算当前位置到原点的距离
     */
    double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();    // 尝试获取乐观读锁(1)
        double currentX = x, currentY = y;      // 将全部变量拷贝到方法体栈内(2)

        // 检查票据是否可用,即写锁有没有被占用(3)
        if (!sl.validate(stamp)) {
            // 如果写锁被抢占,即数据进行了写操作,则重新获取
            stamp = sl.readLock();// 获取悲观读锁(4)
            try {
                // 将全部变量拷贝到方法体栈内(5)
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);// 释放悲观读锁(6)
            }
        }

        return Math.sqrt(currentX * currentX + currentY * currentY);// 真正读取操作,返回计算结果(7)
    }

    /**
     * 悲观读锁readLock
     * 如果当前坐标为原点则移动到指定的位置
     */
    void moveIfAtOrigin(double newX, double newY) {
        long stamp = sl.readLock();// 获取悲观读锁(1)
        try {
            // 如果当前点在原点则移动(2)
            while (x == 0.0 && y == 0.0) {
                long ws = sl.tryConvertToWriteLock(stamp);// 尝试将获取的读锁升级为写锁(3)

                if (ws != 0L) {
                    // 升级成功,则更新票据,并设置坐标值,然后退出循环(4)
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    // 读锁升级写锁失败,则释放读锁,显示获取独占写锁,然后循环重试(5)
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);// 释放写锁(6)
        }
    }
}

参考: