搞懂多线程(四)之《锁》
一、乐观锁和悲观锁
乐观锁:认为自己在使用数据时不会有别的线程修改数据或资源,所以不会添加锁。
在Java中是通过使用无锁编程来实现,只是在更新数据的时候去判断,之前有没有别的线程更新了这个数据。
如果这个数据没有被更新,当前线程将自己修改的数据成功写入
如果这个数据已经被其它线程更新,则根据不同的实现方式执行不同的操作,比如放弃修改、重试抢锁等等
实现方法
可以通过版本号机制Version字段
最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
适合读操作较多场景,有利于提高查询效率
悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
synchronized
关键字和Lock的实现类
都是悲观锁一般适用于
写操作
过程中,较为重
二、synchronized锁
是基于进入
和退出
Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter
指令,在结束位置插入monitor exit
指令(具体见后面锁的原理章节)。
当线程执行到monitor enter
指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的owner
中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
1、对象锁和类锁
synchronized修饰普通方法(对象锁):
锁的是
当前对象
(new出来的对象,类的实例化对象),即this
,等同于synchronized(this)
只要是new出来的同一个对象,该对象中
synchronized修饰的普通方法
持有的都是同一把锁
synchronized修饰静态方法(类锁):
锁的是当前
class对象
(类的初始化对象),所有new出来的对象,都是持有的同一把锁
synchronized修饰同步代码块:
锁的是synchronized(Object)
括号中的对象
,所以使用时应确保括号中对象的唯一性底层使用
monitorenter
和monitorexit
实现由于
synchronized
可以自动释放锁
,所以一般情况下一个monitorenter
对应两个monitorexit
,最后一个防止出现异常,锁未释放,如果在同步代码块中手动抛出异常(中间没有条件判断),则只会有一个monitorexit
2、synchronized锁的自动升级
JDK1.6之前synchronized
使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁
->偏向锁
->轻量级锁
->重量级锁
的升级过程,而不是无论什么情况都使用重量级锁。
可通过对象的对象头中的对象标记(MarkWord)
的锁标记位来查看锁的类型 JVM之对象内存布局
优缺点对比
适用场景
偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似),存在兗争时升级为量级锁,轻显级锁采用的是自旋锁,如果同步方法/代码块扒行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。
重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。
三、公平锁于非公平锁
公平锁:是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票先来的人先买后来的人在队尾排着,这是公平的
Lock lock = new ReentrantLock(true);//true表示公平锁,先来先得
非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能台申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)
Lock lock = new ReentrantLock(false);//false表示非公平锁,后来的也可能先获得锁
Lock lock = new ReentrantLock();//默认非公平锁
为什么默认为非公平锁:
恢复挂起的线程到真正锁的获取还是有时微,但是从CPU的角度来看,这个时间差存在的还是很明显的。间差的,从开发人员来看这个时间微乎其片,尽量减少 CPU 空闲状态时间。所以非公平锁能更充分的利用CPU 的时,
使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请听以刚释放锁的线程在此求锁获取同步状态,然后释放同步状态,刻再次获取同步状态的概率就变得非常大所以就减少了线程的开销。
四、可重入锁(递归锁)
可重入锁:是指在同一个线程
在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(锁的对象是同一个),不会因为之前已经获取过还没释放而阻塞。
例如:一个方法有三层锁,每一层的锁的管程都是同一个,外部获取锁之后,内部的锁不会因为外层没有释放而阻塞。现实生活中的例子,进去大门锁之后,不用释放大门锁就可以访问卫生间锁,不能说要出去大门,才能获取卫生间的锁。
Java中ReentrantLock
和synchronized
都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
可重入原理:
每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行monitorenter
时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有, Java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。
在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么Java虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。
当执行monitorexit
时, Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。
五、偏向锁(JAVA15之后已废弃)
在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。
那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接会去检查锁的MarkWord
里面是不是放的自己的线程ID
。
如果相等,表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程 ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
如果不等,表示与其他线程发生了竞争,锁己经不是偏向于自己的线程了,这个时候会尝试使用CAS来替换MarkWord里面的线程ID为自己的线程的ID,
如果替换成功,表示之前的线程不存在了,MarkWord里面的线程ID替换为自己的线程ID,锁不会升级,仍然为偏向锁。
如果替换失败,这时候可能需要升级变为
轻量级锁
,才能保证线程间公平竞争锁。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
六、自旋锁(SpinLock)
CAS
是实现自旋锁
的基础,CAS利用CPU指令保证了操作的原子性,以达到锁的效果
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
简单来说,利用一个无限循环,执行一个CAS操作,当操作成功返回true时,循环结束;当返回false时,接着执行循环,继续尝试CAS操作,直到返回true。
七、读写锁
在使用synchronized
和Lock
的情况下,同一时刻
一个资源只允许一个线程进行读或写
。
我们期望,一个资源
能够被多个读线程
访问,或者被一个写线程
访问,但是不能同时存在读写线程
,既读读共存
,读写/写写互斥
1、ReentrantReadWriteLock
读锁和写锁主要通过内部类ReadLock
和WriteLock
来实现的
ReentrantReadWriteLock.ReadLock
ReentrantReadWriteLock.WriteLock
优缺点
只有在读多写少情景之下,读写锁才具有较高的性能体现。
存在锁的饥饿问题,读线程没有完成时,其他线程的就没办法写
存在锁降级:
同一个线程
持有了写锁
,在没有释放写锁
的情况下,它还可以继续获得读锁(可重入锁),那么就会一直持续拥有这把锁,但是会降级为读锁
,如果在写入之后
,释放了写锁
,之后再次获取的读锁
,可能存在其他线程已经修改了
数据,当前的线程是无法感知的,再次读取就会读取到错误数据
遵循
获取写锁
->再获取读锁
->再释放写锁
的次序,写锁
能够降级
成为读锁
。当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。所以,需要释放所有读锁,才可获取写锁。
2、StampedLock
写:
writeLock()
和unlockWrite(long)
与ReentrantReadWriteLock.ReadLock一致读:
readLock()
和unlockRead(long)
与ReentrantReadWriteLock.WriteLock一致乐观的读写: 通过
tryOptimisticRead()
返回邮戳,使用validate(long stamp)
验证邮戳是被修改,如果修改业务自行处理
七、锁消除与锁粗话
1、锁消除
如果每次调用该方法都会重新指定一个锁,也是就是说synchronized(o)
的这个o每次都会创建一个新的,从JIT角度
看相当于无视它,因为这个锁对象并没有被共用护散到其它线程使用
2、锁粗话
假如方法中首居相接,前后相领的都是同一个锁对象,那么JIT译器
就会把这几个synchronized
合并成一个大块, 加粗加大范围,一次中请锁使用即可,避免每次申请和释放锁,提升了性能
示例代码
public static void main(String[] args) {
Object o = new Object();
//锁粗话之前
new Thread(() -> {
synchronized (o) {
System.out.println("1111");
}
synchronized (o) {
System.out.println("2222");
}
synchronized (o) {
System.out.println("3333");
}
}, "AA").start();
//锁粗话之后
new Thread(() -> {
synchronized (o) {
System.out.println("1111");
System.out.println("2222");
System.out.println("3333");
}}, "AA").start();
}
}
八、死锁
死锁:是指两个或两个以上
的线程在执行过程中,因争夺资源而造成的一种互相等待
的现象
若无外力干涉那它们都将无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。
死锁示例代码
public static void main(String[] args) {
Object object1 = new Object();
Object object2 = new Object();
new Thread(() -> {
synchronized (object1) {
System.out.println(Thread.currentThread().getName() + "\t 持有object1希望获得object2");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (object2){
System.out.println(Thread.currentThread().getName() + "\t 获得了object2");
}
}
}, "A").start();
new Thread(() -> {
synchronized (object2) {
System.out.println(Thread.currentThread().getName() + "\t 持有object2希望获得object1");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (object1){
System.out.println(Thread.currentThread().getName() + "\t 获得了object1");
}
}
}, "B").start();
}
死锁排查
命令行
jps -l
:查看当前运行的进程信息;jstack
:进程编号
图形化界面
jconsole
九、锁的原理
锁的底层是ObjectMonitor.hpp
实现的,主要参数有以下
锁的原理说明