1.synchronized的特性
要理解synchronized的特性,需要结合多种常见锁的特点来理解。
1.乐观锁与悲观锁
什么是乐观锁,什么是悲观锁?乐观锁是预测锁冲突比较少,围绕加锁准备了较少的应对措施,这种锁就叫做乐观锁;同理悲观锁是预测锁冲突频繁,围绕加锁准备的应对措施较多,这种锁就是悲观锁。乐观锁与悲观锁是从锁冲突的角度区分的。
2.轻量级锁与重量级锁
轻量级锁指的是加锁的开销比较小,需要做的工作比较少;而重量级锁指的是加锁的开销比较大,需要做的工作较多。与锁冲突的角度不同,它是从系统开销的角度区分的。通常情况下,乐观锁预测锁冲突少,围绕加锁需要做的工作少,同样系统开销也会少,因此乐观锁通常也是轻量级锁。而悲观锁预测锁冲突频繁,围绕加锁做的工作较多,因此悲观锁通常也是重量级锁。
3.自旋锁与挂起等待锁
假设锁冲突的概率不高的情况下,获取锁时通过循环方式检测锁是否被释放,一旦锁被释放则会立即获取锁,如果没被释放则继续循环检测,而不是进入阻塞状态。这种锁就是自旋锁。假设锁冲突的概率高,未获取到锁时,进入阻塞状态,等待获取锁,这种锁就是挂起等待锁。因此自旋锁与挂起等待锁是从获取锁的方式的角度区分的。
对于自旋锁,循环检测锁的过程,线程并没有阻塞等待进入休眠,而是在持续检测等待,这种等待的方式也叫做“忙等”,会持续消耗CPU资源。而对于挂起等待锁,阻塞等待则是主动释放CPU资源。因此自旋锁也是乐观锁/轻量级锁,而挂起等待锁则是悲观锁/重量级锁。
4.公平锁与非公平锁
当多个线程竞争同一把锁时,线程获取锁的方式是按照时间先后顺序获取锁,即先等待的先获取,后等待的后获取,就像日常生活中的排队一样,这种锁就叫做公平锁。反之,获取锁时多个线程一起竞争,不论“先来后到”,哪个线程抢占到就归哪个线程,这种锁就叫做非公平锁。
5.可重入锁与不可重入锁
当针对一段代码进行先后两次加锁,如果第二次加锁可以成功加锁,则为可重入锁,如果不能加锁,陷入“死等”的情况,这种锁就是不可重入锁。因此根据是否能够多次加锁区分重入锁与不可重入锁。
6.读写锁
读写锁就是把加锁的操作分为两种,一个为读加锁,一个为写加锁。通常情况下,读操作是线程安全的,当两个线程都按照读的方式加锁,不会产生线程安全问题,因此也不会产生锁冲突;当两个线程中,一个读加锁,一个写加锁,两个线程同时读写会产生线程安全问题,两个线程同时获取锁时会产生锁冲突;当两个线程都是写加锁,且同时进行写操作会产生线程安全问题,获取锁会产生锁冲突。
7.synchronized的特性
那么synchonized是哪种类型的锁呢?理解了这些锁的含义后,再来看synchronized的特性,synchronized实际上是一种“自适应锁”,它具有以下特性:
- 开始是乐观锁,如果锁冲突比较多,就会升级为悲观锁;
- 开始是轻量级锁,如果锁冲突频繁,锁持有的时间较长,就会升级成重量级锁;
- 当synchronized是轻量级锁的时候,可能采用的就是自旋锁的方式,如果锁持有的时间长,就会升级成挂起等待锁;
- 是一种不公平锁;
- 是一种可重入锁;
- 不是读写锁;
2.synchronized的使用
synchronized锁可以保证所修饰的方法或者代码块具备原子性,这是它能保证线程安全的原因,以下是synchronized的常见使用方式:
1.synchronized修饰代码块
synchronized修饰代码块,代码如下:
synchronized括号里需要写一个对象,对象的类型不重要,是否为同一个对象才重要。上面例子中都是针对对象locker进行加锁,那么线程t1中的count++和线程t2中的count++就是互斥的。即同一时刻只有t1或者t2中的count++可以执行,一个线程释放锁,另外一个线程才可以获取锁。
如果t1和t2加锁的对象不同,那么就继续存在线程安全问题。
2.synchronized修饰实例方法
synchronized修饰实例方法,代码如下:
上述代码中只要调用increase方法,就是线程安全的。synchronized修饰实例方法相当于修饰了this,等价如下代码:
这个类实例化出的对象,就是这把锁本身。
3.synchronized修饰静态方法
synchronized修饰静态方法,代码如下:
synchronized修饰静态方法,相当于修饰类对象,代码等价于:
4.避免死锁
1.可重入锁的基本原理
上面总结了synchronized是可重入锁,因此连续加锁多次不会死锁。可重入锁大概逻辑是这样的:锁的内部引入了一个计数器,同一个线程每次针对某一个对象连续多次加锁时,第一次加锁的时候才会真正加锁,计数器的值从0变为1,后面的重复加锁,每加锁1次,计数器就会加1,这个计数器用来统计加锁的次数。同一个线程针对某个对象多次加锁后,每次释放锁,计数器就会减1,直到计数器的值为0,才会真正释放锁。如下图:
0->1{(真正加锁)
1->2{(计数器加1,直接放行不加锁)
2->3{(计数器加1,直接放行不加锁)
}
3->2(计数器减1,不释放锁)
}
2->1(计数器减1,不释放锁)
}
1->0(真正释放锁)
2.两个线程的死锁问题
假设有两个线程两把锁,两个线程分别获取一把之后都尝试再获取对方的锁,这个时候就会出现死锁问题,代码如下:
上述代码t1获取locker1之后再尝试获取locker2,t2获取locker2之后再尝试获取locker1,这时候t1在等待t2释放locker2,t2在等待t1释放locker1.结果就是两个线程谁也获取不到对方的锁,都进入阻塞等待的状态,这就形成了死锁。两个线程谁也无法打印信息,运行结果如下图:
因此写代码的时候要尽量避免锁嵌套,原则上能不写就不写,能更好得避免死锁问题。
3.多个线程的死锁问题
以4个线程为例,我们先假设这样得场景:有4个线程4把锁,分别为t1,t2,t3,t4和locker1,locker2,locker3,locker4. t1要完成工作需要先后获取locker1和locker2,t2完成工作需要先后获取locker2和locker3,t3完成工作需要先后获取locker3和locker4,t4完成工作需要先后获取locker4和locker1,如下图:
通常情况下,这样得设计不会死锁。但是在极端情况下,t1~t4分别都先获取了locker1~locker4,形成了死锁,谁也完成不了工作,代码如下:
运行结果如下:
要解决上述问题,就需要约定,每次获取锁得时候都按照一定规律,比如给锁编号,每次都需要先获取编号小的锁,再获取编号大的锁。对于上述例子,t4需要先获取锁1,再获取锁4,代码修改如下:
代码运行结果:
4.死锁必要条件
通过上述两个锁死锁和多个锁死锁的例子,总结死锁的必要条件如下:
- 锁是互斥的,这是锁的基本特性;
- 锁是不可被抢占的,这也是锁的基本特性;
- 请求和保持,也就是锁嵌套,先获取了锁A,再去获取锁B,就可能会导致死锁问题;
- 环路等待,多个线程获取锁构成环路;
这给我们提供了解决死锁问题的思路,就可以从上述条件入手,即不完全满足上述条件,即可解决死锁问题。上述条件中的1和2,是锁本身具有的基本特性,是不可避免的,因此重点要从3和4中想办法。第一,尽量不要使用锁嵌套,如果非要使用,尽量不要形成环路。
3.synchronized的锁机制
synchronized是一个自适应锁,JVM中将synchronized锁分为4种状态,如下:
无锁 => 偏向锁 => 轻量级锁 => 重量级锁
自适应的过程就是依次升级的过程,且该过程是不可逆的。
1.偏向锁
无锁,轻量级锁,重量级锁都比较好理解,那么偏向锁状态是什么呢?
第一个尝试加锁的线程会进入偏向锁的状态。偏向锁不是真的加锁,而是在对象头中记录一个偏向锁的标记,记录这把锁属于哪个线程。后续如果没有现成来竞争这把锁,锁就不会升级成轻量级锁,避免了加锁的开销;如果后续有其他线程来竞争这把锁,这把锁会取消偏向锁的状态,升级成轻量级锁,并优先被之前记录的线程获取到。
2.轻量级锁
轻量级锁通常也是自旋锁,通过CAS实现。
1.CAS
CAS是compare and swap的首字母缩写,意思是比较与交换。CAS操作涉及3个参数,假设分别为address,expectValue和swapValue,address是内存中的数据,expectValue中存的是期望的值,swapValue中村的是需要交换的数据,如果address的值等于expectValue,那么就把swapValue赋值给address,成功返回true,失败返回false。即:
boolean CAS(address, expectValue, swapValue){
if(address == expectValue) {
address = swapValue;
return true;
}
return false;
}
上述代码展示的是CAS实现的功能,但CAS的本质是CPU的一个原子指令,即这个操作不可分割,在CPU上只需要一个步骤就完成了,因此使用这个操作是线程安全的。当多个线程同时执行CAS操作时,只有一个线程执行成功,其余线程执行失败,并不会出现阻塞的情况,只是返回false。因此CAS也可以看作是一个轻量级锁(乐观锁),或者一种轻量级锁(乐观锁)的实现方式。
2.CAS实现原子类
Java标准库中提供了java.util.concurrent.atomic包,包里面的类是通过CAS来实现的。包含的类如下:
以AtomicInteger举例说明,代码如下:
运行结果:
atomicInteger初始值设置3,执行incrementAndGet方法后值为4. 等价的效果就等同++i。区别就是incrementAndGet方法操作可以理解为原子的,++i则是需要先读内存,再自增,再写内存三个步骤,因此不具备原子性。
如何使用CAS实现原子类?使用CAS模拟实现IncrementAndGet方法如下:
IncrementAndGet实现的功能是上述功能。
那么上述方法能够保证线程安全吗?假设有两个线程同时调用上述方法,过程中出现了穿插,如下图:
结果正确,原因是每次CAS操作都会读内存,检查address和expectValue是否相同,不相同会更新expectValue,这样就能保证每次自增都能在最新的值上自增。
3.CAS实现自旋锁
自旋锁也可以通过CAS实现,代码如下:
加锁时,如果owner为null,表示当前线程可以加锁,继续执行当前线程的代码逻辑;如果owner不为空,说明锁已经被其它线程持有了,当前线程只能“忙等”,持续检查,等待持有锁的线程释放锁。
解锁时,直接将owner置空就可以了。
4.CAS的ABA问题
假设有2个线程t1和t2,和一个变量num,初始值为A;t1线程要修改变量num的值为Z,需要执行两个操作:
当t1执行完1操作后,线程t2中间穿插执行了,先将A改为了B,又将B改为了A。此时线程t1执行2时,发现num的值和expectValue相同,再将值改为了Z。
大部分这种情况,t2反复修改num的数值,对于t1修改num不会有什么影响,但在特殊场景下会出现问题。
比如有一个银行账户余额1000元,t1线程执行扣款动作时,读到1000元后卡了,于是又启动了t2线程继续扣款,t2先扣500元,但是这是出来了个t3线程,给这个账户又充了500元,此时账户余额还是1000元,这是t1不卡了,对比账户余额1000元,和expectvalue相同,于是又执行了一次扣款,于是这个过程就扣了1000元.
如果t3没有给这个账户充值500元,此时t1线程不卡了,对比余额500元,与expectValue并不相同,这样就不会执行扣款,就不会有这个问题。
5.如何解决ABA问题
上面的两个例子,出现ABA问题的原因是num或者账户余额数值会同时存在增加和减少的情况,因此解决ABA问题,可以通过引入版本号的方式,让这个版本号只增加不减少,就不会出现ABA问题。
还是上面的例子,假如给存款账户引入一个版本号1,t1执行扣款操作时,获取到版本号1,此时t1卡了,又启动t2继续扣款,读取到版本号是1,扣款完成后,余额还剩500,把版本号更新成2,这时候t3又给充了500,账户余额1000,版本号更新成3。此时t3恢复了,虽然账户余额还是1000元,但是版本号已经是3了,因此t1不会扣款。
解决这类问题的核心思路是引入一个只增不减的量。
3.重量级锁
如果锁冲突加剧,自旋锁自旋达到一定次数或者达到一定时间,会自动升级成重量级锁。线程获取到锁之前,会阻塞等待,直到其它线程释放锁,才会再唤醒这个线程,尝试重新获取锁。
4.synchronized的其它优化
1.锁消除
有些代码虽然使用了synchronized,但是是在单线程的环境下使用的,这时候JVM会针对代码进行优化,去掉这些锁,节省了后续程序运行时获取释放锁的开销。
2.锁粗化
举个例子:
多个线程在执行上述代码的时候,会频繁的加锁和解锁,虽然每次解锁后,可以支持其它线程调用该方法,但是频繁的加锁和解锁开销是很大的,节省下来的效率远不如增加的开销多,这是得不偿失的。
因此JVM就可能会优化代码,如下:
上述代码对比优化前,加锁粒度变粗了,这种情况就叫做锁粗化。虽然并发效率降低了,但是节省了频繁加锁和释放锁的开销。