目录
1.乐观锁 悲观锁
2.重量级锁 轻量级锁
3.自旋锁 挂起等待锁
4.读写锁
5.可重入锁 不可重入锁
6.公平锁 非公平锁
7.CAS(compare and swap)
8.基于CAS实现线程安全的方式
9.CAS应用场景
9.1 基于CAS实现原子类
9.2 基于CAS实现自旋锁
10.ABA问题及解决方案
11.synchronized几个重要的机制
(1)锁升级
(2)锁消除
(3)锁粗化
是一类锁的一种特性,不是具体的一把锁。悲观还是乐观,是对后续锁冲突是否激烈(频繁)给出的预测。
如果预测接下来锁冲突的概率不大,就可以少做一些工作。称为乐观锁。
如果预测接下来锁冲突的概率很大,就应该多做一些工作,称为悲观锁。
重量级锁,即锁的开销大,轻量级锁,即锁的开销小。和悲观乐观锁是有关联的。乐观锁通常也是轻量级的锁,悲观锁通常也是重量级的锁。
区别:乐观悲观锁是预测锁冲突的概率,而重量级轻量级锁则是实际消耗的开销。
自旋锁就属于是轻量级锁的一种典型实现。往往是在纯用户态实现。比如使用一个while循环,不停的检查当前锁是否被释放,如果没释放,就继续循环,释放了就获取到锁并结束循环。这就相当于一种忙等,消耗CPU但是换来更快的响应速度。
挂起等待锁就属于是重量级锁的一种典型实现。需要借助系统API来实现,一旦出现所竞争了,就会在内核中触发一系列的操作。(比如让这个线程进入阻塞的状态,暂时不参与CPU调度)。
总结:
注意与数据库读写加锁的区别:
一个线程针对同一把锁,连续加锁两次,若不会死锁,就是可重入锁。若死锁,就是不可重入锁。
synchronized就是可重入锁。
当有很多线程尝试去获取加同一把锁的时候,只有一个线程能够拿到锁,其他线程阻塞等待,这是锁的特性。那么当这个线程释放锁之后,接下来哪个线程能拿到锁?
公平锁:按照先来后到顺序获取加锁。
非公平锁:在剩下的线程当中已均等的概率来重新竞争锁。
上述这些锁策略都是描述了一把锁的基本特点的。synchronized这把锁,属于哪种锁策略呢?
初始情况下,synchronized会预测当前的锁冲突的概率,若概率不大,会以乐观锁的模式运行(也就是轻量级锁,基于自旋锁的方式实现)。若概率大,锁冲突情况多,synchronized就会升级成悲观锁,(也就是重量级锁,基于挂起等待的方式实现)。
比如有一个内存M,还有两个寄存器A,B,CAS(M,A,B)
如果M和A的值相同的话,就把M和B里的值进行交换,返回true。如果值不相同,就什么都不做,返回false。
CAS其实是一个cpu指令,比较较换的是内存和寄存器。一个cpu指令,就能完成上述比较交换的逻辑。单个的cpu指令是原子的!!就可以使用CAS完成一些操作,进一步代替"加锁"。这给编写线程安全的代码,进入了新的思路。
也称为"无锁编程"。
CAS本质上是cpu提供的指令,又进一步被操作系统封装,提供成API,又因为操作系统多样,其提供的API也是不同,于是JVM对这些API又进行了统一封装,使得使用者不论在哪个操作系统上使用,只需要掌握一套API即可。下面举出几种应用场景。
比如count++,就不是原子的,因为一个count++过程对应着cpu上三个指令(load,add,save),但是使用CAS操作会让这个过程成为原子的。
如下代码中多个线程对同一个变量进行修改就会发生线程安全问题,结果与预期不符。
public class test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i=0;i<5000;i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for(int i=0;i<5000;i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
编写代码中,我们不能直接使用CAS操作,针对这些,Java提供了API,AtomicInteger类 。atomic翻译为原子。
接下来用Java提供的 AtomicInteger类 实现。注意代码中AtomicInteger的用法。
public class Demo14 {
public static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i=0;i<5000;i++) {
// count++
count.getAndIncrement();
// ++count
//count.incrementAndGet();
// count--
//count.getAndDecrement();
// --count
//count.decrementAndGet();
}
});
Thread t2 = new Thread(() -> {
for(int i=0;i<5000;i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
通过进一步查看 getAndIncrement 方法的源码:
我们会发现其底层使用了CAS操作的。所谓线程不完全,实质上是进行自增的过程中,线程穿插执行了。CAS是让这里的自增不能穿插执行,核心思路是和加锁类似的。但并没有真正加锁。加锁方式是通过阻塞的方式,避免穿插。CAS则是通过重试的方式,避免穿插。
伪代码:
CAS进行操作的关键,是通过值"没有发生变化"来作为"没有其他线程穿插执行"的判断依据。
但是有一种极端的情况,可能有一个线程穿插进来,把值从 A -> B -> A了。看起来这个值是没变,但是实际上已经被穿插执行了。
举个常见的例子:去银行取钱,假设卡里有1000,我准备取走500,当我刚好取钱成功的时候,朋友给我又转了500,这样,原本我取完钱朋友给我又转了500,卡里应该有1000,但因为ABA问题可能会导致500被连续扣了两次,导致卡里只剩500。
解决办法:
锁升级的过程是单向的,不能降级。
偏向锁其实就是没有锁竞争的时候,就吊着不加锁,这样既保证了线程安全,也保证了性能和效率,但当有锁竞争时,就会升级加锁。成为自旋锁,也就是轻量级锁。如果此时这个锁竞争的还比较激烈,就会再次升级成为重量级锁。
通俗点就是没有锁竞争时,能不加尽量不加。当有冲突时,就抢先一步加锁。因为加锁是有一定开销的,这样做既保证了线程安全,也保证了性能和效率。
锁升级的过程,就是在性能和线程安全之间尽量权衡。(因为加锁之后,虽然线程是安全了,但同时效率也变慢了)。
一种编译器优化的手段。
即编译器会自动针对你当前写的加锁的代码做出判定,如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized给优化掉。比如常见的两种可变数组StringBuffer和StringBuilder,
即如果在单个线程中使用StringBuffer,此时编译器就会自动的把synchronized给优化掉。因为只要加锁就会影响性能,效率。
锁的粒度。
synchronized里头的代码越多,就认为锁的粒度越粗,代码越少,锁的粒度就越细。
粒度细的时候,能够并发执行的逻辑就更多,更加有利于利用多核CPU资源。
但是,如果粒度粗的锁,被反复加锁解锁,可能实际效果还不如力度粗的锁(涉及到锁的反复竞争)。
例如: