java“锁”事(2018美团点评技术文章合辑 阅读笔记)

在这里插入图片描述

1.乐观锁 VS 悲观锁

悲观锁 认为自己在 使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。(synchronized和Lock)
乐观锁 认为自己在 使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
在这里插入图片描述

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
		// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
			lock.lock();
			// 操作同步资源
			lock.unlock();
}
// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1

CAS与Synchronized

从思想上来说:

  • Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。
  • CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

2. 自旋锁 VS 适应性自旋锁

自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间(上下文切换耗时)。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

在这里插入图片描述
缺点:它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。
所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理-CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

适应性自旋锁

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。(TicketLock、CLHlock和MCSlock)。

3. 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

java对象在内存中的结构(HotSpot虚拟机)
锁消除:JIT编译时,对上下文进行扫描,去除不可能存在竞争的锁。(StringBuffer 的StringBuffer ("…"))
锁粗化:通过扩大锁的范围,避免反复加锁、解锁(while循环中)
Java对象头里的Mark Word存储结构
 Java对象头的存储结构
Mark Word里存储的数据会随着锁标志位的变化而变化
Mark Word会随着程序的运行发生变化

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word里的结构变成偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的线程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的Thread ID即可,省去了大量有关锁申请的操作。

全局安全点(在这个时间点上没有正
在执行的字节码)

轻量级锁

偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候升级为轻量级锁。(线程交替执行同步块)

重量级锁

多线程同一时间访问同一锁的情况下,膨胀为重量级锁。

锁的优缺点对比

在这里插入图片描述
锁状态特点
四种锁状态特点
在这里插入图片描述
偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
重量级锁是将除了拥有锁的线程以外的线程都阻塞。

4.公平锁 VS 非公平锁

公平锁
指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。

  • 优点:等待锁的线程不会饿死。
  • 缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
    公平锁
    单窗口业务处理 取号后在座椅上等待, 叫号叫到(被唤醒)去窗口办理业务

非公平锁
多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。

  • 优点:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
  • 缺点:处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
    非公平锁的实现

ReentrantLock的源码:公平锁和非公平锁的实现。

5.可重入锁 VS 非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁

public class Widget {
	public synchronized void doSomething() {
		System.out.println("方法1执行...");
		doOthers();
	}
	public synchronized void doOthers() {
		System.out.println("方法2执行...");
	}
}

类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
可重入锁理解(一个村民带多个水桶打水)

但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。非可重入锁

重入锁ReentrantLock非可重入锁NonReentrantLock

源码对比为什么非可重入锁在重复调用同步资源时会出现死锁。
ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。

而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
源码
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1== 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。
而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

6.独享锁 VS 共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。 如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据
JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。 如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据
读写锁ReentrantReadWriteLock
在ReentrantReadWriteLock里面,读锁ReadLock和写锁WriteLock的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。
读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

在独享锁中state这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。
在这里插入图片描述
但是ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。

  • 在线程持有读锁的情况下,该线程不能取得写锁
    (因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)

  • 在线程持有写锁的情况下,该线程可以继续获取读锁
    (获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)

因为当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;
而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,
还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

综上:
一个线程要想同时持有写锁和读锁,必须先获取写锁再获取读锁;
写锁可以“降级”为读锁;
读锁不能“升级”为写锁。

ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。

猜你喜欢

转载自blog.csdn.net/eluanshi12/article/details/84771250
今日推荐