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

12 篇文章 0 订阅

悲观锁,乐观锁

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

CAS

什么是CAS

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

原理

Java中基于unsafe实现,本质上是基于CPU提供的指令

自旋锁

所谓的自旋,就是指循环不停的执行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为原理实现的,作用就是,提供了原子性的Integer,Boolean的操作

CAS存在的问题

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

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

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

AQS(AbstractQueuedSynchronizer,抽象的队列式的同步器)

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

独占锁,共享锁

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

lock

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

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

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

公平锁,非公平锁

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中的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本身是锁对象,基于AQS实现
Lock除了提供获取锁的api,还提供了更多的方式来获取锁,如可中断的获取,非阻塞式的获取,超时获取

使用场景+性能优缺点对比

  • Synchronized竞争锁失败的线程,进入阻塞状态,竞争失败的线程,不停的再阻塞态和被唤醒态之间切换,即存在用户态(被唤醒来竞争锁)与内核态(系统调度线程的状态转换)之间的切换,性能消耗比较大
  • Lock是同步器管理的方式,线程出队并设置等待状态(相对来说开销小)

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

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
1.几种同步方法的区别 lock和Monitor是.NET用一个特殊结构实现的,Monitor对象是完全托管的、完全可移植的,并且在操作系统资源要求方 面可能更为有效,同步速度较快,但不能跨进程同步。lock(Monitor.Enter和Monitor.Exit方法的封装),主要作用是定临界区,使临 界区代码只能被获得的线程执行。Monitor.Wait和Monitor.Pulse用于线程同步,类似信号操作,个人感觉使用比较复杂,容易造成死 。 互斥体Mutex和事件对象EventWaitHandler属于内核对象,利用内核对象进行线程同步,线程必须要在用户模式和内核模 式间切换,所以一般效率很低,但利用互斥对象和事件对象这样的内核对象,可以在多个进程中的各个线程间进行同步。 互斥体Mutex类似于一个接力棒,拿到接力棒的线程才可以开始跑,当然接力棒一次只属于一个线程(Thread Affinity),如果这个线程不释放接力棒(Mutex.ReleaseMutex),那么没办法,其他所有需要接力棒运行的线程都知道能等着看热 闹。 EventWaitHandle 类允许线程通过发信号互相通信。 通常,一个或多个线程在 EventWaitHandle 上阻止,直到一个未阻止的线程调用 Set 方法,以释放一个或多个被阻止的线程。 2.什么时候需要定 首先要理解定是解决竞争条件的,也就是多个线程同时访问某个资源,造成意想不到的结果。比如,最简单的情况是,一个计数器,两个线程 同时加一,后果就是损失了一个计数,但相当频繁的定又可能带来性能上的消耗,还有最可怕的情况死。那么什么情况下我们需要使用,什么情况下不需要 呢? 1)只有共享资源才需要定 只有可以被多线程访问的共享资源才需要考虑定,比如静态变量,再比如某些缓存中的值,而属于线程内部的变量不需要定。 2)多使用lock,少用Mutex 如果你一定要使用定,请尽量不要使用内核模块的定机制,比如.NET的Mutex,Semaphore,AutoResetEvent和 ManuResetEvent,使用这样的机制涉及到了系统在用户模式和内核模式间的切换,性能差很多,但是他们的优点是可以跨进程同步线程,所以应该清 楚的了解到他们的不同和适用范围。 3)了解你的程序是怎么运行的 实际上在web开发中大多数逻辑都是在单个线程中展开的,一个请求都会在一个单独的线程中处理,其中的大部分变量都是属于这个线程的,根本没有必要考虑 定,当然对于ASP.NET中的Application对象中的数据,我们就要考虑加了。 4)把定交给数据库 数 据库除了存储数据之外,还有一个重要的用途就是同步,数据库本身用了一套复杂的机制来保证数据的可靠和一致性,这就为我们节省了很多的精力。保证了数据源 头上的同步,我们多数的精力就可以集中在缓存等其他一些资源的同步访问上了。通常,只有涉及到多个线程修改数据库中同一条记录时,我们才考虑加。 5)业务逻辑对事务和线程安全的要求 这 条是最根本的东西,开发完全线程安全的程序是件很费时费力的事情,在电子商务等涉及金融系统的案例中,许多逻辑都必须严格的线程安全,所以我们不得不牺牲 一些性能,和很多的开发时间来做这方面的工作。而一般的应用中,许多情况下虽然程序有竞争的危险,我们还是可以不使用定,比如有的时候计数器少一多一, 对结果无伤大雅的情况下,我们就可以不用去管它。 3.InterLocked类 Interlocked 类提供了同步对多个线程共享的变量的访问的方法。如果该变量位于共享内存中,则不同进程的线程就可以使用该机制。互操作是原子的,即整个操作是不能由相 同变量上的另一个互操作所中断的单元。这在抢先多线程操作系统中是很重要的,在这样的操作系统中,线程可以在从某个内存地址加载值之后但是在有机会更改 和存储该值之前被挂起。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值