首页 Juc💎
文章
取消

Juc💎

JUC(CAS、Volatile、Synchronized以及锁升级等)

参考书籍📚:《Java并发编程的艺术》

一.CAS

1.概述

​ CAS全称(Compare And Swap) 是一种不使用锁而去实现锁功能的算法 也叫乐观锁、自旋锁

2.为什么出现

​ 常规使用锁会带来上下文的切换 最终导致用户态到内核态的切换 (多执行一次cpu的切换) 加锁释放锁 死锁 影响执行速度

3.具体实现的算法

​ 当从内存中读取一个数据出来 比如说 a=1 ​ 线程k把变量修改成2 —-> a=2 ​ 在把a=2写回内存的期间 去查看原来的a有没有被修改过 ​ 没有则说明 当前k线程操作期间没有其他线程动过 ->修改成功✅ ​ 如果有说明 当前k线程操作期间被其他线程动过 ->修改失败❌

4.优缺点

·优点:cas实现了无锁并发 实现了无锁情况下 原子性的修改变量值 减少上下文切换 ·缺点:cas自旋时间过长会一直占用cpu资源 并且吞吐量低 🕘 只能保证一个变量的原子性 如果要保证多个那么需要用锁🔒 ABA问题:如果在检查之前把修改过的值及时改回来 线程就检测不到 当前变量被别人使用过 -解决?-加版本号 每次使用或者更新变量之前更新版本号-AtomicStampedReference

5.底层原理

​ 次级实现:Unsafe类中的compareAndSet() 方法:是一个本地方法 是硬件级别的方法 ​ 最终实现:lock cmpxchg

二.Volatile

1.概述

​ Volatile是轻量级的Sychronized 除了原子性 其余的特性都有

2.作用

​ 保证线程的单一指令的原子性、可见性、有序性 ​ ​ 可见性: ​ 定义:保证在多线程的情况下 使用的变量值永远是最新的 ​ 原理:==lock前缀指令== (lock addl $0x0,(%esp)) 保证变量被修改的时候及时推送回主存 然后当其他线程的缓存行有它时 致缓存行无效 ​ 实现: ​ 当volatile变量被修改的时候 会通过总线把修改后的数据写回主存 写回主存时 通过缓存一致性协议把它的缓存行锁定 保证修改的原子性 ​ 其他线程在总线通过cpu的嗅探技术 嗅探到自己线程的数据的地址发生变化 那么就致缓存行无效 当下一次使用到该变量时候再去主存中获取 有序性: ​ 定义:为了提高程序执行的性能 编译器和处理器会对指令进行重新排序 ​ 分类: ​ 编译器:单线程情况下不影响 语义的情况下 可以重新排序——JMM会禁止重排特定类型的编译器 ​ 处理器:如果不存在数据依赖则可以重新排序——JMM要求Java编译器在编译期间生成指令序列时 插入内存屏障来禁止指令重排 ​ 内存屏障: ​ Load-Load :读-读:A Load-Load B:A读完才能B读 ​ Load-Store :读-写:A Load-Store B:A读完才能B写 ​ Store-Store:写-写:A Store-Store B:A写完才能B写 ​ Store-Load :写-读:A Store-Load B:A写完才能B读

​ 原则: ​ happens-before:定义一些规则来禁止一些指令的重排序(改变结果的重排) 根据其规则提供的内存可见性保障来编程 ​ 只要不改变程序的执行结果(单线程程序和正确多线程程序) 编译器和处理器怎么优化都可以

