锁
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 指令,就是增加一个完全的内存屏障指令