Java并发

Java并发

java内存模型(JMM)

JMM模型下,每个线程都有自己的本地内存,线程对内存的操作也是先操作本地内存。
之后才将本地内存的数据刷新到主内存里,让各个线程更新共享。

内存屏障

  • 读读屏障:确保读1读取数据后才进行读2
  • 写写屏障:确保写1其他线程可见后才写2
  • 读写屏障:确保之后才
  • 写读屏障:确保其他线程可见后才

volatile

volatile通过写写屏障,确保volatile变量写入后能立即被其他线程可见,并且后面的写不会在volatile之前写入。
以及双重检查锁定与延迟初始化问题。

final

final通过写写屏障,确保final变量写入后能立即被其他线程可见。
并且final变量的写入早于对象的赋值写入,确保拿到该对象的线程的final变量已经完成初始化。
所以如果在构造函数里向其他线程传递this,可能会导致final溢出。

synchronized

java对象的内存结构分为三部分:对象头、实例数据、对其填充。
其中对象头又由markWord、类型指针、数组长度构成。
其中markWord保存着锁标志位和是否偏向锁,分别表示锁的状态是无锁、偏向锁、轻量级锁、重量级锁。
而synchronized在这三种锁的升级叫做锁膨胀

偏向锁

  1. 当锁对象第一次被线程获取时,锁标记位为偏向锁
  2. 通过CAS把markWord的线程ID指向自己,以后进入锁只需要检查markWord的线程ID是不是指向自己即可
  3. 如果线程ID不是当前线程,则证明已经有其他线程占有这个偏向锁,等持锁线程到达安全点后挂起线程。
    如果持锁线程未活动或者已退出同步块,则撤销偏向锁为无锁,唤醒原持锁线程继续执行。
    如果持锁线程还在同步代码块,升级为轻量级锁。对持锁线程执行轻量级锁获取锁过程,唤醒持锁线程继续执行。

获取轻量级锁

  1. 如果对象为无锁或者轻量级锁,在线程的栈帧里创建一个锁记录的空间,用于保存锁对象的markWord
  2. 通过CAS把锁对象的markWord更新为栈帧里锁记录的指针,如果成功则获取锁,并且将锁记录的锁标记位修改为轻量级锁
  3. 如果失败,意味着markWord已经被修改过,检查markWord是否指向自己的锁记录,如果是则是重入,获取锁成功
  4. 如果失败则证明发生了竞争,自适应自旋CAS修改锁记录指针。
    自适应自旋会根据以前自旋成功率来决定自旋次数,越成功自旋越多,否则可能取消自旋直接挂起。
  5. 如果自旋修改失败,升级为重量级锁。锁标记位修改为重量级锁,markWord指向monitor对象指针,线程进入阻塞状态

释放轻量级锁

  1. 通过CAS把锁记录的markWord复制回锁对象,成功则释放锁成功,锁标记恢复为无锁
  2. 如果失败则证明已经升级为重量级锁,释放锁后唤醒阻塞线程

重量级锁

每个锁对象其实都是一个监视器锁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把节点加到队尾。
线程被加到队尾之后,会有个去死循环判断:

  1. 前置节点是不是头结点(头结点不记数据),是的话就通过CAS拿锁,失败继续循环
  2. 前置节点是不是在休眠状态,是的话自己也休眠,等待唤醒,调用LockSupport.park()
  3. 如果醒来发现自己被interrupt的话,返回获取锁失败,并interrupt自己,保存interrupt状态为true

释放锁会调用尝试释放锁方法,如果当前线程不是持锁线程则抛异常,否则修改锁标记。
修改成功之后回去唤醒等待队列里最前面的还在等待获取锁的线程。

JUC

  • Semaphore(信号量):控制并发量。通过锁标记来记录并发数。acquire()方法获取锁,release()方法释放锁。
  • CountDownLatch(减数门栓):锁标记记录未完成剩余计数。线程完成后调用countDown()计数-1,直到计数为0,唤醒被await()阻塞的线程。
    例如主线程需要等待多个组件加载完成,或者一个线程指挥多个线程同时开始。
    但是CountDownLatch的计数无法重置,所以CountDownLatch对象是一次性的。
  • CyclicBarrier(循环栅栏):锁标记记录未完成剩余计数。线程调用await()计数-1并阻塞,直到全部线程完成,计数为0才被唤醒。
    多线程计算,并发计算完成一步之后,在计算下一步。
    CyclicBarrier有reset功能,可以多次使用。

锁升级与锁降级

锁降级发生于读写锁,当我们检查数据后发现需要修改数据的情形。
因此我们需要先获取读锁检查数据,再使用写锁修改数据。
但如果是先释放读锁再去获取写锁,则可能会有其他线程在这个空缺时间里拿到写锁修改了数据。
所以是获取读锁->检查数据->获取写锁->释放读锁->修改数据->释放写锁。
这里的在持有读锁状态下获取写锁,然后释放读锁保留写锁,即读锁->写锁,就叫做锁升级。
反之,如果是持有写锁状态下去获取读锁,然后释放写锁保留读锁,即写锁->读锁,则叫做锁降级。
无论是锁升级还是锁降级,目的都是确保读写的数据的一致性,有点像一个事务。

参考文章:

《深入理解Java虚拟机_JVM高级特性与最佳实践_第3版_周志明》

深入分析synchronized原理和锁膨胀过程(二)

不可不说的Java“锁”事

并发之AQS原理(三)+如何保证并发

AQS原理学习笔记

AQS.md

从ReentrantLock的实现看AQS的原理及应用

java并发编程的艺术学习笔记


Java并发
https://cellargalaxy.github.io/posts/java/18.Java并发/
作者
cellargalaxy
发布于
2020年6月17日
许可协议