常见的锁策略

1. 乐观锁和悲观锁

锁的实现者,预测接下来的锁冲突(锁竞争)的概率是打还是不大。根据这个冲突的概率,来决定接下来该咋做。
乐观锁:预测接下来冲突概率不大
悲观锁:预测接下来冲突概率比较大
所以说这两种锁策略就可能导致最终要做的事情不一样。
详细说明一下这两种锁策略:

乐观锁(Optimistic Locking)是一种乐观的假设,即并发访问的操作很少会造成冲突。在乐观锁的策略下,当多个线程同时操作共享资源时,不会直接加锁,而是在执行操作之前先检查共享资源的状态。如果检查到资源状态没有被其他线程修改过,那么操作可以继续执行;但如果检查到资源状态已经被修改过,那么操作将被中断,需要进行回滚或重试。乐观锁通常使用版本号(Versioning)或时间戳(Timestamp)来标识资源版本,以便检测是否发生了冲突。

悲观锁(Pessimistic Locking)则采取相反的假设,即并发访问的操作很可能会造成冲突。在悲观锁的策略下,当多个线程同时操作共享资源时,会直接对资源进行加锁,确保同一时间只有一个线程可以访问该资源。其他线程在获得锁之前需要等待。悲观锁适用于对共享资源的操作冲突可能性较高的场景,但由于频繁加锁和解锁的开销,可能会降低并发性能。

总的来说,乐观锁适用于并发操作冲突较少的场景,通过检查资源状态来避免冲突,可以提高并发性能。而悲观锁适用于并发操作冲突较多的场景,通过加锁来确保资源的独占性,保证操作的一致性。选择使用哪种锁策略应根据具体应用场景和需求来决定。

我们举一个现实生活中的例子来形象生动的说明一下这两种锁策略:

假设你和你的朋友同时想要借用同一本图书馆中的书籍,下面是乐观锁和悲观锁在这个情景中的应用:

  1. 乐观锁策略:
    在乐观锁策略下,你和你的朋友都没有提前预约这本书。当你们同时到达图书馆时,图书管理员会告诉你们这本书目前是可用的。你和你的朋友可以同时借阅这本书,因为乐观锁策略假设冲突的概率较低。然而,如果在借阅期间,有其他人预约了这本书并成功借出,你和你的朋友将会被要求归还书籍,以便让预约者借阅。这里的预约者相当于其他线程在并发编程中的操作。

  2. 悲观锁策略:
    在悲观锁策略下,你提前预约了这本书,而你的朋友没有。当你到达图书馆时,图书管理员会告诉你这本书已经被你预约,并且会将它暂时锁定,以确保你能够借阅成功。在此期间,即使你的朋友也希望借阅这本书,他们需要等待你完成借阅后才能尝试借阅。这里的预约和锁定相当于悲观锁策略中的加锁操作,确保了资源的独占性。

这两种策略的区别在于乐观锁策略假设并发操作很少会造成冲突,因此允许多个人同时尝试访问资源,只有在冲突发生时才会中断操作。而悲观锁策略则假设并发操作很可能会造成冲突,因此会直接加锁来确保资源的独占性,其他人需要等待锁释放后才能尝试访问资源。在图书借阅的例子中,乐观锁策略允许你和你的朋友同时借阅,只有在冲突发生时才会要求归还;而悲观锁策略则保证了你在借阅期间的独占性,避免了冲突的可能性。

2. 轻量级锁和重量级锁

简单来说:
轻量级锁,加锁解锁的过程更快更高效
重量级锁,加锁解锁的过程更慢更低效
这两种锁策略虽然和上述的乐观锁与悲观锁是两回事,但是确实也有一定的重合,一个乐观锁很可能也是一个轻量级锁,一个悲观锁很可能也是一个重量级锁,当然都不绝对。

举个例子:
假设有一个公共厕所,多个人需要使用该厕所。在这个例子中,厕所可以看作是一个共享资源,而人们则是多个线程。

对于轻量级锁,可以将每个人看作一个线程。当一个人进入厕所时,他会尝试获取锁(即进入厕所的权限)。如果厕所没有被其他人占用,那么他可以顺利进入厕所,并将门锁上。此时,其他人尝试获取锁时会发现锁已经被占用,他们会等待一段时间后再次尝试。这个过程是非常快速的,因为只需要检查门锁的状态。

