Java 锁

Synchronized

  • java 提供的原子性内置锁

    • 内置的并且使用者看不到的锁也被称为监视器锁

  • 依赖操作系统底层互斥锁实现

  • 作用主要就是实现原子性操作和解决共享变量的内存可见性问题

  • 排它锁:当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁

  • 悲观锁:悲观地认为程序中的并发情况严重,所以严防死守

  • 非公平锁

    • Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁

    • 如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的

    • 还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源

  • 可重入锁

ReentrantLock

独占锁 vs 共享锁

  • 独占锁:只能有一个线程获取到锁,其他线程必须在这个锁释放了锁之后才能竞争而获得锁

  • 共享锁则可以允许多个线程获取到锁

可重入锁 ReentrantLock ===> 其实是独占锁

  • 可重入性表现在同一个线程可以多次获得锁,不会因为之前已经获取过还没释放而阻塞

    • 可重入锁的一个优点是可一定程度避免死锁

  • ReetrantLock 是可重入锁

  • 内部自定义了同步器 Sync,加锁的时候通过CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否一样,一样就可重入了

  • 公平锁非公平锁

  • 悲观锁

与 Synchronized 的区别

  • 都是可重入锁

  • R 是显示获取和释放锁,s 是隐式

  • R 更灵活可以知道有没有成功获取锁,可以定义读写锁,是 API 级别,s 是 JVM 级别

  • R 可以定义公平锁;Lock 是接口,s 是 java 中的关键字

  • R 等待可中断,当持有锁的线程长时间不释放锁的时候,等待中的线程可以选择放弃等待,转而处理其他的任务

ReentrantLock基于 AQS(AbstractQueuedSynchronizer 抽象队列同步器) 实现

  • 先通过 CAS 尝试获取锁, 如果此时已经有线程占据了锁,那就加入 AQS 队列并且被挂起

  • 当锁被释放之后, 排在队首的线程会被唤醒 CAS 再次尝试获取锁

  • 如果是非公平锁, 同时还有另一个线程进来尝试获取可能会让这个线程抢到锁

  • 如果是公平锁, 会排到队尾,由队首的线程获取到锁

AQS

  • Node 内部类构成的一个双向链表结构的同步队列

  • AQS 内部维护一个 state 状态位(volatile 的 int 类型),尝试加锁的时候通过 CAS (CompareAndSwap) 修改值

  • 如果成功设置为 1,并且把当前线程 ID 赋值,则代表加锁成功,一旦获取到锁,其他的线程将会被阻塞进入阻塞队列自旋

  • 获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把 state 重新置为0,同时当前线程 ID 置为空

 

AQS 的两种资源共享方式

  • Exclusive:独占,只有一个线程能执行,如 ReentrantLock

  • Share:共享,多个线程可以同时执行,如 Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

CAS

  • 乐观锁:乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新

  • java 并发机制实现原子操作有两种:锁、CAS

  • CAS = Compare and Swap 比较并替换

  • CAS 机制当中使用了3个基本操作数:内存地址V,旧的预期值A(V的一个copy),计算后要修改的新值B

    • 线程 1 想要修改 内存地址 V 存的值 A,将其变成 B1

    • 线程 1 在提交更新之前,被线程 2 抢先了,线程 2 已经将 A 变成了 B2

    • 线程 1 开始提交更新,首先将 A 与内存地址 V 中存的实际值进行比较,发现实际值是 B2,与 A 不相等,于是提交失败

    • 线程 1 于是重新去获取 内存地址 V 存储的值,重新去计算修改后的新值,这个时候对于线程 1 来说,A 其实是 B2,B 是 在 B2 基础上修改得到的值,这个重新尝试的过程被称为自旋

    • 这一次线程 1 发现没有其他线程修改它的 A,也就是 A 和内存地址 V 实际存储的值是相等的

    • 线程 1 进行交换,把内存地址 V 存储的值修改为 B

  • 总结:更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址V对应的值修改为 B

CAS 的缺点

  • ABA问题:ABA 的问题指的是在 CAS 更新的过程中,当读取到的值是 A,然后准备赋值的时候仍然是 A,但是实际上有可能 A 的值被改成了 B,然后又被改回了 A,这个 CAS 更新的漏洞就叫做 ABA

    • ABA 问题大部分场景下都不影响并发的最终效果

    • Java 中有 AtomicStampedReference 来解决这个问题,加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新

  • 循环时间长开销大:自旋 CAS 的方式如果长时间不成功,会给 CPU 带来很大的开销

  • 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行(无法保证i++),多个可以通过 AtomicReference 来处理或者使用锁 synchronized 实现