as-if-serail:保证单线程内程序的执行结果不被改变,单线程程序执行代码时像是串行执行 ​ happens-before:正确同步的多线程程序执行结果不改变,同步正确的多线程程序执行代码时像是串行执行 ​ ​ 规定: ​ ·一个线程的所有操作 happens-before该线程的任意后续操作 ​ ·对一个锁的解锁happens-before对这个锁的加锁 ​ ==·对volatile变量的写happens-before 任意后续对这个volatile变量的读== ​ ·如果A happens-before B, B happens-before C, 那么 A happens-before C ​ ·启动线程时:ThreadA.start() 操作 happens-before 线程 A的任意操作 ​ · 在线程A中执行TheadB.join() 并且成功返回 那么 线程B的任意操作 happens-before 线程A从ThreadB.join()操作成功返回 ​ 原子性: 弱原子性 ​ 通过happed-before原则的结论可知道 volatie的读 总能 得到 最新的 volatitle的写 只要是voaltile变量 除了i++这一类的 都能保证原子性 ​ 实现原理:通过在编译器生成字节码的时候 插入内存屏障来实现 ​ volatile写: ​ 之前:Store-Store ​ 之后:Store-Load ​ volatile读: ​ 之前:Load-Load ​ 之后:Load-Store ​ 为什么不能保证多指令的原子性? ​ 修改volatile变量分为四步: 1)读取volatile变量到local ​ 2)修改变量值 ​ 3)local值写回 ​ 4)插入内存屏障,即lock指令 ​ 显然前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改

3.应用

单例模式下的Double Cheack Lock

​ 1.出现原因:单例模式下会出现下线程安全的问题 ​ new 一个对象时: ​ 1.分配内存空间 赋默认值 ​ 2.调用构造方法 赋初始值 ​ 3.栈中的引用对象 指向 堆中的实例对象的地址 ​ 其中2和3这两个步骤在多线程的情况下可能发生重新排序 导致拿到一个未正确初始化的对象而执行完毕 ​ 2.解决:使用volatile 关键字 在编译时候 加入Load Store屏障和 Store -Load 屏障 表示 只有当前这个线程写完才能读 防止指令的重排序 ​ 3.代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class singleton{
   private static volatile singleton s;
   private singleton(){};
    
   public singleton getInstance(){
      
       if(s==null){
           sychronized(this){
               if(s==null){
                   s = new singleton();
               }
           }
       }
       return s;
   }   
 }

​ ​

三.Synchronized

1.概述

​ 多线程情况下为了保证线程执行安全(线程执行的有序性、可见性、原子性)使用锁去完成 确保同一个时刻最多只有一个线程执行同步代码

2.作用

​ 保证线程的原子性、可见性、有序性 ​ ​ 可见性: ​ 有序性: ​ 原子性: ​ 内存语意:推送到主存-获取最新值🤩 ​ 释放锁 JMM把线程对应的本地变量刷新到主存——对应于vlatile写——相当于线程向接下来要向获取这个锁的某个线程发信息 ​ 获取锁 JMM把线程对应的本地变量设置为无效 从而使被监视器保护的临界区代码必须从主存读取共享变量——对应于vloatile读——线程B接收了 某个线程发出的的信息

3.使用

1.作用在方法:普通方法和静态方法 普通方法:锁的是当前的实例对象 静态方法:锁的是当前类.class对象

2.作用在同步代码块:锁的括号内的对象

3.🌟实现原理:

1
基于一个Monitor对象去实现的 监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的

​ 任何一个对象都对应一个monitor 当monitor被持有的时候 将会处于锁定状态 线程执行到monitorenter时候 ​ 将尝试获取对象所对应的monitor所有权 即尝试获取对象的锁 如果获取到了监视器 就继续执行下去 直到结束 到了 monitorExit ​ 如果获取不到 则进入同步队列 线程阻塞住 直到获取到监视器的线程退出后发出通知 才能出队列

​ 同步代码块: ​ 在代码块开始的时候加 monitorenter 结束的时候加monitorexit(一个正常出口一个异常出口) ​ 当线程执行到monitorenter的时候要先获得锁,才能执行后面的方法。 ​ 当线程执行到monitorexit的时候则要释放锁。 ​ 每个对象自身维护着一个被加锁次数的计数器,当计数器不为0时,只有获得锁的线程才能再次获得锁。

​ 同步方法: ​ 在class文件中 访问标识符被改为ACC_SYCHRONIZED 当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法

4.通信

​ 概述:线程的通信通过一个对象Object调用器其Object父类的wait()-notify()/notifyAll()进行通信(wait方法释放锁、notify方法:通知一个在对象上等待的线程 使其从wait方法返回 返回的前提是线程获取到了对象的锁) ​ 细节:使用wait 、notify之前必须要对对象加锁-就是说对象以及获取到锁了-言下之意就是已经持有这个对象了 ​ wait方法调用后 必须释放当前锁 为什么?-因为要避免死锁(请求保持条件)