对于重量级锁,可以将每个人看作一个线程。当一个人进入厕所时,他会尝试获取锁(即进入厕所的权限)。如果厕所已经被其他人占用,那么他就需要等待,直到厕所被释放。在这个过程中,他会进入一个阻塞状态,不再尝试获取锁,而是等待其他人释放锁后被唤醒。这个过程可能需要较长的时间,因为涉及到操作系统的调度和阻塞唤醒。

总的来说,轻量级锁适用于厕所使用不频繁、竞争不激烈的情况。而重量级锁适用于厕所使用频繁、竞争激烈的情况。在实际应用中,系统会根据厕所的使用情况自动选择使用轻量级锁还是重量级锁,以保证人们能够公平地使用厕所。

3. 自旋锁和挂起等待锁

自旋锁:自旋锁是一种忙等待的锁机制。当一个线程尝试获取自旋锁时,如果锁已被其他线程占用,该线程会一直循环忙等待,直到锁被释放。自旋锁适用于锁的占用时间很短的情况,因为忙等待不会引起线程的切换,可以减少线程上下文切换的开销。但是,如果锁的占用时间较长,自旋锁会导致浪费CPU资源。

挂起等待锁:挂起等待锁是一种阻塞等待的锁机制。当一个线程尝试获取挂起等待锁时,如果锁已被其他线程占用,该线程会被挂起,进入等待状态,直到锁被释放。挂起等待锁适用于锁的占用时间较长的情况,因为线程被挂起后可以释放CPU资源给其他线程使用,避免了忙等待的浪费。但是,挂起和唤醒线程会引起线程上下文切换的开销。

理解自旋锁和挂起等待锁的关键在于锁的占用时间和并发情况。如果锁的占用时间短且并发度高,自旋锁可以减少线程切换的开销;如果锁的占用时间长或并发度低,挂起等待锁可以避免浪费CPU资源。在实际应用中,可以根据具体情况选择适合的锁机制。

自旋锁是轻量级锁的一种典型实现
挂起等待锁是重量级锁的一种典型实现。

举个例子:
假设有一个公共厕所,多个人需要使用该厕所。我们可以使用自旋锁和挂起等待锁来实现对该厕所的并发控制。

自旋锁的例子: 假设有两个人A和B,他们都需要使用公共厕所。当人A到达厕所门口时,发现门是关着的,此时他会一直在门口等待,直到门被人B打开。一旦人B打开了门,人A立即进入厕所使用。这样,人A和人B可以通过自旋锁实现对公共厕所的互斥访问。

挂起等待锁的例子: 假设有两个人A和B,他们都需要使用公共厕所。当人A到达厕所门口时,发现门是关着的,此时他会在门口等待,并告知门口的管理员自己需要使用厕所。管理员会记录下人A的请求,并告知人A等待一段时间。当人B使用完厕所后,管理员会通知人A可以进入厕所了。人A被唤醒后,进入厕所使用。这样,人A和人B可以通过挂起等待锁实现对公共厕所的互斥访问。

总结:自旋锁和挂起等待锁都可以用于实现对公共资源的并发控制。自旋锁适用于资源占用时间短且并发度高的情况,可以减少等待的时间;挂起等待锁适用于资源占用时间长或并发度低的情况,可以避免浪费等待的时间。具体选择哪种锁机制取决于实际应用场景的需求。

4. 互斥锁与读写锁

互斥锁(Mutex Lock)和读写锁(Read-Write Lock)都是用于实现对共享资源的并发控制的机制,但它们在控制访问方式和效率上有所不同。

互斥锁: 互斥锁是一种独占锁,它保证在任意时刻只有一个线程可以访问共享资源。当一个线程获取到互斥锁后,其他线程必须等待该线程释放锁才能访问共享资源。互斥锁适用于对共享资源的写操作,因为写操作需要互斥地进行,以避免数据的不一致性。互斥锁的特点是简单、安全,但在高并发场景下可能会导致线程频繁地竞争锁,从而降低效率。

读写锁: 读写锁是一种共享锁,它允许多个线程同时读取共享资源,但只允许一个线程进行写操作。当一个线程获取到读写锁的读锁后,其他线程也可以获取读锁进行读操作,而不会阻塞。但当一个线程获取到读写锁的写锁后,其他线程无法获取读锁或写锁,必须等待写锁释放才能访问共享资源。读写锁适用于对共享资源的读操作远远多于写操作的场景,可以提高并发度和效率。

synchronized 就是互斥锁,也就是说加锁就是单纯的加锁,进入代码块就加锁,出了代码块就解锁,没有更细化的区分了。
但是读写锁能够把 读 和 写 两种加锁区分开。读写锁有三个操作:

  1. 给读加锁
  2. 给写加锁
  3. 解锁

