干货来了!Java的CAS和synchronized原理解析

Java要实现原子性操作可以利用的工具有CAS、内置锁、显式锁等,本篇稳涨就给大家整理了一下CAS和内置锁的一些原理干货,显式锁ReentrantLock后续文章在AQS专题奉上;祝大家面试顺利!

内容大纲

  • 用户态、内核态、线程上下文切换
  • 线程生命周期、Java线程和常用方法
  • CAS无锁机制原理和内存语义、ABA问题
  • Jdk提供的原子类和LongAdder
  • synchronized内存语义
  • 偏向锁、轻量级锁、重量级锁原理和锁优化

1.进程与线程

进程 :操作系统分配资源的最小单元,是一个程序在操作系统的一个实例抽象。 线程 :是CPU调度执行最小单元,一个进程可包含多个线程,是一个指令序列。

1.1 内核态与用户态

用户态 :程序运行在用户空间,也只能访问到用户空间的数据。 内核态 :程序运行在内核空间,拥有所有资源的访问权限。

1.2 上下文切换

进程(线程)的上下文切换只能发生在内核模式下。

上下文切换主要有三个步骤: 1.暂停当前进程的处理,将该进程的CPU状态存储到内存中PCB。 2.从内存中取出下一个要执行的进程,在CPU寄存器中恢复它。 3.返回到程序计数器指示的位置,恢复执行。

2.线程的生命周期

2.1 操作系统线程生命周期

初始状态 :线程对象创建成功,高级语言层面的线程对象已经初始化完成,但是操作系统内 核暂时还不能分配CPU资源,内核线程还没有与之绑定或者创建。 就绪状态 :线程已经就绪完毕,随时可以进行运行,等待CPU的调度。 运行状态 :线程获得CPU的时间片资源,正在执行指令序列。 阻塞状态 :运行状态的线程调用了一些阻塞API,例如睡眠、挂起等,就会处于阻塞状态。 终止状态 :线程将其指令序列执行完毕或者遇到异常进入终止状态。

五种状态机模型在不同的高级语言层面可能有不同的合并或拆分,例如Java的线程Threa状态就有6种。

2.2 Java语言层面线程生命周期

初始状态 :Java程序new一个Thread对象,内存中线程对象就是初始状态。 运行状态 :合并了就绪状态和运行状态,因为Java线程交给了操作系统调度,所以这两者合 并了。 无限期等待 :程序调用wait()、join()、park()等无参方法,线程处于阻塞状态,等待唤醒。 有限期等待 :程序调用wait(int)、join(int)、park(int)等带参方法,线程处于阻塞状态,等待 超时或唤醒。 阻塞 :线程阻塞于内置锁,直到抢占到内置锁synchronized。 终止状态 :线程将其指令序列执行完毕或者遇到异常进入终止状态。

3.Java线程 - Thread

3.1 Java线程实现方式

3.1.1 继承Thread类,覆盖run()方法。

3.2.2 实现Runnable接口,覆盖run()方法。

Runnable和Thread最大的区别就是一个是线程的抽象,一个是任务的抽象。

3.2.3 实现Callable接口,覆盖call()方法。

callable相对于runnable,callable在线程池中提交任务,并且支持future返回值,支持抛异常。

3.2 线程的优先级

Java线程可以设置其优先级,这个优先级并不能完全保证现在的调度优先级,因为不同的操作系统和不同的高级语言对优先级有不同的评定和分层,Java有10个优先级别。

3.3 用户线程和守护线程

创建出来的Thread默认都是用户线程,线程可以通过调用 setDaemon 来设置该线程是否为守护线程,守护线程会在创建它的线程执行完毕以后自动销毁。

3.4 线程的启动流程 start()

start()和run()方法的本质区别是,start方法时Java线程运行的唯一方式,run方法时普通的java方法,里面包装了线程要执行的指令序列。

3.5 sleep()方法

- 使线程从运行状态变为有限期等待状态,不会释放对象锁。
        - 其它线程可以掉用该线程的`interrupt`方法来中断该睡眠线程,此时会抛出`interruptedException`异常,并且会清除中断标志。
        - 睡眠到点以后,线程处于就绪状态,等待CPU调度。
        - 当sleep()传入的等待时间为0时,和yield()相同。

3.6 yield()方法

- 释放CPU时间片资源,线程从运行状态变为就绪状态,不会释放对象锁。

3.7 join()方法

- B线程调用A线程的join方法,可以让B线程等待A线程执行完毕以后再继续执行。

3.8 stop()方法(不推荐)