​ 具体流程:线程的通信建立在线程的同步之上 因为需要等待线程在等待方法返回时 能感知到通知线程对变量做出的修改->锁的内存语义 ​ 1.当线程进入monitorenter 就尝试获取对象的monitor ​ 如果获取到了就 获取到对象的所有权 ​ 获取到对象所有权后 调用对象的wait()方法 就会进入等待队列 同时 释放持有的monitor对象 ​ 与此同时 另外一个线程B 进入monitorEnter 尝试获取monitor对象 刚好获取到 然后调用notify方法 把等待队列中的对象 移动到同步队列 然后线程B 正常退出 释放锁了 然后在同步队列的线程A 获取到了锁 就从wait返回继续执行

​ 如果获取不到就 进入同步队列 等待获取对象执行到monitorexit的通知

6.锁升级

1.锁升级原因: Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。 JDK1.6的优化-引入锁升级 为了减少锁的释放和创建所带来的开销 减少用户态到内核态之间的切换所带来的开销 所以引入锁升级

2.前置知识: 对象的结构(16字节) ·对象头: ·MarkWord(32位=4字节): 无锁: Hashcode(25位) 分代年龄(4位)、偏向标记位(1位)[0]、锁标记位(2位)[01] 偏向锁: 线程ID(23位)、Epoch(2位)、分代年龄(4位)、偏向标记位(1位)[1]、锁标记位(2位)[01] 轻量级锁: 30位指向栈中锁记录的指针 锁标记位(2位)[00] 重量级锁: 30位指向重量级锁的指针 锁标记位(2位)[10]

​ ·Class Point:(4字节)虚拟机通过这个指针来确定这个对象是哪个类的实例 ​ 对应jvm类加载的第一步的第三小步”在内存中生成代表该类的.Class对象 程序可以通过这个对象 在方法区访问其的入口“ ​ ·(数组对象:) ·实例数据:((4字节)[不开启指针压缩]/(8字节)) ·填充:(4字节)[开启指针压缩]/(0字节)

3.锁升级过程 无锁: 没有加锁的对象 偏向锁: 定义:当某个线程一次获得锁时 并且没有线程和其争抢时 偏向于第一次获得锁的那个线程 原因:Hotspot作者发现 大部分情况下锁不仅不存在竞争而且总是被同一个锁多次获取 为了让线程获得的锁代价更低 引入了偏向锁 流程: 添加偏向锁: 当线程进入同步代码块获取到锁后 会在对象头和栈帧中的锁记录里面存储获得到的线程ID 以后该线程在进入和退出同步代码块时 不需要 进行cas的加锁和解锁 只要简单的测试一下对象头中的MarkWord中偏向锁的线程ID是否 是当前线程ID 如果成功则该线程获得锁 如果不是则 查看MarkWord中偏向锁的偏向锁位置是否为1 为1 尝试用CAS把MarkWord的线程Id改成当前当前线程ID 如果不为1 则说明还不是偏向锁 则使用CAS加锁 比较点:是否为当前线程 是否设置了偏向锁标记位

​ 撤销偏向锁: ​ 当锁出现线程竞争的时候 需要进行锁升级 升级成轻量级锁 要把偏向锁撤销 ​ 偏向锁的撤销需要等到全局的安全点-这个时间点没有正在执行的字节码 ​ 首先会把拥有偏向锁的线程暂停 然后查看持有偏向锁的线程是否活跃 ​ 如果不活跃则 对象头设置为无锁状态 如果活跃拥有偏向锁的栈会被执行 便利对象的锁记录 ​ 栈中的锁记录和MarkWord要么偏向于其他线程 要么就恢复到无锁或者标记为对象不适合作为偏向锁 ​ 最后唤醒暂停的线程

​ 偏向锁的暂停: ​ 偏向锁默认启动 但是会延迟几秒钟 如果明知一定有线程竞争 可以关闭偏向锁 直接进入 轻量级锁