那为什么要给 读 和 写 单独加锁呢,因为如果多个线程同时读同一个变量,这是不会产生线程安全问题的,因为并不涉及到修改。
读写锁约定:

  1. 读锁与读锁之间,不会产生锁竞争,也就不会出现阻塞等待
  2. 写锁与写锁之间,有锁竞争
  3. 读锁与写锁之间,有锁竞争

举个例子:

互斥锁的例子: 假设有两个读者A和B,他们都想借阅同一本书。当读者A到达书架时,发现书已经被读者B借走了,此时他会等待读者B归还书籍。一旦读者B归还了书籍,读者A立即借阅该书。这样,读者A和读者B可以通过互斥锁实现对图书资源的互斥访问。

读写锁的例子: 假设有两个读者A和B,他们都想阅读同一本书。当读者A到达书架时,发现书还在书架上,此时他可以直接阅读该书。同时,读者B也可以到达书架并阅读同一本书,因为读操作是并发允许的。但当读者A想要修改书中的内容时,他必须获取到写锁,此时其他读者无法阅读该书,直到读者A完成修改并释放写锁。这样,读者A和读者B可以通过读写锁实现对图书资源的并发访问,提高了并发度和效率。

5. 可重入锁和不可重入锁

可重入锁(Reentrant Lock)和不可重入锁(Non-reentrant Lock)是两种不同的锁策略,它们在多线程编程中起着不同的作用。

  1. 可重入锁:
    可重入锁允许同一个线程在持有锁的情况下多次获取该锁,而不会发生死锁。也就是说,如果一个线程已经获得了某个锁,并且没有释放锁之前又尝试获取同一个锁,那么这个获取操作会成功。
    可重入锁的主要优点是简化了编程模型,允许在同一个线程中对共享资源进行嵌套调用和操作。这种机制可以避免死锁,并提高代码的可维护性和可读性。

  2. 不可重入锁:
    不可重入锁是指同一个线程在获取锁之后,再次尝试获取同一个锁时会被阻塞,直到其他线程释放该锁。换句话说,不可重入锁不允许同一个线程重复获取同一个锁。
    不可重入锁的一个应用场景是防止线程递归调用同一段代码,避免出现意外的死锁情况。不可重入锁可能会导致线程在获取锁之后无法再次获取锁,造成死锁。

简单来说,如果一个锁在一个线程中,连续对该锁枷锁两次,如果不产生死锁,就叫做可重入锁,如果产生死锁了,就叫不可重入锁。

synchronized 是一个可重入锁:

class A {
	synchronized public void put(int n){
		this.size();
		.....
	}
	synchronized public int size(){
	}
}

这样的代码其实在 java 日常开发中是比较常见的,不会导致死锁,就是因为 synchronized 是可重入锁。

6. 公平锁和非公平锁

  1. 公平锁:
    平锁是指多个线程按照它们请求锁的顺序来获取锁。也就是说,线程在请求锁时,如果锁已经被别的线程占用,那么这个线程就会进入一个等待队列中,等待队列中的线程按照FIFO(先进先出)的原则获取锁。公平锁遵循先来后到原则,避免了“饥饿”现象,即某个线程长时间无法获取资源。
  2. 非公平锁
    非公平锁则不保证按照请求锁的顺序进行分配。也就是说,当一个线程释放锁后,等待锁的线程不一定按照他们请求的顺序获得锁,而是哪个线程抢到就哪个线程获得。这种情况下,新请求锁的线程有可能比已在等待队列中的线程先获取到锁,因此可能造成已在队列中的线程长时间得不到锁。

简单来说,遵循先来后到就是公平锁,不遵循就是非公平锁。

举个现实生活中的例子来说明一下:
假设学校食堂有一个打饭窗口非常热门。如果是公平锁,那么学生老师就会根据先来后到排队打饭,如果是非公平锁,那就没有排队,蜂拥而上,不存在谁先来谁先打饭的说法。

由于系统对于线程的调度是随机的,自带的 synchronized 锁是非公平锁。要想实现公平锁,可以在 synchronized 的基础上,加一个队列来保证这些加锁线程的顺序。

7. synchronized 的特点

  1. 即使乐观锁也是悲观锁
  2. 即使轻量级锁也是重量级锁
  3. 轻量级锁基于自旋锁实现,重量级锁基于挂起等待锁实现
  4. 不是读写锁
  5. 是可重入锁
  6. 是非公平锁

