Java并发
java内存模型(JMM)
JMM模型下,每个线程都有自己的本地内存,线程对内存的操作也是先操作本地内存。
之后才将本地内存的数据刷新到主内存里,让各个线程更新共享。
内存屏障
- 读读屏障:确保读1读取数据后才进行读2
- 写写屏障:确保写1其他线程可见后才写2
- 读写屏障:确保读之后才写
- 写读屏障:确保写其他线程可见后才读
volatile
volatile通过写写屏障,确保volatile变量写入后能立即被其他线程可见,并且后面的写不会在volatile之前写入。
以及双重检查锁定与延迟初始化问题。
final
final通过写写屏障,确保final变量写入后能立即被其他线程可见。
并且final变量的写入早于对象的赋值写入,确保拿到该对象的线程的final变量已经完成初始化。
所以如果在构造函数里向其他线程传递this
,可能会导致final溢出。
synchronized
java对象的内存结构分为三部分:对象头、实例数据、对其填充。
其中对象头又由markWord、类型指针、数组长度构成。
其中markWord保存着锁标志位和是否偏向锁,分别表示锁的状态是无锁、偏向锁、轻量级锁、重量级锁。
而synchronized在这三种锁的升级叫做锁膨胀。
偏向锁
- 当锁对象第一次被线程获取时,锁标记位为偏向锁
- 通过CAS把markWord的线程ID指向自己,以后进入锁只需要检查markWord的线程ID是不是指向自己即可
- 如果线程ID不是当前线程,则证明已经有其他线程占有这个偏向锁,等持锁线程到达安全点后挂起线程。
如果持锁线程未活动或者已退出同步块,则撤销偏向锁为无锁,唤醒原持锁线程继续执行。
如果持锁线程还在同步代码块,升级为轻量级锁。对持锁线程执行轻量级锁获取锁过程,唤醒持锁线程继续执行。
获取轻量级锁
- 如果对象为无锁或者轻量级锁,在线程的栈帧里创建一个锁记录的空间,用于保存锁对象的markWord
- 通过CAS把锁对象的markWord更新为栈帧里锁记录的指针,如果成功则获取锁,并且将锁记录的锁标记位修改为轻量级锁
- 如果失败,意味着markWord已经被修改过,检查markWord是否指向自己的锁记录,如果是则是重入,获取锁成功
- 如果失败则证明发生了竞争,自适应自旋CAS修改锁记录指针。
自适应自旋会根据以前自旋成功率来决定自旋次数,越成功自旋越多,否则可能取消自旋直接挂起。 - 如果自旋修改失败,升级为重量级锁。锁标记位修改为重量级锁,markWord指向
monitor
对象指针,线程进入阻塞状态
释放轻量级锁
- 通过CAS把锁记录的markWord复制回锁对象,成功则释放锁成功,锁标记恢复为无锁
- 如果失败则证明已经升级为重量级锁,释放锁后唤醒阻塞线程
重量级锁
每个锁对象其实都是一个监视器锁monitor
对象,监视器锁会记录进入数,来实现锁重入。
在同步代码块里,字节码是通过monitorenter
指令获取监视器锁的所有权,和monitorexit
指令释放所有权。
在同步方法里,字节码是通过ACC_SYNCHRONIZED
标识,来给JVM标记要去获取监视器锁。
而监视器锁是依赖操作系统的互斥量mutex
,需要将线程从用户态切换到内核态。
获取锁失败的线程会被放到一个同步队列里面。
如果释放锁的时候刚好有线程取锁,操作系统会将锁给这个线程,所以重量级锁是非公平锁。
同时wait()
、notify()
方法也是依赖于监视器锁,所以调用需要在同步块里。
锁消除
JVM通过逃逸分析发现某些对象不会发生并发问题,但却加了锁,会去除这个锁。
锁优化
- 读写锁
- 锁粗化:避免反复获取锁,在锁的范围扩大,一次锁处理更多东西
- 减少锁的颗粒度
- 减少锁的持有时间
队列同步器
队列同步器(AbstractQueuedSynchronizer)是实现各种Lock和并发工具的核心。
队列同步器最主要是通过一个volatile的int变量来表示锁状态,和维护一个队列用于保存等待锁的线程。
一般我们是通过继承队列同步器,去重写他的模板方法,在模板方法里通过CAS去修改锁状态。
模板方法分为两类,独占式和共享式。
锁标记方法 | 功能 |
---|---|
int getState() | 获取锁标记 |
void setState(int) | 设置锁标记 |
boolean compareAndSetState(int expect,int update) | CAS锁标记 |
模板方法 | 功能 |
---|---|
boolean tryAcquire(int) | 独占式,尝试,获取锁 |
boolean tryRelease(int) | 独占式,尝试,释放锁 |
boolean tryAcquireShared(int) | 共享式,尝试,获取锁 |
boolean tryReleaseShared(int) | 共享式,尝试,释放锁 |
boolean isHeldExclusively() | 锁是否被当前线程独占 |
以重入锁为例,当调用锁方法的时候,会调用尝试获取锁方法。
还没有加锁就通过CAS尝试加锁。如果加锁了锁,就判断持锁线程是不是当前线程,是的话就锁标记+1重入。
而重入锁支持非公平锁和公平锁,非公平锁会先通过CAS抢锁。
而公平锁则先判断等待队列里是否有线程在等待,有的话则不去抢锁。
如果CAS获取锁失败的话,AQS会把线程封装到等待队列的节点里,通过CAS把节点加到队尾。
线程被加到队尾之后,会有个去死循环判断:
- 前置节点是不是头结点(头结点不记数据),是的话就通过CAS拿锁,失败继续循环
- 前置节点是不是在休眠状态,是的话自己也休眠,等待唤醒,调用
LockSupport.park()
- 如果醒来发现自己被interrupt的话,返回获取锁失败,并interrupt自己,保存interrupt状态为true
释放锁会调用尝试释放锁方法,如果当前线程不是持锁线程则抛异常,否则修改锁标记。
修改成功之后回去唤醒等待队列里最前面的还在等待获取锁的线程。
JUC
- Semaphore(信号量):控制并发量。通过锁标记来记录并发数。
acquire()
方法获取锁,release()
方法释放锁。 - CountDownLatch(减数门栓):锁标记记录未完成剩余计数。线程完成后调用
countDown()
计数-1,直到计数为0,唤醒被await()
阻塞的线程。
例如主线程需要等待多个组件加载完成,或者一个线程指挥多个线程同时开始。
但是CountDownLatch的计数无法重置,所以CountDownLatch对象是一次性的。 - CyclicBarrier(循环栅栏):锁标记记录未完成剩余计数。线程调用
await()
计数-1并阻塞,直到全部线程完成,计数为0才被唤醒。
多线程计算,并发计算完成一步之后,在计算下一步。
CyclicBarrier有reset功能,可以多次使用。
锁升级与锁降级
锁降级发生于读写锁,当我们检查数据后发现需要修改数据的情形。
因此我们需要先获取读锁检查数据,再使用写锁修改数据。
但如果是先释放读锁再去获取写锁,则可能会有其他线程在这个空缺时间里拿到写锁修改了数据。
所以是获取读锁->检查数据->获取写锁->释放读锁->修改数据->释放写锁。
这里的在持有读锁状态下获取写锁,然后释放读锁保留写锁,即读锁->写锁,就叫做锁升级。
反之,如果是持有写锁状态下去获取读锁,然后释放写锁保留读锁,即写锁->读锁,则叫做锁降级。
无论是锁升级还是锁降级,目的都是确保读写的数据的一致性,有点像一个事务。
参考文章:
《深入理解Java虚拟机_JVM高级特性与最佳实践_第3版_周志明》