Java要实现原子性操作可以利用的工具有CAS、内置锁、显式锁等,本篇稳涨就给大家整理了一下CAS和内置锁的一些原理干货,显式锁ReentrantLock后续文章在AQS专题奉上;祝大家面试顺利!
进程
:操作系统分配资源的最小单元,是一个程序在操作系统的一个实例抽象。 线程
:是CPU调度执行最小单元,一个进程可包含多个线程,是一个指令序列。
用户态
:程序运行在用户空间,也只能访问到用户空间的数据。 内核态
:程序运行在内核空间,拥有所有资源的访问权限。
进程(线程)的上下文切换只能发生在内核模式下。
上下文切换主要有三个步骤: 1.暂停当前进程的处理,将该进程的CPU状态存储到内存中PCB。 2.从内存中取出下一个要执行的进程,在CPU寄存器中恢复它。 3.返回到程序计数器指示的位置,恢复执行。
初始状态
:线程对象创建成功,高级语言层面的线程对象已经初始化完成,但是操作系统内 核暂时还不能分配CPU资源,内核线程还没有与之绑定或者创建。 就绪状态
:线程已经就绪完毕,随时可以进行运行,等待CPU的调度。 运行状态
:线程获得CPU的时间片资源,正在执行指令序列。 阻塞状态
:运行状态的线程调用了一些阻塞API,例如睡眠、挂起等,就会处于阻塞状态。 终止状态
:线程将其指令序列执行完毕或者遇到异常进入终止状态。
五种状态机模型在不同的高级语言层面可能有不同的合并或拆分,例如Java的线程Threa状态就有6种。
初始状态
:Java程序new一个Thread对象,内存中线程对象就是初始状态。 运行状态
:合并了就绪状态和运行状态,因为Java线程交给了操作系统调度,所以这两者合 并了。 无限期等待
:程序调用wait()、join()、park()等无参方法,线程处于阻塞状态,等待唤醒。 有限期等待
:程序调用wait(int)、join(int)、park(int)等带参方法,线程处于阻塞状态,等待 超时或唤醒。 阻塞
:线程阻塞于内置锁,直到抢占到内置锁synchronized。 终止状态
:线程将其指令序列执行完毕或者遇到异常进入终止状态。
Runnable和Thread最大的区别就是一个是线程的抽象,一个是任务的抽象。
callable相对于runnable,callable在线程池中提交任务,并且支持future返回值,支持抛异常。
Java线程可以设置其优先级,这个优先级并不能完全保证现在的调度优先级,因为不同的操作系统和不同的高级语言对优先级有不同的评定和分层,Java有10个优先级别。
创建出来的Thread默认都是用户线程,线程可以通过调用 setDaemon
来设置该线程是否为守护线程,守护线程会在创建它的线程执行完毕以后自动销毁。
start()和run()方法的本质区别是,start方法时Java线程运行的唯一方式,run方法时普通的java方法,里面包装了线程要执行的指令序列。
- 使线程从运行状态变为有限期等待状态,不会释放对象锁。 - 其它线程可以掉用该线程的`interrupt`方法来中断该睡眠线程,此时会抛出`interruptedException`异常,并且会清除中断标志。 - 睡眠到点以后,线程处于就绪状态,等待CPU调度。 - 当sleep()传入的等待时间为0时,和yield()相同。
- 释放CPU时间片资源,线程从运行状态变为就绪状态,不会释放对象锁。
- B线程调用A线程的join方法,可以让B线程等待A线程执行完毕以后再继续执行。
强制终止运行中的线程,会释放锁资源,但JDK不建议使用,因为暴力终止可能造成程序紊乱。
Java没有提供一种安全的方法来停止某个线程,只是提供了中断机制,让用户决定是否以如何方式停止线程。
将线程的中断标志位置为true,线程并不会停下来。
判断当前线程中断标志位是否为true,不会清除中断标志位。
判断当前线程中断标志位是否为true,并清除标志位,置为false。
- volatile的可见性机制来提供线程的通信。 - 等待唤醒/等待通知机制:wait/notify - LockSupport.park()、LockSupport.unpark() - 管道输入输出流 - Thread.join()
CAS 比较并交换 Compare And Swap
,它是一种线程安全的原子语义操作,他的工作流程是: 针对一个变量,首先对变量进行修改,然后比较它的内存值和期望值进行比较,如果相同则将修改后的新值覆盖内存,否则什么都不处理。CAS的比较和交换两个步骤通过CPU的一个汇编指令执行,具有原子性,通常CAS可以看做是一种乐观锁的机制。
Java的CAS机制是通过unsafe类来进行API调用完成,例如对int变量的CAS操作,可以通过调用 Unsafe#compareAndSwapInt()
来完成,但是这是一个native本地方法,是JNI链接通过C++代码实现。 Hotspor虚拟机对 compareAndSwapInt()
的实现是通过调用 Atomic::cmpxchg
方法完成,这个cmpxchg在不同的操作系统和CPU架构模式下都不一样,Linux_X86架构下,CAS最底层就是通过 cmpxchgl
汇编指令来完成,但是因为CAS保证了原子性没有保证可见性,所以Hotspot在 cmpxchgl
前加入了 LOCK_IF_MP
判断是否为多核处理架构,如果是多核则在汇编指令前加入CPU的 Lock前缀指令
来保证可见性问题。所以Java的CAS机制既能保证原子性也能保证可见性。
cmpxchgl
汇编指令本身没有自旋的功能,JDK中原子类和unsafe类提供的CAS是有while自旋操作的,但是如果在高并发场景下对共享变量修改时,会让大量的线程修改失败转而进行自旋,此时CPU会因此大量的自旋从而CPU开销变大,CPU利用率降低。并且,JVM在多核架构下还会添加 Lock前缀指令
造成总线事务的攀升,总线事务嗅探也变得极为繁忙,总线带宽打满,进而造成总线风暴。
JDK提供了AtomicReference类来优化这个问题,但是并不是真正意义上保证多个变量,而是对对象进行CAS操作。
6.3.3.1 ABA解决方案
JDK提供了版本号机制 AtomicStampedReference
类,来解决ABA问题,就是每次修改时更新版本号。 AtomicMarkableReference
类也能解决,只不过该类不关心版本变更了多少次,只关心是否发生了改变。
JDK的JUC包下的 atomic
子包下提供了许多的原子类,他们都是基于CAS机制实现的线程安全的变量计算的类。这些类答题可以分为五大类:
- 基本类型原子类:AtomicInteger、AtomicLong、AtomicBoolean等 - 引用类型原子类:AtomicReference、AtomicStampedRerence、AtomicMarkableRerence - 数组类型原子类:AtomicIntegerArray......等 - 对象属性原子类:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater等 - 原子类型累加器(JDK8):LongAdder、DoubleAdder、LongAccumulator、Striped64等。
- getAndIncrement() 获取原值,然后CAS增1。 - getAndSet() 获取原值,然后CAS设置新值。 - incrementAndGet() CAS增1,然后返回最新值。
- addAndGet(int index, int value) CAS形式对index位置的值变更为新值。 - getAndIncrement(int index) CAS形式对index位置的值自增1。
- compareAndSet(Object o1, Object o2) CAS方式将o1的引用赋值给o2。
Java在JDK8版本引入了LongAdder、DoubleAdder来优化高并发环境下的CAS自旋导致的性能消耗问题,他的设计理念就是 热点分散
、 分而治之
。不过LongAdder并不一定比AtomicLong等优秀,如果在低并发环境下,LongAdder着实有点杀鸡用牛刀的感觉,性能上甚至还不如用普通原子类。
LongAdder在CAS没有发生并发修改失败的场景下,就是对base的线性操作。如果一旦发生了冲突,那么线程就会将在Cell[ ]内操作各自独立负责的单元。而Cell数组的初始化和扩容的问题交给父类Striped64#longAccumulate()方法完成。 LongAdder的使命就是 热点分散
。
该方法主要负责完成Cell[ ]的初始化工作和cell单元格CAS并发冲突时的解决、数组扩容的处理(2倍扩容)。
这个方法就是最后将Cell[ ]合并的方法,但是该方法并没有加锁处理,所以在高并发模式下,sum()方法的汇总统计结果可能会不准确,LongAdder只是近似准确的计数值。
它是对LongAdder的增强,LongAdder只能针对数值进行加减运算,而LongAccumulator提供了自定义函数计算。
一段程序代码内如果存在对共享资源的多线程访问,称这段代码块为 临界区
,共享资源为 临界资源
。
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。解决竞态条件的发生,可以有多挣手段可以解决:
- 锁 - 原子变量
线程是否根据抢锁顺序执行这并不是公平、非公平的判断依据。公平锁和非公平锁的区别在于在 入队之前是否尝试加锁
。只要入队以后,就不存在公不公平的问题了。内置锁和AQS都是这个意思,只不过内置锁和AQS对抢锁顺序的策略有所不同罢了。
synchronized又称 Java内置锁
,底层是基于Monitor监视器机制实现,其内存原语是基于操作系统的 Mutex互斥量
进行的。JDK5之前内置锁是重量级的,性能较低,在JDK5之后JVM进行了对其优化,例如锁粗化、锁消除、轻量级锁、偏向锁、自适应自旋等技术来避免了许多重量级开销,性能大幅提高。 synchronized在JVM层面是由两个指令 Monitorenter
和 Monitorexit
实现。
管程
是指管理共享变量以及对共享变量操作的过程,让其支持并发。管程的思想不仅仅局限于Java,操作系统都管程的思想。synchronized、wait()/notify()/notifyAll()都是管程技术的范畴。
管程的概念模型从发展上总共分为3大模型:Hasen模型、Hoare模型、MESA模型。目前使用实现最广泛的模型就是MESA模型。
JDK借鉴了MESA模型,对MESA进行了精简,Java内置锁只有1个条件变量来实现等待唤醒机制。java.lang.Object中的wait()/notify()/notifyAll()方法依赖于C++实现的 ObjectMonitor
对象实现,其ObjectMonitor的主要数据结构包括:对象头mark指针、锁的重入次数、锁对象、拥有监视器的线程ID、WaitSet等待队列、CXQ等待栈、EntryList等待队列。
在尝试获取锁的时候,将当前线程入栈CXQ,当释放锁时,如果EntryList为空,就将CXQ的线程出栈插入到EntryList中,并唤醒第一个线程;如果EntryList不为空,则直接从EntryList中唤醒线程。
锁的状态被记录在对象头中MarkWord中:
偏向锁
是一种针对加锁操作的优化手段,在一般情况下线程并没有竞争,而是由同一个线程多次获得锁资源,为了消除无竞争产生的性能消耗,JDK引入了偏向锁,提高性能。
JDK6开始默认开启了偏向锁模式,新new出来的对象MarkWord的ThreadID为0,说明该对象处于可以偏向,但未偏向任何线程的状态,称之为匿名偏向。
HotSpot虚拟机在JVM启动后有4s的延迟才会对新new出来的对象开启偏向模式,这是因为JVM在启动过程中会有很多系统配置,这些类里面有很多内置锁,为了减少JVM启动时间,JVM提出了 延迟偏向
的功能。可以通过 -XX:BiasedLockingStartupDelay=0
来控制延迟时间。 -XX:-UseBiasedLocking
可以禁止偏向锁。 -XX:+UseBiasedLocking
开启偏向锁。
当调用对象的 #hashCode()
or Sstem.identityHashCode()
方法时,会导致偏向对象的偏向撤销。因为hashCode没有地方保存,所以撤销以后,这些hashCode等记录会根据不同的锁状态存在不同地方:轻量级锁存储在锁记录中。重量级锁存储在Monitor对象中。
当对象处于匿名偏向或已偏向状态下,调用对象的hashCode方法会导致对象再也无法偏向。
1.当对象处于匿名偏向时,调用hashCode()会让锁升级为轻量级锁。 2.当对象处于偏向锁时,在同步代码块中调用hashCode()会使偏向锁强制升级为重量级 锁。 偏向撤销是一个消耗性能的过程,一个好的程序流程不应该频繁的偏相关撤销,偏向撤销要等待全局安全点,会造成JVM的STW。
在偏向锁状态下执行notify(),会让锁升级为轻量级锁。执行wait()时,升级为重量级锁,因为wait()本来就是基于Monitor监视器实现。
通过在栈中创建lock record记录来标识锁的重入次数,当同一个线程再次获取锁时,如果对象头中的线程ID是自己的话,无需CAS修改对象头。
如果偏向锁失败,JVM并不会立即升级为重量级锁,而是通过轻量级锁来进行优化。轻量级锁的场景就是 线程交替执行代码块
。也就是说不存在锁的竞争,如果同一时刻多个线程抢锁,就会导致轻量级锁膨胀为重量级锁。
当偏向锁释放以后,锁状态仍然为偏向锁。此时,如果有另一个线程来加锁,则会升级为轻量级锁,会在当前线程中创建 lcok record
结构指向对象,存储锁的状态信息。通过CAS来修改对象头的指针信息。轻量级锁释放后会 降级为无锁
,将lock record中的信息拷贝回对象头。
如果在轻量级锁的模式下,发生了线程竞争,也即是说CAS修改对象头失败,那么当前竞争线程就会膨胀为重量级锁。重量级锁就会进入Monitor监视器模式。重量级锁的锁记录等信息保存在ObjectMonitor对象中,重量级锁释放后变为无锁。
如果锁对象一直是同一个线程进行加锁,那么偏向锁的性能很高,但是当有竞争时,就会发生偏向撤销,转而升级为轻量级锁or重量级锁,这个开销蛮大。JDK为此进行了优化,方案就是批量重偏向和批量撤销。 批量重偏向的原理是: 以class为单位,每个class都会维护一个偏向锁撤销计数器,每当这个class的对象发生过一次偏向撤销,计数器就+1,当达到一个阈值(默认20次),JVM就认为该class的偏向锁有问题,转而进行批量重偏向。每次锁对象发生批量重偏向后,对象的epoch值就会+1,同时遍历JVM所有线程栈,找到这个class的所有 被持有的
锁对象,将其epoch值更新(只会更新正在锁定的对象)。线程下次获取锁时,发现当前对象的epoch和class维护的epoch值不相等,说明这个锁对象的偏向锁模式已失效,进而重偏向。 批量重偏向
的机制是为了解决一个线程创建了大量对象进入偏向模式后,另外线程也将这些对象进行加锁操作,这些对象就会频繁偏向撤销,偏向撤销会消耗性能。偏向锁重偏向一次之后不可再次重偏向
当class维护的偏向锁撤销计数器达到了阈值(默认40),JVM就认为这个class的所有锁对象的偏向模式有问题,将这个calss的所有锁对象置为不可偏向,后面有线程加锁,直接就是轻量级锁。新new出来的对象同样是不可偏向。但是这个计数器会有时间范围(默认25秒),过了这个时间就会重置清0。
自旋发生在膨胀为重量级锁的过程中,因为最坏的情况,重量级锁是内核态,性能消耗大。在JDK6后,膨胀为重量级锁的过程中,尝试多次加锁,这个自旋是自适应次数的。
在一段没有线程竞争的程序中,例如方法体内局部变量,多次的加锁解锁,例如StringBuffer的#append(),JVM就会优化,进而扩大加锁范围,避免频繁加解锁。
这是一个栈结构,线程在入栈之前会通过CAS自适应自旋操作来获取锁,实在获取不到才进入CXQ栈中( 说明synchronized是非公平锁
)。
和CXQ一样时等待的队列,不过EntryList是队列结构FIFO,为了避免多线程并发修改CXQ问题,JVM引入了EntryList等待队列。当持有锁的线程释放后,JVM从EntryList中弹出一个就绪的线程作为竞争锁的线程Reday Thread,此时就绪线程并非owner,因为synchronized是非公平的,reday Thread不一定就能拿到锁。
持有锁的线程调用wait()方法时,就会放弃锁,进入WaitSet等待队列,等待其他线程调用锁对象的notify()、notifyAll()或超时等方法来唤醒,唤醒后会立即进入EntryList,走EntryList的流程。
线程的挂起操作是调用操作系统的API完成,这是一个系统调用,需要用户态到内核态的切换,Linux提供了 pthread_mutex_lock
函数来实现线程的park。用户态到内核态的切换有时比用户的同步代码执行时间还要长,所以synchronized才如此复杂繁琐的优化,尽可能避免park。