8. synchronized 原理

8.1 锁竞争

synchronized 的关键策略:锁升级。
首先加锁的升级过程如下图所示:
在这里插入图片描述
把上图用文字说明一下就是,刚开始是无锁状态,刚开始加锁是偏向锁状态,遇到锁竞争就是自旋锁(轻量级锁),竞争更激烈就会变成重量级锁(交给内核阻塞等待)。还有一点就是主流的 JVM 实现中,锁的升级是单向的,也就是说,它只能从偏向锁升级到轻量级锁,然后升级到重量级锁,不能反向降级。这是因为一旦发生锁竞争,意味着后续可能会有更激烈的锁竞争,因此没有降级的必要。
上图中我们除了偏向锁没有介绍过,其他都已经在上文中介绍了,那么接下来我们就来介绍一下什么是偏向锁:
轻量级锁是Java对synchronized关键字的一种优化实现。它是相对于使用操作系统互斥量来实现的重量级锁而言的。
轻量级锁的实现Characteristics:

  • 轻量级锁针对对象头中的Mark Word进行操作,不需要申请互斥量。
  • 在无竞争的情况下,可以提高同步性能。
  • 在获取锁失败时会进行锁自旋,避免线程直接进入阻塞。
  • 轻量级锁在释放锁时不需要调用操作系统接口,降低开销。
  • 存在线程数限制,当竞争线程数超过限制会导致锁膨胀为重量级锁。

轻量级锁获取锁的大致流程:

  1. 通过CAS操作将Mark Word更新为指向当前线程栈帧的指针来表示占有锁。
  2. 成功表示获取了锁,失败表示存在竞争,需要进入自旋等待或者膨胀为重量级锁。
  3. 锁释放时简单设置Mark Word的值即可。

此时可能又有人疑惑什么是 Mark Word:
在 HotSpot VM (Oracle 的 Java 虚拟机实现)的对象头中,Mark Word用来存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁等信息。
Mark Word具体包含以下几种状态:

  • 对象正常状态 - 存储对象的哈希码、分代年龄等数据。
  • 偏向锁状态 - 标记对象由一个线程偏向锁定,存储线程ID。
  • 轻量级锁状态 - 标记对象被轻量级锁定,存储指向当前线程栈帧的指针。
  • 重量级锁状态 - 标记对象被重量级锁定,存储指向互斥量(Monitor)的指针。
  • GC标记状态 - 存储对象是否可回收的标记位。

由此可见,Mark Word是一个非常重要的数据结构,它极大地优化和方便了锁的实现。通过对它的值进行CAS原子操作,就可以完成对对象锁状态的修改。

8.2 锁消除

这个锁消除其实就是非必要不加锁,与上面锁升级不同的是,这个锁消除是在程序编一阶段做的优化手段,就是在编一阶段检测当前代码是不是多线程执行/是否有必要加锁,如果没必要,又把所给加了就会自动把锁去掉,举个例子:

private void someMethod() {
  Object localObj = new Object();
  synchronized(localObj) {
    // 方法体 
  }
}

这里的 localObj 没有被其它线程访问到, Synchronized monitor 可以被安全地删除,等同于:

private void someMethod() {
  Object localObj = new Object();
  
  // 方法体
}

此时锁消除就避免了不必要的同步开销,提高性能。

8.3 锁粗化

首先我们要知道锁的粒度这个概念,所谓的锁的粒度就是 synchronized 代码块,包含的代码的多少(代码越多,粒度越粗,越少,越细)。
一般写代码的时候多数情况下,我们希望所得粒度更小一点,因为这就意味着串行执行的代码少,能并发执行的代码就多,那么效率就高。但是如果一段代码里连续的多次请求同一个锁(也就是对同一把锁连续的加锁解锁),那么将多次加锁解锁操作合并为一次,就可以减少加锁解锁的次数,从而减少线程上下文切换的开销。
举个例子:

public void someMethod() {
  synchronized(lock) {
    // 语句1
  }
  
  synchronized(lock) { 
    // 语句2
  }
}

这段代码对同一个锁lock进行了两次加锁解锁。锁粗化可以优化为只加锁一次:

public void someMethod() {
  synchronized(lock) {
    // 语句1
    // 语句2
  }
}

这样可以减少进入和退出同步块的次数,因为进入/退出同步块需要操作系统保存/恢复线程的上下文,这些操作都需要消耗CPU。

  • 14
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 14
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不想菜的鸟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值