Java并发机制底层原理
本文为对Java并发编程中并发机制底层原理的总结,包括以下几部分:
- 为什么多线程下会有并发问题
- Synchronized锁中对象头Mark Word的数据变更
- 偏向锁加锁、释放锁以及锁升级
- 轻量级锁加锁以及锁升级
- 重量级锁的实现原理
- 基于自旋CAS实现的乐观锁
为什么多线程下会有并发问题
我理解的并发不安全问题是指,在多线程场景下,多个线程对同一个共享变量的读写而出现的数据不一致问题。比如有A、B两个线程,A线程写共享变量,B读共享变量,若不做保护,B线程读取的数据可能会A修改前的数据。此时若B线程继续使用旧数据进行业务操作,将导致业务不正确。
造成上述并发不安全问题的根源是因为SMP(对称多处理器)架构,SMP架构如下图所示:
SMP(对称多处理器)架构.png
1.为了加快程序的处理速度,每个处理器内核都会维护自己的缓存,也就是常说的L1、L2、L3。处理器在执行指令过程中,若需要对共享数据进行访问,会先将共享数据拷贝到缓存中。
2.在多个线程同时访问共享数据时,每个线程对应的处理器L1缓存中都有一份共享数据的副本。
3.当有线程修改了共享数据,其实只是修改了处理器L1缓存中的数据,此时其他线程中的数据仍为初始读到的数据。
基于以上三点,Java多线程场景就会出现并发安全问题。下面我们就看下Java是如何解决并发安全的。
Synchronized锁中对象头Mark Word的数据变更
首先,我们要理解Synchronized锁的是非null的对象:
1.若Synchronized修饰的是静态方法,则锁的是Class类对象。
2.若Synchronized修饰的是普通方法,则锁的是当前对象。
3.若Synchronized修饰的同步代码块,则锁的是()中配置的对象。
每个Java对象都有一个Monitor对象与其对应,同步的实现是基于monitorenter和monitorexit指令来实现的。
Synchronized的锁是存在Java对象头里面的,如果是对象是非数组的,则使用2字宽(1字宽=4字节),也就是32bit,称之为Mark Word。存储结果如下:
对象头Mark Word.png
从图中可以看到:
1.如果是偏向锁,Mark Word中存储了持有锁的线程ID,线程每次进入同步代码时只需判断Mark Word中的线程ID是否是自己的线程ID,是则直接持有锁。
2.如果是轻量级锁,存储的是指向栈中锁的指针。
3.如果是重量级锁,存储的是指向互斥量(重量级锁)的指针。
偏向锁加锁、释放锁以及锁升级
偏向锁的加锁、释放锁以及锁升级的流程参考下图:
偏向锁获取和撤销流程.png
流程主要有以下几点需要注意:
1.当检查对象头存储的不是自己线程时,使用CAS来替换Mark Word,替换成功时Mark Word的线程ID将指向自己。
2.若替换失败,则说明有其他线程持有锁,当前线程将会发起撤销锁操作。
3.偏向锁的撤销需要等待全局安全点,也就是没有字节码在执行。
4.偏向锁的撤销分为两种情况,一是线程不存在了,则直接撤销锁即可;二是线程还存在,则升级为轻量级锁,然后恢复线程。
轻量级锁加锁以及锁升级
轻量级锁加锁以及锁升级流程见下图:
轻量级锁及膨胀流程.png
关键流程说明:
1.线程访问同步块时,先为锁记录分配栈空间,然后将Mark Word复制到栈空间内。
2.轻量级锁的加锁方式为使用CAS修改Mark Word的锁标志,若成功则说明获取到了锁,此时再将Mark Word中HashCode和age位置数据替换为指向栈中锁记录的指针。
3.若失败,则说明已经有线程持有锁了,此时线程将通过自旋来重复上面的步骤。
4.当超过一定的阈值后,还未获取到锁,则进入锁膨胀升级为重量级锁,方式为修改Mark Word中的锁标志为重量级锁。
5.持有锁的线程在执行完同步块代码后,使用CAS再替换Mark Word时若失败,则直接释放锁,并唤醒等待的线程。
重量级锁的实现原理
重量级锁的完整实现过程见下图:
重量级锁实现原理.png
在图中有三个流程:
- 黑色线条:申请锁过程
- 蓝色线条:释放锁过程
- 红色线条:线程阻塞唤醒过程
下面我们对上面三个流程逐步分析。
申请锁
1.所有线程申请锁都是通过调用Monitor.enter方法的。
2.Monitor.enter成功的线程将获取到锁,然后执行同步代码块。
3.Monitor.enter失败的线程将进入等待队列。其中等待队列中有两个队列,一个是ContentionList,是一个FILO队列,只是一个有Node和next串起来的链表,所有线程都是进入该链表。另外一个是EntryList,当持有锁线程执行完后,将从EntryList中取出一个线程,成为Read Thread,然后参与锁竞争。
申请锁最终两种状态,一是申请锁成功将持有锁,二是申请锁失败进入队列等待被拉起重新参与锁竞争。
释放锁
释放锁的过程在上面申请锁的第3点中已经描述。
线程阻塞唤醒过程
线程在执行过程中,若执行wait()方法,则将暂停线程,并将线程加入到Bloking Queue阻塞队列中,等待其他线程调用notify()方法来唤醒,然后加入到EntryList中。
重量级锁常见三个问题:
1.可重入是怎么实现的:可重入是在Monitor中维护了一个锁计数器,每次线程若调用Monitor.enter时发现已持有锁,则直接返回成功,并将锁计数器加1。每次退出同步块调用Monitor.exit时,锁计数器减1,最终若锁计数器为0了,则释放锁。
2.为什么是非公平锁:因为线程释放锁后,每次只从EntryList中取出一个线程,而且该线程不是直接持有锁,而是需要和其他线程参与竞争,最终有可能线程没有获取到锁而重新加入等待队列。因此不是公平的,也就是不满足FIFO(先进先得到锁)。
3.什么自旋锁:自旋锁其实就是JDK对竞争锁时做了优化,若线程调用Monitor.enter失败后,不会立即进入到等待队列,而是会通过自旋来再次调用Monitor.enter。若自旋一定次数后仍失败,这时才将线程加入到等待队列中。
基于自旋CAS实现的乐观锁
基于自旋CAS实现的乐观锁,其实就是通过CAS的方式来尝试获取锁,若CAS失败,则通过不断循环来重复CAS过程,也叫自旋。
CAS实现原子的三大问题:
- ABA问题:通过使用版本号来解决。
- 循环时间开销大:CPU需要一直分配时间片。
- 只能保证一个共享变量的原子操作:CAS每次只能对一个变量进行比较替换。
至此,本文就把Java并发底层原理总结完了,还有下一步将总结下Java中的Lock锁。