JUC全文
synchronized
- 来修饰方法、代码块。属于独占锁、悲观锁、可重入锁、非公平锁、互斥锁、同步锁
- 保证代码线程安全,原子运行
锁方法
- 锁普通方法
synchronized void add() {
num++;
}
//等价于
void add() {
synchronized (this) {
num++;
}
}
- 锁静态方法
synchronized static void add() {
num++;
}
//等价于
static void add() {
synchronized (Test.class) {
num++;
}
}
- 优化,用原子类
AtomicInteger integer = new AtomicInteger();
public AtomicInteger getInteger() {
return integer;
}
void add() {
//优化
integer.incrementAndGet();
}
底层
- 每个对象都有个
monitor对象,加锁就是在竞争monitor对象,当monitor被占用时,就会处于锁定状态,线程执行monitorenter尝试获取monitor wait/notify基于monitor对象,静态方法中不能使用,且只能锁对象去调用,wait后的线程被notify唤醒后,会继续尝试抢锁,执行下面的代码
monitor对象
header记录锁对象的markwordcount记录个数,被获取时++,被释放时--Contention List / cxq所有请求锁的线程将被首先放置到该竞争队列,单向链表waitSet处理wait状态的线程,会被加入到waitSet,等待唤醒,循环双向链表EntryList处理等待锁block状态的线程,会被加入该队列,双向链表Owner当一个线程获取到这个monitor对象,会指向这个线程,当线程释放掉之后,owner置为null- 当多个线程同时访问一段同步代码时
- 每个等待锁的线程都会被封装成
ObjectWaiter,插入cxq队列的队首,调用park,使线程挂起,进入blocked状态,后续根据唤醒策略不同,决定是否放入entryList,取cxq或者EntryList- 当线程获取到对象的
monitor后,并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1- 若线程调用
wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒,唤醒后,移动到cxq或entryList继续争锁- 若当前线程执行完毕,将
owner置为null,也将释放monitor并复位count的值,以便其他线程进入获取monitor
- 唤醒策略,当释放锁会调用
- QMode == 2 且
cxq非空,取队首对象,并唤醒该线程,并直接返回- QMode == 3 且
cxq非空,把cxq队列插到EntryList末尾- QMode == 4 且
cxq非空,把cxq队列插到EntryList队首- QMode == 0 什么都不做
- 后续
- 如果
EntryList队首非空,唤醒该线程- 如果
EntryList为空,将cxq放入EntryList,如果QMode == 1 则需要反转顺序,否则直接加入,并取队首元素
notify策略,此时并未真正唤醒等待线程,而是将等待的线程加入队列,当锁对象进行exit后,真正从cxq/EntryList取下一个线程
- 放入
EntryList队首- 放入
EntryList末尾EntryList为空就放入EntryList,否则放入cxq队首- 放入
cxq末尾
- 以上也可以看出,
synchronized的非公平特点,后来的等待的反而可能先获得锁
字节码层面
- 当修饰代码块时,是在代码块前后分别加上
monitorenter和monitorexit指令来实现的
有两个
monitorexit,第二个为异常退出
- 当直接修饰方式时,字节码层面没有指令,会在
方法访问标识上体现
执行线程将先获取
monitor(klass类型的),获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象
monitorenter
- 如果
monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者- 如果线程已经占有该
monitor,只是重新进入,则进入monitor的进入数加1,重入机制- 如果其他线程已经占用了
monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
monitorexit
- 执行
monitorexit的线程必须是锁对象所对应的monitor的所有者monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者- 其他被这个
monitor阻塞的线程可以尝试去获取这个monitor的所有权
细节
synchronized1.6之前,为重量级锁,没有拿到锁的线程,会被加入到队列中synchronized1.6之后,锁升级
和Lock的区别
- Lock
- 是Java中的接口,可重入锁、悲观锁、独占锁、互斥锁、同步锁
- 需要手动获取锁和释放锁
- 发生异常时,如果没有主动通过
unlock()释放锁,会发生死锁现象,使用时需要在finally中释放- 可以让等待的线程响应中断
- 可以通过
lock.isLocked()知道有没有成功获得锁- 可以通过读写锁提高多个线程读操作的效率
synchronized
- 关键字,内置的语言实现
- 在发生异常时,自动释放线程锁,不会导致死锁
- 等待的线程会一直等待下去,不能响应中断
- 无法知道是否获取锁
synchronized优势
- 简单
- 异常时,自动释放锁,抛出异常
- 使用Lock时,很难知道哪些锁对象被线程持有,而
synchronized需要程序员写入锁对象
和ReentrantLock的区别
ReentrantLock,继承了Lock类,可重入锁、悲观锁、独占锁、互斥锁、同步锁- 相同点
- 解决了共享变量安全访问的问题
- 都是可重入锁,递归锁,同一个线程可以多次获得一个锁
- 保证了线程安全两大特性,可见性/原子性
- 不同点
ReentrantLock手动显式操作锁;synchronized隐式操作锁ReentrantLock可响应中断;synchronized不可响应中断ReentrantLockAPI级别的;synchronizedJVM级别的ReentrantLock可以实现公平锁/非公平锁;synchronized只是非公平锁ReentrantLock可以通过Condition绑定多个条件,通过await()/signal(),在特定的情况下休眠/唤醒,达到线程间通信ReentrantLock对于等待的线程,先来先获得;synchronized对于等待的线程,可能后来先获得
锁类别
- 锁膨胀/升级,线程竞争;不得不(偏向锁还未初始化完成,只能由无锁膨胀成轻量级锁)
无锁
- 对于一个对象来说,是该对象没有成为锁对象
- 对于CAS来说是,代码层面没有锁,但是实际上在汇编级别和硬件级别会加锁
匿名偏向锁与偏向锁
- 需要模拟出匿名偏向锁,需要延迟偏向锁初始化4秒以上
Thread.sleep(5000);
Test test = new Test();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
//test.hashCode();
synchronized (test) {
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
- 匿名偏向锁与偏向锁
- 加上
test.hashCode();,变成了轻量级锁,先会撤销为无锁,再变为轻量级锁,需要记录对象的hashcode
匿名偏向锁
- 锁标识位是101,锁对象头的线程id记录为
null - 当被当作同步执行的锁对象时,膨胀成偏向锁
偏向锁
- 适用于单个线程重入的场景,在无竞争的情况下把整个同步都消除掉,不去做
CAS操作 - 偏向某个线程,优化大部分时间只有一个线程执行的同步代码,如果在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将不需要进行同步操作
记录线程id,放锁对象内部暂存,当有线程要执行这块代码时,只需要
判断线程id是否相等,不需要额外的判断,直接执行
- 优点,把整个同步都消除,CAS操作都不去做,优于轻量级锁
- 缺点,如果程序中大多数的锁总是被多个不同线程访问,那偏向锁就是多余的
- 偏向锁锁撤销,当其他线程进行竞争时发生
- 需要等到全局安全点(这个时间点上没有字节码正在执行),栈帧是动态的,要遍历栈帧中的
lock record- 暂停拥有偏向锁的线程,检查持有偏向锁的线程是否存活,如果线程不处于活动状态,设置为无锁或者匿名偏向
- 如果线程活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录
BasicObjecLock,如果此时还在同步方法内,需要升级为轻量级锁,原线程仍持有锁;如果不在同步方法内,撤销为无锁或者匿名偏向锁
- 如果将线程id写入当前锁对象头,则代表抢锁成功
- 解锁,将最近一条
lock record/BasicObjecLock设置为null
轻量级锁与重量级锁
- 重量级锁,涉及
线程阻塞、上下文切换、操作系统线程调度、内存态/用户态切换,比较占系统资源 - 轻量级锁,比较不占系统资源,
线程不阻塞、较少的切换、较少的线程调度 - 轻量级锁不一定比重量级锁效率高
当大量线程
CAS空转、自旋不成功,会大量消耗cpu资源;此时,不如直接放入队列,使用重量级锁
轻量级锁
- 适用于两个线程交替执行,不发生冲突的场景
- 无竞争的情况下,使用
CAS操作消除同步使用的互斥量,在没有多线程竞争的前提下,减少重量级锁使用操作系统互斥量产生的性能消耗,如果两条以上线程争用同一个锁,轻量级锁将不会有效,必然膨胀成重量级锁
- 优先,如果没有竞争,通过CAS操作避免互斥量的开销,减少线程切换
- 缺点,如果存在竞争,不仅需要转换成重量级锁,产生互斥量的开销,还有额外的CAS操作的开销
- 在虚拟机内部,使用一个
BasicObjecLock的对象实现,这个对象内部由一个BasicLock对象(BasicLock_lock)和一个持有该锁的Java对象指针(oop_obj)组成。 - 实现过程
- 首先,
BasicLock通过set_displaced_header()方法备份了原对象的Mark Word,并且是无锁状态,为了恢复成无锁状态做准备。BasicLock还会作为重入标识,如果不是重入,存储无锁状态的Mark Word;如果是重入,则为null;对于双层sync,里面一层的重入标识为null,跳出一层后,判断为null,不回写,最外层不为null,则需要回写,进行锁撤销- 每次重入,一个锁对象都会在线程栈中生成一个
BasicLock,并依次弹出- 接着,使用CAS操作,尝试将BasicLock的地址复制到对象头的Mark Word。如果复制成功,那么加锁成功。如果加锁失败,那么轻量级锁就有可能被膨胀为重量级锁
BasicobjectLock对象放置在Java栈的栈帧中的栈顶,局部变量表的上面- 解锁时,将
BasicLock中的值还给该对象,如果替换成功,则解锁完成;如果失败,则说明有其他线程在等待这个锁,那么锁升级为重量锁,需要去monitor的EntryList队列,唤醒一个线程
重量级锁
- c++中的类
ObjectMonitor,Mark Word指向monitor的地址 - 底层借助内核态的
mutex结构体互斥量,涉及到应用态/用户态和内核态的切换(中断触发) synchorized通过对象内部的一个监视器锁 monitor来实现,依赖底层操作系统饿的mutex lock实现- 操作系统实现线程的切换需要从用户态切换到和心态,成本非常高
公平锁和非公平锁
synchroized是非公平锁,ReentrantLock通过构造函数指定,默认非公平,true公平,false非公平- 是一种思想
- 唯一的差异是存在一个临界区,占有锁的线程已经执行完任务,释放了锁,将状态位恢复,这时恰好来了一个线程的时候
- 公平锁,多个线程按照申请锁的顺序来获取锁,通过队列(AQS其实是双向链表)排队,要求先来后到;如果当前等待队列为空,则占有锁,如果等待队列不为空,则加入到等待队列的末尾
- 非公平锁,新来的线程直接尝试获取锁,如果抢不到,则按照公平锁的方式排队,在排队的线程依次获取
- 公平锁
- 非公平锁
- 优点,非公平锁的性能高于公平锁
- 缺点,有可能造成线程饥饿,某一个线程长时间获取不到锁
乐观锁与悲观锁
- 乐观锁,乐观思想,可以同时进行读操作,读的时候不能进行写操作;只允许一个线程进行写操作,写的时候不能读
- 面对读多写少的场景,遇到并发写的概率较低
- 认为读的同时不会进行修改,不会上锁
- 写数据时,通过CAS方式,进行写
- java中的乐观锁,CAS、
RenntrantReadWriteLock
- 悲观锁,悲观思想,只能有一个线程进行读操作或者写操作,其他线程的读写操作均不能进行
- 面对写多读少的场景,遇到并发写的可能高
- 每次读数据都会被认为被其他线程修改,每次读写数据都会上锁
- 其他线程想要读写这个数据,就会被锁持有线程
block,直到释放- java中的悲观锁,
synchronized、ReentrantLock
自旋锁
- CAS操作中的比较操作失败后的自旋等待
- 是一种技术,为了让线程等待,让线程进行一个忙循环,不放弃处理器的执行时间,不断检查持有锁的线程是否会释放锁
- 优点,避免线程切换开销,挂起线程和恢复线程的操作都需要转入内核态完成,会给应用带来性能压力
- 缺点,占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,带来性能的浪费
自旋等待的时间需要有一定限度,如果自旋超过限定次数仍然没有成功获得锁,就使用传统方式挂起
(转换为重量级锁,加入队列等待)
- 自旋次数的默认值,10次,可以使用
-XX:PreBlockSpin更改
jdk6/7之后失效,每个monitor上记录自适应自旋的信息,来决定在阻塞前要尝试自旋多少次,使用动态调整的次数
- 自适应自旋,意味着自旋的时间不再是固定的,而是
由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准
可重入锁
synchronized、ReentrantLock- 递归锁,一种技术,任意线程在获取锁之后能够再次获取该锁,而不会被锁阻塞
- 原理,通过组合自定义同步器来实现锁的获取和释放,多层锁
- 再次获取锁,识别获取锁的线程是否是当前占据锁的线程,如果是,则再次获取,获取锁后,计数+1
(ReentrantLock中的state)- 释放锁,计数自减,需要减到0,才能释放
- 作用,避免死锁
- 可重入锁加了两把,但是只释放了一把锁,会出现什么问题
程序卡死,线程不能出来,申请了几把锁,需要释放几把
- 只加了一把锁,但是释放两次
报异常
java.lang.IllegalMonitorStateException
读写锁
- 是一种技术,通过
ReentrantReadWriteLock实现 - 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁,读是无阻塞的
- 多个读锁不互斥,读锁与写锁互斥
- 读锁,允许多个线程获取读锁,允许同时访问同一个资源
- 写锁,只允许一个线程获取写锁,不允许同时访问写同一个资源
分段锁
- 一种机制,
ConcurrentHashMap - 原理,内部细分若干个小的
HashMap(段 Segment),默认情况分为16个段,也是锁的并发度,当需要添加一项key-value时,先根据hashcode获得该key-value应该放到哪个段,然后对该段加锁,完成put操作
在多线程环境下,如果多个线程同时进行
put操作,只要被加入的key-value不放在一个段中,就可以做到真正的并行
- 线程安全,
ConcurrentHashMap是一个Segment数组,Segment通过继承ReentrantLock来进行加锁,所以每次需要加锁的操作锁住的是一个Segment,这 样只要保证每个Segment是线程安全的,也就实现了全局的线程安全
共享锁、独占锁、互斥锁和同步锁
- 共享锁
RenntrantReadWriteLock、CAS- 一种思想,可以有多个线程获取读锁,以共享的方式持有锁
- 和乐观锁,读写锁同义
- 独占锁
RenntrantLock、synchronized- 一种思想,只能有一个线程获取锁,以独占的方式持有锁
- 和悲观锁,互斥锁同义
- 互斥锁
- 与悲观锁、独占锁同义
- 某个资源只能被一个线程访问,其他线程不能访问
synchronized
- 同步锁
- 与悲观锁、独占锁同义
- 并发执行的多个线程,在同一个时间只允许一个线程访问共享数据
synchronized
分布式锁
- 分布式应用访问同一个数据库,会造成并发问题,而一个jvm应用只能保证一个应用内的线程安全
对于redis
- 借助
redis的setnx方法的特点,用协议的方式设置一个锁key
setnx key val //操作前上锁
del key //操作完后解锁
- 为了避免意外情况应用崩掉,无法及时释放锁,设置
key的超时时间
expire key seconds
- 但是由于存在业务代码执行不确定,无法确定超时时间,会导致业务还没有执行完毕,锁已经被释放掉,导致其他线程进行访问,执行完后,释放掉前一个线程的锁。
可以为所有锁的
val加上一个id,在释放锁时需要进行判断,当前线程只能释放当前线程加的锁
setnx key uuid
- 但是判断和删除并非原子操作,如果在判断结束,删除前,
key过期,导致其他线程马上访问,需要处理超时时间
- 锁续命,请求加锁成功,同时开一个分线程,执行定时任务,定时检查业务线程是否还持有锁,将锁的过期时间重新设置,当没有持有锁了,分线程也停掉
redisson操作redis的客户端,针对分布式场景redission底层通过lua脚本(保证原子性),实现上锁逻辑的原子性操作
tryLockInnerAsync //实现上锁
renewExpiration //实现锁续命
unlockInnerAsync //实现释放锁
- 但是,由于上锁了,面对高并发场景会有性能问题,其他线程只能等待
分布式锁把并行的请求串行化执行,类似
ConcurrentHashMap的分段锁实现,如果有100个商品,将其分为10组,就可以让10个请求同时方法,可以通过实现负载均衡,灵活合并分组情况
- 对于
redis主从架构,当线程1在master创建了key,但是master还没有将key更新到slave时,master就崩了,导致锁失效
zookeeper树形存储架构,redis高并发效果比zookeeper好,先同步,再返回,有半数同步成功之后才返回客户端,在选举时,会优先选举同步成功的子节点作为leader- 通过
redlock解决,建立一个没有主从关系的redis节点的结构,超过半数的redis节点加锁成功,才算加锁成功,RedissonRedLock可以实现
- CAP原则,
一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance),最多实现两点
redis实现AP,zookeeper实现CP
死锁
- 一种现象,如果线程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁
- Java中的死锁不能自行打破,所以线程死锁后,线程不能进行响应
锁行为
锁升级
- 最开始仅有一个线程运行同步代码时,为
偏向锁,当有多个线程来争抢锁了,需要进行锁升级,升级到轻量级锁,当有大量的线程来争抢,导致大量线程进行空转,升级到重量级锁,将线程放入队列 - 具体实现上,当轻量级锁,自旋次数达到自适应自旋数,就会放到队列中,转换为重量级锁
- 1.8时,两个线程就有机会升级为重量级锁
延迟偏向
- 偏向锁默认延时初始化,偏向锁初始化之前,基于类生成的对象都是无锁对象,偏向锁初始化之后,基于类生成的对象都是匿名偏向锁
- jvm参数默认延时4秒开启偏向锁,
-XX:BiasedLockingStartupDelay=0取消延时;-XX:UseBiasedLocking=false关闭偏向锁 - jvm在初始化时,需要创建很多类和对象,而偏向锁初始化需要在安全点进行,如果在开始就初始化,会导致jvm初始化非常慢
- 在四秒前创建的对象,成为锁对象后,直接升级为轻量级锁
- 代码案例
Test test = new Test();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
Thread.sleep(4000);
Test test2 = new Test();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
System.out.println(ClassLayout.parseInstance(test2).toPrintable());
无锁膨胀成轻量级锁
- 程序直接执行,此时偏向锁还没有初始化,只能膨胀成轻量级锁
Test test = new Test();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
synchronized (test) {
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
锁粗化
- 一种技术,如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体之中,就算没有线程竞争,也会导致性能消耗
- 把加锁的范围
扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,减少性能损耗- 把连续的、多次的、频繁的加锁行为,合并成一个整体
锁消除
- 一种优化技术,把锁干掉,jvm发现某些共享数据不会被线程竞争,就会进行锁消除
- 如何判断共享数据不会被线程竞争
- 逃逸分析,分析对象的作用域,如果A方法定义后,会被作为参数或者返回值,逃出方法,则为方法逃逸;如果能被其他线程访问,则为线程逃逸
- 如果堆上某个数据不会逃逸,那么就可以当作栈上数据对象,认为其是线程私有,去掉同步锁
锁降级
- 针对读写锁,对于数据比较敏感, 需要在对数据修改以后, 获取到修改后的值, 并进行接下来的其它操作
某些操作中,需要更新完数据,立即获得,防止这个过程中,有其他线程进行修改
- 顺序
rw.writeLock().lock();
System.out.println("rw.writeLock().lock();");
rw.readLock().lock();
System.out.println("rw.readLock().lock();");
rw.writeLock().unlock();
rw.readLock().unlock();
- 写锁是独占锁,获得写锁时,可以进行锁降级,获得读锁,但是有读锁时,写锁必须等所有读锁结束之后才能获得
- 以下会阻塞
rw.readLock().lock();
System.out.println("rw.readLock().lock();");
rw.writeLock().lock();
System.out.println("rw.writeLock().lock();");
hashcode对锁的影响
- 调用
hashcode()才生成hashcode - 对于多层
sync,轻量级锁的内层BasicLock为null,不能回到无锁状态,在此作为锁对象时,只能膨胀成重量级锁 - 如果是程序启动四秒后创建的对象,会天然的带上匿名偏向锁的标识,如果调用
hashcode()方法,会撤销为无锁状态,需要对象头中的hashcode - 如果是已偏向锁被线程获取,在同步代码中调用
hashcode()方法,会直接膨胀成重量级锁,偏向锁内没有部分可以存hashcode,也没有hashcode可以复制到轻量级锁的lock record - 如果是由无锁状态升级为轻量级锁后,并在同步代码块中调用
hashcode(),会升级为重量级锁,原因是轻量级锁的lock record来源于对象头信息的复制,而此时是没有的,只能升级为重量级锁通过monitor获取
偏向锁的批量锁撤销和重偏向
- 批量锁撤销,存在明显多线程竞争的场景下使用偏向锁是不合适的
- 批量重偏向,一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作
- 以class为单位,为每个class维护一个
偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。不会破坏正在使用的锁,会将对应的epoch+1,当下次获得锁的时候,该对象的epoch与class的epoch不同,直接修改改锁对象的ThreadID - 当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,撤销为无锁,之后,对于该class的锁,直接走轻量级锁的逻辑
对象头
- 对象头组成结构
- 元数数据区
markword- 类型指针,指向元空间的类元信息
- 数组长度,数组才有
- markword部分
- 转化为轻量级锁或重量级锁后,其他信息放到其他地方暂存
- 当升级为重量级锁之后,重量级锁指针指向
monitor
- 打印对象头信息,需要导包
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
<scope>provided</scope>
</dependency>