编程知识 cdmana.com

Java '锁'事

锁的类型

锁的类型从不同的角度看,主要分为以下几种

  • 悲观锁、乐观锁
  • 阻塞、非阻塞、自旋锁
  • 公平、非公平
  • 可重入、不可重入
  • 共享锁、排他锁

悲观锁和乐观锁

悲观锁

  • 悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。

乐观锁

  • 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
  • 乐观锁在java中通过使用无锁编程来实现,原子类中的递增操作就是通过CAS自旋来实现的。伪代码如下:
do {
    value = getXXXVolatile()
} while(!compareAndSwapInt(obj, address, value, value + delta)) 

CAS

CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。

CAS是一个原子操作,底层通过Unsafe类进行操作。

阻塞、非阻塞、自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

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

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS

公平锁和非公平锁

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

公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

ReentrantLock支持公平锁和非公平锁两种,默认是非公平锁。

公平和非公平锁内部都依赖AQS来实现。

AQS

AQS全程AbstractQueuedSynchronizer,即抽象同步队列。内部主要包含以下重要组件:

  1. CLH 等待队列,双向链表,每个节点包含了Thread,nextWaiter和waitState,前驱节点和后驱节点
  2. state 同步状态,初始值为0.
  3. 条件变量

公平和非公平的实现是放在AQS的两个子类中实现的。

  • 公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。公平锁在尝试加锁的时候,会检测当前线程是否是CLH等待队列中的第一个。
  • 非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。

可重入锁和非可重入锁

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

而非可重入锁则限定一个线程内的同一个锁,只能被一个资源获取。

AQS逻辑

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

排他锁和共享锁

共享锁和排他锁也称读写锁,分为读锁和写锁。

  • 读锁为共享锁,指该锁可以被多个线程所持有,如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
  • 写锁为排他锁,指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。

也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

AQS实现

在jdk中读写锁的实现类为ReentrantReadWriteLock,它内部有两个锁:ReadLock和WriteLock。读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

在ReentrantReadWriteLock中,将state字段拆成了两部分:高16位和低16位。高16位表示读状态,低16位表示写状态。

如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。

因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。

版权声明
本文为[纳兰清风]所创,转载请带上原文链接,感谢
https://my.oschina.net/nalenwind/blog/5011280

Scroll to Top