多线程学习 各种锁类型与对比 lock

Posted *^O^*—*^O^*

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程学习 各种锁类型与对比 lock相关的知识,希望对你有一定的参考价值。

悲观锁,乐观锁

悲观锁:悲观的方式看待临界资源(多线程共享变量)线程安全问题。
背景:大多数情况下,同一个时间点,常常有多个线程竞争同一把锁
实现:竞争同一把锁失败的线程,阻塞的方式等待锁的释放
乐观锁:乐观的方式看待临界资源线程安全问题
背景:大多数情况下,同一个时间点,常常只有一个线程竞争同一把锁
实现方式:直接对临界资源进行修改(Java层面看起来是无锁的操作),没有线程安全问题(没有其他线程并发修改),直接修改成功,如果存在线程安全问题,修改失败,代码上,表现为返回值Boolean

CAS

什么是CAS

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

自旋锁

所谓的自旋,就是指循环不停的执行cas操作,
while(!cas(V,O,N)){
}
自旋还可以加入其它的退出条件:如可中断式的自旋,超时退出的自旋,重拾次数推出的自旋

CAS的操作过程

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:
V 内存地址存放的实际值;
O 预期的值(旧值);
N 更新的新值。
当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。
V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。

CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

CAS存在的问题

ABA问题
因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。

在JDK1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。
自旋会浪费大量的处理器资源与线程阻塞相比。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。
我们可以用等红绿灯作为例子。Java 线程的阻塞相当于熄火停车,而自旋状态相当于怠速停车。如果红灯的等待时间非常长,那么熄火停车相对省油一些;如果红灯的等待时间非常短,比如我们在同步代码块中只做了一个整型加法,那么在短时间内锁肯定会被释放出来,因此怠速停车更合适。然而,对于JVM来说,它并不能看到红灯的剩余时间,也就没法根据等待时间的长短来选择是自旋还是阻塞。JVM给出的方案是自适应自旋,根据以往自旋等待时能否获取锁,来动态调整自旋的时间(循环数)。
就我们的例子来说,如果之前不熄火等待了绿灯,那么这次不熄火的时间就长一点;如果之前不熄火没等待绿灯,那么这次不熄火的时间就短一点。

公平性
自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。内建锁无法实现公平机制,而lock体系可以实现公平锁。

公平锁,非公平锁

ReentrantLock支持两种锁:公平锁和非公平锁。何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。ReentrantLock的构造方法无参时是构造非公平锁

public ReentrantLock() {
	sync = new NonfairSync();
}

另外还提供了另外一种方式,可传入一个boolean值,true时为公平锁,false时为非公平锁

public ReentrantLock(boolean fair) {
	sync = fair ? new FairSync() : new NonfairSync();
}

公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象。
公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。

AQS

作用:提供了一系列的模板方法,用于方便的构建独占锁和共享锁的实现
原理:提供了一个数据结构(队列,实现是双向链表),AQS本身持有双向链表的头尾节点;
AQS用于管理线程的等待状态(同步,通信)(节点保存了线程的引用和等待状态);
加锁操作失败的线程,入队(运行态——等待WAITING状态);
竞争成功的线程,出队(线程由等待——运行态)。

独占锁,共享锁

独占锁:一把锁,只有一个线程加锁成功
共享锁:一把锁,可以有多个线程加锁成功,提供一个线程同步的数量,在指定数量范围内的线程,都可以并发并行的执行超出指定数量的线程,就需要等待,加锁成功的线程数量<=规定的数量
Semaphore
一个计数信号量,主要用于控制多线程对共同资源库访问的限制。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

lock

这里我们先引入死锁产生的四个条件,互斥,占有且等待,不可抢占,循环等待
那么只要破坏其中的一个,就可以成功的避免死锁的产生,因为锁本来就是互斥的,所以在这里就不考虑了

  1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
    synchronized是无法解决这个问题的。lock它提供了与synchronized一样的锁功能。虽然它失去了像synchronize关键字隐式加锁解锁的便捷性,但是却拥有了锁获取和释放的可操作性,可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。