强制终止运行中的线程,会释放锁资源,但JDK不建议使用,因为暴力终止可能造成程序紊乱。

4.Java线程的中断机制 interrupt

Java没有提供一种安全的方法来停止某个线程,只是提供了中断机制,让用户决定是否以如何方式停止线程。

4.1 interrupt()方法

将线程的中断标志位置为true,线程并不会停下来。

4.2 isInterrupted()方法

判断当前线程中断标志位是否为true,不会清除中断标志位。

4.3 Thread.interrupted()静态方法

判断当前线程中断标志位是否为true,并清除标志位,置为false。

5.Java线程的通信

- volatile的可见性机制来提供线程的通信。
  - 等待唤醒/等待通知机制:wait/notify
  - LockSupport.park()、LockSupport.unpark()
  - 管道输入输出流
  - Thread.join()

6.CAS(比较并交换、无锁机制、乐观锁)

CAS 比较并交换 Compare And Swap ,它是一种线程安全的原子语义操作,他的工作流程是: 针对一个变量,首先对变量进行修改,然后比较它的内存值和期望值进行比较,如果相同则将修改后的新值覆盖内存,否则什么都不处理。CAS的比较和交换两个步骤通过CPU的一个汇编指令执行,具有原子性,通常CAS可以看做是一种乐观锁的机制。

6.1 CAS工作流程

6.2 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机制既能保证原子性也能保证可见性。

6.3 CAS的缺陷

6.3.1 Unsafe中的CAS自旋方式缺陷

cmpxchgl 汇编指令本身没有自旋的功能,JDK中原子类和unsafe类提供的CAS是有while自旋操作的,但是如果在高并发场景下对共享变量修改时,会让大量的线程修改失败转而进行自旋,此时CPU会因此大量的自旋从而CPU开销变大,CPU利用率降低。并且,JVM在多核架构下还会添加 Lock前缀指令 造成总线事务的攀升,总线事务嗅探也变得极为繁忙,总线带宽打满,进而造成总线风暴。

6.3.2 只能保证一个共享变量的原子操作缺陷

JDK提供了AtomicReference类来优化这个问题,但是并不是真正意义上保证多个变量,而是对对象进行CAS操作。

6.3.3 ABA问题

6.3.3.1 ABA解决方案

JDK提供了版本号机制 AtomicStampedReference<V> 类,来解决ABA问题,就是每次修改时更新版本号。 AtomicMarkableReference<V> 类也能解决,只不过该类不关心版本变更了多少次,只关心是否发生了改变。

7.Java常用原子类

JDK的JUC包下的 atomic 子包下提供了许多的原子类,他们都是基于CAS机制实现的线程安全的变量计算的类。这些类答题可以分为五大类:

- 基本类型原子类:AtomicInteger、AtomicLong、AtomicBoolean等
  - 引用类型原子类:AtomicReference、AtomicStampedRerence、AtomicMarkableRerence
  - 数组类型原子类:AtomicIntegerArray......等
  - 对象属性原子类:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater等
  - 原子类型累加器(JDK8):LongAdder、DoubleAdder、LongAccumulator、Striped64等。

7.1 AtomicInteger对int类型的CAS操作

- getAndIncrement() 获取原值,然后CAS增1。
        - getAndSet() 获取原值,然后CAS设置新值。
        - incrementAndGet() CAS增1,然后返回最新值。

7.2 AtomicIntegerArray对int[ ]的CAS操作

- addAndGet(int index, int value) CAS形式对index位置的值变更为新值。
        - getAndIncrement(int index) CAS形式对index位置的值自增1。

7.3 AtomicReference对引用类型的CAS操作

- compareAndSet(Object o1, Object o2) CAS方式将o1的引用赋值给o2。

8.LongAdder(优化高并发下CAS自旋瓶颈问题)

Java在JDK8版本引入了LongAdder、DoubleAdder来优化高并发环境下的CAS自旋导致的性能消耗问题,他的设计理念就是 热点分散 、 分而治之 。不过LongAdder并不一定比AtomicLong等优秀,如果在低并发环境下,LongAdder着实有点杀鸡用牛刀的感觉,性能上甚至还不如用普通原子类。

8.1 LongAdder#add()方法

LongAdder在CAS没有发生并发修改失败的场景下,就是对base的线性操作。如果一旦发生了冲突,那么线程就会将在Cell[ ]内操作各自独立负责的单元。而Cell数组的初始化和扩容的问题交给父类Striped64#longAccumulate()方法完成。 LongAdder的使命就是 热点分散 。

8.2 Striped64#longAccumulate()方法