信号量Semaphore

  • 基于 AQS 实现

  • 在构造的时候会设置一个值,代表着资源数量

  • 信号量主要是应用于是用于多个共享资源的互斥使用,和用于并发线程数的控制

  • 信号量也分公平和非公平的情况,基本方式和 reentrantLock 差不多

  • 在请求资源调用 task 时,会用自旋的方式减 1,如果成功,则获取成功了,如果失败,导致资源数变为了 0,就会加入队列里面去等待。调用 release 的时候会加一,补充资源,并唤醒等待队列

应用

  • acquire() release() 可用于对象池,资源池的构建,比如静态全局对象池,数据库连接池

  • 可创建计数为 1 的 S,作为互斥锁(二元信号量)

锁的优化机制

简单介绍

JDK1.6 之后,synchronized 本身也在不断优化锁的机制。

优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁

锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁。(有时候降级也是有可能发生的)

自旋锁

  • 由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。

  • 自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置 -XX:+UseSpining 来开启,自旋的默认次数是 10 次,可以使用 -XX:PreBlockSpin 设置

自适应锁

  • 自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定

锁消除

  • 锁消除指的是 JVM 检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除

锁粗化

  • 锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外

偏向锁

  • 当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程 ID

  • 之后这个线程再次进入同步块时都不需要 CAS 来加锁和解锁了

  • 偏向锁会永远偏向第一个获得锁的线程

  • 如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步

  • 反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁

  • 可以用过设置 -XX:+UseBiasedLocking 开启偏向锁

轻量级锁(自旋锁)

  • JVM 的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM 将会使用 CAS 方式来尝试获取锁

  • 如果更新成功,则会把对象头中的状态位标记为轻量级锁

  • 如果更新失败,当前线程就尝试自旋来获得锁

自旋锁升到重量级锁

  • 某线程自旋次数超过 10 次

  • 等待的自旋线程超过了系统 core 数的一半

总结

  • 偏向锁就是通过对象头的偏向线程 ID 来对比,甚至都不需要 CAS 了

  • 轻量级锁主要就是通过 CAS 修改对象头锁记录和自旋来实现

  • 重量级锁则是除了拥有锁的线程其他全部阻塞

volatile

简介

  • 相比 synchronized 的加锁方式来解决共享变量的内存可见性问题,volatile 就是更轻量的选择

  • 没有上下文切换的额外开销成本

  • 使用 volatile 声明的变量,可以确保值被更新的时候对其他线程立刻可见

  • volatile 使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题

线程会带来可见性问题

  • 线程都是从主内存中读取共享变量到工作内存来操作,完成之后再把结果写回主内存,但是这样就会带来可见性问题

 

volatile如何解决上述问题

  • X 变量用 volatile 修饰

  • 当线程 A 再次读取变量 X 的话,CPU 就会根据缓存一致性协议强制线程 A 重新从主内存加载最新的值到自己的工作内存,而不是直接用缓存中的值

  • volatile 修饰之后会加入不同的内存屏障来保证可见性的问题能正确执行

    • StoreStore 屏障,保证上面的普通写不和 volatile 写发生重排序

    • StoreLoad 屏障,保证 volatile 写与后面可能的 volatile 读写不发生重排序

    • LoadLoad 屏障,禁止 volatile 读与后面的普通读重排序

    • LoadStore 屏障,禁止 volatile 读和后面的普通写重排序

 

volatile只能修饰变量

  • 变量可见性

  • 防止指令重排序

  • 保障变量单次读,写操作的原子性,但不能保证 i++ 这种操作的原子性,因为本质是读,写两次操作

volatile如何保证线程间可见和避免指令重排

volatile 可见性是有指令原子性保证的,在 JMM 中定义了 8 类原子性指令,比如write,store,read,load。而volatile 就要求 write-store,load-read 成为一个原子性操作,这样子可以确保在读取的时候都是从主内存读入,写入的时候会同步到主内存中(准确来说也是内存屏障),指令重排则是由内存屏障来保证的,由两个内存屏障:

  • 一个是编译器屏障:阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行

  • 第二个是cpu屏障:sfence 保证写入,lfence 保证读取,lock 类似于锁的方式。java 多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个 lock 指令,就是增加一个完全的内存屏障指令

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值