synchronized vs CAS

虽然标题为synchronized vs CAS,但从大的范围来说其实是:悲观锁 vs 乐观锁,本文使用synchronized和CAS来进行介绍,并对比。

首先锁的出现是为了,保证同一时间内,只有一个线程进入临界区。

什么是悲观锁和乐观锁?

悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

synchronized实现原理

static final Object lock = new Object();
static int cnt = 0;
public static void main(String[] args) {
    synchronized (lock) {
        cnt++;
    }
}

查看对应的字节码会发现是这样的:【1】

 0 getstatic #2 <com/optimjie/Main.lock : Ljava/lang/Object;>
 3 dup
 4 astore_1
 5 monitorenter
 6 getstatic #3 <com/optimjie/Main.cnt : I>
 9 iconst_1
10 iadd
11 putstatic #3 <com/optimjie/Main.cnt : I>
14 aload_1
15 monitorexit
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return

解释字节码前先看一下synchronized的底层实现monitor

synchronized的底层依赖一个叫ObjectMonitor模式实现,这是JVM内部C++实现的机制。【2】

重要的几个字段:

  1. _owner:指向持有ObjectMonitor对象的线程地址。
  2. _WaitSet:存放调用wait方法,而进入等待状态的线程的队列。
  3. _EntryList 这里是等待锁block状态的线程的队列。
  4. _recursions 锁的重入次数。
  5. _count 线程获取锁的次数。

加锁与解锁过程:

  1. 如果_owner为空,则将_owner改为当前线程,这里是通过CAS
  2. 其他线程再来获取锁时,会进入到_EntryList中去
  3. 当_owner线程释放锁时,会唤醒_EntryList中的线程,这里竞争是非公平的

看到这里其实会发现AQS和这里的monitor的作用是很类似的:( [1] synchronized和ReentrantLock对比)

  1. 变量
  2. 挂起和唤醒线程
  3. 使用一个集合来管理线程

回到上面的字节码中,monitorenter其实就是将lock对象的mark word中的重量级锁的指针指向monitor对象并且将_owner置为当前线程,而monitorexit就是将mark word中的指针重置,并且唤醒_EntryList中等待的线程。而这里有两处monitorexit,是因为19-22这几个字节码是临界区内发生异常时来执行的命令,说白了就是异常情况下也要释放锁。

JVM中对于synchronized的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。【3】

主要升级过程为(只能升,不能降):

  1. 偏向锁:只有一个线程加锁时,没有竞争

    • 当某个线程尝试获取锁时,查看thread是否为当前线程(这是对象头中应该是无锁或者偏向锁的状态),如果没有的话,就是用CAS修改mark work,将thread改为当前线程(OS),执行完临界区的代码后,并不会清空thread,因为下一次该线程再进来时可以查看thread是不是当前的线程
  2. 轻量级锁:有多个线程加锁时,没有竞争

    • 当上面的线程执行结束后,另一个线程再进入临界区时会升级为轻量级锁。

    加锁过程:

    • 当前栈帧中创建一个lock record,同时在lock record中分配mark word的空间
    • 通过cas将mark word拷贝到lock record中,同时将lock的mark word中ptr_to_lock_record指向栈帧中的lock record,锁标记改为00
    • 如果cas失败的话有两种情况,一种是重入了那么会在当前栈帧中新建一个lock record,只不过cas拷贝的这里是null,第二种情况就是存在竞争力,这种情况下轻量级锁会失效,锁会升级为重量级锁,此时锁标记为10,同时指针指向重量级锁,也就是monitor

    解锁过程:

    • 从栈帧冲弹出lock record,如果是null就直接弹出吧,如果不为null,就通过cas将lock record恢复给mark word,这样lock的mark word就和加锁前是一样的了。当然这里cas也有可能失败,这里我期望当前lock的mark word中锁标记应该是00,但是有可能发生了竞争导致升级为重量级锁,锁标记就成10了,此时就应该走重量级锁的解锁过程了
  3. 重量级锁:有多个线程加锁时,有竞争

    • 上面提到了当一个线程拿到锁之后,另一个线程再想通过cas将对象头的mark word拷贝到栈帧中的lock word的话就会失败
    • 为lock对象申请Monitor锁,让lock指向重量级锁的地址
    • 自己进入到Monitor的_EntryList中,这个时候Monitor的_owner为线程A,即之前通过轻量级锁的方式拿到锁的线程
    • 当线程A执行完同步代码块中的内容后,执行轻量级的解锁过程,即通过cas的方式,将mark word的值拷贝给lock对象,这时候会失败,之后走重量级锁的解锁过程(这个上面也说到了)
    • 将Monitor的_owner置为null,唤醒_EntryList中BLOCKED的线程(这里是非公平的),Monitor中还有WaitSet,这里面存的是之前获得过锁,但是条件不满足进入到WAITING的状态

到这里就会发现这个过程其实就是对上面monitor加锁和解锁的细化

上面的问题

  1. synchronized和ReentrantLock对比

参考

  1. 黑马程序员深入学习Java并发编程,JUC并发编程全套教程
  2. https://www.cnblogs.com/minikobe/p/12123065.html
  3. https://www.pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
posted @ 2023-08-11 02:17  optimjie  阅读(68)  评论(0)    收藏  举报