这么多锁你都知道吗?
Posted 赵jc
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了这么多锁你都知道吗?相关的知识,希望对你有一定的参考价值。
锁
公平锁和非公平锁
公平锁
- 定义:多个线程按照申请锁的顺序去获得锁,线程会直接进入到队列去排队,永远都是队列的第一个才会得到锁。(不管大人还是小孩,都需排队,谁在队伍前面,谁先获取锁)
- 优点:所有的线程都能得到资源,不会饿死在队列中在
- 缺点:吞吐量会下降许多,队列里除了第一个线程,其他线程都会阻塞,cpu唤醒阻塞线程的开销会很大
- 实现:使用ReenTrantLock(true)实现
Lock lock = new ReentrantLock(true);
非公平锁
- 定义:多个线程去获取锁的时候,会直接去尝试获取(争夺似的),获取到了就直接执行相应代码,获取不到,再去等待队列中排队。
- 优点:争抢似的获取,效率更高,可以减少CPU唤醒线程的开销
- 缺点:有可能会导致队列中的线程一直获取不到锁导致饿了(比如队列中有小孩,和大人去争强,小孩肯定抢不过大人啦)
- 实现:在Java语言中为了高效率,所有的锁默认都是非公平锁(synchronized和ReentrantLock()默认都是非公平锁)
乐观锁和悲观锁
乐观锁
- 定义:乐观锁假设认为数据一般情况下不会产生并发冲突,只有在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果未发生冲突,则修改数据,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
- 缺点:并不总是能处理所有问题,所以会引入一定的系统复杂度
- 经典示例:
CAS(Compare and swap)比较和交换
悲观锁
- 定义:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
- **缺点:**总是需要竞争锁,进而发生线程切换,导致效率不高
- 经典实现:synchronized、lock等java提供得默认锁
CAS
什么是CAS?
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V(交换),如果不相等则报错
- 返回操作是否成功。
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。可见 CAS 其实是一个乐观锁。
下面结合图片来理解一下
CAS的实现原理是什么?
CAS在Java中是通过UnSafe类中C/C++提供的原生方法实现的,而C/C++又是通过调用操作系统的Atomic::cmpxchg(原子指令)来实现的,( Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。)
简而言之,是因为硬件予以了支持,软件层面才能做到。
CAS的应用
Java中的Atomic*下的所有方法都是基于CAS实现的,可以保证线程的安全,下面举一个多线程下保证i++操作线程安全的例子
private static AtomicInteger count =
new AtomicInteger(0);
private static final int MAXSIZE = 100000;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < MAXSIZE; i++) {
count.getAndIncrement(); // i++
// count.incrementAndGet(); // ++i
}
}
});
t1.start();
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < MAXSIZE; i++) {
count.getAndDecrement(); // i--
}
}
});
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + count);
}
CAS中的ABA问题和解决方案
- ABA问题是什么?
ABA 的问题,就是一个值从A变成了B又变成了A,(但此时这两个A已经不一样了)这干巴巴的概念肯定不好理解了,我们来举一个例子吧
- ABA问题该如何解决
使用版本号,每次修改的时候判断预期的旧值和版本号两方面,每次修改成功之后也要更改版本号,这样即使预期的值A和V值相等,但因为版本号的不同也不能进行修改,从而解决ABA问题,例如使用AtomicStampedReference(Stamped是带邮标签的意思,方便记忆)
private static AtomicStampedReference money =
new AtomicStampedReference(1000, 1);
public static void main(String[] args) throws InterruptedException {
// 转账 -1000
/**
* compareAndSet()参数 旧值 新值 旧版本号 新版本号
*/
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
boolean result = money.compareAndSet(1000, 0,
1, 2);
System.out.println("线程1执行转账:" + result);
}
});
t1.start();
t1.join();
// 账户增加了 1000
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
boolean result = money.compareAndSet(0,
1000,
2, 3);
System.out.println("线程3转入1000元:" + result);
}
});
t3.start();
t3.join();
// 转账 -100
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
boolean result = money.compareAndSet(1000,
0,
1, 2);
System.out.println("线程2执行转账:" + result);
}
});
t2.start();
}
注意事项:
AtomicStampedReference可以解决ABA问题,但里面旧值对比的是引用而不是值
这是由于Integer高速缓存的原因,数据在[-128~127]中会直接使用缓存值而不会重新new对象,可以修改jvm的参数来修改范围
synchronized
synchronized如何实现的?
在Java层面:将锁标识信息放在对象头中
在JVM层面:是基于monitor监视器锁实现的
monitor是线程私有的数据结构,每一个线程都有一个可用monitor列表,同时还有一个全局的可用列表,先来看monitor的内部
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor失败的线程。
- RcThis:表示blocked或waiting在该monitor上的所有线程的个数。
- Nest:用来实现重入锁的计数。
- HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
- Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。
在操作系统层面:是基于互斥锁mutex实现的
- 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
synchronized在java1.6之后有什么优化?
1.6之前锁没有分类,都为重量级锁,1.6之后,线程A创建之后先是无锁状态,之后如果得到锁了话会变成偏向锁,之后再有别的线程B来访问的话,但此时占有着锁,线程B会自旋等待,等待一段时间后,偏向锁会升级为轻量级锁,如果自旋一段时间之后还没有获得锁资源,那么就会升级为重量级锁
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
- 无锁:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。
- 偏向锁:对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续的执行中自动获取锁, 降低获取锁带来的性能开销。偏向锁,指的就是偏向第一个加锁线程,该线程是不会主动释放偏向锁 的,只有当其他线程尝试竞争偏向锁才会被释放。
偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁;如果线程处于活动状态,升级为轻量级锁的状态。 - 轻量级锁:轻量级锁是指当锁是偏向锁的时候,被第二个线程 B 所访问,此时偏向锁就会升级为轻量级锁,线程 B会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会 升级为重量级锁;当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁 也会升级为重量级锁。
- 重量级锁:指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
独占锁和共享锁
独占锁
- 定义:一把锁只能被一个线程拥有(见名知义)
- 经典实现:synchronized
共享锁
- 定义:一把锁可以被多个线程同时拥有
- 经典实现:读写锁ReadWriteLock(),读读共享,但写写互斥,读写互斥,优点(将锁的粒度更加细化,从而提高锁的性能)
public static void main(String[] args) {
// 创建一个读写锁
ReentrantReadWriteLock readWriteLock =
new ReentrantReadWriteLock();
// 得到读锁
ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
// 写锁
ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
// 创建线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 10, 0, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000));
// 任务一:读锁演示
executor.execute(new Runnable() {
@Override
public void run() {
// 加锁
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() +
" 进入了读锁,时间:" + new Date());
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
readLock.unlock();
}
}
});
// 任务二:读锁演示
executor.execute(new Runnable() {
@Override
public void run() {
// 加锁
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() +
" 进入了读锁,时间:" + new Date());
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
readLock.unlock();
}
}
});
// 任务三:写锁
executor.execute(new Runnable() {
@Override
public void run() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() +
" 执行了写锁,时间:" + new Date());
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
});
// 任务四:写锁
executor.execute(new Runnable() {
@Override
public void run() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() +
" 执行了写锁,时间:" + new Date());
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
}
});
}
可重入锁
- 定义:可以重新进入的锁,即允许同一个线程多次获取同一把锁。
public static void main(String[] args) {
Object object = new Object();
synchronized (object) {
System.out.println("进入了方法");
synchronized (object) {
System.out.println("重复进入了方法");
}
}
}
- 使用场景:synchronized和ReentractLock
自旋锁
- 定义:按之前的方式处理下,线程在抢锁失败后进入阻塞状态,放弃
CPU,需要过很久才能再次被调度。但经过测算,实际的生活中,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。基于这个事实,自旋锁诞生了。
while (抢锁(lock) == 失败) {
if(抢锁(lock) == 成功) {
break;
}
}
- 使用场景:synchronized的升级
以上是关于这么多锁你都知道吗?的主要内容,如果未能解决你的问题,请参考以下文章
Java8用了这么久了,Stream 流用法及语法你都知道吗?