Jvm synchorize解决线程同步

369 阅读6分钟

这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战

i++问题

class A {
static int i;
public void inc() {
    i++;
}
}

还记得上一篇《Jvm volatile解决可见性》中的i++,问题吗,除了可见性问题,还有一个更为重要的顺序问题。

i++顺序问题

从虚拟机字节码看,i++操作分为了三步。

  1. 从变量i的内存地址获取i的值
  2. CPU执行i+1
  3. 将CPU执行i+1的结果写入到变量i中

image.png

假设上面三步每个操作都是原子的,由于Java多线程执行并发执行时,取出的i=0,在执行i+1后两个线程都为2。所以为了保证i++结果的准确,在执行上面三步时候,同一时间只能有一个线程执行,这三个需要同步执行的步骤我们称为临界区。

锁设计

线程的竞争好比5个人上厕所,但是坑位只有一个。考虑到5个人上厕所的时间不确定,设计如下方案:

image.png

主要从以下几个问题考虑:

  1. 两个线程同时到达临界区,如果竞争? 可以通过临界区设置一个变量,比如tid(线程的id),两个线程同时修改这个变量的值为自己线程的id,修改成功的表示竞争的胜利者,可以执行下面的临界区代码。但是问题似乎回来了,之前是两个线程竞争临界区,这个是两个线程竞争变量,这不是同个问题吗?当然不是,对于单个变量赋值的竞争,JVM提供了CAS指令做到原子操作。但是对于临界区的代码就无能为力了。原子操作可以参考,AtomicInterget#compareAndSet()

  2. 竞争成功线程可以执行临界区代码,失败的线程去干嘛? 失败的线程就只能等待了,但是线程的只要处于活动状态就会占用CPU的资源,所以一般会将等待的线程进行睡眠,让其失去CPU执行权,不占用CPU资源。由于会有多个线程竞争失败的情况,所以我们通过一个容器存储竞争失败的线程,这个容器我们称为阻塞队列

  3. 竞争失败的线程什么时候恢复? 当竞争成功的线程退出临界区时,会将阻塞队列中的线程唤醒,重新进行竞争。如此循环。

synchronized

这个锁机制基本是java synchronized的简单实现,但实现起来需要考虑很多,比如阻塞队列的入队需要并发执行,如何实现?

当使用synchronized关键字的时候,需要指定一个对象,这个对象的作用其实就是临界区的一个标识,用来关联对应的阻塞队列,以及竞争的变量,不然进入哪个临界区的阻塞队列?竞争哪个临界区的变量?选择哪个临界区的阻塞队列唤醒?

我们也可以通过synchronized关键字的虚拟机字节码一窥端倪:

image.png

对象的数据结构

既然竞争的变量与synchronized指定的对象有关,这个变量存到存储到对象的元数据,我觉得非常的何时。

一个new出来的对象,除了它本身的实例变量之外,还需要存储哪些东西?我的看法如下:

  1. 垃圾回收eden进入old的分代年龄,这是每个对象特有的,需要存储到对象上。
  2. object.getClass()可以直接获取对象的Class,所以它必然还需要存储对应Class的引用,或者是对应的内存地址。

在《并发编程的艺术》书中,Java对象的结构如下:

image.png

对于Mark Word的数据结构,在不同锁状态下其内部数据结构不同

image.png

锁优化

偏向锁

经过测试,如果线程竞争不激烈情况下,发现大多数情况下都是同个线程获取锁,频繁加锁解锁损耗性能。所以偏向锁情况下。线程执行临街区代码过程:

  1. 判断mark word 中threadid,是否是当前线程id
  2. 如果相同,则直接执行临街区代码。若不同,则查看持有偏向锁的线程是否存活,持有偏向锁线程已经死亡则将对象头设置为无锁状态。如果持有锁线程存活,则等待该线程到达全局安全点的情况下,停止该线程执行的用户代码,然后使对象头中的mark word偏向其它线程或者恢复到无锁状态,然后升级锁到轻量级锁。

轻量级锁

如果临街区代码执行很快,则线程抢锁失败通常不需要睡眠,使用自旋转可能更好一些,虽然占用了CPU但是不需要额外的线程切换的开销。 轻量级锁情况下进入临街区代码:

  1. 线程在栈中分配轻量级锁的内存空间,并将displaced mark word数据复制进去
  2. 使用cas修改对象头的mark word。修改成功的获取锁执行临街区代码。修改失败则再次尝试CAS,知道cas次数达到某个阈值时,轻量级锁转为重量级锁。
  3. 当cas成功的线程执行完临街区代码,使用cas替换当前对象头的mark word到displaced mark word。如果成功则退出。如果失败,则说明mark word已经变为了重量级锁导致失败。此时唤醒阻塞队列线程。

重量级锁

重量级锁即前文中锁设计提到的。

锁升级总结

书中提到的锁升级过程非常模糊,比如 偏向锁只说了恢复到无锁或者偏向其它线程,那如何升级到轻量级锁?我一个线程拿到偏向锁,你把我暂停了,然后给别人执行?我觉得这个地方书中描写的模糊不清,自己猜测是偏向锁如果有竞争情况下,暂停持有锁的线程执行的用户代码,然后让该线程执行偏向锁升级到轻量级锁,该线程在栈中开辟轻量级锁的内存空降,复制displaced mark word数据,在用cas替换mark word。而此时竞争线程则必须等待,等待锁的升级。

对于偏向锁,书中提到解锁时使用cas替换回displaced mark word,这个地方有个疑惑是为啥cas加锁成功但是,cas 解锁还能失败?其实是因为在cas成功后mark word内容已经改变了,导致mard word的内容已经不是栈中轻量级锁的指针。书没提示这点困惑了很久。还有个细节问题没有提到,那就是多个竞争线程同时升级为重量级所如何处理?个人猜测是用c++提供的同步方式进行double check。

全文总结

synchorized的锁由jvm锁实现,具体细节还需要自己看jvm源码,难度大,只能看个大概思想。如果真想要看实现并且还能进行改造,还是需要看AQS锁实现,并且使用时还能自己改动。