​ 轻量级锁: ​ 定义:也叫做自旋锁 当锁有轻量的线程竞争的时候 锁状态升级为轻量级锁 ​ 加锁:在进入同步代码块前 会在当前线程的栈帧中生产一个锁记录对象然后把对象头的markword信息复制到锁记录 ​ 然后线程将尝试用把CAS把markword的前30位的指针 指向 这个锁记录 ​ 解锁:用CAS把栈帧中的MarkWord去替换回到对象头 如果替换不成功则说明 存在锁竞争 就升级成重量级锁 ​ 重量级锁: ​ JDK1.6-自旋超过10次,升级为轻量级锁 如果线程数量过多 超过 cpu核数的一半 消耗CPU过大 不如升级成重量级锁进入等待队列 ​ 处于重量级锁状态下 没获取到锁的线程 会被阻塞 进入阻塞队列 线程挂起 不占用cpu资源

​ ·重量级锁的原理:JVM中每个对象都会有一个’监视器’,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务 是保证(同一时间)只有一个线程可以访问被保护的临界区代码块 ​ ·Hotspot虚拟机中,监视器是由C++类ObjectMonitor实现的 ​ 组成:Cxq:竞争队列(Contention Queue),所有请求锁的线程首先被放在这个竞争队列中 ​ EntryList:Cxq中那些有资格成为候选资源的线程被移动到EntryList中 ​ WaitSet:某个拥有ObjectMonitor的线程在调用Object.wait()方法之后将被阻塞,然后该线程将被放置在WaitSet链表中获得到minitor的对象会 进入owner ​ ·重量级锁会发生线程的上下文切换,从用户态切换到内核态。系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,系统调用就会 从用户态切换到内核态,这种切换是需要消耗很多时间的,而JVM重量级锁使用了Linux内核态下的互斥锁mutex。

各种锁的比较 偏向锁: 适合只有一个线程访问同步代码块 加锁解锁不要耗费资源 但是 如果存在多个线程的锁竞争则会带开 偏向锁撤销的消耗

​ 轻量级锁: 适合追求响应比的场景 同步代码块执行速度很快 线程不会阻塞 但是 如果始终得不到锁竞争的线程 则会空耗cpu资源

​ 重量级锁: 适合吞吐量高的场景 同步代码块时间长 线程之间竞争不自选 而是挂起 不耗费资源 但是 线程会阻塞 等待时间长 响应比低

四.AQS

定义:

​ AbstractQueueSynchronized 抽象队列同步器 是构建锁和其他同步组件的基础框架 采用模版方法模式

组成:

​ 由一个volatile修饰的==state==(表示资源)和 一个 ==双端双向同步队列==(占有资源的在队头 没有获取到的在队尾)组成

实现:

​ 当线程修改state值时 尝试用修改state的值 成功构建成一个结点插入队头 因为只有一个线程去成功获取到同步状态 所以不要cas保证 ​ 不成功 则 把当前线程和等待状态信息 构建结点 用CAS的方式插入队尾 并且阻塞结点的线程 ​ 当线程使用完资源后 释放资源 发出通知 后续结点又可以通过cas修改state值

方法:

​ ==AQS可重写的方法:== ​ tryAcquire() 独占式获取同步状态/获取锁 如果符合预期则用cas修改 ​ tryRelease() 独占式释放同步状态/获取锁 如果符合预期则用cas修改 ​ tryAcquireShared() 共享式获取同步状态/获取锁 如果符合预期则用cas修改 ​ tryReleaseShared() 共享式释放同步状态/获取锁 如果符合预期则用cas修改

​ ==AQS的模版方法:== ​ acquire() 独占式获取同步状态/获取锁 如果成功则返回 不成功加入同步队列等待 该方法重写tryAcquire()方法 ​ acquireInterruptibly() 独占式获取同步状态/获取锁 和acquire()相同只是方法会响应中断 ​ tryAcquireNanos() 在acquireInterruptibly()基础上加了超时限制 如果在一个时间限制内没有获取到锁则返回false

