相信每一个在Java 海洋里畅游的水手们都听过 "锁" 这个东西,今天我们就来解开它的神秘面纱。
首先我们要知道 "锁" 是什么?
锁是在多线程情况下为了保证数据的安全性而衍生出来的,保证数据在每个线程中是一致的。在这里我们要记住一点 "锁" 是和 多线程是联系在一起的,只有在多线程情况下才有意义。
互斥 与 同步
1)首先我们要记住,互斥与同步是两种特性,而锁是一种东西,两者是不同的,他们的联系是锁具有了互斥/同步的特性。
2)同样地,互斥与同步也是发生在多线程的情况下。
3)互斥:指的是多个线程抢占资源,其中某一个线程持有了资源(锁),不让其他资源继续持有,等待执行完之后再释放资源(锁)。
4)同步:同样也是多个线程使用资源,但多个线程是按照一定的顺序持有资源,按照规定好的顺序。
互斥与同步的最大区别就是是否按照顺序持有资源。(大多数情况下,同步是建立在互斥的基础上)
对象锁 VS 类锁
- 对象锁:指的是锁的是一个对象实例,有一点需要注意的是,以下两种情况,锁的对象都是同一个
private synchronized void testA() {
synchronized (this) {
}
}
private synchronized void testB() {
}
- 类锁:指的是锁的对象是这个类的对象实例(特殊的对象锁)以下两种情况是同一把锁:
private static synchronized void testA() {
}
---------------------------------------------------------------------------------------------------
private void testA() {
synchronized (MyClass.class) {
}
}
乐观锁 VS 悲观锁
1)悲观锁:指的是在操作数据时,认为会有其他线程来修改数据,因此在操作数据前都会加锁。
2)乐观锁:指的是在操作数据时,认为不会有其他线程来修改数据,因此不会进行加锁,整个过程是,先对数据进行操作,然后判断是否有其他线程修改了数据,如果其他线程没有修改数据则把操作完的数据成功写入;如果有其他线程修改了数据,则抛出错误或者进行重试(再次修改,然后进行判断)
因此:
- 乐观锁:适用于多读少写的场景;
- 悲观锁:适用于多写少读的场景;
在 Java 中一般是通过CAS(compare and swap)也就是比较和交换,来实现乐观锁的(不加锁)
那CAS是如何通过不加锁而实现多线程下变量同步的呢?
通过查看JDK_8源码可以看出,通过compareAndSwapInt这个方法来进行比较和替换,这个方法是CPU封装的一个原子操作:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
而正是因为封装成了一个原子操作也才可以实现无锁,让多线程下变量同步。
在通过CAS实现多线程下变量同步的过程中会出现 3 个比较严重的问题:
-
ABA 问题:前面提到,使用CAS时需要去判断数据是否出现了变化,那么现在先将A 修改为B,继而又修改为A,那么CAS再进行检验时,就会判定为没发生改变,而实际上是已经发生了改变。那么解决这个问题的思路是在每次改变时带上一个版本号,每次改变版本号就加 1。因此 A-B-A就会变成1A-2B-3A。JDK自1.5开始就提供了两个类来解决 ABA 问题:
1)AtomicMarkableReference:在引用变量过程中修改了几次(使用一个 int 变量记录
2)AtomicStampedReference:在引用变量过程中是否被修改了(使用一个boolea变量记录) - 开销过大问题:我们都知道,在判断条件不满足的时候一般会进行循环重试,也就是自旋,但如果循环过久对CPU消耗也是不可小觑的。
- 只能保证一个共享变量的原子操作,对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。因此自JDK1.5开始就提供了AtomicReference 类来保证多个共享变量的原子操作,可以将多个变量保存到一个对象里面,然后再对这个对象进行原子操作。
自旋锁 VS 适应性自旋锁
在介绍自旋锁之前我们要知道一些知识:
在对多线程中进行变量同步时,我们通常会使用锁来进行使其他线程阻塞,当获得到锁的时候时唤醒线程,也就是我们经常听到的CPU上下文切换,阻塞与唤醒(上下文切换)其实是非常耗费性能的。
如果在某些同步线程中要执行的逻辑又是很简单,此时如果再进行上下文切换,就有可能让用户等待上下文切换的时间比真正执行任务的时间还长。
因此同步资源的锁定时间比较短,而为了这小段时间进行线程切换,线程挂起和恢复,对系统来说是非常得不偿失的。
在两个或多线程允许并行执行的情况下,当前线程不放弃CPU的执行时间,进行一个自旋等待,等待前面线程会释放锁(资源),从而实现不切换线程而获锁(资源),这就是所谓的自旋锁
自旋锁本身也是会耗费性能的,他虽然节省了线程切换的开销,但同时占用了CPU的时间。如果占用的时间较短,性能会非常好,但如果占用较长,仍然需要使用锁机制,这也就是为什么仍然需要使用存在"锁"的原因。
一般来说,自旋的默认次数是10,当超过了次数后,没有获取锁,就应当挂起线程。
适应性自旋锁
从 JDK6 中引入了适应性自旋锁,从名字可以看出,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
- 如果在同一个锁对象上,刚刚成功获取过锁,而且获取锁的线程正在执行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
- 如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
公平锁 VS 非公平锁
1)公平锁:在多线程中,每个线程按照顺序获取锁对象,其他线程阻塞线程,按照顺序进行阻塞与唤醒,新进的线程排在队列尾部。
- 优点:等待锁的线程不会饿死
- 缺点:整体的吞吐率相对非公平锁会比较低,除了队列第一个线程外,其他线程都会阻塞,CPU唤醒线程的开销比较大
2)非公平锁:多个线程加锁时,先尝试获取锁,获取不到再排到队列尾部。因此,就存在后申请锁,先获取锁的情况。 - 优点:使用非公平锁吞吐率比较高,减少了CPU唤醒线程的开销,因为有线程可能几乎不阻塞就获取锁
- 缺点:处于等待队列的线程有可能会饿死
(我们平时经常用的synchronized 与 ReentrantLock 都是非公平锁)
ReentrantLock 也是支持公平锁的,只需在构造函数中传入 true
ReentrantLock reentrantLock = new ReentrantLock(true);
--------------------------------------------------------------------------------------------
//以下是ReentrantLock 的构造函数,从这里可以看出,当传入为true时,ReentrantLock 是公平锁
public ReentrantLock(boolean var1) {
this.sync = (ReentrantLock.Sync)(var1 ? new ReentrantLock.FairSync() : new ReentrantLock.NonfairSync());
}
可重入锁 VS 不可重入锁
所谓可重入锁,也可以理解为递归锁,就是持有相同锁对象的一系列流程可以反复调用同一把个锁对象。在底层维持有一个计数器,每获取一次锁对象,计数器+1。
private synchronized void testB() {
System.out.println("test---B");
testE();
}
private synchronized void testE() {
System.out.println("test---E");
}
可以看出,这两个方法都是持有的相同一把对象锁,同时又因为是可重入锁,因此,可以执行到 testE。如果是不可重入锁,因为锁对象一开始被testB 持有,此时再调用testE,则会进行等待,从而造成死锁。
同时前面说到的ReentrantLock 也是属于可重入锁。
以上就是对Java中常见锁的一些讲解,希望对你有所帮助
网友评论