Lock lock = new ReentrantLock();
lock.lock();
try{
	.......
}finally{
	lock.unlock();
}

需要注意的是synchronized同步块执行完成或者遇到异常是锁会自动释放,而lock必须调用unlock()方法释放锁,因此在finally块中释放锁。
在这里插入图片描述

读锁,写锁

写锁:
同步组件的实现聚合了同步器(AQS),并通过重写重写同步器(AQS)中的方法实现同步组件的同步语义因此,写锁的实现依然也是采用这种方式。在同一时刻写锁是不能被多个线程所获取,很显然写锁是独占式锁,而实现写锁的同步语义是通过重写AQS中的tryAcquire方法实现的。
读锁:
读锁不是独占式锁,即同一时刻该锁可以被多个读线程获取也就是一种共享式锁。按照之前对AQS介绍,实现共享式同步组件的同步语义需要通过重写AQS的tryAcquireShared方法和tryReleaseShared方法。

用于读多写少的场景,读操作时读锁加锁,写操作时写锁加锁
产生的作用:读读并发,读写互斥,写写互斥

Volatile+加锁操作,保证线程安全,同时尽可能提升效率:

  1. 写操作加锁(一般的共享变量的写操作都会依赖共享变量)
  2. 读操作使用volatile保证线程安全(提高效率)
    读读并发,读写并发,写写互斥

可重入锁

ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重
入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。在java关键字
synchronized隐式支持重入性,synchronized通过获取自增,释放自减的方式实现重入。与此同时,
ReentrantLock还支持公平锁和非公平锁两种方式。

要想支持重入性,就要解决两个问题:

  1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
  2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功

以非公平锁为例,判断当前线程能否获得锁为例,核心方法为nonfairTryAcquire:

final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 1.如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
	if (compareAndSetState(0, acquires)) {
		setExclusiveOwnerThread(current);
		return true;
	}
}
// 2.若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
	// 3.再次获取,计数+1
	int nextc = c + acquires;
	if (nextc < 0) // overflow
		throw new Error("Maximum lock count exceeded");
	setState(nextc);
	return true;
	}
	return false;
}

如果该锁已经被线程所占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回true,表示可以再次获取成功。

protected final boolean tryRelease(int releases) {
	// 1.同步状态-1
	int c = getState() - releases;
	if (Thread.currentThread() != getExclusiveOwnerThread())
		throw new IllegalMonitorStateException();
	boolean free = false;
	if (c == 0) {
		// 2.只有当同步状态为0时,锁成功释放,返回false
		free = true;
		setExclusiveOwnerThread(null);
	}
	// 3.锁未被完全释放,返回false
	setState(c);
	return free;
}

重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。到现在我们可以理清ReentrantLock重入性的实现了,也就是理解了同步语义的第一条。

Synchronized和lock对比:

Synchronized是内建锁(隐式的加锁和释放锁),lock是显式的加锁和释放锁,lock更加灵活
Lock除了提供获取锁的api,还提供了更多的方式来获取锁,如可中断的获取,非阻塞式的获取,超时获取
使用场景+性能优缺点对比

  • Synchronized竞争锁失败的线程,进入阻塞状态,竞争失败的线程,不停的再阻塞态和被唤醒态之间切换,即存在用户态(被唤醒来竞争锁)与内核态(系统调度线程的状态转换)之间的切换,性能消耗比较大

  • Lock竞争锁失败的线程,进入等待状态,AQS来进行线程状态的管理(相对来说开销小)

    结果:同一个时间点竞争同一把锁的线程很多(线程冲突的机率很大):lock性能要好很多

以上是关于多线程学习 各种锁类型与对比 lock的主要内容,如果未能解决你的问题,请参考以下文章

Java多线程——Lock&Condition

Java多线程与并发库高级应用-工具类介绍

Java多线程学习篇Lock

Java多线程与并发库高级应用-工具类介绍

“全栈2019”Java多线程第二十七章:Lock获取lock/释放unlock锁

Java学习笔记—多线程(java.util.concurrent.locks包,转载)