​ acquireShared() 共享式获取同步状态/获取锁 如果成功则返回 不成功加入同步队列等待 该方法重写tryAcquire()方法 ​ acquireSharedInterruptibly() 共享式获取同步状态/获取锁 和acquire()相同只是方法会响应中断 ​ tryAcquireSharedNanos() 在acquireInterruptibly()基础上加了超时限制 如果在一个时间限制内没有获取到锁则返回false ​ ​ release()独占式释放锁 ​ releaseShared()共享式释放锁

五.各种锁的对比

CAS和Synchronized的区别

1.加锁的开销:死锁 加锁 解锁 线程切换 内核态到用户态的切换 线程阻塞等待时间长–追求吞吐量 2.CAS的空循环占cpu资源 线程不会阻塞 –追求相应比

Volatile和Syncronized区别

1.volatile是弱原子性、只能保证单一指令的原子性 而Snchronized不是 保证复合指令的原子性 2.作用对象:volatile作用在变量 范围单一 而 Synchronized 可以作用在代码块和方法上 可以作用在多个变量 3.底层实现:volatile是依靠lock addl 而Synchronized依靠 lock cmpexchge 4.是否阻塞对象:volatile不会阻塞对象 而Synronized对于那些没有拿到锁的对象会阻塞进入同步队列 5.编译器优化:volatile修饰的变量保证不会被编译器优化(除了那些不改变结果的编译器优化) synchronized 修饰的代码块会被编译器优化

Lock和Syncronized区别

1.Lock是接口而Syncronized是关键字 2.Lock显式加锁而Syncronized隐式加锁 3.Lock拓展了很多Synchronized的功能: 1>.可中断加锁 2>.可超时加锁 3>.非阻塞加锁 4.Lock作用在代码块而Synchronized作用在代码块和方法 5.底层原理: 1>.Lock是AQS去实现:CAS➕Volatile ——> Lock CMPEXCHG➕Lock指令 AQS:抽象队列同步器是构建锁和其他同步组件的基础框架 组成: 由一个volatile修饰的state(表示资源)和 一个 双端双向队列(占有资源的在队头 没有获取到的在队尾)组成 当线程修改state值时 尝试用cas修改state的值 成功构建成一个结点插入队头 不成功 则构建结点用CAS的方式插入队尾 当线程使用完资源后 释放资源 发出通知 后续结点又可以通过cas修改state值

​ 2>.Sychronized是通过Monitor去实现:汇编底层是Lock CMPEXCHG指令——>操作系统底层是mutex ​ 底层是c++的monitor实现的:每个对象都有monitor 当进入同步代码块后 首先需要获取monitor对象 如果获取到了就可以进入代码块内 就持有monitor ​ 如果获取不到则进入cxq竞争队列 竞争队列的线程有可能进入同步队列 同步队列的线程 需要等待 持有monitor的线程退出 同步代码块后释放持有的monitor对象才能再次去获取锁 6.Lock有一个同步队列和多个等待队列 而 Sychronized只有一个同步队列和一个等待队列 ​ 在lock中获取完锁后 调用await方法 加入等待队列 此过程不需要cas去保证 因为在此之前 线程一直是持有锁的 通过锁保证没有其他线程去竞争

7.通信:Lock采用condition机制去通信:await()-signal()/signalAll() 而 Synchronized 采用 wait()和notify()/notifyAll()去实现 8.Lock是可公平可不公平的锁 而Sychronized是不公平锁 公平的含义: 先对锁进行获取的请求先被满足那么就是公平锁 实现原理:在尝试获取锁的时候 需要在AQS的队列中查看当前获取资源的结点是否有前驱结点 有的话就返回不继续执行 没有的话则可以尝试获取资源 作用:公平锁可以防止线程饥饿 但是会频繁的切换线程上下文-用户态到内核态的转换频繁-导致cpu占用高开销大最终导致吞吐量低

9.Lock加锁后 线程进入等待态 而 Synchronizd 则是进入阻塞态 10.都是可重入锁 重入的概念:线程获取完锁之后还能对资源进行反复的获取锁 实现: 1.验证是否是同一个线程获取锁 2.如果是成功获取锁 则需要对内部一个计数器进行自增 执行完后释放锁需要对其进行自减 当为0的时候 表示最终成功释放

本文由作者按照 CC BY 4.0 进行授权