该方法主要负责完成Cell[ ]的初始化工作和cell单元格CAS并发冲突时的解决、数组扩容的处理(2倍扩容)。

8.3 LongAdder#sum()方法

这个方法就是最后将Cell[ ]合并的方法,但是该方法并没有加锁处理,所以在高并发模式下,sum()方法的汇总统计结果可能会不准确,LongAdder只是近似准确的计数值。

8.4 LongAccumulator类

它是对LongAdder的增强,LongAdder只能针对数值进行加减运算,而LongAccumulator提供了自定义函数计算。

9.锁基础概念

9.1 临界区 critical section

一段程序代码内如果存在对共享资源的多线程访问,称这段代码块为 临界区 ,共享资源为 临界资源 。

9.2 竞态条件 race condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。解决竞态条件的发生,可以有多挣手段可以解决:

- 锁
        - 原子变量

9.3 公平/非公平锁

线程是否根据抢锁顺序执行这并不是公平、非公平的判断依据。公平锁和非公平锁的区别在于在 入队之前是否尝试加锁 。只要入队以后,就不存在公不公平的问题了。内置锁和AQS都是这个意思,只不过内置锁和AQS对抢锁顺序的策略有所不同罢了。

10.synchronized的底层原理

synchronized又称 Java内置锁 ,底层是基于Monitor监视器机制实现,其内存原语是基于操作系统的 Mutex互斥量 进行的。JDK5之前内置锁是重量级的,性能较低,在JDK5之后JVM进行了对其优化,例如锁粗化、锁消除、轻量级锁、偏向锁、自适应自旋等技术来避免了许多重量级开销,性能大幅提高。 synchronized在JVM层面是由两个指令 Monitorenter 和 Monitorexit 实现。

10.1 管程/监视器 Monitor

管程 是指管理共享变量以及对共享变量操作的过程,让其支持并发。管程的思想不仅仅局限于Java,操作系统都管程的思想。synchronized、wait()/notify()/notifyAll()都是管程技术的范畴。

10.2 MESA模型

管程的概念模型从发展上总共分为3大模型:Hasen模型、Hoare模型、MESA模型。目前使用实现最广泛的模型就是MESA模型。

10.3 synchronized管程和Monitor机制

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中唤醒线程。

10.4 锁记录和对象头

锁的状态被记录在对象头中MarkWord中:

10.4.1 MarkWord中锁标记枚举

10.4.2 锁状态

10.5 偏向锁

偏向锁 是一种针对加锁操作的优化手段,在一般情况下线程并没有竞争,而是由同一个线程多次获得锁资源,为了消除无竞争产生的性能消耗,JDK引入了偏向锁,提高性能。

10.5.1 匿名偏向

JDK6开始默认开启了偏向锁模式,新new出来的对象MarkWord的ThreadID为0,说明该对象处于可以偏向,但未偏向任何线程的状态,称之为匿名偏向。

10.5.2 延迟偏向

HotSpot虚拟机在JVM启动后有4s的延迟才会对新new出来的对象开启偏向模式,这是因为JVM在启动过程中会有很多系统配置,这些类里面有很多内置锁,为了减少JVM启动时间,JVM提出了 延迟偏向 的功能。可以通过 -XX:BiasedLockingStartupDelay=0 来控制延迟时间。 -XX:-UseBiasedLocking 可以禁止偏向锁。 -XX:+UseBiasedLocking 开启偏向锁。

10.5.3 偏向撤销 - hashCode()

当调用对象的 #hashCode() or Sstem.identityHashCode() 方法时,会导致偏向对象的偏向撤销。因为hashCode没有地方保存,所以撤销以后,这些hashCode等记录会根据不同的锁状态存在不同地方:轻量级锁存储在锁记录中。重量级锁存储在Monitor对象中。

当对象处于匿名偏向或已偏向状态下,调用对象的hashCode方法会导致对象再也无法偏向。

1.当对象处于匿名偏向时,调用hashCode()会让锁升级为轻量级锁。 2.当对象处于偏向锁时,在同步代码块中调用hashCode()会使偏向锁强制升级为重量级 锁。 偏向撤销是一个消耗性能的过程,一个好的程序流程不应该频繁的偏相关撤销,偏向撤销要等待全局安全点,会造成JVM的STW。

10.5.4 偏向撤销 - wait()/notify()

在偏向锁状态下执行notify(),会让锁升级为轻量级锁。执行wait()时,升级为重量级锁,因为wait()本来就是基于Monitor监视器实现。

10.5.5 锁重入

通过在栈中创建lock record记录来标识锁的重入次数,当同一个线程再次获取锁时,如果对象头中的线程ID是自己的话,无需CAS修改对象头。

10.6 轻量级锁

如果偏向锁失败,JVM并不会立即升级为重量级锁,而是通过轻量级锁来进行优化。轻量级锁的场景就是 线程交替执行代码块 。也就是说不存在锁的竞争,如果同一时刻多个线程抢锁,就会导致轻量级锁膨胀为重量级锁。

当偏向锁释放以后,锁状态仍然为偏向锁。此时,如果有另一个线程来加锁,则会升级为轻量级锁,会在当前线程中创建 lcok record 结构指向对象,存储锁的状态信息。通过CAS来修改对象头的指针信息。轻量级锁释放后会 降级为无锁 ,将lock record中的信息拷贝回对象头。

10.7 重量级锁

如果在轻量级锁的模式下,发生了线程竞争,也即是说CAS修改对象头失败,那么当前竞争线程就会膨胀为重量级锁。重量级锁就会进入Monitor监视器模式。重量级锁的锁记录等信息保存在ObjectMonitor对象中,重量级锁释放后变为无锁。

10.8 锁升级/锁状态转移主流程

11.synchronized锁优化

11.1 批量重偏向

如果锁对象一直是同一个线程进行加锁,那么偏向锁的性能很高,但是当有竞争时,就会发生偏向撤销,转而升级为轻量级锁or重量级锁,这个开销蛮大。JDK为此进行了优化,方案就是批量重偏向和批量撤销。 批量重偏向的原理是: 以class为单位,每个class都会维护一个偏向锁撤销计数器,每当这个class的对象发生过一次偏向撤销,计数器就+1,当达到一个阈值(默认20次),JVM就认为该class的偏向锁有问题,转而进行批量重偏向。每次锁对象发生批量重偏向后,对象的epoch值就会+1,同时遍历JVM所有线程栈,找到这个class的所有 被持有的 锁对象,将其epoch值更新(只会更新正在锁定的对象)。线程下次获取锁时,发现当前对象的epoch和class维护的epoch值不相等,说明这个锁对象的偏向锁模式已失效,进而重偏向。 批量重偏向 的机制是为了解决一个线程创建了大量对象进入偏向模式后,另外线程也将这些对象进行加锁操作,这些对象就会频繁偏向撤销,偏向撤销会消耗性能。偏向锁重偏向一次之后不可再次重偏向

11.2 批量偏向撤销

当class维护的偏向锁撤销计数器达到了阈值(默认40),JVM就认为这个class的所有锁对象的偏向模式有问题,将这个calss的所有锁对象置为不可偏向,后面有线程加锁,直接就是轻量级锁。新new出来的对象同样是不可偏向。但是这个计数器会有时间范围(默认25秒),过了这个时间就会重置清0。

11.3 自适应自旋

自旋发生在膨胀为重量级锁的过程中,因为最坏的情况,重量级锁是内核态,性能消耗大。在JDK6后,膨胀为重量级锁的过程中,尝试多次加锁,这个自旋是自适应次数的。

11.4 锁粗化

在一段没有线程竞争的程序中,例如方法体内局部变量,多次的加锁解锁,例如StringBuffer的#append(),JVM就会优化,进而扩大加锁范围,避免频繁加解锁。

11.5 锁消除 - JIT及时编译优化

12.Monitor、重量级锁原理

12.1 CXQ竞争队列

这是一个栈结构,线程在入栈之前会通过CAS自适应自旋操作来获取锁,实在获取不到才进入CXQ栈中( 说明synchronized是非公平锁 )。

12.2 EntryList等待队列

和CXQ一样时等待的队列,不过EntryList是队列结构FIFO,为了避免多线程并发修改CXQ问题,JVM引入了EntryList等待队列。当持有锁的线程释放后,JVM从EntryList中弹出一个就绪的线程作为竞争锁的线程Reday Thread,此时就绪线程并非owner,因为synchronized是非公平的,reday Thread不一定就能拿到锁。

12.3 WaitSet等待队列

持有锁的线程调用wait()方法时,就会放弃锁,进入WaitSet等待队列,等待其他线程调用锁对象的notify()、notifyAll()或超时等方法来唤醒,唤醒后会立即进入EntryList,走EntryList的流程。

12.4 park操作(Linux#Mutex)

线程的挂起操作是调用操作系统的API完成,这是一个系统调用,需要用户态到内核态的切换,Linux提供了 pthread_mutex_lock 函数来实现线程的park。用户态到内核态的切换有时比用户的同步代码执行时间还要长,所以synchronized才如此复杂繁琐的优化,尽可能避免park。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值