这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战
i++问题
class A {
static int i;
public void inc() {
i++;
}
}
还记得上一篇《Jvm volatile解决可见性》中的i++,问题吗,除了可见性问题,还有一个更为重要的顺序问题。
i++顺序问题
从虚拟机字节码看,i++操作分为了三步。
- 从变量i的内存地址获取i的值
- CPU执行i+1
- 将CPU执行i+1的结果写入到变量i中
假设上面三步每个操作都是原子的,由于Java多线程执行并发执行时,取出的i=0,在执行i+1后两个线程都为2。所以为了保证i++结果的准确,在执行上面三步时候,同一时间只能有一个线程执行,这三个需要同步执行的步骤我们称为临界区。
锁设计
线程的竞争好比5个人上厕所,但是坑位只有一个。考虑到5个人上厕所的时间不确定,设计如下方案:
主要从以下几个问题考虑:
-
两个线程同时到达临界区,如果竞争? 可以通过临界区设置一个变量,比如tid(线程的id),两个线程同时修改这个变量的值为自己线程的id,修改成功的表示竞争的胜利者,可以执行下面的临界区代码。但是问题似乎回来了,之前是两个线程竞争临界区,这个是两个线程竞争变量,这不是同个问题吗?当然不是,对于单个变量赋值的竞争,JVM提供了CAS指令做到原子操作。但是对于临界区的代码就无能为力了。原子操作可以参考,
AtomicInterget#compareAndSet()。 -
竞争成功线程可以执行临界区代码,失败的线程去干嘛? 失败的线程就只能等待了,但是线程的只要处于活动状态就会占用CPU的资源,所以一般会将等待的线程进行睡眠,让其失去CPU执行权,不占用CPU资源。由于会有多个线程竞争失败的情况,所以我们通过一个容器存储竞争失败的线程,这个容器我们称为阻塞队列。
-
竞争失败的线程什么时候恢复? 当竞争成功的线程退出临界区时,会将阻塞队列中的线程唤醒,重新进行竞争。如此循环。
synchronized
这个锁机制基本是java synchronized的简单实现,但实现起来需要考虑很多,比如阻塞队列的入队需要并发执行,如何实现?
当使用synchronized关键字的时候,需要指定一个对象,这个对象的作用其实就是临界区的一个标识,用来关联对应的阻塞队列,以及竞争的变量,不然进入哪个临界区的阻塞队列?竞争哪个临界区的变量?选择哪个临界区的阻塞队列唤醒?
我们也可以通过synchronized关键字的虚拟机字节码一窥端倪:
对象的数据结构
既然竞争的变量与synchronized指定的对象有关,这个变量存到存储到对象的元数据,我觉得非常的何时。
一个new出来的对象,除了它本身的实例变量之外,还需要存储哪些东西?我的看法如下:
- 垃圾回收eden进入old的分代年龄,这是每个对象特有的,需要存储到对象上。
- object.getClass()可以直接获取对象的Class,所以它必然还需要存储对应Class的引用,或者是对应的内存地址。
在《并发编程的艺术》书中,Java对象的结构如下:
对于Mark Word的数据结构,在不同锁状态下其内部数据结构不同
锁优化
偏向锁
经过测试,如果线程竞争不激烈情况下,发现大多数情况下都是同个线程获取锁,频繁加锁解锁损耗性能。所以偏向锁情况下。线程执行临街区代码过程:
- 判断mark word 中threadid,是否是当前线程id
- 如果相同,则直接执行临街区代码。若不同,则查看持有偏向锁的线程是否存活,持有偏向锁线程已经死亡则将对象头设置为无锁状态。如果持有锁线程存活,则等待该线程到达全局安全点的情况下,停止该线程执行的用户代码,然后使对象头中的mark word偏向其它线程或者恢复到无锁状态,然后升级锁到轻量级锁。
轻量级锁
如果临街区代码执行很快,则线程抢锁失败通常不需要睡眠,使用自旋转可能更好一些,虽然占用了CPU但是不需要额外的线程切换的开销。 轻量级锁情况下进入临街区代码:
- 线程在栈中分配轻量级锁的内存空间,并将displaced mark word数据复制进去
- 使用cas修改对象头的mark word。修改成功的获取锁执行临街区代码。修改失败则再次尝试CAS,知道cas次数达到某个阈值时,轻量级锁转为重量级锁。
- 当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锁实现,并且使用时还能自己改动。